Ether Framework
Unified API docs for Ether modules
Loading...
Searching...
No Matches
GlowrootJettyHandler.java
Go to the documentation of this file.
1package dev.rafex.ether.glowroot.jetty12;
2
3import java.util.Collections;
4import java.util.HashMap;
5import java.util.HashSet;
6import java.util.Map;
7import java.util.Set;
8import java.util.concurrent.TimeUnit;
9import java.util.function.Function;
10
11import org.eclipse.jetty.server.Handler;
12import org.eclipse.jetty.server.Request;
13import org.eclipse.jetty.server.Response;
14import org.eclipse.jetty.util.Callback;
15import org.glowroot.agent.api.Glowroot;
16
17/*-
18 * #%L
19 * ether-glowroot-jetty12
20 * %%
21 * Copyright (C) 2025 - 2026 Raúl Eduardo González Argote
22 * %%
23 * Permission is hereby granted, free of charge, to any person obtaining a copy
24 * of this software and associated documentation files (the "Software"), to deal
25 * in the Software without restriction, including without limitation the rights
26 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
27 * copies of the Software, and to permit persons to whom the Software is
28 * furnished to do so, subject to the following conditions:
29 *
30 * The above copyright notice and this permission notice shall be included in
31 * all copies or substantial portions of the Software.
32 *
33 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
34 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
35 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
36 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
37 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
38 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
39 * THE SOFTWARE.
40 * #L%
41 */
42
43import dev.rafex.ether.http.jetty12.JettyAuthHandler;
44import dev.rafex.ether.observability.core.request.RequestIdGenerator;
45
46/**
47 * Jetty-level {@link Handler.Wrapper} that provides comprehensive Glowroot APM
48 * instrumentation in a single handler, designed for use with
49 * {@link dev.rafex.ether.http.jetty12.JettyMiddleware}-based architectures.
50 *
51 * <p>
52 * Combines all the capabilities of the ether-level middleware suite into one
53 * Jetty handler, making it compatible with projects that use Jetty's
54 * {@code Handler.Wrapper} chain rather than the ether {@code HttpExchange}
55 * middleware model.
56 * </p>
57 *
58 * <h2>What it instruments</h2>
59 * <ul>
60 * <li><b>Transaction type/name</b> — {@code "Web"} +
61 * {@code "METHOD /normalized/path"}</li>
62 * <li><b>Response status</b> — {@code http.status} and
63 * {@code http.status_class}</li>
64 * <li><b>Authenticated user</b> — {@code Glowroot.setTransactionUser()} via
65 * configurable extractor</li>
66 * <li><b>Request ID</b> — from a configurable header (e.g.
67 * {@code X-Request-Id}), with optional UUID generation</li>
68 * <li><b>Health check suppression</b> — raises slow-threshold to max for probe
69 * paths</li>
70 * <li><b>Per-route slow thresholds</b> — different thresholds per normalized
71 * path</li>
72 * <li><b>Error attributes</b> — {@code error} and {@code error.message} on
73 * uncaught exceptions</li>
74 * </ul>
75 *
76 * <h2>Usage (Jetty middleware / Kiwi-style registration)</h2>
77 *
78 * <pre>{@code
79 * final var glowroot = GlowrootJettyHandler.builder().healthPath("/health").requestIdHeader("X-Request-Id")
80 * .defaultSlowThreshold(2_000).userExtractor(ctx -> ctx instanceof MyAuthContext a ? a.subject() : null)
81 * .build();
82 *
83 * middlewareRegistry.add(glowroot::wrap); // Kiwi
84 * etherMiddlewares.add(glowroot::wrap); // ether JettyMiddleware
85 * }</pre>
86 */
87public final class GlowrootJettyHandler extends Handler.Wrapper {
88
89 private final Set<String> healthPaths;
90 private final Map<String, Long> thresholdByNormalizedPath;
91 private final long defaultThresholdMs;
92 private final String requestIdHeader;
93 private final RequestIdGenerator requestIdGenerator;
94 private final Function<Object, String> userExtractor;
95
96 private GlowrootJettyHandler(final Handler next, final Builder builder) {
97 super(next);
98 healthPaths = Set.copyOf(builder.healthPaths);
99 thresholdByNormalizedPath = Map.copyOf(builder.thresholds);
100 defaultThresholdMs = builder.defaultThresholdMs;
101 requestIdHeader = builder.requestIdHeader;
102 requestIdGenerator = builder.requestIdGenerator;
103 userExtractor = builder.userExtractor;
104 }
105
106 /** Returns a new {@link Builder}. */
107 public static Builder builder() {
108 return new Builder();
109 }
110
111 @Override
112 public boolean handle(final Request request, final Response response, final Callback callback) throws Exception {
113 final var method = request.getMethod();
114 final var path = request.getHttpURI() != null ? request.getHttpURI().getPath() : null;
115 final var normalized = PathNormalizer.normalize(path);
116
117 // ── 1. Transaction identity ──────────────────────────────────────────
118 try {
119 Glowroot.setTransactionType("Web");
120 Glowroot.setTransactionName(method + " " + normalized);
121 Glowroot.addTransactionAttribute("http.method", method);
122 Glowroot.addTransactionAttribute("http.path", path == null ? "unknown" : path);
123 Glowroot.addTransactionAttribute("http.normalized_path", normalized);
124 } catch (final Throwable ignore) {
125 }
126
127 // ── 2. Slow-threshold (health suppression or per-route) ──────────────
128 if (path != null && healthPaths.contains(path)) {
129 try {
130 Glowroot.setTransactionSlowThreshold(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
131 } catch (final Throwable ignore) {
132 }
133 } else {
134 final var threshold = thresholdByNormalizedPath.getOrDefault(normalized, defaultThresholdMs);
135 if (threshold > 0) {
136 try {
137 Glowroot.setTransactionSlowThreshold(threshold, TimeUnit.MILLISECONDS);
138 } catch (final Throwable ignore) {
139 }
140 }
141 }
142
143 // ── 3. Request ID ────────────────────────────────────────────────────
144 if (requestIdHeader != null) {
145 try {
146 var reqId = request.getHeaders().get(requestIdHeader);
147 if ((reqId == null || reqId.isBlank()) && requestIdGenerator != null) {
148 reqId = requestIdGenerator.nextId();
149 }
150 if (reqId != null && !reqId.isBlank()) {
151 Glowroot.addTransactionAttribute("request.id", reqId);
152 }
153 } catch (final Throwable ignore) {
154 }
155 }
156
157 // ── 4. Delegate to next handler ──────────────────────────────────────
158 try {
159 final var result = super.handle(request, response, callback);
160
161 // ── 5. Response status (available after synchronous handler) ─────
162 try {
163 final var status = response.getStatus();
164 if (status > 0) {
165 Glowroot.addTransactionAttribute("http.status", String.valueOf(status));
166 Glowroot.addTransactionAttribute("http.status_class", status / 100 + "xx");
167 }
168 } catch (final Throwable ignore) {
169 }
170
171 // ── 6. Authenticated user (set by JettyAuthHandler inside chain) ─
172 if (userExtractor != null) {
173 try {
174 final var ctx = request.getAttribute(JettyAuthHandler.REQ_ATTR_AUTH);
175 if (ctx != null) {
176 final var user = userExtractor.apply(ctx);
177 if (user != null && !user.isBlank()) {
178 Glowroot.setTransactionUser(user);
179 Glowroot.addTransactionAttribute("auth.user", user);
180 }
181 }
182 } catch (final Throwable ignore) {
183 }
184 }
185
186 return result;
187
188 } catch (final Throwable t) {
189 // ── 7. Uncaught exception attributes ─────────────────────────────
190 try {
191 Glowroot.addTransactionAttribute("error", t.getClass().getName());
192 Glowroot.addTransactionAttribute("error.message", t.getMessage() == null ? "" : t.getMessage());
193 } catch (final Throwable ignore) {
194 }
195 throw t;
196 }
197 }
198
199 /* ── Builder ─────────────────────────────────────────────────────────── */
200
201 public static final class Builder {
202
203 private final Set<String> healthPaths = new HashSet<>();
204 private final Map<String, Long> thresholds = new HashMap<>();
205 private long defaultThresholdMs = 2_000L;
206 private String requestIdHeader = null;
207 private RequestIdGenerator requestIdGenerator = null;
208 private Function<Object, String> userExtractor = null;
209
210 private Builder() {
211 }
212
213 /**
214 * Adds a path that should never appear in Glowroot's slow-transaction list
215 * (e.g. Kubernetes liveness/readiness probes).
216 */
217 public Builder healthPath(final String path) {
218 healthPaths.add(path);
219 return this;
220 }
221
222 /** Adds multiple health-check paths at once. */
223 public Builder healthPaths(final String... paths) {
224 Collections.addAll(healthPaths, paths);
225 return this;
226 }
227
228 /**
229 * Registers a custom slow-threshold (in ms) for a specific normalized path. The
230 * path should use placeholders: {@code "/api/export/:id"}.
231 */
232 public Builder slowThreshold(final String normalizedPath, final long thresholdMs) {
233 thresholds.put(normalizedPath, thresholdMs);
234 return this;
235 }
236
237 /**
238 * Sets the default slow-threshold (ms) used for paths with no specific entry.
239 * Defaults to {@code 2 000} ms.
240 */
241 public Builder defaultSlowThreshold(final long thresholdMs) {
242 defaultThresholdMs = thresholdMs;
243 return this;
244 }
245
246 /**
247 * Enables request-ID capture from the given header name.
248 *
249 * @param header header to read (e.g. {@code "X-Request-Id"})
250 */
251 public Builder requestIdHeader(final String header) {
252 requestIdHeader = header;
253 requestIdGenerator = null;
254 return this;
255 }
256
257 /**
258 * Enables request-ID capture from the given header name, with automatic UUID
259 * generation when the header is absent.
260 */
261 public Builder requestIdHeader(final String header, final boolean generateIfAbsent) {
262 requestIdHeader = header;
263 requestIdGenerator = generateIfAbsent ? new GlowrootRequestIdGenerator() : null;
264 return this;
265 }
266
267 /**
268 * Enables request-ID capture from the given header and delegates generation to
269 * a {@link RequestIdGenerator} when the header is absent.
270 */
271 public Builder requestIdHeader(final String header, final RequestIdGenerator requestIdGenerator) {
272 requestIdHeader = header;
273 this.requestIdGenerator = requestIdGenerator;
274 return this;
275 }
276
277 /**
278 * Sets the function used to extract the transaction user from the auth-context
279 * object stored in {@link JettyAuthHandler#REQ_ATTR_AUTH}.
280 *
281 * <p>
282 * The function receives the raw {@code Object} that was passed to
283 * {@link dev.rafex.ether.http.jetty12.TokenVerificationResult#ok(Object)} and
284 * should return the user identifier string, or {@code null} to skip user
285 * recording.
286 * </p>
287 *
288 * <p>
289 * Example for a custom {@code AuthContext} record:
290 * </p>
291 *
292 * <pre>{@code
293 * .userExtractor(ctx -> ctx instanceof MyAuthContext a ? a.subject() : null)
294 * }</pre>
295 */
296 public Builder userExtractor(final Function<Object, String> extractor) {
297 userExtractor = extractor;
298 return this;
299 }
300
301 /**
302 * Creates a {@link GlowrootJettyHandler} wrapping {@code next}.
303 *
304 * <p>
305 * This method is designed to be used as a method reference, matching both the
306 * {@link dev.rafex.ether.http.jetty12.JettyMiddleware} and any Kiwi-style
307 * {@code Middleware} functional interfaces:
308 * </p>
309 *
310 * <pre>{@code
311 * middlewareRegistry.add(glowrootBuilder::wrap);
312 * }</pre>
313 */
314 public GlowrootJettyHandler wrap(final Handler next) {
315 return new GlowrootJettyHandler(next, this);
316 }
317 }
318}
boolean handle(final Request request, final Response response, final Callback callback)