Skip to content

Commit dc225ab

Browse files
committed
Add HTTP response time metric
1 parent d0862d5 commit dc225ab

File tree

7 files changed

+504
-0
lines changed

7 files changed

+504
-0
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package org.opentripplanner.standalone.config.routerconfig;
2+
3+
import java.util.Set;
4+
5+
/**
6+
* Configuration for HTTP client request metrics.
7+
*
8+
* @param enabled whether client metrics are enabled
9+
* @param clientHeader the HTTP header name used to identify the client
10+
* @param knownClients the set of known client names to track individually
11+
*/
12+
public record ClientMetricsConfig(boolean enabled, String clientHeader, Set<String> knownClients) {
13+
public static final String DEFAULT_CLIENT_HEADER = "x-client-name";
14+
public static final ClientMetricsConfig DISABLED = new ClientMetricsConfig(
15+
false,
16+
DEFAULT_CLIENT_HEADER,
17+
Set.of()
18+
);
19+
20+
public ClientMetricsConfig {
21+
knownClients = Set.copyOf(knownClients);
22+
}
23+
}

application/src/main/java/org/opentripplanner/standalone/config/routerconfig/ServerConfig.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import java.time.Duration;
88
import java.util.List;
9+
import java.util.Set;
910
import org.opentripplanner.apis.support.graphql.injectdoc.ApiDocumentationProfile;
1011
import org.opentripplanner.framework.application.OtpAppException;
1112
import org.opentripplanner.standalone.config.framework.json.NodeAdapter;
@@ -17,6 +18,7 @@ public class ServerConfig implements OTPWebApplicationParameters {
1718
private final Duration apiProcessingTimeout;
1819
private final List<RequestTraceParameter> traceParameters;
1920
private final ApiDocumentationProfile apiDocumentationProfile;
21+
private final ClientMetricsConfig clientMetrics;
2022

2123
public ServerConfig(String parameterName, NodeAdapter root) {
2224
NodeAdapter c = root
@@ -106,6 +108,50 @@ messages across multiple (micro-)services from the same user. This is done by se
106108
.asBoolean(false)
107109
)
108110
);
111+
112+
var clientMetricsNode = c
113+
.of("clientMetrics")
114+
.since(V2_7)
115+
.summary("Configuration for HTTP client request metrics.")
116+
.description(
117+
"""
118+
When enabled, records response time metrics per client. The client is identified by a
119+
configurable HTTP header (`clientHeader`). Only clients in the `knownClients` list are
120+
tracked individually; unknown clients are grouped under "other" to prevent metric
121+
cardinality explosion. Requires the ActuatorAPI feature to be enabled.
122+
"""
123+
)
124+
.asObject();
125+
126+
boolean clientMetricsEnabled = clientMetricsNode
127+
.of("enabled")
128+
.since(V2_7)
129+
.summary("Enable client request metrics.")
130+
.asBoolean(false);
131+
132+
String clientHeader = clientMetricsNode
133+
.of("clientHeader")
134+
.since(V2_7)
135+
.summary("HTTP header name used to identify the client.")
136+
.asString(ClientMetricsConfig.DEFAULT_CLIENT_HEADER);
137+
138+
Set<String> knownClients = Set.copyOf(
139+
clientMetricsNode
140+
.of("knownClients")
141+
.since(V2_7)
142+
.summary("List of known client names to track individually.")
143+
.description(
144+
"""
145+
Clients not in this list will be grouped under "other". This prevents high cardinality
146+
metrics when unknown clients send requests.
147+
"""
148+
)
149+
.asStringList(List.of())
150+
);
151+
152+
this.clientMetrics = clientMetricsEnabled
153+
? new ClientMetricsConfig(true, clientHeader, knownClients)
154+
: ClientMetricsConfig.DISABLED;
109155
}
110156

