001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet.otel;
018
019import com.soklet.ConnectionRejectionReason;
020import com.soklet.MarshaledResponse;
021import com.soklet.McpEndpoint;
022import com.soklet.McpSessionTerminationReason;
023import com.soklet.MetricsCollector;
024import com.soklet.Request;
025import com.soklet.RequestReadFailureReason;
026import com.soklet.RequestRejectionReason;
027import com.soklet.ResourceMethod;
028import com.soklet.ResourcePathDeclaration;
029import com.soklet.ServerType;
030import com.soklet.SseComment;
031import com.soklet.SseConnection;
032import com.soklet.SseEvent;
033import com.soklet.StreamTermination;
034import io.opentelemetry.api.GlobalOpenTelemetry;
035import io.opentelemetry.api.OpenTelemetry;
036import io.opentelemetry.api.common.AttributeKey;
037import io.opentelemetry.api.common.Attributes;
038import io.opentelemetry.api.metrics.DoubleHistogram;
039import io.opentelemetry.api.metrics.LongCounter;
040import io.opentelemetry.api.metrics.LongHistogram;
041import io.opentelemetry.api.metrics.LongUpDownCounter;
042import io.opentelemetry.api.metrics.Meter;
043import io.opentelemetry.api.metrics.MeterBuilder;
044import org.jspecify.annotations.NonNull;
045import org.jspecify.annotations.Nullable;
046
047import javax.annotation.concurrent.NotThreadSafe;
048import javax.annotation.concurrent.ThreadSafe;
049import java.net.InetSocketAddress;
050import java.time.Duration;
051import java.util.List;
052import java.util.Locale;
053
054import static java.util.Objects.requireNonNull;
055
056/**
057 * OpenTelemetry-backed {@link MetricsCollector} for Soklet HTTP, SSE, and MCP telemetry.
058 * <p>
059 * This implementation records counters/histograms via OpenTelemetry's metrics API and is designed to be
060 * lightweight, thread-safe, and non-blocking in request hot paths.
061 * <p>
062 * By default, standard HTTP metrics use OpenTelemetry Semantic Convention names. Soklet-specific concepts
063 * (for example SSE queue/drop/broadcast details) are emitted with {@code soklet.*} names.
064 * For request-body size, the default semantic-convention strategy records the encoded payload size as
065 * transferred, while the {@link MetricNamingStrategy#SOKLET} strategy records the handler-visible body size.
066 * If an oversized request is rejected before its complete encoded payload size is known, the
067 * semantic-convention body-size sample is omitted instead of recording an inaccurate zero.
068 * Response-body size is based on the finalized {@link MarshaledResponse}; if the HTTP transport applies
069 * dynamic gzip afterward, that metric remains the pre-compression size.
070 * <p>
071 * If inbound requests include W3C trace context, Soklet exposes it via {@link Request#getTraceContext()} to
072 * custom metrics collectors and application code. This metrics-only implementation intentionally does not emit
073 * trace IDs, parent IDs, or {@code tracestate} values as metric attributes because those values are high-cardinality
074 * and belong in logs, spans, or exemplar-aware tracing integrations instead.
075 * <p>
076 * See <a href="https://soklet.com/docs/metrics-collection">https://soklet.com/docs/metrics-collection</a> for Soklet's metrics/telemetry documentation.
077 *
078 * @author <a href="https://www.revetkn.com">Mark Allen</a>
079 */
080@ThreadSafe
081public final class OpenTelemetryMetricsCollector implements MetricsCollector {
082        @NonNull
083        private static final String UNMATCHED_ROUTE;
084        @NonNull
085        private static final String UNKNOWN_COMMENT_TYPE;
086        @NonNull
087        private static final String BROADCAST_PAYLOAD_EVENT;
088        @NonNull
089        private static final String BROADCAST_PAYLOAD_COMMENT;
090        @NonNull
091        private static final String DEFAULT_INSTRUMENTATION_NAME;
092        @NonNull
093        private static final String URL_SCHEME_HTTP;
094
095        @NonNull
096        private static final AttributeKey<String> SERVER_TYPE_ATTRIBUTE_KEY;
097        @NonNull
098        private static final AttributeKey<String> FAILURE_REASON_ATTRIBUTE_KEY;
099        @NonNull
100        private static final AttributeKey<String> ERROR_TYPE_ATTRIBUTE_KEY;
101        @NonNull
102        private static final AttributeKey<String> HTTP_METHOD_ATTRIBUTE_KEY;
103        @NonNull
104        private static final AttributeKey<String> HTTP_ROUTE_ATTRIBUTE_KEY;
105        @NonNull
106        private static final AttributeKey<String> URL_SCHEME_ATTRIBUTE_KEY;
107        @NonNull
108        private static final AttributeKey<Long> HTTP_STATUS_CODE_ATTRIBUTE_KEY;
109        @NonNull
110        private static final AttributeKey<String> SSE_TERMINATION_REASON_ATTRIBUTE_KEY;
111        @NonNull
112        private static final AttributeKey<String> SSE_DROP_REASON_ATTRIBUTE_KEY;
113        @NonNull
114        private static final AttributeKey<String> SSE_COMMENT_TYPE_ATTRIBUTE_KEY;
115        @NonNull
116        private static final AttributeKey<String> SSE_BROADCAST_PAYLOAD_TYPE_ATTRIBUTE_KEY;
117        @NonNull
118        private static final AttributeKey<String> MCP_ENDPOINT_CLASS_ATTRIBUTE_KEY;
119        @NonNull
120        private static final AttributeKey<String> MCP_SESSION_TERMINATION_REASON_ATTRIBUTE_KEY;
121        @NonNull
122        private static final List<Double> LONG_LIVED_DURATION_BUCKET_BOUNDARIES;
123
124        static {
125                UNMATCHED_ROUTE = "_unmatched";
126                UNKNOWN_COMMENT_TYPE = "unknown";
127                BROADCAST_PAYLOAD_EVENT = "event";
128                BROADCAST_PAYLOAD_COMMENT = "comment";
129                DEFAULT_INSTRUMENTATION_NAME = "com.soklet.otel";
130                URL_SCHEME_HTTP = "http";
131
132                SERVER_TYPE_ATTRIBUTE_KEY = AttributeKey.stringKey("soklet.server.type");
133                FAILURE_REASON_ATTRIBUTE_KEY = AttributeKey.stringKey("soklet.failure.reason");
134                ERROR_TYPE_ATTRIBUTE_KEY = AttributeKey.stringKey("error.type");
135                HTTP_METHOD_ATTRIBUTE_KEY = AttributeKey.stringKey("http.request.method");
136                HTTP_ROUTE_ATTRIBUTE_KEY = AttributeKey.stringKey("http.route");
137                URL_SCHEME_ATTRIBUTE_KEY = AttributeKey.stringKey("url.scheme");
138                HTTP_STATUS_CODE_ATTRIBUTE_KEY = AttributeKey.longKey("http.response.status_code");
139                SSE_TERMINATION_REASON_ATTRIBUTE_KEY = AttributeKey.stringKey("soklet.sse.termination.reason");
140                SSE_DROP_REASON_ATTRIBUTE_KEY = AttributeKey.stringKey("soklet.sse.drop.reason");
141                SSE_COMMENT_TYPE_ATTRIBUTE_KEY = AttributeKey.stringKey("soklet.sse.comment.type");
142                SSE_BROADCAST_PAYLOAD_TYPE_ATTRIBUTE_KEY = AttributeKey.stringKey("soklet.sse.broadcast.payload.type");
143                MCP_ENDPOINT_CLASS_ATTRIBUTE_KEY = AttributeKey.stringKey("soklet.mcp.endpoint.class");
144                MCP_SESSION_TERMINATION_REASON_ATTRIBUTE_KEY = AttributeKey.stringKey("soklet.mcp.session.termination.reason");
145                // SSE streams and MCP sessions live for minutes-to-hours (MCP's default idle timeout is 24 hours),
146                // so OpenTelemetry's request-oriented default buckets (which top out at 10 seconds) would collapse
147                // nearly all measurements into the +Inf bucket. Advise boundaries suited to those lifetimes instead.
148                LONG_LIVED_DURATION_BUCKET_BOUNDARIES = List.of(1D, 10D, 60D, 300D, 1_800D, 3_600D, 14_400D, 86_400D);
149        }
150
151        @NonNull
152        private final LongCounter connectionsAcceptedCounter;
153        @NonNull
154        private final LongCounter connectionsRejectedCounter;
155        @NonNull
156        private final LongCounter requestsAcceptedCounter;
157        @NonNull
158        private final LongCounter requestsRejectedCounter;
159        @NonNull
160        private final LongCounter requestReadFailureCounter;
161        @NonNull
162        private final LongCounter transportFailureCounter;
163        @NonNull
164        private final LongUpDownCounter activeRequestsCounter;
165        @NonNull
166        private final DoubleHistogram requestDurationHistogram;
167        @NonNull
168        private final DoubleHistogram responseWriteDurationHistogram;
169        @NonNull
170        private final LongCounter responseWriteFailureCounter;
171        @NonNull
172        private final LongCounter requestThrowableCounter;
173        @NonNull
174        private final LongHistogram requestBodySizeHistogram;
175        @NonNull
176        private final LongHistogram responseBodySizeHistogram;
177
178        @NonNull
179        private final LongUpDownCounter activeServerSentEventStreamsCounter;
180        @NonNull
181        private final LongCounter serverSentEventStreamsEstablishedCounter;
182        @NonNull
183        private final LongCounter serverSentEventHandshakeFailureCounter;
184        @NonNull
185        private final LongCounter serverSentEventStreamsTerminatedCounter;
186        @NonNull
187        private final DoubleHistogram serverSentEventStreamDurationHistogram;
188        @NonNull
189        private final LongCounter serverSentEventWrittenCounter;
190        @NonNull
191        private final LongCounter serverSentEventWriteFailureCounter;
192        @NonNull
193        private final DoubleHistogram serverSentEventWriteDurationHistogram;
194        @NonNull
195        private final DoubleHistogram serverSentEventDeliveryLagHistogram;
196        @NonNull
197        private final LongHistogram serverSentEventPayloadSizeHistogram;
198        @NonNull
199        private final LongHistogram serverSentEventQueueDepthHistogram;
200        @NonNull
201        private final LongCounter serverSentEventDropCounter;
202
203        @NonNull
204        private final LongCounter serverSentEventCommentWrittenCounter;
205        @NonNull
206        private final LongCounter serverSentEventCommentWriteFailureCounter;
207        @NonNull
208        private final DoubleHistogram serverSentEventCommentWriteDurationHistogram;
209        @NonNull
210        private final DoubleHistogram serverSentEventCommentDeliveryLagHistogram;
211        @NonNull
212        private final LongHistogram serverSentEventCommentPayloadSizeHistogram;
213        @NonNull
214        private final LongHistogram serverSentEventCommentQueueDepthHistogram;
215        @NonNull
216        private final LongCounter serverSentEventCommentDropCounter;
217
218        @NonNull
219        private final LongCounter serverSentEventBroadcastAttemptCounter;
220        @NonNull
221        private final LongCounter serverSentEventBroadcastEnqueuedCounter;
222        @NonNull
223        private final LongCounter serverSentEventBroadcastDroppedCounter;
224
225        @NonNull
226        private final LongUpDownCounter activeMcpSessionsCounter;
227        @NonNull
228        private final LongCounter mcpSessionsCreatedCounter;
229        @NonNull
230        private final LongCounter mcpSessionsTerminatedCounter;
231        @NonNull
232        private final DoubleHistogram mcpSessionDurationHistogram;
233
234        @NonNull
235        private final MetricNamingStrategy metricNamingStrategy;
236
237        /**
238         * Acquires a builder for {@link OpenTelemetryMetricsCollector} instances, using {@link GlobalOpenTelemetry}
239         * by default.
240         *
241         * @return the builder
242         */
243        @NonNull
244        public static Builder builder() {
245                return new Builder();
246        }
247
248        /**
249         * Acquires a builder seeded with a required {@link Meter}.
250         *
251         * @param meter the meter used to build instruments
252         * @return the builder
253         */
254        @NonNull
255        public static Builder withMeter(@NonNull Meter meter) {
256                requireNonNull(meter);
257                return builder().meter(meter);
258        }
259
260        /**
261         * Acquires a builder seeded with a required {@link OpenTelemetry} instance.
262         *
263         * @param openTelemetry the OpenTelemetry instance used to build a meter
264         * @return the builder
265         */
266        @NonNull
267        public static Builder withOpenTelemetry(@NonNull OpenTelemetry openTelemetry) {
268                requireNonNull(openTelemetry);
269                return builder().openTelemetry(openTelemetry);
270        }
271
272        /**
273         * Creates an instance from a required {@link Meter} without additional customization.
274         *
275         * @param meter the meter used to build instruments
276         * @return an {@link OpenTelemetryMetricsCollector} instance
277         */
278        @NonNull
279        public static OpenTelemetryMetricsCollector fromMeter(@NonNull Meter meter) {
280                return withMeter(meter).build();
281        }
282
283        /**
284         * Creates an instance from a required {@link OpenTelemetry} without additional customization.
285         *
286         * @param openTelemetry the OpenTelemetry instance used to build a meter
287         * @return an {@link OpenTelemetryMetricsCollector} instance
288         */
289        @NonNull
290        public static OpenTelemetryMetricsCollector fromOpenTelemetry(@NonNull OpenTelemetry openTelemetry) {
291                return withOpenTelemetry(openTelemetry).build();
292        }
293
294        private OpenTelemetryMetricsCollector(@NonNull Builder builder) {
295                requireNonNull(builder);
296                Meter meter = requireNonNull(builder.resolveMeter());
297                this.metricNamingStrategy = requireNonNull(builder.metricNamingStrategy);
298
299                String activeRequestsMetricName = activeRequestsMetricNameFor(this.metricNamingStrategy);
300                String requestDurationMetricName = requestDurationMetricNameFor(this.metricNamingStrategy);
301                String requestBodySizeMetricName = requestBodySizeMetricNameFor(this.metricNamingStrategy);
302                String requestBodySizeDescription = requestBodySizeDescriptionFor(this.metricNamingStrategy);
303                String responseBodySizeMetricName = responseBodySizeMetricNameFor(this.metricNamingStrategy);
304
305                this.connectionsAcceptedCounter = meter.counterBuilder("soklet.server.connections.accepted")
306                                .setDescription("Total number of accepted inbound TCP connections.")
307                                .setUnit("{connection}")
308                                .build();
309                this.connectionsRejectedCounter = meter.counterBuilder("soklet.server.connections.rejected")
310                                .setDescription("Total number of rejected inbound TCP connections.")
311                                .setUnit("{connection}")
312                                .build();
313                this.requestsAcceptedCounter = meter.counterBuilder("soklet.server.requests.accepted")
314                                .setDescription("Total number of accepted requests before app-level handling.")
315                                .setUnit("{request}")
316                                .build();
317                this.requestsRejectedCounter = meter.counterBuilder("soklet.server.requests.rejected")
318                                .setDescription("Total number of rejected requests before app-level handling.")
319                                .setUnit("{request}")
320                                .build();
321                this.requestReadFailureCounter = meter.counterBuilder("soklet.server.request.read.failures")
322                                .setDescription("Total number of request read/parse failures.")
323                                .setUnit("{request}")
324                                .build();
325                this.transportFailureCounter = meter.counterBuilder("soklet.server.transport.failures")
326                                .setDescription("Total number of low-level transport failures.")
327                                .setUnit("{failure}")
328                                .build();
329                this.activeRequestsCounter = meter.upDownCounterBuilder(activeRequestsMetricName)
330                                .setDescription("Number of in-flight requests currently being handled.")
331                                .setUnit("{request}")
332                                .build();
333                this.requestDurationHistogram = meter.histogramBuilder(requestDurationMetricName)
334                                .setDescription("Total request handling duration.")
335                                .setUnit("s")
336                                .build();
337                this.responseWriteDurationHistogram = meter.histogramBuilder("soklet.server.response.write.duration")
338                                .setDescription("Duration spent writing response bytes.")
339                                .setUnit("s")
340                                .build();
341                this.responseWriteFailureCounter = meter.counterBuilder("soklet.server.response.write.failures")
342                                .setDescription("Total number of response write failures.")
343                                .setUnit("{response}")
344                                .build();
345                this.requestThrowableCounter = meter.counterBuilder("soklet.server.request.throwables")
346                                .setDescription("Total number of throwables observed during request handling.")
347                                .setUnit("{throwable}")
348                                .build();
349                this.requestBodySizeHistogram = meter.histogramBuilder(requestBodySizeMetricName)
350                                .ofLongs()
351                                .setDescription(requestBodySizeDescription)
352                                .setUnit("By")
353                                .build();
354                this.responseBodySizeHistogram = meter.histogramBuilder(responseBodySizeMetricName)
355                                .ofLongs()
356                                .setDescription("Response body size in bytes.")
357                                .setUnit("By")
358                                .build();
359
360                this.activeServerSentEventStreamsCounter = meter.upDownCounterBuilder("soklet.sse.streams.active")
361                                .setDescription("Number of active SSE streams.")
362                                .setUnit("{stream}")
363                                .build();
364                this.serverSentEventStreamsEstablishedCounter = meter.counterBuilder("soklet.sse.streams.established")
365                                .setDescription("Total number of SSE streams established.")
366                                .setUnit("{stream}")
367                                .build();
368                this.serverSentEventHandshakeFailureCounter = meter.counterBuilder("soklet.sse.handshakes.rejected")
369                                .setDescription("Total number of rejected SSE handshakes.")
370                                .setUnit("{handshake}")
371                                .build();
372                this.serverSentEventStreamsTerminatedCounter = meter.counterBuilder("soklet.sse.streams.terminated")
373                                .setDescription("Total number of terminated SSE streams.")
374                                .setUnit("{stream}")
375                                .build();
376                this.serverSentEventStreamDurationHistogram = meter.histogramBuilder("soklet.sse.stream.duration")
377                                .setDescription("SSE stream duration.")
378                                .setUnit("s")
379                                .setExplicitBucketBoundariesAdvice(LONG_LIVED_DURATION_BUCKET_BOUNDARIES)
380                                .build();
381                this.serverSentEventWrittenCounter = meter.counterBuilder("soklet.sse.events.written")
382                                .setDescription("Total number of SSE events successfully written.")
383                                .setUnit("{event}")
384                                .build();
385                this.serverSentEventWriteFailureCounter = meter.counterBuilder("soklet.sse.events.write.failures")
386                                .setDescription("Total number of SSE events that failed to write.")
387                                .setUnit("{event}")
388                                .build();
389                this.serverSentEventWriteDurationHistogram = meter.histogramBuilder("soklet.sse.events.write.duration")
390                                .setDescription("SSE event write duration.")
391                                .setUnit("s")
392                                .build();
393                this.serverSentEventDeliveryLagHistogram = meter.histogramBuilder("soklet.sse.events.delivery.lag")
394                                .setDescription("Time spent waiting in the SSE queue before write.")
395                                .setUnit("s")
396                                .build();
397                this.serverSentEventPayloadSizeHistogram = meter.histogramBuilder("soklet.sse.events.payload.size")
398                                .ofLongs()
399                                .setDescription("Serialized SSE event payload size in bytes.")
400                                .setUnit("By")
401                                .build();
402                this.serverSentEventQueueDepthHistogram = meter.histogramBuilder("soklet.sse.events.queue.depth")
403                                .ofLongs()
404                                .setDescription("Queued element depth when SSE event write/drop outcome is observed.")
405                                .setUnit("{item}")
406                                .build();
407                this.serverSentEventDropCounter = meter.counterBuilder("soklet.sse.events.dropped")
408                                .setDescription("Total number of SSE events dropped before enqueue.")
409                                .setUnit("{event}")
410                                .build();
411
412                this.serverSentEventCommentWrittenCounter = meter.counterBuilder("soklet.sse.comments.written")
413                                .setDescription("Total number of SSE comments successfully written.")
414                                .setUnit("{comment}")
415                                .build();
416                this.serverSentEventCommentWriteFailureCounter = meter.counterBuilder("soklet.sse.comments.write.failures")
417                                .setDescription("Total number of SSE comments that failed to write.")
418                                .setUnit("{comment}")
419                                .build();
420                this.serverSentEventCommentWriteDurationHistogram = meter.histogramBuilder("soklet.sse.comments.write.duration")
421                                .setDescription("SSE comment write duration.")
422                                .setUnit("s")
423                                .build();
424                this.serverSentEventCommentDeliveryLagHistogram = meter.histogramBuilder("soklet.sse.comments.delivery.lag")
425                                .setDescription("Time spent waiting in the SSE queue before comment write.")
426                                .setUnit("s")
427                                .build();
428                this.serverSentEventCommentPayloadSizeHistogram = meter.histogramBuilder("soklet.sse.comments.payload.size")
429                                .ofLongs()
430                                .setDescription("Serialized SSE comment payload size in bytes.")
431                                .setUnit("By")
432                                .build();
433                this.serverSentEventCommentQueueDepthHistogram = meter.histogramBuilder("soklet.sse.comments.queue.depth")
434                                .ofLongs()
435                                .setDescription("Queued element depth when SSE comment write/drop outcome is observed.")
436                                .setUnit("{item}")
437                                .build();
438                this.serverSentEventCommentDropCounter = meter.counterBuilder("soklet.sse.comments.dropped")
439                                .setDescription("Total number of SSE comments dropped before enqueue.")
440                                .setUnit("{comment}")
441                                .build();
442
443                this.serverSentEventBroadcastAttemptCounter = meter.counterBuilder("soklet.sse.broadcast.attempted")
444                                .setDescription("Total number of attempted SSE broadcast deliveries.")
445                                .setUnit("{delivery}")
446                                .build();
447                this.serverSentEventBroadcastEnqueuedCounter = meter.counterBuilder("soklet.sse.broadcast.enqueued")
448                                .setDescription("Total number of SSE broadcast deliveries successfully enqueued.")
449                                .setUnit("{delivery}")
450                                .build();
451                this.serverSentEventBroadcastDroppedCounter = meter.counterBuilder("soklet.sse.broadcast.dropped")
452                                .setDescription("Total number of SSE broadcast deliveries dropped before enqueue.")
453                                .setUnit("{delivery}")
454                                .build();
455
456                this.activeMcpSessionsCounter = meter.upDownCounterBuilder("soklet.mcp.sessions.active")
457                                .setDescription("Number of active MCP sessions.")
458                                .setUnit("{session}")
459                                .build();
460                this.mcpSessionsCreatedCounter = meter.counterBuilder("soklet.mcp.sessions.created")
461                                .setDescription("Total number of MCP sessions created.")
462                                .setUnit("{session}")
463                                .build();
464                this.mcpSessionsTerminatedCounter = meter.counterBuilder("soklet.mcp.sessions.terminated")
465                                .setDescription("Total number of MCP sessions terminated.")
466                                .setUnit("{session}")
467                                .build();
468                this.mcpSessionDurationHistogram = meter.histogramBuilder("soklet.mcp.session.duration")
469                                .setDescription("MCP session lifetime from creation to termination.")
470                                .setUnit("s")
471                                .setExplicitBucketBoundariesAdvice(LONG_LIVED_DURATION_BUCKET_BOUNDARIES)
472                                .build();
473        }
474
475        @Override
476        public void didAcceptConnection(@NonNull ServerType serverType,
477                                                                                                                                        @Nullable InetSocketAddress remoteAddress) {
478                requireNonNull(serverType);
479                this.connectionsAcceptedCounter.add(1, serverTypeAttributes(serverType));
480        }
481
482        @Override
483        public void didFailToAcceptConnection(@NonNull ServerType serverType,
484                                                                                                                                                                @Nullable InetSocketAddress remoteAddress,
485                                                                                                                                                                @NonNull ConnectionRejectionReason reason,
486                                                                                                                                                                @Nullable Throwable throwable) {
487                requireNonNull(serverType);
488                requireNonNull(reason);
489                this.connectionsRejectedCounter.add(1, serverTypeAndReasonAttributes(serverType, reason));
490        }
491
492        @Override
493        public void didAcceptRequest(@NonNull ServerType serverType,
494                                                                                                                         @Nullable InetSocketAddress remoteAddress,
495                                                                                                                         @Nullable String requestTarget) {
496                requireNonNull(serverType);
497                this.requestsAcceptedCounter.add(1, serverTypeAttributes(serverType));
498        }
499
500        @Override
501        public void didFailToAcceptRequest(@NonNull ServerType serverType,
502                                                                                                                                                 @Nullable InetSocketAddress remoteAddress,
503                                                                                                                                                 @Nullable String requestTarget,
504                                                                                                                                                 @NonNull RequestRejectionReason reason,
505                                                                                                                                                 @Nullable Throwable throwable) {
506                requireNonNull(serverType);
507                requireNonNull(reason);
508                this.requestsRejectedCounter.add(1, serverTypeAndReasonAttributes(serverType, reason));
509        }
510
511        @Override
512        public void didFailToReadRequest(@NonNull ServerType serverType,
513                                                                                                                                         @Nullable InetSocketAddress remoteAddress,
514                                                                                                                                         @Nullable String requestTarget,
515                                                                                                                                         @NonNull RequestReadFailureReason reason,
516                                                                                                                                         @Nullable Throwable throwable) {
517                requireNonNull(serverType);
518                requireNonNull(reason);
519                this.requestReadFailureCounter.add(1, serverTypeAndReasonAttributes(serverType, reason));
520        }
521
522        @Override
523        public void didRecordTransportFailure(@NonNull ServerType serverType,
524                                                                                                                                                                @NonNull TransportFailureReason reason,
525                                                                                                                                                                @Nullable Throwable throwable) {
526                requireNonNull(serverType);
527                requireNonNull(reason);
528                this.transportFailureCounter.add(1, transportFailureAttributes(serverType, reason, throwable));
529        }
530
531        @Override
532        public void didStartRequestHandling(@NonNull ServerType serverType,
533                                                                                                                                                        @NonNull Request request,
534                                                                                                                                                        @Nullable ResourceMethod resourceMethod) {
535                requireNonNull(serverType);
536                requireNonNull(request);
537
538                this.activeRequestsCounter.add(1, activeRequestAttributes(serverType, request));
539        }
540
541        @Override
542        public void didFinishRequestHandling(@NonNull ServerType serverType,
543                                                                                                                                                         @NonNull Request request,
544                                                                                                                                                         @Nullable ResourceMethod resourceMethod,
545                                                                                                                                                         @NonNull MarshaledResponse marshaledResponse,
546                                                                                                                                                         @NonNull Duration duration,
547                                                                                                                                                         @NonNull List<@NonNull Throwable> throwables) {
548                requireNonNull(serverType);
549                requireNonNull(request);
550                requireNonNull(marshaledResponse);
551                requireNonNull(duration);
552                requireNonNull(throwables);
553
554                Throwable throwable = throwables.isEmpty() ? null : throwables.get(0);
555                Attributes attributes = requestAttributes(serverType, request, resourceMethod, marshaledResponse.getStatusCode(), throwable);
556
557                this.activeRequestsCounter.add(-1, activeRequestAttributes(serverType, request));
558                this.requestDurationHistogram.record(seconds(duration), attributes);
559                long requestBodySizeInBytes = this.metricNamingStrategy == MetricNamingStrategy.SEMCONV
560                                ? request.getEncodedBodySizeInBytes().longValue()
561                                : request.getBody().map(body -> (long) body.length).orElse(0L);
562
563                if (this.metricNamingStrategy != MetricNamingStrategy.SEMCONV
564                                || !request.isContentTooLarge()
565                                || requestBodySizeInBytes > 0)
566                        this.requestBodySizeHistogram.record(requestBodySizeInBytes, attributes);
567                this.responseBodySizeHistogram.record(marshaledResponse.getBodyLength(), attributes);
568
569                if (!throwables.isEmpty())
570                        this.requestThrowableCounter.add(throwables.size(), attributes);
571        }
572
573        @Override
574        public void didWriteResponse(@NonNull ServerType serverType,
575                                                                                                                         @NonNull Request request,
576                                                                                                                         @Nullable ResourceMethod resourceMethod,
577                                                                                                                         @NonNull MarshaledResponse marshaledResponse,
578                                                                                                                         @NonNull Duration responseWriteDuration) {
579                requireNonNull(serverType);
580                requireNonNull(request);
581                requireNonNull(marshaledResponse);
582                requireNonNull(responseWriteDuration);
583
584                this.responseWriteDurationHistogram.record(
585                                seconds(responseWriteDuration),
586                                requestAttributes(serverType, request, resourceMethod, marshaledResponse.getStatusCode(), null)
587                );
588        }
589
590        @Override
591        public void didFailToWriteResponse(@NonNull ServerType serverType,
592                                                                                                                                                 @NonNull Request request,
593                                                                                                                                                 @Nullable ResourceMethod resourceMethod,
594                                                                                                                                                 @NonNull MarshaledResponse marshaledResponse,
595                                                                                                                                                 @NonNull Duration responseWriteDuration,
596                                                                                                                                                 @NonNull Throwable throwable) {
597                requireNonNull(serverType);
598                requireNonNull(request);
599                requireNonNull(marshaledResponse);
600                requireNonNull(responseWriteDuration);
601                requireNonNull(throwable);
602
603                Attributes attributes = requestAttributes(serverType, request, resourceMethod, marshaledResponse.getStatusCode(), throwable);
604                this.responseWriteFailureCounter.add(1, attributes);
605                this.responseWriteDurationHistogram.record(seconds(responseWriteDuration), attributes);
606        }
607
608        @Override
609        public void didEstablishSseConnection(@NonNull SseConnection sseConnection) {
610                requireNonNull(sseConnection);
611
612                Attributes attributes = serverSentEventAttributes(sseConnection);
613                this.activeServerSentEventStreamsCounter.add(1, attributes);
614                this.serverSentEventStreamsEstablishedCounter.add(1, attributes);
615        }
616
617        @Override
618        public void didFailToEstablishSseConnection(@NonNull Request request,
619                                                                                                                                                                                        @Nullable ResourceMethod resourceMethod,
620                                                                                                                                                                                        SseConnection.@NonNull HandshakeFailureReason reason,
621                                                                                                                                                                                        @Nullable Throwable throwable) {
622                requireNonNull(request);
623                requireNonNull(reason);
624
625                this.serverSentEventHandshakeFailureCounter.add(1,
626                                Attributes.builder()
627                                                .put(HTTP_METHOD_ATTRIBUTE_KEY, request.getHttpMethod().name())
628                                                .put(HTTP_ROUTE_ATTRIBUTE_KEY, routeFor(resourceMethod))
629                                                .put(FAILURE_REASON_ATTRIBUTE_KEY, enumValue(reason))
630                                                .build()
631                );
632        }
633
634        @Override
635        public void didTerminateSseConnection(@NonNull SseConnection sseConnection,
636                                                                                                                                                                @NonNull StreamTermination termination) {
637                requireNonNull(sseConnection);
638                requireNonNull(termination);
639
640                Attributes routeAttributes = serverSentEventAttributes(sseConnection);
641                Attributes durationAttributes = Attributes.builder()
642                                .putAll(routeAttributes)
643                                .put(SSE_TERMINATION_REASON_ATTRIBUTE_KEY, enumValue(termination.getReason()))
644                                .build();
645
646                this.activeServerSentEventStreamsCounter.add(-1, routeAttributes);
647                this.serverSentEventStreamsTerminatedCounter.add(1, durationAttributes);
648                this.serverSentEventStreamDurationHistogram.record(seconds(termination.getDuration()), durationAttributes);
649        }
650
651        @Override
652        public void didWriteSseEvent(@NonNull SseConnection sseConnection,
653                                                                                                                         @NonNull SseEvent sseEvent,
654                                                                                                                         @NonNull Duration writeDuration,
655                                                                                                                         @Nullable Duration deliveryLag,
656                                                                                                                         @Nullable Integer payloadBytes,
657                                                                                                                         @Nullable Integer queueDepth) {
658                requireNonNull(sseConnection);
659                requireNonNull(sseEvent);
660                requireNonNull(writeDuration);
661
662                Attributes attributes = serverSentEventAttributes(sseConnection);
663                this.serverSentEventWrittenCounter.add(1, attributes);
664                this.serverSentEventWriteDurationHistogram.record(seconds(writeDuration), attributes);
665
666                if (deliveryLag != null)
667                        this.serverSentEventDeliveryLagHistogram.record(seconds(deliveryLag), attributes);
668                if (payloadBytes != null)
669                        this.serverSentEventPayloadSizeHistogram.record(payloadBytes, attributes);
670                if (queueDepth != null)
671                        this.serverSentEventQueueDepthHistogram.record(queueDepth, attributes);
672        }
673
674        @Override
675        public void didFailToWriteSseEvent(@NonNull SseConnection sseConnection,
676                                                                                                                                                 @NonNull SseEvent sseEvent,
677                                                                                                                                                 @NonNull Duration writeDuration,
678                                                                                                                                                 @NonNull Throwable throwable,
679                                                                                                                                                 @Nullable Duration deliveryLag,
680                                                                                                                                                 @Nullable Integer payloadBytes,
681                                                                                                                                                 @Nullable Integer queueDepth) {
682                requireNonNull(sseConnection);
683                requireNonNull(sseEvent);
684                requireNonNull(writeDuration);
685                requireNonNull(throwable);
686
687                Attributes attributes = serverSentEventAttributes(sseConnection);
688                this.serverSentEventWriteFailureCounter.add(1, attributes);
689                this.serverSentEventWriteDurationHistogram.record(seconds(writeDuration), attributes);
690
691                if (deliveryLag != null)
692                        this.serverSentEventDeliveryLagHistogram.record(seconds(deliveryLag), attributes);
693                if (payloadBytes != null)
694                        this.serverSentEventPayloadSizeHistogram.record(payloadBytes, attributes);
695                if (queueDepth != null)
696                        this.serverSentEventQueueDepthHistogram.record(queueDepth, attributes);
697        }
698
699        @Override
700        public void didDropSseEvent(@NonNull SseConnection sseConnection,
701                                                                                                                        @NonNull SseEvent sseEvent,
702                                                                                                                        @NonNull SseEventDropReason reason,
703                                                                                                                        @Nullable Integer payloadBytes,
704                                                                                                                        @Nullable Integer queueDepth) {
705                requireNonNull(sseConnection);
706                requireNonNull(sseEvent);
707                requireNonNull(reason);
708
709                Attributes attributes = Attributes.builder()
710                                .putAll(serverSentEventAttributes(sseConnection))
711                                .put(SSE_DROP_REASON_ATTRIBUTE_KEY, enumValue(reason))
712                                .build();
713                this.serverSentEventDropCounter.add(1, attributes);
714
715                if (payloadBytes != null)
716                        this.serverSentEventPayloadSizeHistogram.record(payloadBytes, attributes);
717                if (queueDepth != null)
718                        this.serverSentEventQueueDepthHistogram.record(queueDepth, attributes);
719        }
720
721        @Override
722        public void didWriteSseComment(@NonNull SseConnection sseConnection,
723                                                                                                                                 @NonNull SseComment sseComment,
724                                                                                                                                 @NonNull Duration writeDuration,
725                                                                                                                                 @Nullable Duration deliveryLag,
726                                                                                                                                 @Nullable Integer payloadBytes,
727                                                                                                                                 @Nullable Integer queueDepth) {
728                requireNonNull(sseConnection);
729                requireNonNull(sseComment);
730                requireNonNull(writeDuration);
731
732                Attributes attributes = serverSentEventCommentAttributes(sseConnection, sseComment.getCommentType());
733                this.serverSentEventCommentWrittenCounter.add(1, attributes);
734                this.serverSentEventCommentWriteDurationHistogram.record(seconds(writeDuration), attributes);
735
736                if (deliveryLag != null)
737                        this.serverSentEventCommentDeliveryLagHistogram.record(seconds(deliveryLag), attributes);
738                if (payloadBytes != null)
739                        this.serverSentEventCommentPayloadSizeHistogram.record(payloadBytes, attributes);
740                if (queueDepth != null)
741                        this.serverSentEventCommentQueueDepthHistogram.record(queueDepth, attributes);
742        }
743
744        @Override
745        public void didFailToWriteSseComment(@NonNull SseConnection sseConnection,
746                                                                                                                                                         @NonNull SseComment sseComment,
747                                                                                                                                                         @NonNull Duration writeDuration,
748                                                                                                                                                         @NonNull Throwable throwable,
749                                                                                                                                                         @Nullable Duration deliveryLag,
750                                                                                                                                                         @Nullable Integer payloadBytes,
751                                                                                                                                                         @Nullable Integer queueDepth) {
752                requireNonNull(sseConnection);
753                requireNonNull(sseComment);
754                requireNonNull(writeDuration);
755                requireNonNull(throwable);
756
757                Attributes attributes = serverSentEventCommentAttributes(sseConnection, sseComment.getCommentType());
758                this.serverSentEventCommentWriteFailureCounter.add(1, attributes);
759                this.serverSentEventCommentWriteDurationHistogram.record(seconds(writeDuration), attributes);
760
761                if (deliveryLag != null)
762                        this.serverSentEventCommentDeliveryLagHistogram.record(seconds(deliveryLag), attributes);
763                if (payloadBytes != null)
764                        this.serverSentEventCommentPayloadSizeHistogram.record(payloadBytes, attributes);
765                if (queueDepth != null)
766                        this.serverSentEventCommentQueueDepthHistogram.record(queueDepth, attributes);
767        }
768
769        @Override
770        public void didDropSseComment(@NonNull SseConnection sseConnection,
771                                                                                                                                @NonNull SseComment sseComment,
772                                                                                                                                @NonNull SseEventDropReason reason,
773                                                                                                                                @Nullable Integer payloadBytes,
774                                                                                                                                @Nullable Integer queueDepth) {
775                requireNonNull(sseConnection);
776                requireNonNull(sseComment);
777                requireNonNull(reason);
778
779                Attributes attributes = Attributes.builder()
780                                .putAll(serverSentEventCommentAttributes(sseConnection, sseComment.getCommentType()))
781                                .put(SSE_DROP_REASON_ATTRIBUTE_KEY, enumValue(reason))
782                                .build();
783                this.serverSentEventCommentDropCounter.add(1, attributes);
784
785                if (payloadBytes != null)
786                        this.serverSentEventCommentPayloadSizeHistogram.record(payloadBytes, attributes);
787                if (queueDepth != null)
788                        this.serverSentEventCommentQueueDepthHistogram.record(queueDepth, attributes);
789        }
790
791        @Override
792        public void didBroadcastSseEvent(@NonNull ResourcePathDeclaration route,
793                                                                                                                                         int attempted,
794                                                                                                                                         int enqueued,
795                                                                                                                                         int dropped) {
796                requireNonNull(route);
797                recordBroadcastTotals(route, BROADCAST_PAYLOAD_EVENT, UNKNOWN_COMMENT_TYPE, attempted, enqueued, dropped);
798        }
799
800        @Override
801        public void didBroadcastSseComment(@NonNull ResourcePathDeclaration route,
802                                                                                                                                                 SseComment.@NonNull CommentType commentType,
803                                                                                                                                                 int attempted,
804                                                                                                                                                 int enqueued,
805                                                                                                                                                 int dropped) {
806                requireNonNull(route);
807                requireNonNull(commentType);
808                recordBroadcastTotals(route, BROADCAST_PAYLOAD_COMMENT, enumValue(commentType), attempted, enqueued, dropped);
809        }
810
811        @Override
812        public void didCreateMcpSession(@NonNull Request request,
813                                                                                                                                        @NonNull Class<? extends McpEndpoint> endpointClass,
814                                                                                                                                        @NonNull String sessionId) {
815                requireNonNull(request);
816                requireNonNull(endpointClass);
817                requireNonNull(sessionId);
818
819                // The session ID is intentionally not emitted as an attribute: it is unbounded-cardinality.
820                Attributes attributes = mcpSessionAttributes(endpointClass);
821                this.activeMcpSessionsCounter.add(1, attributes);
822                this.mcpSessionsCreatedCounter.add(1, attributes);
823        }
824
825        @Override
826        public void didTerminateMcpSession(@NonNull Class<? extends McpEndpoint> endpointClass,
827                                                                                                                                                 @NonNull String sessionId,
828                                                                                                                                                 @NonNull Duration sessionDuration,
829                                                                                                                                                 @NonNull McpSessionTerminationReason terminationReason,
830                                                                                                                                                 @Nullable Throwable throwable) {
831                requireNonNull(endpointClass);
832                requireNonNull(sessionId);
833                requireNonNull(sessionDuration);
834                requireNonNull(terminationReason);
835
836                // The active counter's decrement must use the SAME attribute set as didCreateMcpSession's
837                // increment (endpoint class only - creation cannot know the eventual termination reason),
838                // otherwise per-series values would never net back to zero.
839                this.activeMcpSessionsCounter.add(-1, mcpSessionAttributes(endpointClass));
840
841                Attributes terminationAttributes = Attributes.builder()
842                                .putAll(mcpSessionAttributes(endpointClass))
843                                .put(MCP_SESSION_TERMINATION_REASON_ATTRIBUTE_KEY, enumValue(terminationReason))
844                                .build();
845
846                this.mcpSessionsTerminatedCounter.add(1, terminationAttributes);
847                this.mcpSessionDurationHistogram.record(seconds(sessionDuration), terminationAttributes);
848        }
849
850        @NonNull
851        private Attributes serverTypeAttributes(@NonNull ServerType serverType) {
852                requireNonNull(serverType);
853                return Attributes.of(SERVER_TYPE_ATTRIBUTE_KEY, enumValue(serverType));
854        }
855
856        @NonNull
857        private Attributes serverTypeAndReasonAttributes(@NonNull ServerType serverType,
858                                                                                                                                                                                                         @NonNull Enum<?> reason) {
859                requireNonNull(serverType);
860                requireNonNull(reason);
861                return Attributes.builder()
862                                .put(SERVER_TYPE_ATTRIBUTE_KEY, enumValue(serverType))
863                                .put(FAILURE_REASON_ATTRIBUTE_KEY, enumValue(reason))
864                                .build();
865        }
866
867        @NonNull
868        private Attributes transportFailureAttributes(@NonNull ServerType serverType,
869                                                                                                                                                                                                @NonNull TransportFailureReason reason,
870                                                                                                                                                                                                @Nullable Throwable throwable) {
871                requireNonNull(serverType);
872                requireNonNull(reason);
873
874                var builder = Attributes.builder()
875                                .put(SERVER_TYPE_ATTRIBUTE_KEY, enumValue(serverType))
876                                .put(FAILURE_REASON_ATTRIBUTE_KEY, enumValue(reason));
877
878                if (throwable != null)
879                        builder.put(ERROR_TYPE_ATTRIBUTE_KEY, throwable.getClass().getName());
880
881                return builder.build();
882        }
883
884        @NonNull
885        private Attributes activeRequestAttributes(@NonNull ServerType serverType,
886                                                                                                                                                                                 @NonNull Request request) {
887                requireNonNull(serverType);
888                requireNonNull(request);
889
890                var builder = Attributes.builder()
891                                .put(HTTP_METHOD_ATTRIBUTE_KEY, request.getHttpMethod().name());
892
893                if (this.metricNamingStrategy == MetricNamingStrategy.SEMCONV) {
894                        builder.put(URL_SCHEME_ATTRIBUTE_KEY, URL_SCHEME_HTTP);
895                } else {
896                        builder.put(SERVER_TYPE_ATTRIBUTE_KEY, enumValue(serverType));
897                }
898
899                return builder.build();
900        }
901
902        @NonNull
903        private Attributes requestAttributes(@NonNull ServerType serverType,
904                                                                                                                                                         @NonNull Request request,
905                                                                                                                                                         @Nullable ResourceMethod resourceMethod,
906                                                                                                                                                         @Nullable Integer statusCode,
907                                                                                                                                                         @Nullable Throwable throwable) {
908                requireNonNull(serverType);
909                requireNonNull(request);
910
911                var builder = Attributes.builder()
912                                .put(HTTP_METHOD_ATTRIBUTE_KEY, request.getHttpMethod().name());
913
914                if (this.metricNamingStrategy == MetricNamingStrategy.SEMCONV) {
915                        builder.put(URL_SCHEME_ATTRIBUTE_KEY, URL_SCHEME_HTTP);
916
917                        if (resourceMethod != null)
918                                builder.put(HTTP_ROUTE_ATTRIBUTE_KEY, routeFor(resourceMethod));
919                } else {
920                        builder.put(SERVER_TYPE_ATTRIBUTE_KEY, enumValue(serverType))
921                                        .put(HTTP_ROUTE_ATTRIBUTE_KEY, routeFor(resourceMethod));
922                }
923
924                if (statusCode != null)
925                        builder.put(HTTP_STATUS_CODE_ATTRIBUTE_KEY, statusCode.longValue());
926
927                String errorType = errorTypeFor(statusCode, throwable);
928
929                if (errorType != null)
930                        builder.put(ERROR_TYPE_ATTRIBUTE_KEY, errorType);
931
932                return builder.build();
933        }
934
935        @NonNull
936        private static Attributes mcpSessionAttributes(@NonNull Class<? extends McpEndpoint> endpointClass) {
937                requireNonNull(endpointClass);
938                return Attributes.of(MCP_ENDPOINT_CLASS_ATTRIBUTE_KEY, endpointClass.getName());
939        }
940
941        @NonNull
942        private Attributes serverSentEventAttributes(@NonNull SseConnection sseConnection) {
943                requireNonNull(sseConnection);
944                return Attributes.of(
945                                HTTP_ROUTE_ATTRIBUTE_KEY,
946                                routeFor(sseConnection.getResourceMethod())
947                );
948        }
949
950        @NonNull
951        private Attributes serverSentEventCommentAttributes(@NonNull SseConnection sseConnection,
952                                                                                                                                                                                                                        SseComment.@NonNull CommentType commentType) {
953                requireNonNull(sseConnection);
954                requireNonNull(commentType);
955                return Attributes.builder()
956                                .putAll(serverSentEventAttributes(sseConnection))
957                                .put(SSE_COMMENT_TYPE_ATTRIBUTE_KEY, enumValue(commentType))
958                                .build();
959        }
960
961        private void recordBroadcastTotals(@NonNull ResourcePathDeclaration route,
962                                                                                                                                                 @NonNull String payloadType,
963                                                                                                                                                 @NonNull String commentType,
964                                                                                                                                                 int attempted,
965                                                                                                                                                 int enqueued,
966                                                                                                                                                 int dropped) {
967                requireNonNull(route);
968                requireNonNull(payloadType);
969                requireNonNull(commentType);
970
971                Attributes attributes = Attributes.builder()
972                                .put(HTTP_ROUTE_ATTRIBUTE_KEY, route.getPath())
973                                .put(SSE_BROADCAST_PAYLOAD_TYPE_ATTRIBUTE_KEY, payloadType)
974                                .put(SSE_COMMENT_TYPE_ATTRIBUTE_KEY, commentType)
975                                .build();
976
977                if (attempted > 0)
978                        this.serverSentEventBroadcastAttemptCounter.add(attempted, attributes);
979                if (enqueued > 0)
980                        this.serverSentEventBroadcastEnqueuedCounter.add(enqueued, attributes);
981                if (dropped > 0)
982                        this.serverSentEventBroadcastDroppedCounter.add(dropped, attributes);
983        }
984
985        @NonNull
986        private static String activeRequestsMetricNameFor(@NonNull MetricNamingStrategy metricNamingStrategy) {
987                requireNonNull(metricNamingStrategy);
988                if (metricNamingStrategy == MetricNamingStrategy.SEMCONV)
989                        return "http.server.active_requests";
990                return "soklet.server.requests.active";
991        }
992
993        @NonNull
994        private static String requestDurationMetricNameFor(@NonNull MetricNamingStrategy metricNamingStrategy) {
995                requireNonNull(metricNamingStrategy);
996                if (metricNamingStrategy == MetricNamingStrategy.SEMCONV)
997                        return "http.server.request.duration";
998                return "soklet.server.request.duration";
999        }
1000
1001        @NonNull
1002        private static String requestBodySizeMetricNameFor(@NonNull MetricNamingStrategy metricNamingStrategy) {
1003                requireNonNull(metricNamingStrategy);
1004                if (metricNamingStrategy == MetricNamingStrategy.SEMCONV)
1005                        return "http.server.request.body.size";
1006                return "soklet.server.request.body.size";
1007        }
1008
1009        @NonNull
1010        private static String requestBodySizeDescriptionFor(@NonNull MetricNamingStrategy metricNamingStrategy) {
1011                requireNonNull(metricNamingStrategy);
1012                if (metricNamingStrategy == MetricNamingStrategy.SEMCONV)
1013                        return "Encoded request payload body size in bytes as transferred, excluding headers and transfer framing.";
1014                return "Request body size in bytes as observed by handlers.";
1015        }
1016
1017        @NonNull
1018        private static String responseBodySizeMetricNameFor(@NonNull MetricNamingStrategy metricNamingStrategy) {
1019                requireNonNull(metricNamingStrategy);
1020                if (metricNamingStrategy == MetricNamingStrategy.SEMCONV)
1021                        return "http.server.response.body.size";
1022                return "soklet.server.response.body.size";
1023        }
1024
1025        @NonNull
1026        private static String routeFor(@Nullable ResourceMethod resourceMethod) {
1027                if (resourceMethod == null)
1028                        return UNMATCHED_ROUTE;
1029                return resourceMethod.getResourcePathDeclaration().getPath();
1030        }
1031
1032        @NonNull
1033        private static String enumValue(@NonNull Enum<?> value) {
1034                requireNonNull(value);
1035                return value.name().toLowerCase(Locale.ROOT);
1036        }
1037
1038        @Nullable
1039        private String errorTypeFor(@Nullable Integer statusCode,
1040                                                                                                                        @Nullable Throwable throwable) {
1041                if (throwable != null)
1042                        return throwable.getClass().getName();
1043
1044                if (this.metricNamingStrategy == MetricNamingStrategy.SEMCONV
1045                                && statusCode != null
1046                                && statusCode >= 500)
1047                        return String.valueOf(statusCode);
1048
1049                return null;
1050        }
1051
1052        private static double seconds(@NonNull Duration duration) {
1053                requireNonNull(duration);
1054                return duration.toNanos() / 1_000_000_000D;
1055        }
1056
1057        /**
1058         * Naming strategy for HTTP metric instrument names.
1059         * <p>
1060         * SSE- and Soklet-specific concepts remain under the {@code soklet.*} namespace in all strategies.
1061         */
1062        public enum MetricNamingStrategy {
1063                /**
1064                 * Use OpenTelemetry Semantic Convention names for standard HTTP server metrics.
1065                 * Request-body size records the encoded payload size as transferred.
1066                 */
1067                SEMCONV,
1068                /**
1069                 * Use {@code soklet.*} names for all metrics.
1070                 * Request-body size records the body size visible to handlers.
1071                 */
1072                SOKLET
1073        }
1074
1075        /**
1076         * Builder used to construct instances of {@link OpenTelemetryMetricsCollector}.
1077         */
1078        @NotThreadSafe
1079        public static final class Builder {
1080                @Nullable
1081                private Meter meter;
1082                @Nullable
1083                private OpenTelemetry openTelemetry;
1084                @NonNull
1085                private MetricNamingStrategy metricNamingStrategy;
1086                @NonNull
1087                private String instrumentationName;
1088                @Nullable
1089                private String instrumentationVersion;
1090
1091                private Builder() {
1092                        this.openTelemetry = GlobalOpenTelemetry.get();
1093                        this.metricNamingStrategy = MetricNamingStrategy.SEMCONV;
1094                        this.instrumentationName = DEFAULT_INSTRUMENTATION_NAME;
1095                        this.instrumentationVersion = null;
1096                }
1097
1098                /**
1099                 * Sets a specific meter to use for metric instruments.
1100                 *
1101                 * @param meter the meter to use
1102                 * @return this builder
1103                 */
1104                @NonNull
1105                public Builder meter(@NonNull Meter meter) {
1106                        this.meter = requireNonNull(meter);
1107                        return this;
1108                }
1109
1110                /**
1111                 * Sets the OpenTelemetry API object used to construct a meter if {@link #meter(Meter)} is not set.
1112                 *
1113                 * @param openTelemetry the OpenTelemetry instance
1114                 * @return this builder
1115                 */
1116                @NonNull
1117                public Builder openTelemetry(@NonNull OpenTelemetry openTelemetry) {
1118                        this.openTelemetry = requireNonNull(openTelemetry);
1119                        return this;
1120                }
1121
1122                /**
1123                 * Sets the naming strategy for HTTP metrics.
1124                 *
1125                 * @param metricNamingStrategy the naming strategy
1126                 * @return this builder
1127                 */
1128                @NonNull
1129                public Builder metricNamingStrategy(@NonNull MetricNamingStrategy metricNamingStrategy) {
1130                        this.metricNamingStrategy = requireNonNull(metricNamingStrategy);
1131                        return this;
1132                }
1133
1134                /**
1135                 * Sets the instrumentation scope name to use when constructing a meter.
1136                 *
1137                 * @param instrumentationName the instrumentation scope name
1138                 * @return this builder
1139                 */
1140                @NonNull
1141                public Builder instrumentationName(@NonNull String instrumentationName) {
1142                        this.instrumentationName = requireNonNull(instrumentationName);
1143                        return this;
1144                }
1145
1146                /**
1147                 * Sets an optional instrumentation scope version to use when constructing a meter.
1148                 *
1149                 * @param instrumentationVersion the instrumentation scope version, or {@code null}
1150                 * @return this builder
1151                 */
1152                @NonNull
1153                public Builder instrumentationVersion(@Nullable String instrumentationVersion) {
1154                        this.instrumentationVersion = instrumentationVersion;
1155                        return this;
1156                }
1157
1158                @NonNull
1159                private Meter resolveMeter() {
1160                        if (this.meter != null)
1161                                return this.meter;
1162
1163                        MeterBuilder meterBuilder = requireNonNull(this.openTelemetry).meterBuilder(this.instrumentationName);
1164
1165                        if (this.instrumentationVersion != null)
1166                                meterBuilder = meterBuilder.setInstrumentationVersion(this.instrumentationVersion);
1167
1168                        return meterBuilder.build();
1169                }
1170
1171                /**
1172                 * Builds the collector.
1173                 *
1174                 * @return the collector instance
1175                 */
1176                @NonNull
1177                public OpenTelemetryMetricsCollector build() {
1178                        return new OpenTelemetryMetricsCollector(this);
1179                }
1180        }
1181}