diff --git a/build.gradle b/build.gradle index a74e1497a0..f0358803ef 100644 --- a/build.gradle +++ b/build.gradle @@ -337,7 +337,8 @@ subprojects { check.dependsOn("testModules") - if (!(project.name in ['micrometer-jakarta9', 'micrometer-java11'])) { // add projects here that do not exist in the previous minor so should be excluded from japicmp + if (!(project.name in ['micrometer-jakarta9', 'micrometer-java11', 'micrometer-jetty12'])) { + // add projects here that do not exist in the previous minor so should be excluded from japicmp apply plugin: 'me.champeau.gradle.japicmp' apply plugin: 'de.undercouch.download' diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fe53b5a85b..01dffce050 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,7 +43,7 @@ javax-inject = "1" jaxb = "2.3.1" jetty9 = "9.4.53.v20231009" jetty11 = "11.0.16" -jetty12 = "12.0.3" +jetty12 = "12.0.6" jersey2 = "2.41" jersey3 = "3.0.11" jmh = "1.37" diff --git a/micrometer-jetty12/build.gradle b/micrometer-jetty12/build.gradle new file mode 100644 index 0000000000..0a0890c7b5 --- /dev/null +++ b/micrometer-jetty12/build.gradle @@ -0,0 +1,36 @@ +description 'Micrometer instrumentation for Jetty 12' + +// skip this module when building with jdk <17 +if (!javaLanguageVersion.canCompileOrRun(17)) { + project.tasks.configureEach { task -> task.enabled = false } +} + +dependencies { + api project(":micrometer-core") + api libs.jetty12Server + + // Test sample project with SLFJ4 2.x / Logback 1.4 + runtimeOnly(libs.logback14) { + version { + strictly libs.logback14.get().version + } + } + testRuntimeOnly(libs.logback14) { + version { + strictly libs.logback14.get().version + } + } + + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.assertj:assertj-core' +} + +java { + targetCompatibility = 17 +} + +compileJava { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + options.release = 17 +} diff --git a/micrometer-jetty12/src/main/java/io/micrometer/jetty12/DefaultJettyCoreRequestTagsProvider.java b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/DefaultJettyCoreRequestTagsProvider.java new file mode 100644 index 0000000000..18519d27e7 --- /dev/null +++ b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/DefaultJettyCoreRequestTagsProvider.java @@ -0,0 +1,67 @@ +/* + * Copyright 2020 VMware, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.micrometer.jetty12; + +import io.micrometer.core.annotation.Incubating; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.binder.http.Outcome; +import org.eclipse.jetty.server.Request; + +/** + * Default {@link JettyCoreRequestTagsProvider}. + * + * @author Joakim Erdfelt + * @since 1.11.0 + */ +@Incubating(since = "1.11.0") +public class DefaultJettyCoreRequestTagsProvider implements JettyCoreRequestTagsProvider { + + private static final Tag STATUS_UNKNOWN = Tag.of("status", "UNKNOWN"); + + private static final Tag METHOD_UNKNOWN = Tag.of("method", "UNKNOWN"); + + @Override + public Iterable getTags(Request request) { + return Tags.of(method(request), status(request), outcome(request)); + } + + private Tag method(Request request) { + return (request != null) ? Tag.of("method", request.getMethod()) : METHOD_UNKNOWN; + } + + private Tag status(Request request) { + if (request == null) + return STATUS_UNKNOWN; + + Object status = request.getAttribute(TimedHandler.RESPONSE_STATUS_ATTRIBUTE); + if (status instanceof Integer statusInt) + return Tag.of("status", Integer.toString(statusInt)); + return STATUS_UNKNOWN; + } + + private Tag outcome(Request request) { + Outcome outcome = Outcome.UNKNOWN; + if (request != null) { + Object status = request.getAttribute(TimedHandler.RESPONSE_STATUS_ATTRIBUTE); + if (status instanceof Integer statusInt) + outcome = Outcome.forStatus(statusInt); + } + return outcome.asTag(); + } + +} diff --git a/micrometer-jetty12/src/main/java/io/micrometer/jetty12/JettyCoreRequestTagsProvider.java b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/JettyCoreRequestTagsProvider.java new file mode 100644 index 0000000000..0c14e8c721 --- /dev/null +++ b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/JettyCoreRequestTagsProvider.java @@ -0,0 +1,40 @@ +/* + * Copyright 2022 VMware, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.micrometer.jetty12; + +import io.micrometer.core.annotation.Incubating; +import io.micrometer.core.instrument.Tag; +import org.eclipse.jetty.server.Request; + +/** + * Provides {@link Tag Tags} for Jetty Core request handling. + * + * @author Joakim Erdfelt + * @since 1.11.0 + */ +@Incubating(since = "1.11.0") +@FunctionalInterface +public interface JettyCoreRequestTagsProvider { + + /** + * Provides tags to be associated with metrics for the given {@code request}. + * @param request the request + * @return tags to associate with metrics for the request + */ + Iterable getTags(Request request); + +} diff --git a/micrometer-jetty12/src/main/java/io/micrometer/jetty12/TimedHandler.java b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/TimedHandler.java new file mode 100644 index 0000000000..851ec1af17 --- /dev/null +++ b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/TimedHandler.java @@ -0,0 +1,222 @@ +/* + * Copyright 2022 VMware, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.jetty12; + +import io.micrometer.core.instrument.LongTaskTimer; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.handler.EventsHandler; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.component.Graceful; + +import java.nio.ByteBuffer; +import java.util.concurrent.CompletableFuture; + +/** + * Jetty 12 Metrics Handler. + * + * @author Jon Schneider + * @author Joakim Erdfelt + * @since 1.10.0 + */ +public class TimedHandler extends EventsHandler implements Graceful { + + private static final String SAMPLE_TIMER_ATTRIBUTE = "__micrometer_timer_sample"; + + private static final String SAMPLE_REQUEST_LONG_TASK_TIMER_ATTRIBUTE = "__micrometer_request_ltt_sample"; + + private static final String SAMPLE_HANDLER_LONG_TASK_TIMER_ATTRIBUTE = "__micrometer_handler_ltt_sample"; + + protected static final String RESPONSE_STATUS_ATTRIBUTE = "__micrometer_jetty_core_response_status"; + + private final MeterRegistry registry; + + private final Iterable tags; + + private final JettyCoreRequestTagsProvider tagsProvider; + + private final Shutdown shutdown = new Shutdown(this) { + @Override + public boolean isShutdownDone() { + return timerRequest.activeTasks() == 0; + } + }; + + /** + * Full Request LifeCycle (inside and out of Handlers) + */ + private final LongTaskTimer timerRequest; + + /** + * How many Request are inside handle() calls. + */ + private final LongTaskTimer timerHandle; + + public TimedHandler(MeterRegistry registry, Iterable tags) { + this(registry, tags, new DefaultJettyCoreRequestTagsProvider()); + } + + public TimedHandler(MeterRegistry registry, Iterable tags, JettyCoreRequestTagsProvider tagsProvider) { + this.registry = registry; + this.tags = tags; + this.tagsProvider = tagsProvider; + + this.timerRequest = LongTaskTimer.builder("jetty.server.requests.open") + .description("Jetty requests that are currently in progress") + .tags(tags) + .register(registry); + + this.timerHandle = LongTaskTimer.builder("jetty.server.handling.open") + .description("Jetty requests inside handle() calls") + .tags(tags) + .register(registry); + } + + @Override + protected void onBeforeHandling(Request request) { + beginRequestTiming(request); + beginHandlerTiming(request); + super.onBeforeHandling(request); + } + + @Override + protected void onRequestRead(Request request, Content.Chunk chunk) { + super.onRequestRead(request, chunk); + } + + @Override + protected void onAfterHandling(Request request, boolean handled, Throwable failure) { + stopHandlerTiming(request); + super.onAfterHandling(request, handled, failure); + } + + @Override + protected void onResponseBegin(Request request, int status, HttpFields headers) { + // If we see a status of 0 here, that mean the Handler hasn't set a status code. + request.setAttribute(RESPONSE_STATUS_ATTRIBUTE, status); + super.onResponseBegin(request, status, headers); + } + + @Override + protected void onResponseWrite(Request request, boolean last, ByteBuffer content) { + super.onResponseWrite(request, last, content); + } + + @Override + protected void onResponseWriteComplete(Request request, Throwable failure) { + super.onResponseWriteComplete(request, failure); + } + + @Override + protected void onResponseTrailersComplete(Request request, HttpFields trailers) { + super.onResponseTrailersComplete(request, trailers); + } + + @Override + protected void onComplete(Request request, Throwable failure) { + stopRequestTiming(request); + super.onComplete(request, failure); + } + + private void beginRequestTiming(Request request) { + LongTaskTimer.Sample requestSample = timerRequest.start(); + request.setAttribute(SAMPLE_REQUEST_LONG_TASK_TIMER_ATTRIBUTE, requestSample); + } + + private void stopRequestTiming(Request request) { + Timer.Sample sample = getTimerSample(request); + LongTaskTimer.Sample requestSample = (LongTaskTimer.Sample) request + .getAttribute(SAMPLE_REQUEST_LONG_TASK_TIMER_ATTRIBUTE); + if (requestSample == null) + return; // timing complete + + sample.stop(Timer.builder("jetty.server.requests") + .description("HTTP requests to the Jetty server") + .tags(tagsProvider.getTags(request)) + .tags(tags) + .register(registry)); + + request.removeAttribute(SAMPLE_REQUEST_LONG_TASK_TIMER_ATTRIBUTE); + + requestSample.stop(); + } + + private void beginHandlerTiming(Request request) { + LongTaskTimer.Sample handlerSample = timerHandle.start(); + request.setAttribute(SAMPLE_HANDLER_LONG_TASK_TIMER_ATTRIBUTE, handlerSample); + } + + private void stopHandlerTiming(Request request) { + Timer.Sample sample = getTimerSample(request); + LongTaskTimer.Sample handlerSample = (LongTaskTimer.Sample) request + .getAttribute(SAMPLE_HANDLER_LONG_TASK_TIMER_ATTRIBUTE); + if (handlerSample == null) + return; // timing complete + + sample.stop(Timer.builder("jetty.server.handling") + .description("Requests being processed by Jetty handlers") + .tags(tagsProvider.getTags(request)) + .tags(tags) + .register(registry)); + + request.removeAttribute(SAMPLE_HANDLER_LONG_TASK_TIMER_ATTRIBUTE); + + handlerSample.stop(); + } + + private Timer.Sample getTimerSample(Request request) { + return (Timer.Sample) request.getAttribute(SAMPLE_TIMER_ATTRIBUTE); + } + + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception { + Timer.Sample sample = Timer.start(registry); + request.setAttribute(SAMPLE_TIMER_ATTRIBUTE, sample); + return super.handle(request, response, callback); + } + + @Override + protected void doStart() throws Exception { + shutdown.cancel(); + super.doStart(); + } + + @Override + protected void doStop() throws Exception { + shutdown.cancel(); + super.doStop(); + } + + @Override + public CompletableFuture shutdown() { + return shutdown.shutdown(); + } + + @Override + public boolean isShutdown() { + return shutdown.isShutdown(); + } + + protected Shutdown getShutdown() { + return shutdown; + } + +} diff --git a/micrometer-jetty12/src/main/java/io/micrometer/jetty12/package-info.java b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/package-info.java new file mode 100644 index 0000000000..636aee9154 --- /dev/null +++ b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2022 VMware, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Instrumentation for Jetty 12. + */ +@NonNullApi +@NonNullFields +package io.micrometer.jetty12; + +import io.micrometer.common.lang.NonNullApi; +import io.micrometer.common.lang.NonNullFields; diff --git a/micrometer-jetty12/src/test/java/io/micrometer/jetty12/TimedHandlerTest.java b/micrometer-jetty12/src/test/java/io/micrometer/jetty12/TimedHandlerTest.java new file mode 100644 index 0000000000..74427e01ad --- /dev/null +++ b/micrometer-jetty12/src/test/java/io/micrometer/jetty12/TimedHandlerTest.java @@ -0,0 +1,214 @@ +/* + * Copyright 2022 VMware, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.jetty12; + +import io.micrometer.core.instrument.MockClock; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.binder.http.Outcome; +import io.micrometer.core.instrument.simple.SimpleConfig; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpTester; +import org.eclipse.jetty.server.*; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.component.Graceful; +import org.eclipse.jetty.util.component.LifeCycle; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Testing Jetty 12 TimedHandler + */ +class TimedHandlerTest { + + private SimpleMeterRegistry registry; + + private TimedHandler timedHandler; + + private Server server; + + private LocalConnector connector; + + private LatchHandler latchHandler; + + @BeforeEach + void setup() { + this.registry = new SimpleMeterRegistry(SimpleConfig.DEFAULT, new MockClock()); + this.timedHandler = new TimedHandler(registry, Tags.empty()); + + this.server = new Server(); + this.connector = new LocalConnector(server); + server.addConnector(connector); + + latchHandler = new LatchHandler(); + + server.setHandler(latchHandler); + latchHandler.setHandler(timedHandler); + } + + @AfterEach + void tearDown() { + LifeCycle.stop(server); + } + + @Test + void testRequest() throws Exception { + CyclicBarrier[] barrier = { new CyclicBarrier(3), new CyclicBarrier(3) }; + latchHandler.reset(2); + + timedHandler.setHandler(new Handler.Abstract() { + @Override + public boolean handle(Request request, Response response, Callback callback) { + try { + response.setStatus(200); + barrier[0].await(5, TimeUnit.SECONDS); + barrier[1].await(5, TimeUnit.SECONDS); + response.write(true, BufferUtil.EMPTY_BUFFER, callback); + } + catch (Exception x) { + Thread.currentThread().interrupt(); + callback.failed(x); + } + return true; + } + }); + // server.setDumpAfterStart(true); + server.start(); + + try (LocalConnector.LocalEndPoint endpoint1 = connector.connect(); + LocalConnector.LocalEndPoint endpoint2 = connector.connect()) { + // Initiate two requests, on different endpoints to avoid HTTP/1.1 persistent + // connection behaviors. + String request = "GET / HTTP/1.1\r\n" + "Host: localhost\r\n" + "\r\n"; + endpoint1.addInputAndExecute(request); + endpoint2.addInputAndExecute(request); + + barrier[0].await(5, TimeUnit.SECONDS); + assertThat(registry.get("jetty.server.requests.open").longTaskTimer().activeTasks()).isEqualTo(2); + + barrier[1].await(5, TimeUnit.SECONDS); + assertThat(latchHandler.await()).isTrue(); + + // Read the two responses to ensure that they are complete + HttpTester.Response response1 = HttpTester.parseResponse(endpoint1.getResponse()); + assertThat(response1.getStatus()).isEqualTo(HttpStatus.OK_200); + assertThat(response1.getContent()).isEqualTo(""); + HttpTester.Response response2 = HttpTester.parseResponse(endpoint2.getResponse()); + assertThat(response2.getStatus()).isEqualTo(HttpStatus.OK_200); + assertThat(response2.getContent()).isEqualTo(""); + + assertThat(registry.get("jetty.server.requests") + .tag("outcome", Outcome.SUCCESS.name()) + .tag("method", "GET") + .tag("status", "200") + .timer() + .count()).isEqualTo(2); + } + } + + @Test + void testRequestWithShutdown() throws Exception { + long delay = 500; + CountDownLatch serverLatch = new CountDownLatch(1); + timedHandler.setHandler(new Handler.Abstract() { + @Override + public boolean handle(Request request, Response response, Callback callback) { + response.setStatus(200); + // commit response + response.write(false, BufferUtil.EMPTY_BUFFER, Callback.NOOP); + new Thread(() -> { + // let test proceed + serverLatch.countDown(); + try { + // wait on finishing the response + Thread.sleep(delay); + } + catch (InterruptedException e) { + response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR_500); + callback.failed(e); + return; + } + // finish response + response.write(true, BufferUtil.EMPTY_BUFFER, callback); + }).start(); + return true; + } + }); + server.start(); + + try (LocalConnector.LocalEndPoint endpoint = connector.connect()) { + String request = "GET / HTTP/1.1\r\n" + "Host: localhost\r\n" + "\r\n"; + endpoint.addInputAndExecute(request); + + // wait till we reach the Handler + assertThat(serverLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // initiate a shutdown + Future shutdownFuture = timedHandler.shutdown(); + Graceful.Shutdown shutdown = timedHandler.getShutdown(); + assertThat(shutdownFuture.isDone()).isFalse(); + + // delay half what the handler is sleeping + Thread.sleep(delay / 2); + // response is still active, so don't shutdown. + shutdown.check(); + assertThat(shutdownFuture.isDone()).isFalse(); + + // Read response to ensure it is done + HttpTester.Response response1 = HttpTester.parseResponse(endpoint.getResponse()); + assertThat(response1.getStatus()).isEqualTo(HttpStatus.OK_200); + assertThat(response1.getContent()).isEqualTo(""); + + Thread.sleep(delay); + shutdown.check(); + assertThat(shutdownFuture.isDone()).isTrue(); + } + } + + private static class LatchHandler extends Handler.Wrapper { + + private volatile CountDownLatch latch = new CountDownLatch(1); + + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception { + try { + return super.handle(request, response, callback); + } + finally { + latch.countDown(); + } + } + + private void reset(int count) { + latch = new CountDownLatch(count); + } + + private boolean await() throws InterruptedException { + return latch.await(5, TimeUnit.SECONDS); + } + + } + +} diff --git a/micrometer-jetty12/src/test/resources/logback.xml b/micrometer-jetty12/src/test/resources/logback.xml new file mode 100644 index 0000000000..a40423827b --- /dev/null +++ b/micrometer-jetty12/src/test/resources/logback.xml @@ -0,0 +1,40 @@ + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + diff --git a/settings.gradle b/settings.gradle index 63bbe5ef99..5c636803b0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -46,5 +46,6 @@ include 'micrometer-bom' include 'micrometer-jakarta9' include 'micrometer-java11' include 'micrometer-jetty11' +include 'micrometer-jetty12' include 'micrometer-osgi-test' include 'docs'