111157
public Duration apiProcessingTimeout() {
@@ -121,6 +167,11 @@ public ApiDocumentationProfile apiDocumentationProfile() {
121167
return apiDocumentationProfile;
122168
}
123169

170+
@Override
171+
public ClientMetricsConfig clientMetrics() {
172+
return clientMetrics;
173+
}
174+
124175
public void validate(Duration streetRoutingTimeout) {
125176
if (
126177
!apiProcessingTimeout.isNegative() &&
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package org.opentripplanner.standalone.server;
2+
3+
import io.micrometer.core.instrument.MeterRegistry;
4+
import io.micrometer.core.instrument.Metrics;
5+
import io.micrometer.core.instrument.Timer;
6+
import jakarta.ws.rs.container.ContainerRequestContext;
7+
import jakarta.ws.rs.container.ContainerRequestFilter;
8+
import jakarta.ws.rs.container.ContainerResponseContext;
9+
import jakarta.ws.rs.container.ContainerResponseFilter;
10+
import java.time.Duration;
11+
import java.util.Locale;
12+
import java.util.Set;
13+
import java.util.concurrent.ConcurrentHashMap;
14+
import java.util.concurrent.TimeUnit;
15+
import java.util.stream.Collectors;
16+
17+
/**
18+
* A Jersey filter that records HTTP request response times with client identification.
19+
* <p>
20+
* The client is identified by a configurable HTTP header. Only known clients
21+
* (configured via {@code server.clientMetrics.knownClients}) are tracked individually;
22+
* unknown or missing client names are grouped under the "other" tag to prevent cardinality explosion.
23+
* <p>
24+
* The metric {@code http.client.requests} is recorded as a Timer with percentile histograms,
25+
* allowing analysis of response time distribution per client.
26+
*/
27+
public class ClientRequestMetricsFilter implements ContainerRequestFilter, ContainerResponseFilter {
28+
29+
static final String METRIC_NAME = "http_server_requests";
30+
private static final String START_TIME_PROPERTY = "metrics.startTime";
31+
private static final String OTHER_CLIENT = "other";
32+
33+
private static final ClientRequestMetricsFilter DISABLED = new ClientRequestMetricsFilter();
34+
35+
private final String clientHeader;
36+
private final Set<String> knownClients;
37+
private final MeterRegistry registry;
38+
private final boolean enabled;
39+
private final ConcurrentHashMap<TimerKey, Timer> timerCache;
40+
41+
private record TimerKey(String client, String uri) {}
42+
43+
/**
44+
* Creates a filter for recording client request metrics.
45+
*
46+
* @param clientHeader the HTTP header name used to identify the client
47+
* @param knownClients the set of known client names to track individually (case-insensitive)
48+
* @param registry the meter registry to record metrics to
49+
*/
50+
public ClientRequestMetricsFilter(
51+
String clientHeader,
52+
Set<String> knownClients,
53+
MeterRegistry registry
54+
) {
55+
this.clientHeader = clientHeader;
56+
this.knownClients = knownClients
57+
.stream()
58+
.map(s -> s.toLowerCase(Locale.ROOT))
59+
.collect(Collectors.toUnmodifiableSet());
60+
this.registry = registry;
61+
this.enabled = true;
62+
this.timerCache = new ConcurrentHashMap<>();
63+
}
64+
65+
/**
66+
* Creates a filter using the global meter registry.
67+
*
68+
* @param clientHeader the HTTP header name used to identify the client
69+
* @param knownClients the set of known client names to track individually
70+
*/
71+
public ClientRequestMetricsFilter(String clientHeader, Set<String> knownClients) {
72+
this(clientHeader, knownClients, Metrics.globalRegistry);
73+
}
74+
75+
/**
76+
* Private constructor for disabled filter.
77+
*/
78+
private ClientRequestMetricsFilter() {
79+
this.clientHeader = null;
80+
this.knownClients = Set.of();
81+
this.registry = null;
82+
this.enabled = false;
83+
this.timerCache = null;
84+
}
85+
86+
/**
87+
* Returns a disabled filter that does nothing.
88+
*/
89+
public static ClientRequestMetricsFilter disabled() {
90+
return DISABLED;
91+
}
92+
93+
@Override
94+
public void filter(ContainerRequestContext requestContext) {
95+
if (enabled) {
96+
requestContext.setProperty(START_TIME_PROPERTY, System.nanoTime());
97+
}
98+
}
99+
100+
@Override
101+
public void filter(
102+
ContainerRequestContext requestContext,
103+
ContainerResponseContext responseContext
104+
) {
105+
if (!enabled) {
106+
return;
107+
}
108+
109+
Long startTime = (Long) requestContext.getProperty(START_TIME_PROPERTY);
110+
if (startTime == null) {
111+
return;
112+
}
113+
114+
String clientName = requestContext.getHeaderString(clientHeader);
115+
String clientTag = resolveClientTag(clientName);
116+
String uri = requestContext.getUriInfo().getRequestUri().getPath();
117+
118+
long duration = System.nanoTime() - startTime;
119+
120+
Timer timer = getTimer(clientTag, uri);
121+
timer.record(duration, TimeUnit.NANOSECONDS);
122+
}
123+
124+
private Timer getTimer(String clientTag, String uri) {
125+
return timerCache.computeIfAbsent(new TimerKey(clientTag, uri), key ->
126+
Timer.builder(METRIC_NAME)
127+
.description("HTTP request response time by client")
128+
.tag("client", key.client())
129+
.tag("uri", key.uri())
130+
.publishPercentileHistogram()
131+
.minimumExpectedValue(Duration.ofMillis(100))
132+
.maximumExpectedValue(Duration.ofMillis(1000))
133+
.register(registry)
134+
);
135+
}
136+
137+
private String resolveClientTag(String clientName) {
138+
if (clientName != null) {
139+
String lowercaseName = clientName.toLowerCase(Locale.ROOT);
140+
if (knownClients.contains(lowercaseName)) {
141+
return lowercaseName;
142+
}
143+
}
144+
return OTHER_CLIENT;
145+
}
146+
}

application/src/main/java/org/opentripplanner/standalone/server/OTPWebApplication.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.opentripplanner.apis.APIEndpoints;
2121
import org.opentripplanner.framework.application.OTPFeature;
2222
import org.opentripplanner.standalone.api.OtpServerRequestContext;
23+
import org.opentripplanner.standalone.config.routerconfig.ClientMetricsConfig;
2324
import org.slf4j.bridge.SLF4JBridgeHandler;
2425

2526
/**
@@ -36,6 +37,7 @@ public class OTPWebApplication extends Application {
3637
private final Supplier<OtpServerRequestContext> contextProvider;
3738

3839
private final List<Class<? extends ContainerResponseFilter>> customFilters;
40+
private final ClientMetricsConfig clientMetricsConfig;
3941

4042
static {
4143
// Remove existing handlers attached to the j.u.l root logger
@@ -51,6 +53,7 @@ public OTPWebApplication(
5153
) {
5254
this.contextProvider = contextProvider;
5355
this.customFilters = createCustomFilters(parameters.traceParameters());
56+
this.clientMetricsConfig = parameters.clientMetrics();
5457
}
5558

5659
/**
@@ -108,6 +111,16 @@ public Set<Object> getSingletons() {
108111

109112
if (OTPFeature.ActuatorAPI.isOn()) {
110113
singletons.add(getBoundPrometheusRegistry());
114+
115+
// Add client request metrics filter if enabled
116+
if (clientMetricsConfig.enabled()) {
117+
singletons.add(
118+
new ClientRequestMetricsFilter(
119+
clientMetricsConfig.clientHeader(),
120+
clientMetricsConfig.knownClients()
121+
)
122+
);
123+
}
111124
}
112125

113126
return singletons;

application/src/main/java/org/opentripplanner/standalone/server/OTPWebApplicationParameters.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.opentripplanner.standalone.server;
22

33
import java.util.List;
4+
import org.opentripplanner.standalone.config.routerconfig.ClientMetricsConfig;
45

56
/**
67
* Parameters used to configure the {@link OTPWebApplication}.
@@ -14,4 +15,11 @@ public interface OTPWebApplicationParameters {
1415
default boolean requestTraceLoggingEnabled() {
1516
return traceParameters().stream().anyMatch(RequestTraceParameter::hasLogKey);
1617
}
18+
19+
/**
20+
* Configuration for client request metrics.
21+
*/
22+
default ClientMetricsConfig clientMetrics() {
23+
return ClientMetricsConfig.DISABLED;
24+
}
1725
}

0 commit comments

Comments
 (0)