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; 018 019import org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.NotThreadSafe; 023import javax.annotation.concurrent.ThreadSafe; 024import java.net.InetSocketAddress; 025import java.time.Duration; 026import java.util.Arrays; 027import java.util.Collections; 028import java.util.LinkedHashMap; 029import java.util.List; 030import java.util.Map; 031import java.util.Optional; 032import java.util.concurrent.atomic.AtomicLong; 033import java.util.concurrent.atomic.LongAdder; 034import java.util.function.Predicate; 035 036import static java.util.Objects.requireNonNull; 037 038/** 039 * Contract for collecting operational metrics from Soklet. 040 * <p> 041 * Soklet's standard implementation, available via {@link #defaultInstance()}, supports detailed histogram collection, 042 * connection accept/reject counters, immutable snapshots (via {@link #snapshot()}), and provides Prometheus 043 * (text format v0.0.4) / OpenMetrics (1.0) export helpers for convenience. 044 * To disable metrics collection without a custom implementation, use {@link #disabledInstance()}. 045 * <p> 046 * If you prefer OpenTelemetry, Micrometer, or another metrics system for monitoring, you might choose to create your own 047 * implementation of this interface. 048 * <p> 049 * Example configuration: 050 * <pre><code> 051 * SokletConfig config = SokletConfig.withHttpServer(HttpServer.fromPort(8080)) 052 * // This is already the default; specifying it here is optional 053 * .metricsCollector(MetricsCollector.defaultInstance()) 054 * .build(); 055 * </code></pre> 056 * <p> 057 * To disable metrics collection entirely, specify Soklet's no-op implementation: 058 * <pre><code> 059 * SokletConfig config = SokletConfig.withHttpServer(HttpServer.fromPort(8080)) 060 * // Use this instead of null to disable metrics collection 061 * .metricsCollector(MetricsCollector.disabledInstance()) 062 * .build(); 063 * </code></pre> 064 * <p> 065 * <p>All methods must be: 066 * <ul> 067 * <li><strong>Thread-safe</strong> — called concurrently from multiple request threads</li> 068 * <li><strong>Non-blocking</strong> — should not perform I/O or acquire locks that might contend</li> 069 * <li><strong>Failure-tolerant</strong> — exceptions are caught and logged, never break request handling</li> 070 * </ul> 071 * <p> 072 * Example usage: 073 * <pre><code> 074 * {@literal @}GET("/metrics") 075 * public MarshaledResponse getMetrics(@NonNull MetricsCollector metricsCollector) { 076 * SnapshotTextOptions options = SnapshotTextOptions 077 * .fromMetricsFormat(MetricsFormat.PROMETHEUS); 078 * 079 * String body = metricsCollector.snapshotText(options).orElse(null); 080 * 081 * if (body == null) 082 * return MarshaledResponse.fromStatusCode(204); 083 * 084 * return MarshaledResponse.withStatusCode(200) 085 * .headers(Map.of("Content-Type", Set.of("text/plain; charset=UTF-8"))) 086 * .body(body.getBytes(StandardCharsets.UTF_8)) 087 * .build(); 088 * } 089 * </code></pre> 090 * <p> 091 * See <a href="https://www.soklet.com/docs/metrics-collection">https://www.soklet.com/docs/metrics-collection</a> for detailed documentation. 092 * 093 * @author <a href="https://www.revetkn.com">Mark Allen</a> 094 */ 095@ThreadSafe 096public interface MetricsCollector { 097 /** 098 * Called when a server is about to accept a new TCP connection. 099 * 100 * @param serverType the server type that is accepting the connection 101 * @param remoteAddress the best-effort remote address, or {@code null} if unavailable 102 */ 103 default void willAcceptConnection(@NonNull ServerType serverType, 104 @Nullable InetSocketAddress remoteAddress) { 105 // No-op by default 106 } 107 108 /** 109 * Called after a server accepts a new TCP connection. 110 * 111 * @param serverType the server type that accepted the connection 112 * @param remoteAddress the best-effort remote address, or {@code null} if unavailable 113 */ 114 default void didAcceptConnection(@NonNull ServerType serverType, 115 @Nullable InetSocketAddress remoteAddress) { 116 // No-op by default 117 } 118 119 /** 120 * Called after a server fails to accept a new TCP connection. 121 * 122 * @param serverType the server type that failed to accept the connection 123 * @param remoteAddress the best-effort remote address, or {@code null} if unavailable 124 * @param reason the failure reason 125 * @param throwable an optional underlying cause, or {@code null} if not applicable 126 */ 127 default void didFailToAcceptConnection(@NonNull ServerType serverType, 128 @Nullable InetSocketAddress remoteAddress, 129 @NonNull ConnectionRejectionReason reason, 130 @Nullable Throwable throwable) { 131 // No-op by default 132 } 133 134 /** 135 * Called when a request is about to be accepted for application-level handling. 136 * 137 * @param serverType the server type that received the request 138 * @param remoteAddress the best-effort remote address, or {@code null} if unavailable 139 * @param requestTarget the raw request target (path + query) if known, or {@code null} if unavailable 140 */ 141 default void willAcceptRequest(@NonNull ServerType serverType, 142 @Nullable InetSocketAddress remoteAddress, 143 @Nullable String requestTarget) { 144 // No-op by default 145 } 146 147 /** 148 * Called after a request is accepted for application-level handling. 149 * 150 * @param serverType the server type that received the request 151 * @param remoteAddress the best-effort remote address, or {@code null} if unavailable 152 * @param requestTarget the raw request target (path + query) if known, or {@code null} if unavailable 153 */ 154 default void didAcceptRequest(@NonNull ServerType serverType, 155 @Nullable InetSocketAddress remoteAddress, 156 @Nullable String requestTarget) { 157 // No-op by default 158 } 159 160 /** 161 * Called when a request fails to be accepted before application-level handling begins. 162 * 163 * @param serverType the server type that received the request 164 * @param remoteAddress the best-effort remote address, or {@code null} if unavailable 165 * @param requestTarget the raw request target (path + query) if known, or {@code null} if unavailable 166 * @param reason the rejection reason 167 * @param throwable an optional underlying cause, or {@code null} if not applicable 168 */ 169 default void didFailToAcceptRequest(@NonNull ServerType serverType, 170 @Nullable InetSocketAddress remoteAddress, 171 @Nullable String requestTarget, 172 @NonNull RequestRejectionReason reason, 173 @Nullable Throwable throwable) { 174 // No-op by default 175 } 176 177 /** 178 * Called when Soklet is about to read or parse a request into a valid {@link Request}. 179 * 180 * @param serverType the server type that received the request 181 * @param remoteAddress the best-effort remote address, or {@code null} if unavailable 182 * @param requestTarget the raw request target (path + query) if known, or {@code null} if unavailable 183 */ 184 default void willReadRequest(@NonNull ServerType serverType, 185 @Nullable InetSocketAddress remoteAddress, 186 @Nullable String requestTarget) { 187 // No-op by default 188 } 189 190 /** 191 * Called when a request was successfully read or parsed into a valid {@link Request}. 192 * 193 * @param serverType the server type that received the request 194 * @param remoteAddress the best-effort remote address, or {@code null} if unavailable 195 * @param requestTarget the raw request target (path + query) if known, or {@code null} if unavailable 196 */ 197 default void didReadRequest(@NonNull ServerType serverType, 198 @Nullable InetSocketAddress remoteAddress, 199 @Nullable String requestTarget) { 200 // No-op by default 201 } 202 203 /** 204 * Called when a request could not be read or parsed into a valid {@link Request}. 205 * 206 * @param serverType the server type that received the request 207 * @param remoteAddress the best-effort remote address, or {@code null} if unavailable 208 * @param requestTarget the raw request target (path + query) if known, or {@code null} if unavailable 209 * @param reason the failure reason 210 * @param throwable an optional underlying cause, or {@code null} if not applicable 211 */ 212 default void didFailToReadRequest(@NonNull ServerType serverType, 213 @Nullable InetSocketAddress remoteAddress, 214 @Nullable String requestTarget, 215 @NonNull RequestReadFailureReason reason, 216 @Nullable Throwable throwable) { 217 // No-op by default 218 } 219 220 /** 221 * Called as soon as a request is received and a <em>Resource Method</em> has been resolved to handle it. 222 * 223 * @param serverType the server type that received the request 224 */ 225 default void didStartRequestHandling(@NonNull ServerType serverType, 226 @NonNull Request request, 227 @Nullable ResourceMethod resourceMethod) { 228 // No-op by default 229 } 230 231 /** 232 * Called after a request finishes processing. 233 */ 234 default void didFinishRequestHandling(@NonNull ServerType serverType, 235 @NonNull Request request, 236 @Nullable ResourceMethod resourceMethod, 237 @NonNull MarshaledResponse marshaledResponse, 238 @NonNull Duration duration, 239 @NonNull List<@NonNull Throwable> throwables) { 240 // No-op by default 241 } 242 243 /** 244 * Called before response data is written. 245 */ 246 default void willWriteResponse(@NonNull ServerType serverType, 247 @NonNull Request request, 248 @Nullable ResourceMethod resourceMethod, 249 @NonNull MarshaledResponse marshaledResponse) { 250 // No-op by default 251 } 252 253 /** 254 * Called after response data is written. 255 */ 256 default void didWriteResponse(@NonNull ServerType serverType, 257 @NonNull Request request, 258 @Nullable ResourceMethod resourceMethod, 259 @NonNull MarshaledResponse marshaledResponse, 260 @NonNull Duration responseWriteDuration) { 261 // No-op by default 262 } 263 264 /** 265 * Called after response data fails to write. 266 */ 267 default void didFailToWriteResponse(@NonNull ServerType serverType, 268 @NonNull Request request, 269 @Nullable ResourceMethod resourceMethod, 270 @NonNull MarshaledResponse marshaledResponse, 271 @NonNull Duration responseWriteDuration, 272 @NonNull Throwable throwable) { 273 // No-op by default 274 } 275 276 /** 277 * Called after an MCP session is durably created. 278 */ 279 default void didCreateMcpSession(@NonNull Request request, 280 @NonNull Class<? extends McpEndpoint> endpointClass, 281 @NonNull String sessionId) { 282 // No-op by default 283 } 284 285 /** 286 * Called after an MCP session is terminated. 287 */ 288 default void didTerminateMcpSession(@NonNull Class<? extends McpEndpoint> endpointClass, 289 @NonNull String sessionId, 290 @NonNull Duration sessionDuration, 291 @NonNull McpSessionTerminationReason terminationReason, 292 @Nullable Throwable throwable) { 293 // No-op by default 294 } 295 296 /** 297 * Called after a valid MCP JSON-RPC request begins handling. 298 */ 299 default void didStartMcpRequestHandling(@NonNull Request request, 300 @NonNull Class<? extends McpEndpoint> endpointClass, 301 @Nullable String sessionId, 302 @NonNull String jsonRpcMethod, 303 @Nullable McpJsonRpcRequestId jsonRpcRequestId) { 304 // No-op by default 305 } 306 307 /** 308 * Called after MCP JSON-RPC request handling finishes. 309 */ 310 default void didFinishMcpRequestHandling(@NonNull Request request, 311 @NonNull Class<? extends McpEndpoint> endpointClass, 312 @Nullable String sessionId, 313 @NonNull String jsonRpcMethod, 314 @Nullable McpJsonRpcRequestId jsonRpcRequestId, 315 @NonNull McpRequestOutcome requestOutcome, 316 @Nullable McpJsonRpcError jsonRpcError, 317 @NonNull Duration duration, 318 @NonNull List<@NonNull Throwable> throwables) { 319 // No-op by default 320 } 321 322 /** 323 * Called after an MCP GET stream is established. 324 */ 325 default void didEstablishMcpSseStream(@NonNull McpSseStream stream) { 326 // No-op by default 327 } 328 329 /** 330 * Called after an MCP GET stream is terminated. 331 */ 332 default void didTerminateMcpSseStream(@NonNull McpSseStream stream, 333 @NonNull StreamTermination termination) { 334 // No-op by default 335 } 336 337 /** 338 * Called before an SSE connection is established. 339 */ 340 default void willEstablishSseConnection(@NonNull Request request, 341 @Nullable ResourceMethod resourceMethod) { 342 // No-op by default 343 } 344 345 /** 346 * Called after an SSE connection is established. 347 */ 348 default void didEstablishSseConnection(@NonNull SseConnection sseConnection) { 349 // No-op by default 350 } 351 352 /** 353 * Called if an SSE connection fails to establish. 354 * 355 * @param reason the handshake failure reason 356 * @param throwable an optional underlying cause, or {@code null} if not applicable 357 */ 358 default void didFailToEstablishSseConnection(@NonNull Request request, 359 @Nullable ResourceMethod resourceMethod, 360 SseConnection.@NonNull HandshakeFailureReason reason, 361 @Nullable Throwable throwable) { 362 // No-op by default 363 } 364 365 /** 366 * Called before an SSE connection is terminated. 367 */ 368 default void willTerminateSseConnection(@NonNull SseConnection sseConnection, 369 @NonNull StreamTermination termination) { 370 // No-op by default 371 } 372 373 /** 374 * Called after an SSE connection is terminated. 375 */ 376 default void didTerminateSseConnection(@NonNull SseConnection sseConnection, 377 @NonNull StreamTermination termination) { 378 // No-op by default 379 } 380 381 /** 382 * Called before an SSE event is written. 383 */ 384 default void willWriteSseEvent(@NonNull SseConnection sseConnection, 385 @NonNull SseEvent sseEvent) { 386 // No-op by default 387 } 388 389 /** 390 * Called after an SSE event is written. 391 * 392 * @param sseConnection the connection the event was written to 393 * @param sseEvent the event that was written 394 * @param writeDuration how long it took to write the event 395 * @param deliveryLag elapsed time between enqueue and write start, or {@code null} if unknown 396 * @param payloadBytes size of the serialized payload in bytes, or {@code null} if unknown 397 * @param queueDepth number of queued elements remaining at write time, or {@code null} if unknown 398 */ 399 default void didWriteSseEvent(@NonNull SseConnection sseConnection, 400 @NonNull SseEvent sseEvent, 401 @NonNull Duration writeDuration, 402 @Nullable Duration deliveryLag, 403 @Nullable Integer payloadBytes, 404 @Nullable Integer queueDepth) { 405 // No-op by default 406 } 407 408 /** 409 * Called after an SSE event fails to write. 410 * 411 * @param sseConnection the connection the event was written to 412 * @param sseEvent the event that was written 413 * @param writeDuration how long it took to attempt the write 414 * @param throwable the failure cause 415 * @param deliveryLag elapsed time between enqueue and write start, or {@code null} if unknown 416 * @param payloadBytes size of the serialized payload in bytes, or {@code null} if unknown 417 * @param queueDepth number of queued elements remaining at write time, or {@code null} if unknown 418 */ 419 default void didFailToWriteSseEvent(@NonNull SseConnection sseConnection, 420 @NonNull SseEvent sseEvent, 421 @NonNull Duration writeDuration, 422 @NonNull Throwable throwable, 423 @Nullable Duration deliveryLag, 424 @Nullable Integer payloadBytes, 425 @Nullable Integer queueDepth) { 426 // No-op by default 427 } 428 429 /** 430 * Called before an SSE comment is written. 431 */ 432 default void willWriteSseComment(@NonNull SseConnection sseConnection, 433 @NonNull SseComment sseComment) { 434 // No-op by default 435 } 436 437 /** 438 * Called after an SSE comment is written. 439 * 440 * @param sseConnection the connection the comment was written to 441 * @param sseComment the comment that was written 442 * @param writeDuration how long it took to write the comment 443 * @param deliveryLag elapsed time between enqueue and write start, or {@code null} if unknown 444 * @param payloadBytes size of the serialized payload in bytes, or {@code null} if unknown 445 * @param queueDepth number of queued elements remaining at write time, or {@code null} if unknown 446 */ 447 default void didWriteSseComment(@NonNull SseConnection sseConnection, 448 @NonNull SseComment sseComment, 449 @NonNull Duration writeDuration, 450 @Nullable Duration deliveryLag, 451 @Nullable Integer payloadBytes, 452 @Nullable Integer queueDepth) { 453 // No-op by default 454 } 455 456 /** 457 * Called after an SSE comment fails to write. 458 * 459 * @param sseConnection the connection the comment was written to 460 * @param sseComment the comment that was written 461 * @param writeDuration how long it took to attempt the write 462 * @param throwable the failure cause 463 * @param deliveryLag elapsed time between enqueue and write start, or {@code null} if unknown 464 * @param payloadBytes size of the serialized payload in bytes, or {@code null} if unknown 465 * @param queueDepth number of queued elements remaining at write time, or {@code null} if unknown 466 */ 467 default void didFailToWriteSseComment(@NonNull SseConnection sseConnection, 468 @NonNull SseComment sseComment, 469 @NonNull Duration writeDuration, 470 @NonNull Throwable throwable, 471 @Nullable Duration deliveryLag, 472 @Nullable Integer payloadBytes, 473 @Nullable Integer queueDepth) { 474 // No-op by default 475 } 476 477 /** 478 * Called after an SSE event is dropped before it can be enqueued for delivery. 479 * 480 * @param sseConnection the connection the event was targeting 481 * @param sseEvent the event that was dropped 482 * @param reason the drop reason 483 * @param payloadBytes size of the serialized payload in bytes, or {@code null} if unknown 484 * @param queueDepth number of queued elements at drop time, or {@code null} if unknown 485 */ 486 default void didDropSseEvent(@NonNull SseConnection sseConnection, 487 @NonNull SseEvent sseEvent, 488 @NonNull SseEventDropReason reason, 489 @Nullable Integer payloadBytes, 490 @Nullable Integer queueDepth) { 491 // No-op by default 492 } 493 494 /** 495 * Called after an SSE comment is dropped before it can be enqueued for delivery. 496 * 497 * @param sseConnection the connection the comment was targeting 498 * @param sseComment the comment that was dropped 499 * @param reason the drop reason 500 * @param payloadBytes size of the serialized payload in bytes, or {@code null} if unknown 501 * @param queueDepth number of queued elements at drop time, or {@code null} if unknown 502 */ 503 default void didDropSseComment(@NonNull SseConnection sseConnection, 504 @NonNull SseComment sseComment, 505 @NonNull SseEventDropReason reason, 506 @Nullable Integer payloadBytes, 507 @Nullable Integer queueDepth) { 508 // No-op by default 509 } 510 511 /** 512 * Called after a broadcast attempt for a Server-Sent Event payload. 513 * 514 * @param route the route declaration that was broadcast to 515 * @param attempted number of connections targeted 516 * @param enqueued number of connections for which enqueue succeeded 517 * @param dropped number of connections for which enqueue failed 518 */ 519 default void didBroadcastSseEvent(@NonNull ResourcePathDeclaration route, 520 int attempted, 521 int enqueued, 522 int dropped) { 523 // No-op by default 524 } 525 526 /** 527 * Called after a broadcast attempt for a Server-Sent Event comment payload. 528 * 529 * @param route the route declaration that was broadcast to 530 * @param commentType the comment type 531 * @param attempted number of connections targeted 532 * @param enqueued number of connections for which enqueue succeeded 533 * @param dropped number of connections for which enqueue failed 534 */ 535 default void didBroadcastSseComment(@NonNull ResourcePathDeclaration route, 536 SseComment.@NonNull CommentType commentType, 537 int attempted, 538 int enqueued, 539 int dropped) { 540 // No-op by default 541 } 542 543 /** 544 * Returns a snapshot of metrics collected so far, if supported. 545 * 546 * @return an optional metrics snapshot 547 */ 548 @NonNull 549 default Optional<Snapshot> snapshot() { 550 return Optional.empty(); 551 } 552 553 /** 554 * Returns a text snapshot of metrics collected so far, if supported. 555 * <p> 556 * The default collector supports Prometheus (text format v0.0.4) and OpenMetrics (1.0) text exposition formats. 557 * 558 * @param options the snapshot rendering options 559 * @return a textual metrics snapshot, or {@link Optional#empty()} if unsupported 560 */ 561 @NonNull 562 default Optional<String> snapshotText(@NonNull SnapshotTextOptions options) { 563 requireNonNull(options); 564 return Optional.empty(); 565 } 566 567 /** 568 * Resets any in-memory metrics state, if supported. 569 */ 570 default void reset() { 571 // No-op by default 572 } 573 574 /** 575 * Text format to use for {@link #snapshotText(SnapshotTextOptions)}. 576 * <p> 577 * This controls serialization only; the underlying metrics data is unchanged. 578 */ 579 enum MetricsFormat { 580 /** 581 * Prometheus text exposition format (v0.0.4). 582 */ 583 PROMETHEUS, 584 /** 585 * OpenMetrics text exposition format (1.0), including the {@code # EOF} trailer. 586 */ 587 OPEN_METRICS_1_0 588 } 589 590 /** 591 * Options for rendering a textual metrics snapshot. 592 * <p> 593 * Use {@link #withMetricsFormat(MetricsFormat)} to obtain a builder and customize output. 594 * <p> 595 * Key options: 596 * <ul> 597 * <li>{@code metricFilter} allows per-sample filtering by name and labels</li> 598 * <li>{@code histogramFormat} controls bucket vs count/sum output</li> 599 * <li>{@code includeZeroBuckets} drops empty bucket samples when false</li> 600 * </ul> 601 */ 602 @ThreadSafe 603 final class SnapshotTextOptions { 604 @NonNull 605 private final MetricsFormat metricsFormat; 606 @Nullable 607 private final Predicate<MetricSample> metricFilter; 608 @NonNull 609 private final HistogramFormat histogramFormat; 610 @NonNull 611 private final Boolean includeZeroBuckets; 612 613 private SnapshotTextOptions(@NonNull Builder builder) { 614 requireNonNull(builder); 615 616 this.metricsFormat = requireNonNull(builder.metricsFormat); 617 this.metricFilter = builder.metricFilter; 618 this.histogramFormat = requireNonNull(builder.histogramFormat); 619 this.includeZeroBuckets = builder.includeZeroBuckets == null ? true : builder.includeZeroBuckets; 620 } 621 622 /** 623 * Begins building options with the specified format. 624 * 625 * @param metricsFormat the text exposition format 626 * @return a builder seeded with the format 627 */ 628 @NonNull 629 public static Builder withMetricsFormat(@NonNull MetricsFormat metricsFormat) { 630 return new Builder(metricsFormat); 631 } 632 633 /** 634 * Creates options with the specified format and defaults for all other fields. 635 * 636 * @param metricsFormat the text exposition format 637 * @return a {@link SnapshotTextOptions} instance 638 */ 639 @NonNull 640 public static SnapshotTextOptions fromMetricsFormat(@NonNull MetricsFormat metricsFormat) { 641 return withMetricsFormat(metricsFormat).build(); 642 } 643 644 /** 645 * The text exposition format to emit. 646 * 647 * @return the metrics format 648 */ 649 @NonNull 650 public MetricsFormat getMetricsFormat() { 651 return this.metricsFormat; 652 } 653 654 /** 655 * Optional filter for rendered samples. 656 * 657 * @return the filter, if present 658 */ 659 @NonNull 660 public Optional<Predicate<MetricSample>> getMetricFilter() { 661 return Optional.ofNullable(this.metricFilter); 662 } 663 664 /** 665 * The histogram rendering strategy. 666 * 667 * @return the histogram format 668 */ 669 @NonNull 670 public HistogramFormat getHistogramFormat() { 671 return this.histogramFormat; 672 } 673 674 /** 675 * Whether zero-count buckets should be emitted. 676 * 677 * @return {@code true} if zero-count buckets are included 678 */ 679 @NonNull 680 public Boolean getIncludeZeroBuckets() { 681 return this.includeZeroBuckets; 682 } 683 684 /** 685 * Supported histogram rendering strategies. 686 */ 687 public enum HistogramFormat { 688 /** 689 * Emit full histogram series (buckets, count, and sum). 690 */ 691 FULL_BUCKETS, 692 /** 693 * Emit only {@code _count} and {@code _sum} samples (omit buckets). 694 */ 695 COUNT_SUM_ONLY, 696 /** 697 * Suppress histogram output entirely. 698 */ 699 NONE 700 } 701 702 /** 703 * A single text-format sample with its label set. 704 * <p> 705 * Filters receive these instances for each rendered sample. For histogram buckets, 706 * the sample name includes {@code _bucket} and the labels include {@code le}. 707 * Label maps are immutable and preserve insertion order. 708 */ 709 public static final class MetricSample { 710 @NonNull 711 private final String name; 712 @NonNull 713 private final Map<@NonNull String, @NonNull String> labels; 714 715 /** 716 * Creates a metrics sample definition. 717 * 718 * @param name the sample name (e.g. {@code soklet_http_request_duration_nanos_bucket}) 719 * @param labels the sample labels 720 */ 721 public MetricSample(@NonNull String name, 722 @NonNull Map<@NonNull String, @NonNull String> labels) { 723 this.name = requireNonNull(name); 724 this.labels = Collections.unmodifiableMap(new LinkedHashMap<>(requireNonNull(labels))); 725 } 726 727 /** 728 * The name for this sample. 729 * 730 * @return the sample name 731 */ 732 @NonNull 733 public String getName() { 734 return this.name; 735 } 736 737 /** 738 * The label set for this sample. 739 * 740 * @return immutable labels 741 */ 742 @NonNull 743 public Map<@NonNull String, @NonNull String> getLabels() { 744 return this.labels; 745 } 746 } 747 748 /** 749 * Builder for {@link SnapshotTextOptions}. 750 * <p> 751 * Defaults are {@link HistogramFormat#FULL_BUCKETS} and {@code includeZeroBuckets=true}. 752 */ 753 @ThreadSafe 754 public static final class Builder { 755 @NonNull 756 private final MetricsFormat metricsFormat; 757 @Nullable 758 private Predicate<MetricSample> metricFilter; 759 @NonNull 760 private HistogramFormat histogramFormat; 761 @Nullable 762 private Boolean includeZeroBuckets; 763 764 private Builder(@NonNull MetricsFormat metricsFormat) { 765 this.metricsFormat = requireNonNull(metricsFormat); 766 this.histogramFormat = HistogramFormat.FULL_BUCKETS; 767 this.includeZeroBuckets = true; 768 } 769 770 /** 771 * Sets an optional per-sample filter. 772 * 773 * @param metricFilter the filter to apply, or {@code null} to disable filtering 774 * @return this builder 775 */ 776 @NonNull 777 public Builder metricFilter(@Nullable Predicate<MetricSample> metricFilter) { 778 this.metricFilter = metricFilter; 779 return this; 780 } 781 782 /** 783 * Sets how histograms are rendered in the text snapshot. 784 * 785 * @param histogramFormat the histogram format 786 * @return this builder 787 */ 788 @NonNull 789 public Builder histogramFormat(@NonNull HistogramFormat histogramFormat) { 790 this.histogramFormat = requireNonNull(histogramFormat); 791 return this; 792 } 793 794 /** 795 * Controls whether zero-count buckets are emitted. 796 * 797 * @param includeZeroBuckets {@code true} to include zero-count buckets, {@code false} to omit them 798 * @return this builder 799 */ 800 @NonNull 801 public Builder includeZeroBuckets(@Nullable Boolean includeZeroBuckets) { 802 this.includeZeroBuckets = includeZeroBuckets; 803 return this; 804 } 805 806 /** 807 * Builds a {@link SnapshotTextOptions} instance. 808 * 809 * @return the built options 810 */ 811 @NonNull 812 public SnapshotTextOptions build() { 813 return new SnapshotTextOptions(this); 814 } 815 } 816 } 817 818 /** 819 * Immutable snapshot of collected metrics. 820 * <p> 821 * Durations are in nanoseconds, sizes are in bytes, and queue depths are raw counts. 822 * Histogram values are captured as {@link HistogramSnapshot} instances. 823 * Connection counts report total accepted/rejected connections for the HTTP, SSE, and MCP servers. 824 * Request read failures and request rejections are reported separately for HTTP, SSE, and MCP traffic. 825 * Instances are typically produced by {@link MetricsCollector#snapshot()} but can also be built 826 * manually via {@link #builder()}. 827 * 828 * @author <a href="https://www.revetkn.com">Mark Allen</a> 829 */ 830 @ThreadSafe 831 final class Snapshot { 832 @NonNull 833 private final Long activeRequests; 834 @NonNull 835 private final Long activeSseStreams; 836 @NonNull 837 private final Long activeMcpSessions; 838 @NonNull 839 private final Long activeMcpSseStreams; 840 @NonNull 841 private final Long httpConnectionsAccepted; 842 @NonNull 843 private final Long httpConnectionsRejected; 844 @NonNull 845 private final Long sseConnectionsAccepted; 846 @NonNull 847 private final Long sseConnectionsRejected; 848 @NonNull 849 private final Long mcpConnectionsAccepted; 850 @NonNull 851 private final Long mcpConnectionsRejected; 852 @NonNull 853 private final Map<@NonNull RequestReadFailureKey, @NonNull Long> httpRequestReadFailures; 854 @NonNull 855 private final Map<@NonNull RequestRejectionKey, @NonNull Long> httpRequestRejections; 856 @NonNull 857 private final Map<@NonNull RequestReadFailureKey, @NonNull Long> sseRequestReadFailures; 858 @NonNull 859 private final Map<@NonNull RequestRejectionKey, @NonNull Long> sseRequestRejections; 860 @NonNull 861 private final Map<@NonNull RequestReadFailureKey, @NonNull Long> mcpRequestReadFailures; 862 @NonNull 863 private final Map<@NonNull RequestRejectionKey, @NonNull Long> mcpRequestRejections; 864 @NonNull 865 private final Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> httpRequestDurations; 866 @NonNull 867 private final Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> httpHandlerDurations; 868 @NonNull 869 private final Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> httpTimeToFirstByte; 870 @NonNull 871 private final Map<@NonNull HttpServerRouteKey, @NonNull HistogramSnapshot> httpRequestBodyBytes; 872 @NonNull 873 private final Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> httpResponseBodyBytes; 874 @NonNull 875 private final Map<@NonNull SseEventRouteKey, @NonNull Long> sseHandshakesAccepted; 876 @NonNull 877 private final Map<@NonNull SseEventRouteHandshakeFailureKey, @NonNull Long> sseHandshakesRejected; 878 @NonNull 879 private final Map<@NonNull SseEventRouteEnqueueOutcomeKey, @NonNull Long> sseEventEnqueueOutcomes; 880 @NonNull 881 private final Map<@NonNull SseCommentRouteEnqueueOutcomeKey, @NonNull Long> sseCommentEnqueueOutcomes; 882 @NonNull 883 private final Map<@NonNull SseEventRouteDropKey, @NonNull Long> sseEventDrops; 884 @NonNull 885 private final Map<@NonNull SseCommentRouteDropKey, @NonNull Long> sseCommentDrops; 886 @NonNull 887 private final Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseTimeToFirstEvent; 888 @NonNull 889 private final Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseEventWriteDurations; 890 @NonNull 891 private final Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseEventDeliveryLag; 892 @NonNull 893 private final Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseEventSizes; 894 @NonNull 895 private final Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseQueueDepth; 896 @NonNull 897 private final Map<@NonNull SseCommentRouteKey, @NonNull HistogramSnapshot> sseCommentDeliveryLag; 898 @NonNull 899 private final Map<@NonNull SseCommentRouteKey, @NonNull HistogramSnapshot> sseCommentSizes; 900 @NonNull 901 private final Map<@NonNull SseCommentRouteKey, @NonNull HistogramSnapshot> sseCommentQueueDepth; 902 @NonNull 903 private final Map<@NonNull SseStreamRouteTerminationKey, @NonNull HistogramSnapshot> sseStreamDurations; 904 @NonNull 905 private final Map<@NonNull McpEndpointRequestOutcomeKey, @NonNull Long> mcpRequests; 906 @NonNull 907 private final Map<@NonNull McpEndpointRequestOutcomeKey, @NonNull HistogramSnapshot> mcpRequestDurations; 908 @NonNull 909 private final Map<@NonNull McpEndpointSessionTerminationKey, @NonNull HistogramSnapshot> mcpSessionDurations; 910 @NonNull 911 private final Map<@NonNull McpEndpointSseStreamTerminationKey, @NonNull HistogramSnapshot> mcpSseStreamDurations; 912 913 /** 914 * Acquires an "empty" builder for {@link Snapshot} instances. 915 * 916 * @return the builder 917 */ 918 @NonNull 919 public static Builder builder() { 920 return new Builder(); 921 } 922 923 private Snapshot(@NonNull Builder builder) { 924 requireNonNull(builder); 925 926 this.activeRequests = requireNonNull(builder.activeRequests); 927 this.activeSseStreams = requireNonNull(builder.activeSseStreams); 928 this.activeMcpSessions = requireNonNull(builder.activeMcpSessions); 929 this.activeMcpSseStreams = requireNonNull(builder.activeMcpSseStreams); 930 this.httpConnectionsAccepted = requireNonNull(builder.httpConnectionsAccepted); 931 this.httpConnectionsRejected = requireNonNull(builder.httpConnectionsRejected); 932 this.sseConnectionsAccepted = requireNonNull(builder.sseConnectionsAccepted); 933 this.sseConnectionsRejected = requireNonNull(builder.sseConnectionsRejected); 934 this.mcpConnectionsAccepted = requireNonNull(builder.mcpConnectionsAccepted); 935 this.mcpConnectionsRejected = requireNonNull(builder.mcpConnectionsRejected); 936 this.httpRequestReadFailures = copyOrEmpty(builder.httpRequestReadFailures); 937 this.httpRequestRejections = copyOrEmpty(builder.httpRequestRejections); 938 this.sseRequestReadFailures = copyOrEmpty(builder.sseRequestReadFailures); 939 this.sseRequestRejections = copyOrEmpty(builder.sseRequestRejections); 940 this.mcpRequestReadFailures = copyOrEmpty(builder.mcpRequestReadFailures); 941 this.mcpRequestRejections = copyOrEmpty(builder.mcpRequestRejections); 942 this.httpRequestDurations = copyOrEmpty(builder.httpRequestDurations); 943 this.httpHandlerDurations = copyOrEmpty(builder.httpHandlerDurations); 944 this.httpTimeToFirstByte = copyOrEmpty(builder.httpTimeToFirstByte); 945 this.httpRequestBodyBytes = copyOrEmpty(builder.httpRequestBodyBytes); 946 this.httpResponseBodyBytes = copyOrEmpty(builder.httpResponseBodyBytes); 947 this.sseHandshakesAccepted = copyOrEmpty(builder.sseHandshakesAccepted); 948 this.sseHandshakesRejected = copyOrEmpty(builder.sseHandshakesRejected); 949 this.sseEventEnqueueOutcomes = copyOrEmpty(builder.sseEventEnqueueOutcomes); 950 this.sseCommentEnqueueOutcomes = copyOrEmpty(builder.sseCommentEnqueueOutcomes); 951 this.sseEventDrops = copyOrEmpty(builder.sseEventDrops); 952 this.sseCommentDrops = copyOrEmpty(builder.sseCommentDrops); 953 this.sseTimeToFirstEvent = copyOrEmpty(builder.sseTimeToFirstEvent); 954 this.sseEventWriteDurations = copyOrEmpty(builder.sseEventWriteDurations); 955 this.sseEventDeliveryLag = copyOrEmpty(builder.sseEventDeliveryLag); 956 this.sseEventSizes = copyOrEmpty(builder.sseEventSizes); 957 this.sseQueueDepth = copyOrEmpty(builder.sseQueueDepth); 958 this.sseCommentDeliveryLag = copyOrEmpty(builder.sseCommentDeliveryLag); 959 this.sseCommentSizes = copyOrEmpty(builder.sseCommentSizes); 960 this.sseCommentQueueDepth = copyOrEmpty(builder.sseCommentQueueDepth); 961 this.sseStreamDurations = copyOrEmpty(builder.sseStreamDurations); 962 this.mcpRequests = copyOrEmpty(builder.mcpRequests); 963 this.mcpRequestDurations = copyOrEmpty(builder.mcpRequestDurations); 964 this.mcpSessionDurations = copyOrEmpty(builder.mcpSessionDurations); 965 this.mcpSseStreamDurations = copyOrEmpty(builder.mcpSseStreamDurations); 966 } 967 968 /** 969 * Returns the number of active HTTP requests. 970 * 971 * @return the active HTTP request count 972 */ 973 @NonNull 974 public Long getActiveRequests() { 975 return this.activeRequests; 976 } 977 978 /** 979 * Returns the number of active server-sent event streams. 980 * 981 * @return the active SSE stream count 982 */ 983 @NonNull 984 public Long getActiveSseStreams() { 985 return this.activeSseStreams; 986 } 987 988 /** 989 * Returns the number of active MCP sessions. 990 * 991 * @return the active MCP session count 992 */ 993 @NonNull 994 public Long getActiveMcpSessions() { 995 return this.activeMcpSessions; 996 } 997 998 /** 999 * Returns the number of active MCP SSE streams. 1000 * 1001 * @return the active MCP SSE stream count 1002 */ 1003 @NonNull 1004 public Long getActiveMcpSseStreams() { 1005 return this.activeMcpSseStreams; 1006 } 1007 1008 /** 1009 * Returns the total number of accepted HTTP connections. 1010 * 1011 * @return total accepted HTTP connections 1012 */ 1013 @NonNull 1014 public Long getHttpConnectionsAccepted() { 1015 return this.httpConnectionsAccepted; 1016 } 1017 1018 /** 1019 * Returns the total number of rejected HTTP connections. 1020 * 1021 * @return total rejected HTTP connections 1022 */ 1023 @NonNull 1024 public Long getHttpConnectionsRejected() { 1025 return this.httpConnectionsRejected; 1026 } 1027 1028 /** 1029 * Returns the total number of accepted SSE connections. 1030 * 1031 * @return total accepted SSE connections 1032 */ 1033 @NonNull 1034 public Long getSseConnectionsAccepted() { 1035 return this.sseConnectionsAccepted; 1036 } 1037 1038 /** 1039 * Returns the total number of rejected SSE connections. 1040 * 1041 * @return total rejected SSE connections 1042 */ 1043 @NonNull 1044 public Long getSseConnectionsRejected() { 1045 return this.sseConnectionsRejected; 1046 } 1047 1048 /** 1049 * Returns the total number of accepted MCP connections. 1050 * 1051 * @return total accepted MCP connections 1052 */ 1053 @NonNull 1054 public Long getMcpConnectionsAccepted() { 1055 return this.mcpConnectionsAccepted; 1056 } 1057 1058 /** 1059 * Returns the total number of rejected MCP connections. 1060 * 1061 * @return total rejected MCP connections 1062 */ 1063 @NonNull 1064 public Long getMcpConnectionsRejected() { 1065 return this.mcpConnectionsRejected; 1066 } 1067 1068 /** 1069 * Returns HTTP request read failure counters keyed by failure reason. 1070 * 1071 * @return HTTP request read failure counters 1072 */ 1073 @NonNull 1074 public Map<@NonNull RequestReadFailureKey, @NonNull Long> getHttpRequestReadFailures() { 1075 return this.httpRequestReadFailures; 1076 } 1077 1078 /** 1079 * Returns HTTP request rejection counters keyed by rejection reason. 1080 * 1081 * @return HTTP request rejection counters 1082 */ 1083 @NonNull 1084 public Map<@NonNull RequestRejectionKey, @NonNull Long> getHttpRequestRejections() { 1085 return this.httpRequestRejections; 1086 } 1087 1088 /** 1089 * Returns SSE request read failure counters keyed by failure reason. 1090 * 1091 * @return SSE request read failure counters 1092 */ 1093 @NonNull 1094 public Map<@NonNull RequestReadFailureKey, @NonNull Long> getSseRequestReadFailures() { 1095 return this.sseRequestReadFailures; 1096 } 1097 1098 /** 1099 * Returns SSE request rejection counters keyed by rejection reason. 1100 * 1101 * @return SSE request rejection counters 1102 */ 1103 @NonNull 1104 public Map<@NonNull RequestRejectionKey, @NonNull Long> getSseRequestRejections() { 1105 return this.sseRequestRejections; 1106 } 1107 1108 /** 1109 * Returns MCP request read failure counters keyed by failure reason. 1110 * 1111 * @return MCP request read failure counters 1112 */ 1113 @NonNull 1114 public Map<@NonNull RequestReadFailureKey, @NonNull Long> getMcpRequestReadFailures() { 1115 return this.mcpRequestReadFailures; 1116 } 1117 1118 /** 1119 * Returns MCP request rejection counters keyed by rejection reason. 1120 * 1121 * @return MCP request rejection counters 1122 */ 1123 @NonNull 1124 public Map<@NonNull RequestRejectionKey, @NonNull Long> getMcpRequestRejections() { 1125 return this.mcpRequestRejections; 1126 } 1127 1128 /** 1129 * Returns HTTP request duration histograms keyed by server route and status class. 1130 * 1131 * @return HTTP request duration histograms 1132 */ 1133 @NonNull 1134 public Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> getHttpRequestDurations() { 1135 return this.httpRequestDurations; 1136 } 1137 1138 /** 1139 * Returns HTTP handler duration histograms keyed by server route and status class. 1140 * 1141 * @return HTTP handler duration histograms 1142 */ 1143 @NonNull 1144 public Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> getHttpHandlerDurations() { 1145 return this.httpHandlerDurations; 1146 } 1147 1148 /** 1149 * Returns HTTP time-to-first-byte histograms keyed by server route and status class. 1150 * 1151 * @return HTTP time-to-first-byte histograms 1152 */ 1153 @NonNull 1154 public Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> getHttpTimeToFirstByte() { 1155 return this.httpTimeToFirstByte; 1156 } 1157 1158 /** 1159 * Returns HTTP request body size histograms keyed by server route. 1160 * 1161 * @return HTTP request body size histograms 1162 */ 1163 @NonNull 1164 public Map<@NonNull HttpServerRouteKey, @NonNull HistogramSnapshot> getHttpRequestBodyBytes() { 1165 return this.httpRequestBodyBytes; 1166 } 1167 1168 /** 1169 * Returns HTTP response body size histograms keyed by server route and status class. 1170 * 1171 * @return HTTP response body size histograms 1172 */ 1173 @NonNull 1174 public Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> getHttpResponseBodyBytes() { 1175 return this.httpResponseBodyBytes; 1176 } 1177 1178 /** 1179 * Returns SSE handshake acceptance counters keyed by route. 1180 * 1181 * @return SSE handshake acceptance counters 1182 */ 1183 @NonNull 1184 public Map<@NonNull SseEventRouteKey, @NonNull Long> getSseHandshakesAccepted() { 1185 return this.sseHandshakesAccepted; 1186 } 1187 1188 /** 1189 * Returns SSE handshake rejection counters keyed by route and failure reason. 1190 * 1191 * @return SSE handshake rejection counters 1192 */ 1193 @NonNull 1194 public Map<@NonNull SseEventRouteHandshakeFailureKey, @NonNull Long> getSseHandshakesRejected() { 1195 return this.sseHandshakesRejected; 1196 } 1197 1198 /** 1199 * Returns SSE event enqueue outcome counters keyed by route and outcome. 1200 * 1201 * @return SSE event enqueue outcome counters 1202 */ 1203 @NonNull 1204 public Map<@NonNull SseEventRouteEnqueueOutcomeKey, @NonNull Long> getSseEventEnqueueOutcomes() { 1205 return this.sseEventEnqueueOutcomes; 1206 } 1207 1208 /** 1209 * Returns SSE comment enqueue outcome counters keyed by route, comment type, and outcome. 1210 * 1211 * @return SSE comment enqueue outcome counters 1212 */ 1213 @NonNull 1214 public Map<@NonNull SseCommentRouteEnqueueOutcomeKey, @NonNull Long> getSseCommentEnqueueOutcomes() { 1215 return this.sseCommentEnqueueOutcomes; 1216 } 1217 1218 /** 1219 * Returns SSE event drop counters keyed by route and drop reason. 1220 * 1221 * @return SSE event drop counters 1222 */ 1223 @NonNull 1224 public Map<@NonNull SseEventRouteDropKey, @NonNull Long> getSseEventDrops() { 1225 return this.sseEventDrops; 1226 } 1227 1228 /** 1229 * Returns SSE comment drop counters keyed by route, comment type, and drop reason. 1230 * 1231 * @return SSE comment drop counters 1232 */ 1233 @NonNull 1234 public Map<@NonNull SseCommentRouteDropKey, @NonNull Long> getSseCommentDrops() { 1235 return this.sseCommentDrops; 1236 } 1237 1238 /** 1239 * Returns SSE time-to-first-event histograms keyed by route. 1240 * 1241 * @return SSE time-to-first-event histograms 1242 */ 1243 @NonNull 1244 public Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> getSseTimeToFirstEvent() { 1245 return this.sseTimeToFirstEvent; 1246 } 1247 1248 /** 1249 * Returns SSE event write duration histograms keyed by route. 1250 * 1251 * @return SSE event write duration histograms 1252 */ 1253 @NonNull 1254 public Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> getSseEventWriteDurations() { 1255 return this.sseEventWriteDurations; 1256 } 1257 1258 /** 1259 * Returns SSE event delivery lag histograms keyed by route. 1260 * 1261 * @return SSE event delivery lag histograms 1262 */ 1263 @NonNull 1264 public Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> getSseEventDeliveryLag() { 1265 return this.sseEventDeliveryLag; 1266 } 1267 1268 /** 1269 * Returns SSE event size histograms keyed by route. 1270 * 1271 * @return SSE event size histograms 1272 */ 1273 @NonNull 1274 public Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> getSseEventSizes() { 1275 return this.sseEventSizes; 1276 } 1277 1278 /** 1279 * Returns SSE queue depth histograms keyed by route. 1280 * 1281 * @return SSE queue depth histograms 1282 */ 1283 @NonNull 1284 public Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> getSseQueueDepth() { 1285 return this.sseQueueDepth; 1286 } 1287 1288 /** 1289 * Returns SSE comment delivery lag histograms keyed by route and comment type. 1290 * 1291 * @return SSE comment delivery lag histograms 1292 */ 1293 @NonNull 1294 public Map<@NonNull SseCommentRouteKey, @NonNull HistogramSnapshot> getSseCommentDeliveryLag() { 1295 return this.sseCommentDeliveryLag; 1296 } 1297 1298 /** 1299 * Returns SSE comment size histograms keyed by route and comment type. 1300 * 1301 * @return SSE comment size histograms 1302 */ 1303 @NonNull 1304 public Map<@NonNull SseCommentRouteKey, @NonNull HistogramSnapshot> getSseCommentSizes() { 1305 return this.sseCommentSizes; 1306 } 1307 1308 /** 1309 * Returns SSE comment queue depth histograms keyed by route and comment type. 1310 * 1311 * @return SSE comment queue depth histograms 1312 */ 1313 @NonNull 1314 public Map<@NonNull SseCommentRouteKey, @NonNull HistogramSnapshot> getSseCommentQueueDepth() { 1315 return this.sseCommentQueueDepth; 1316 } 1317 1318 /** 1319 * Returns SSE stream duration histograms keyed by route and termination reason. 1320 * 1321 * @return SSE stream duration histograms 1322 */ 1323 @NonNull 1324 public Map<@NonNull SseStreamRouteTerminationKey, @NonNull HistogramSnapshot> getSseStreamDurations() { 1325 return this.sseStreamDurations; 1326 } 1327 1328 /** 1329 * Returns MCP request outcome counters keyed by endpoint, JSON-RPC method, and outcome. 1330 * 1331 * @return MCP request outcome counters 1332 */ 1333 @NonNull 1334 public Map<@NonNull McpEndpointRequestOutcomeKey, @NonNull Long> getMcpRequests() { 1335 return this.mcpRequests; 1336 } 1337 1338 /** 1339 * Returns MCP request duration histograms keyed by endpoint, JSON-RPC method, and outcome. 1340 * 1341 * @return MCP request duration histograms 1342 */ 1343 @NonNull 1344 public Map<@NonNull McpEndpointRequestOutcomeKey, @NonNull HistogramSnapshot> getMcpRequestDurations() { 1345 return this.mcpRequestDurations; 1346 } 1347 1348 /** 1349 * Returns MCP session duration histograms keyed by endpoint and termination reason. 1350 * 1351 * @return MCP session duration histograms 1352 */ 1353 @NonNull 1354 public Map<@NonNull McpEndpointSessionTerminationKey, @NonNull HistogramSnapshot> getMcpSessionDurations() { 1355 return this.mcpSessionDurations; 1356 } 1357 1358 /** 1359 * Returns MCP SSE stream duration histograms keyed by endpoint and termination reason. 1360 * 1361 * @return MCP SSE stream duration histograms 1362 */ 1363 @NonNull 1364 public Map<@NonNull McpEndpointSseStreamTerminationKey, @NonNull HistogramSnapshot> getMcpSseStreamDurations() { 1365 return this.mcpSseStreamDurations; 1366 } 1367 1368 @NonNull 1369 private static <K, V> Map<K, V> copyOrEmpty(@Nullable Map<K, V> map) { 1370 return map == null ? Map.of() : Map.copyOf(map); 1371 } 1372 1373 /** 1374 * Builder used to construct instances of {@link Snapshot}. 1375 * <p> 1376 * This class is intended for use by a single thread. 1377 * 1378 * @author <a href="https://www.revetkn.com">Mark Allen</a> 1379 */ 1380 @NotThreadSafe 1381 public static final class Builder { 1382 @NonNull 1383 private Long activeRequests; 1384 @NonNull 1385 private Long activeSseStreams; 1386 @NonNull 1387 private Long activeMcpSessions; 1388 @NonNull 1389 private Long activeMcpSseStreams; 1390 @NonNull 1391 private Long httpConnectionsAccepted; 1392 @NonNull 1393 private Long httpConnectionsRejected; 1394 @NonNull 1395 private Long sseConnectionsAccepted; 1396 @NonNull 1397 private Long sseConnectionsRejected; 1398 @NonNull 1399 private Long mcpConnectionsAccepted; 1400 @NonNull 1401 private Long mcpConnectionsRejected; 1402 @Nullable 1403 private Map<@NonNull RequestReadFailureKey, @NonNull Long> httpRequestReadFailures; 1404 @Nullable 1405 private Map<@NonNull RequestRejectionKey, @NonNull Long> httpRequestRejections; 1406 @Nullable 1407 private Map<@NonNull RequestReadFailureKey, @NonNull Long> sseRequestReadFailures; 1408 @Nullable 1409 private Map<@NonNull RequestRejectionKey, @NonNull Long> sseRequestRejections; 1410 @Nullable 1411 private Map<@NonNull RequestReadFailureKey, @NonNull Long> mcpRequestReadFailures; 1412 @Nullable 1413 private Map<@NonNull RequestRejectionKey, @NonNull Long> mcpRequestRejections; 1414 @Nullable 1415 private Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> httpRequestDurations; 1416 @Nullable 1417 private Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> httpHandlerDurations; 1418 @Nullable 1419 private Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> httpTimeToFirstByte; 1420 @Nullable 1421 private Map<@NonNull HttpServerRouteKey, @NonNull HistogramSnapshot> httpRequestBodyBytes; 1422 @Nullable 1423 private Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> httpResponseBodyBytes; 1424 @Nullable 1425 private Map<@NonNull SseEventRouteKey, @NonNull Long> sseHandshakesAccepted; 1426 @Nullable 1427 private Map<@NonNull SseEventRouteHandshakeFailureKey, @NonNull Long> sseHandshakesRejected; 1428 @Nullable 1429 private Map<@NonNull SseEventRouteEnqueueOutcomeKey, @NonNull Long> sseEventEnqueueOutcomes; 1430 @Nullable 1431 private Map<@NonNull SseCommentRouteEnqueueOutcomeKey, @NonNull Long> sseCommentEnqueueOutcomes; 1432 @Nullable 1433 private Map<@NonNull SseEventRouteDropKey, @NonNull Long> sseEventDrops; 1434 @Nullable 1435 private Map<@NonNull SseCommentRouteDropKey, @NonNull Long> sseCommentDrops; 1436 @Nullable 1437 private Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseTimeToFirstEvent; 1438 @Nullable 1439 private Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseEventWriteDurations; 1440 @Nullable 1441 private Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseEventDeliveryLag; 1442 @Nullable 1443 private Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseEventSizes; 1444 @Nullable 1445 private Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseQueueDepth; 1446 @Nullable 1447 private Map<@NonNull SseCommentRouteKey, @NonNull HistogramSnapshot> sseCommentDeliveryLag; 1448 @Nullable 1449 private Map<@NonNull SseCommentRouteKey, @NonNull HistogramSnapshot> sseCommentSizes; 1450 @Nullable 1451 private Map<@NonNull SseCommentRouteKey, @NonNull HistogramSnapshot> sseCommentQueueDepth; 1452 @Nullable 1453 private Map<@NonNull SseStreamRouteTerminationKey, @NonNull HistogramSnapshot> sseStreamDurations; 1454 @Nullable 1455 private Map<@NonNull McpEndpointRequestOutcomeKey, @NonNull Long> mcpRequests; 1456 @Nullable 1457 private Map<@NonNull McpEndpointRequestOutcomeKey, @NonNull HistogramSnapshot> mcpRequestDurations; 1458 @Nullable 1459 private Map<@NonNull McpEndpointSessionTerminationKey, @NonNull HistogramSnapshot> mcpSessionDurations; 1460 @Nullable 1461 private Map<@NonNull McpEndpointSseStreamTerminationKey, @NonNull HistogramSnapshot> mcpSseStreamDurations; 1462 1463 private Builder() { 1464 this.activeRequests = 0L; 1465 this.activeSseStreams = 0L; 1466 this.activeMcpSessions = 0L; 1467 this.activeMcpSseStreams = 0L; 1468 this.httpConnectionsAccepted = 0L; 1469 this.httpConnectionsRejected = 0L; 1470 this.sseConnectionsAccepted = 0L; 1471 this.sseConnectionsRejected = 0L; 1472 this.mcpConnectionsAccepted = 0L; 1473 this.mcpConnectionsRejected = 0L; 1474 } 1475 1476 /** 1477 * Sets the active HTTP request count. 1478 * 1479 * @param activeRequests the active HTTP request count 1480 * @return this builder 1481 */ 1482 @NonNull 1483 public Builder activeRequests(@NonNull Long activeRequests) { 1484 this.activeRequests = requireNonNull(activeRequests); 1485 return this; 1486 } 1487 1488 /** 1489 * Sets the active server-sent event stream count. 1490 * 1491 * @param activeSseStreams the active SSE stream count 1492 * @return this builder 1493 */ 1494 @NonNull 1495 public Builder activeSseStreams(@NonNull Long activeSseStreams) { 1496 this.activeSseStreams = requireNonNull(activeSseStreams); 1497 return this; 1498 } 1499 1500 /** 1501 * Sets the active MCP session count. 1502 * 1503 * @param activeMcpSessions the active MCP session count 1504 * @return this builder 1505 */ 1506 @NonNull 1507 public Builder activeMcpSessions(@NonNull Long activeMcpSessions) { 1508 this.activeMcpSessions = requireNonNull(activeMcpSessions); 1509 return this; 1510 } 1511 1512 /** 1513 * Sets the active MCP SSE stream count. 1514 * 1515 * @param activeMcpSseStreams the active MCP SSE stream count 1516 * @return this builder 1517 */ 1518 @NonNull 1519 public Builder activeMcpSseStreams(@NonNull Long activeMcpSseStreams) { 1520 this.activeMcpSseStreams = requireNonNull(activeMcpSseStreams); 1521 return this; 1522 } 1523 1524 /** 1525 * Sets the total number of accepted HTTP connections. 1526 * 1527 * @param httpConnectionsAccepted total accepted HTTP connections 1528 * @return this builder 1529 */ 1530 @NonNull 1531 public Builder httpConnectionsAccepted(@NonNull Long httpConnectionsAccepted) { 1532 this.httpConnectionsAccepted = requireNonNull(httpConnectionsAccepted); 1533 return this; 1534 } 1535 1536 /** 1537 * Sets the total number of rejected HTTP connections. 1538 * 1539 * @param httpConnectionsRejected total rejected HTTP connections 1540 * @return this builder 1541 */ 1542 @NonNull 1543 public Builder httpConnectionsRejected(@NonNull Long httpConnectionsRejected) { 1544 this.httpConnectionsRejected = requireNonNull(httpConnectionsRejected); 1545 return this; 1546 } 1547 1548 /** 1549 * Sets the total number of accepted SSE connections. 1550 * 1551 * @param sseConnectionsAccepted total accepted SSE connections 1552 * @return this builder 1553 */ 1554 @NonNull 1555 public Builder sseConnectionsAccepted(@NonNull Long sseConnectionsAccepted) { 1556 this.sseConnectionsAccepted = requireNonNull(sseConnectionsAccepted); 1557 return this; 1558 } 1559 1560 /** 1561 * Sets the total number of rejected SSE connections. 1562 * 1563 * @param sseConnectionsRejected total rejected SSE connections 1564 * @return this builder 1565 */ 1566 @NonNull 1567 public Builder sseConnectionsRejected(@NonNull Long sseConnectionsRejected) { 1568 this.sseConnectionsRejected = requireNonNull(sseConnectionsRejected); 1569 return this; 1570 } 1571 1572 /** 1573 * Sets the total number of accepted MCP connections. 1574 * 1575 * @param mcpConnectionsAccepted total accepted MCP connections 1576 * @return this builder 1577 */ 1578 @NonNull 1579 public Builder mcpConnectionsAccepted(@NonNull Long mcpConnectionsAccepted) { 1580 this.mcpConnectionsAccepted = requireNonNull(mcpConnectionsAccepted); 1581 return this; 1582 } 1583 1584 /** 1585 * Sets the total number of rejected MCP connections. 1586 * 1587 * @param mcpConnectionsRejected total rejected MCP connections 1588 * @return this builder 1589 */ 1590 @NonNull 1591 public Builder mcpConnectionsRejected(@NonNull Long mcpConnectionsRejected) { 1592 this.mcpConnectionsRejected = requireNonNull(mcpConnectionsRejected); 1593 return this; 1594 } 1595 1596 /** 1597 * Sets HTTP request read failure counters keyed by failure reason. 1598 * 1599 * @param httpRequestReadFailures the HTTP request read failure counters 1600 * @return this builder 1601 */ 1602 @NonNull 1603 public Builder httpRequestReadFailures( 1604 @Nullable Map<@NonNull RequestReadFailureKey, @NonNull Long> httpRequestReadFailures) { 1605 this.httpRequestReadFailures = httpRequestReadFailures; 1606 return this; 1607 } 1608 1609 /** 1610 * Sets HTTP request rejection counters keyed by rejection reason. 1611 * 1612 * @param httpRequestRejections the HTTP request rejection counters 1613 * @return this builder 1614 */ 1615 @NonNull 1616 public Builder httpRequestRejections( 1617 @Nullable Map<@NonNull RequestRejectionKey, @NonNull Long> httpRequestRejections) { 1618 this.httpRequestRejections = httpRequestRejections; 1619 return this; 1620 } 1621 1622 /** 1623 * Sets SSE request read failure counters keyed by failure reason. 1624 * 1625 * @param sseRequestReadFailures the SSE request read failure counters 1626 * @return this builder 1627 */ 1628 @NonNull 1629 public Builder sseRequestReadFailures( 1630 @Nullable Map<@NonNull RequestReadFailureKey, @NonNull Long> sseRequestReadFailures) { 1631 this.sseRequestReadFailures = sseRequestReadFailures; 1632 return this; 1633 } 1634 1635 /** 1636 * Sets SSE request rejection counters keyed by rejection reason. 1637 * 1638 * @param sseRequestRejections the SSE request rejection counters 1639 * @return this builder 1640 */ 1641 @NonNull 1642 public Builder sseRequestRejections( 1643 @Nullable Map<@NonNull RequestRejectionKey, @NonNull Long> sseRequestRejections) { 1644 this.sseRequestRejections = sseRequestRejections; 1645 return this; 1646 } 1647 1648 /** 1649 * Sets MCP request read failure counters keyed by failure reason. 1650 * 1651 * @param mcpRequestReadFailures the MCP request read failure counters 1652 * @return this builder 1653 */ 1654 @NonNull 1655 public Builder mcpRequestReadFailures( 1656 @Nullable Map<@NonNull RequestReadFailureKey, @NonNull Long> mcpRequestReadFailures) { 1657 this.mcpRequestReadFailures = mcpRequestReadFailures; 1658 return this; 1659 } 1660 1661 /** 1662 * Sets MCP request rejection counters keyed by rejection reason. 1663 * 1664 * @param mcpRequestRejections the MCP request rejection counters 1665 * @return this builder 1666 */ 1667 @NonNull 1668 public Builder mcpRequestRejections( 1669 @Nullable Map<@NonNull RequestRejectionKey, @NonNull Long> mcpRequestRejections) { 1670 this.mcpRequestRejections = mcpRequestRejections; 1671 return this; 1672 } 1673 1674 /** 1675 * Sets HTTP request duration histograms keyed by server route and status class. 1676 * 1677 * @param httpRequestDurations the HTTP request duration histograms 1678 * @return this builder 1679 */ 1680 @NonNull 1681 public Builder httpRequestDurations( 1682 @Nullable Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> httpRequestDurations) { 1683 this.httpRequestDurations = httpRequestDurations; 1684 return this; 1685 } 1686 1687 /** 1688 * Sets HTTP handler duration histograms keyed by server route and status class. 1689 * 1690 * @param httpHandlerDurations the HTTP handler duration histograms 1691 * @return this builder 1692 */ 1693 @NonNull 1694 public Builder httpHandlerDurations( 1695 @Nullable Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> httpHandlerDurations) { 1696 this.httpHandlerDurations = httpHandlerDurations; 1697 return this; 1698 } 1699 1700 /** 1701 * Sets HTTP time-to-first-byte histograms keyed by server route and status class. 1702 * 1703 * @param httpTimeToFirstByte the HTTP time-to-first-byte histograms 1704 * @return this builder 1705 */ 1706 @NonNull 1707 public Builder httpTimeToFirstByte( 1708 @Nullable Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> httpTimeToFirstByte) { 1709 this.httpTimeToFirstByte = httpTimeToFirstByte; 1710 return this; 1711 } 1712 1713 /** 1714 * Sets HTTP request body size histograms keyed by server route. 1715 * 1716 * @param httpRequestBodyBytes the HTTP request body size histograms 1717 * @return this builder 1718 */ 1719 @NonNull 1720 public Builder httpRequestBodyBytes( 1721 @Nullable Map<@NonNull HttpServerRouteKey, @NonNull HistogramSnapshot> httpRequestBodyBytes) { 1722 this.httpRequestBodyBytes = httpRequestBodyBytes; 1723 return this; 1724 } 1725 1726 /** 1727 * Sets HTTP response body size histograms keyed by server route and status class. 1728 * 1729 * @param httpResponseBodyBytes the HTTP response body size histograms 1730 * @return this builder 1731 */ 1732 @NonNull 1733 public Builder httpResponseBodyBytes( 1734 @Nullable Map<@NonNull HttpServerRouteStatusKey, @NonNull HistogramSnapshot> httpResponseBodyBytes) { 1735 this.httpResponseBodyBytes = httpResponseBodyBytes; 1736 return this; 1737 } 1738 1739 /** 1740 * Sets SSE handshake acceptance counters keyed by route. 1741 * 1742 * @param sseHandshakesAccepted SSE handshake acceptance counters 1743 * @return this builder 1744 */ 1745 @NonNull 1746 public Builder sseHandshakesAccepted( 1747 @Nullable Map<@NonNull SseEventRouteKey, @NonNull Long> sseHandshakesAccepted) { 1748 this.sseHandshakesAccepted = sseHandshakesAccepted; 1749 return this; 1750 } 1751 1752 /** 1753 * Sets SSE handshake rejection counters keyed by route and failure reason. 1754 * 1755 * @param sseHandshakesRejected SSE handshake rejection counters 1756 * @return this builder 1757 */ 1758 @NonNull 1759 public Builder sseHandshakesRejected( 1760 @Nullable Map<@NonNull SseEventRouteHandshakeFailureKey, @NonNull Long> sseHandshakesRejected) { 1761 this.sseHandshakesRejected = sseHandshakesRejected; 1762 return this; 1763 } 1764 1765 /** 1766 * Sets SSE event enqueue outcome counters keyed by route and outcome. 1767 * 1768 * @param sseEventEnqueueOutcomes the SSE event enqueue outcome counters 1769 * @return this builder 1770 */ 1771 @NonNull 1772 public Builder sseEventEnqueueOutcomes( 1773 @Nullable Map<@NonNull SseEventRouteEnqueueOutcomeKey, @NonNull Long> sseEventEnqueueOutcomes) { 1774 this.sseEventEnqueueOutcomes = sseEventEnqueueOutcomes; 1775 return this; 1776 } 1777 1778 /** 1779 * Sets SSE comment enqueue outcome counters keyed by route, comment type, and outcome. 1780 * 1781 * @param sseCommentEnqueueOutcomes the SSE comment enqueue outcome counters 1782 * @return this builder 1783 */ 1784 @NonNull 1785 public Builder sseCommentEnqueueOutcomes( 1786 @Nullable Map<@NonNull SseCommentRouteEnqueueOutcomeKey, @NonNull Long> sseCommentEnqueueOutcomes) { 1787 this.sseCommentEnqueueOutcomes = sseCommentEnqueueOutcomes; 1788 return this; 1789 } 1790 1791 /** 1792 * Sets SSE event drop counters keyed by route and drop reason. 1793 * 1794 * @param sseEventDrops the SSE event drop counters 1795 * @return this builder 1796 */ 1797 @NonNull 1798 public Builder sseEventDrops( 1799 @Nullable Map<@NonNull SseEventRouteDropKey, @NonNull Long> sseEventDrops) { 1800 this.sseEventDrops = sseEventDrops; 1801 return this; 1802 } 1803 1804 /** 1805 * Sets SSE comment drop counters keyed by route, comment type, and drop reason. 1806 * 1807 * @param sseCommentDrops the SSE comment drop counters 1808 * @return this builder 1809 */ 1810 @NonNull 1811 public Builder sseCommentDrops( 1812 @Nullable Map<@NonNull SseCommentRouteDropKey, @NonNull Long> sseCommentDrops) { 1813 this.sseCommentDrops = sseCommentDrops; 1814 return this; 1815 } 1816 1817 /** 1818 * Sets SSE time-to-first-event histograms keyed by route. 1819 * 1820 * @param sseTimeToFirstEvent the SSE time-to-first-event histograms 1821 * @return this builder 1822 */ 1823 @NonNull 1824 public Builder sseTimeToFirstEvent( 1825 @Nullable Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseTimeToFirstEvent) { 1826 this.sseTimeToFirstEvent = sseTimeToFirstEvent; 1827 return this; 1828 } 1829 1830 /** 1831 * Sets SSE event write duration histograms keyed by route. 1832 * 1833 * @param sseEventWriteDurations the SSE event write duration histograms 1834 * @return this builder 1835 */ 1836 @NonNull 1837 public Builder sseEventWriteDurations( 1838 @Nullable Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseEventWriteDurations) { 1839 this.sseEventWriteDurations = sseEventWriteDurations; 1840 return this; 1841 } 1842 1843 /** 1844 * Sets SSE event delivery lag histograms keyed by route. 1845 * 1846 * @param sseEventDeliveryLag the SSE event delivery lag histograms 1847 * @return this builder 1848 */ 1849 @NonNull 1850 public Builder sseEventDeliveryLag( 1851 @Nullable Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseEventDeliveryLag) { 1852 this.sseEventDeliveryLag = sseEventDeliveryLag; 1853 return this; 1854 } 1855 1856 /** 1857 * Sets SSE event size histograms keyed by route. 1858 * 1859 * @param sseEventSizes the SSE event size histograms 1860 * @return this builder 1861 */ 1862 @NonNull 1863 public Builder sseEventSizes( 1864 @Nullable Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseEventSizes) { 1865 this.sseEventSizes = sseEventSizes; 1866 return this; 1867 } 1868 1869 /** 1870 * Sets SSE queue depth histograms keyed by route. 1871 * 1872 * @param sseQueueDepth the SSE queue depth histograms 1873 * @return this builder 1874 */ 1875 @NonNull 1876 public Builder sseQueueDepth( 1877 @Nullable Map<@NonNull SseEventRouteKey, @NonNull HistogramSnapshot> sseQueueDepth) { 1878 this.sseQueueDepth = sseQueueDepth; 1879 return this; 1880 } 1881 1882 /** 1883 * Sets SSE comment delivery lag histograms keyed by route and comment type. 1884 * 1885 * @param sseCommentDeliveryLag the SSE comment delivery lag histograms 1886 * @return this builder 1887 */ 1888 @NonNull 1889 public Builder sseCommentDeliveryLag( 1890 @Nullable Map<@NonNull SseCommentRouteKey, @NonNull HistogramSnapshot> sseCommentDeliveryLag) { 1891 this.sseCommentDeliveryLag = sseCommentDeliveryLag; 1892 return this; 1893 } 1894 1895 /** 1896 * Sets SSE comment size histograms keyed by route and comment type. 1897 * 1898 * @param sseCommentSizes the SSE comment size histograms 1899 * @return this builder 1900 */ 1901 @NonNull 1902 public Builder sseCommentSizes( 1903 @Nullable Map<@NonNull SseCommentRouteKey, @NonNull HistogramSnapshot> sseCommentSizes) { 1904 this.sseCommentSizes = sseCommentSizes; 1905 return this; 1906 } 1907 1908 /** 1909 * Sets SSE comment queue depth histograms keyed by route and comment type. 1910 * 1911 * @param sseCommentQueueDepth the SSE comment queue depth histograms 1912 * @return this builder 1913 */ 1914 @NonNull 1915 public Builder sseCommentQueueDepth( 1916 @Nullable Map<@NonNull SseCommentRouteKey, @NonNull HistogramSnapshot> sseCommentQueueDepth) { 1917 this.sseCommentQueueDepth = sseCommentQueueDepth; 1918 return this; 1919 } 1920 1921 /** 1922 * Sets SSE stream duration histograms keyed by route and termination reason. 1923 * 1924 * @param sseStreamDurations the SSE stream duration histograms 1925 * @return this builder 1926 */ 1927 @NonNull 1928 public Builder sseStreamDurations( 1929 @Nullable Map<@NonNull SseStreamRouteTerminationKey, @NonNull HistogramSnapshot> sseStreamDurations) { 1930 this.sseStreamDurations = sseStreamDurations; 1931 return this; 1932 } 1933 1934 /** 1935 * Sets MCP request outcome counters keyed by endpoint, JSON-RPC method, and outcome. 1936 * 1937 * @param mcpRequests the MCP request outcome counters 1938 * @return this builder 1939 */ 1940 @NonNull 1941 public Builder mcpRequests( 1942 @Nullable Map<@NonNull McpEndpointRequestOutcomeKey, @NonNull Long> mcpRequests) { 1943 this.mcpRequests = mcpRequests; 1944 return this; 1945 } 1946 1947 /** 1948 * Sets MCP request duration histograms keyed by endpoint, JSON-RPC method, and outcome. 1949 * 1950 * @param mcpRequestDurations the MCP request duration histograms 1951 * @return this builder 1952 */ 1953 @NonNull 1954 public Builder mcpRequestDurations( 1955 @Nullable Map<@NonNull McpEndpointRequestOutcomeKey, @NonNull HistogramSnapshot> mcpRequestDurations) { 1956 this.mcpRequestDurations = mcpRequestDurations; 1957 return this; 1958 } 1959 1960 /** 1961 * Sets MCP session duration histograms keyed by endpoint and termination reason. 1962 * 1963 * @param mcpSessionDurations the MCP session duration histograms 1964 * @return this builder 1965 */ 1966 @NonNull 1967 public Builder mcpSessionDurations( 1968 @Nullable Map<@NonNull McpEndpointSessionTerminationKey, @NonNull HistogramSnapshot> mcpSessionDurations) { 1969 this.mcpSessionDurations = mcpSessionDurations; 1970 return this; 1971 } 1972 1973 /** 1974 * Sets MCP SSE stream duration histograms keyed by endpoint and termination reason. 1975 * 1976 * @param mcpSseStreamDurations the MCP SSE stream duration histograms 1977 * @return this builder 1978 */ 1979 @NonNull 1980 public Builder mcpSseStreamDurations( 1981 @Nullable Map<@NonNull McpEndpointSseStreamTerminationKey, @NonNull HistogramSnapshot> mcpSseStreamDurations) { 1982 this.mcpSseStreamDurations = mcpSseStreamDurations; 1983 return this; 1984 } 1985 1986 /** 1987 * Builds a {@link Snapshot} instance. 1988 * 1989 * @return the built snapshot 1990 */ 1991 @NonNull 1992 public Snapshot build() { 1993 return new Snapshot(this); 1994 } 1995 } 1996 } 1997 1998 /** 1999 * A thread-safe histogram with fixed bucket boundaries. 2000 * <p> 2001 * Negative values are ignored. Buckets use inclusive upper bounds, and snapshots include 2002 * an overflow bucket represented by a {@link HistogramSnapshot#getBucketBoundary(int)} of 2003 * {@link Long#MAX_VALUE}. 2004 * 2005 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2006 */ 2007 @ThreadSafe 2008 final class Histogram { 2009 @NonNull 2010 private final long[] bucketBoundaries; 2011 @NonNull 2012 private final LongAdder[] bucketCounts; 2013 @NonNull 2014 private final LongAdder count; 2015 @NonNull 2016 private final LongAdder sum; 2017 @NonNull 2018 private final AtomicLong min; 2019 @NonNull 2020 private final AtomicLong max; 2021 2022 /** 2023 * Creates a histogram with the provided bucket boundaries. 2024 * 2025 * @param bucketBoundaries inclusive upper bounds for buckets 2026 */ 2027 public Histogram(@NonNull long[] bucketBoundaries) { 2028 requireNonNull(bucketBoundaries); 2029 2030 this.bucketBoundaries = bucketBoundaries.clone(); 2031 Arrays.sort(this.bucketBoundaries); 2032 this.bucketCounts = new LongAdder[this.bucketBoundaries.length + 1]; 2033 for (int i = 0; i < this.bucketCounts.length; i++) 2034 this.bucketCounts[i] = new LongAdder(); 2035 this.count = new LongAdder(); 2036 this.sum = new LongAdder(); 2037 this.min = new AtomicLong(Long.MAX_VALUE); 2038 this.max = new AtomicLong(Long.MIN_VALUE); 2039 } 2040 2041 /** 2042 * Records a value into the histogram. 2043 * 2044 * @param value the value to record 2045 */ 2046 public void record(long value) { 2047 if (value < 0) 2048 return; 2049 2050 this.count.increment(); 2051 this.sum.add(value); 2052 updateMin(value); 2053 updateMax(value); 2054 2055 int bucketIndex = bucketIndex(value); 2056 this.bucketCounts[bucketIndex].increment(); 2057 } 2058 2059 /** 2060 * Captures an immutable snapshot of the histogram. 2061 * 2062 * @return the histogram snapshot 2063 */ 2064 @NonNull 2065 public HistogramSnapshot snapshot() { 2066 long[] boundariesWithOverflow = Arrays.copyOf(this.bucketBoundaries, this.bucketBoundaries.length + 1); 2067 boundariesWithOverflow[boundariesWithOverflow.length - 1] = Long.MAX_VALUE; 2068 2069 long[] cumulativeCounts = new long[this.bucketCounts.length]; 2070 long cumulative = 0; 2071 for (int i = 0; i < this.bucketCounts.length; i++) { 2072 cumulative += this.bucketCounts[i].sum(); 2073 cumulativeCounts[i] = cumulative; 2074 } 2075 2076 long countSnapshot = this.count.sum(); 2077 long sumSnapshot = this.sum.sum(); 2078 long minSnapshot = this.min.get(); 2079 long maxSnapshot = this.max.get(); 2080 2081 if (minSnapshot == Long.MAX_VALUE) 2082 minSnapshot = 0; 2083 if (maxSnapshot == Long.MIN_VALUE) 2084 maxSnapshot = 0; 2085 2086 return new HistogramSnapshot(boundariesWithOverflow, cumulativeCounts, countSnapshot, sumSnapshot, minSnapshot, maxSnapshot); 2087 } 2088 2089 /** 2090 * Resets all counts and min/max values. 2091 */ 2092 public void reset() { 2093 this.count.reset(); 2094 this.sum.reset(); 2095 this.min.set(Long.MAX_VALUE); 2096 this.max.set(Long.MIN_VALUE); 2097 for (LongAdder bucket : this.bucketCounts) 2098 bucket.reset(); 2099 } 2100 2101 private int bucketIndex(long value) { 2102 for (int i = 0; i < this.bucketBoundaries.length; i++) 2103 if (value <= this.bucketBoundaries[i]) 2104 return i; 2105 2106 return this.bucketBoundaries.length; 2107 } 2108 2109 private void updateMin(long value) { 2110 long current; 2111 while (value < (current = this.min.get())) { 2112 if (this.min.compareAndSet(current, value)) 2113 break; 2114 } 2115 } 2116 2117 private void updateMax(long value) { 2118 long current; 2119 while (value > (current = this.max.get())) { 2120 if (this.max.compareAndSet(current, value)) 2121 break; 2122 } 2123 } 2124 } 2125 2126 /** 2127 * Immutable snapshot of a {@link Histogram}. 2128 * <p> 2129 * Bucket counts are cumulative. Boundaries are inclusive upper bounds, and the final 2130 * boundary is {@link Long#MAX_VALUE} to represent the overflow bucket. Units are the same 2131 * as values passed to {@link Histogram#record(long)}. 2132 * 2133 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2134 */ 2135 @ThreadSafe 2136 final class HistogramSnapshot { 2137 @NonNull 2138 private final long[] bucketBoundaries; 2139 @NonNull 2140 private final long[] bucketCumulativeCounts; 2141 private final long count; 2142 private final long sum; 2143 private final long min; 2144 private final long max; 2145 2146 /** 2147 * Creates an immutable histogram snapshot. 2148 * 2149 * @param bucketBoundaries inclusive upper bounds for buckets, including overflow 2150 * @param bucketCumulativeCounts cumulative counts for each bucket 2151 * @param count total number of samples recorded 2152 * @param sum sum of all recorded values 2153 * @param min smallest recorded value (or 0 if none) 2154 * @param max largest recorded value (or 0 if none) 2155 */ 2156 public HistogramSnapshot(@NonNull long[] bucketBoundaries, 2157 @NonNull long[] bucketCumulativeCounts, 2158 long count, 2159 long sum, 2160 long min, 2161 long max) { 2162 requireNonNull(bucketBoundaries); 2163 requireNonNull(bucketCumulativeCounts); 2164 2165 if (bucketBoundaries.length != bucketCumulativeCounts.length) 2166 throw new IllegalArgumentException("Bucket boundaries and cumulative counts must be the same length"); 2167 2168 this.bucketBoundaries = bucketBoundaries.clone(); 2169 this.bucketCumulativeCounts = bucketCumulativeCounts.clone(); 2170 this.count = count; 2171 this.sum = sum; 2172 this.min = min; 2173 this.max = max; 2174 } 2175 2176 /** 2177 * Number of histogram buckets, including the overflow bucket. 2178 * 2179 * @return the bucket count 2180 */ 2181 public int getBucketCount() { 2182 return this.bucketBoundaries.length; 2183 } 2184 2185 /** 2186 * The inclusive upper bound for the bucket at the given index. 2187 * 2188 * @param index the bucket index 2189 * @return the bucket boundary 2190 */ 2191 public long getBucketBoundary(int index) { 2192 return this.bucketBoundaries[index]; 2193 } 2194 2195 /** 2196 * The cumulative count for the bucket at the given index. 2197 * 2198 * @param index the bucket index 2199 * @return the cumulative count 2200 */ 2201 public long getBucketCumulativeCount(int index) { 2202 return this.bucketCumulativeCounts[index]; 2203 } 2204 2205 /** 2206 * Total number of recorded values. 2207 * 2208 * @return the count 2209 */ 2210 public long getCount() { 2211 return this.count; 2212 } 2213 2214 /** 2215 * Sum of all recorded values. 2216 * 2217 * @return the sum 2218 */ 2219 public long getSum() { 2220 return this.sum; 2221 } 2222 2223 /** 2224 * Smallest recorded value, or 0 if no values were recorded. 2225 * 2226 * @return the minimum value 2227 */ 2228 public long getMin() { 2229 return this.min; 2230 } 2231 2232 /** 2233 * Largest recorded value, or 0 if no values were recorded. 2234 * 2235 * @return the maximum value 2236 */ 2237 public long getMax() { 2238 return this.max; 2239 } 2240 2241 /** 2242 * Returns an approximate percentile based on bucket boundaries. 2243 * 2244 * @param percentile percentile between 0 and 100 2245 * @return the approximated percentile value 2246 */ 2247 public long getPercentile(double percentile) { 2248 if (percentile <= 0.0) 2249 return this.min; 2250 if (percentile >= 100.0) 2251 return this.max; 2252 if (this.count == 0) 2253 return 0; 2254 2255 long threshold = (long) Math.ceil((percentile / 100.0) * this.count); 2256 2257 for (int i = 0; i < this.bucketCumulativeCounts.length; i++) 2258 if (this.bucketCumulativeCounts[i] >= threshold) 2259 return this.bucketBoundaries[i]; 2260 2261 return this.bucketBoundaries[this.bucketBoundaries.length - 1]; 2262 } 2263 2264 @Override 2265 public String toString() { 2266 return String.format("%s{count=%d, min=%d, max=%d, sum=%d, bucketBoundaries=%s}", 2267 getClass().getSimpleName(), this.count, this.min, this.max, this.sum, Arrays.toString(this.bucketBoundaries)); 2268 } 2269 } 2270 2271 /** 2272 * Indicates whether a request was matched to a {@link ResourcePathDeclaration}. 2273 * 2274 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2275 */ 2276 enum RouteType { 2277 /** 2278 * The request matched a {@link ResourcePathDeclaration}. 2279 */ 2280 MATCHED, 2281 /** 2282 * The request did not match any {@link ResourcePathDeclaration}. 2283 */ 2284 UNMATCHED 2285 } 2286 2287 /** 2288 * Outcomes for a Server-Sent Event enqueue attempt. 2289 * 2290 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2291 */ 2292 enum SseEventEnqueueOutcome { 2293 /** 2294 * An enqueue attempt to a connection was made. 2295 */ 2296 ATTEMPTED, 2297 /** 2298 * An enqueue attempt succeeded. 2299 */ 2300 ENQUEUED, 2301 /** 2302 * An enqueue attempt failed because the payload was dropped. 2303 */ 2304 DROPPED 2305 } 2306 2307 /** 2308 * Reasons a Server-Sent Event payload or comment was dropped before it could be written. 2309 * 2310 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2311 */ 2312 enum SseEventDropReason { 2313 /** 2314 * The per-connection write queue was full. 2315 */ 2316 QUEUE_FULL 2317 } 2318 2319 /** 2320 * Key for request read failures grouped by reason. 2321 * 2322 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2323 */ 2324 record RequestReadFailureKey(@NonNull RequestReadFailureReason reason) { 2325 public RequestReadFailureKey { 2326 requireNonNull(reason); 2327 } 2328 } 2329 2330 /** 2331 * Key for request rejections grouped by reason. 2332 * 2333 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2334 */ 2335 record RequestRejectionKey(@NonNull RequestRejectionReason reason) { 2336 public RequestRejectionKey { 2337 requireNonNull(reason); 2338 } 2339 } 2340 2341 /** 2342 * Key for metrics grouped by HTTP method and route match information. 2343 * 2344 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2345 */ 2346 record HttpServerRouteKey(@NonNull HttpMethod method, 2347 @NonNull RouteType routeType, 2348 @Nullable ResourcePathDeclaration route) { 2349 public HttpServerRouteKey { 2350 requireNonNull(method); 2351 requireNonNull(routeType); 2352 if (routeType == RouteType.MATCHED && route == null) 2353 throw new IllegalArgumentException("Route must be provided when RouteType is MATCHED"); 2354 if (routeType == RouteType.UNMATCHED && route != null) 2355 throw new IllegalArgumentException("Route must be null when RouteType is UNMATCHED"); 2356 } 2357 } 2358 2359 /** 2360 * Key for metrics grouped by HTTP method, route match information, and status class (e.g. 2xx). 2361 * 2362 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2363 */ 2364 record HttpServerRouteStatusKey(@NonNull HttpMethod method, 2365 @NonNull RouteType routeType, 2366 @Nullable ResourcePathDeclaration route, 2367 @NonNull String statusClass) { 2368 public HttpServerRouteStatusKey { 2369 requireNonNull(method); 2370 requireNonNull(routeType); 2371 if (routeType == RouteType.MATCHED && route == null) 2372 throw new IllegalArgumentException("Route must be provided when RouteType is MATCHED"); 2373 if (routeType == RouteType.UNMATCHED && route != null) 2374 throw new IllegalArgumentException("Route must be null when RouteType is UNMATCHED"); 2375 requireNonNull(statusClass); 2376 } 2377 } 2378 2379 /** 2380 * Key for metrics grouped by Server-Sent Event comment type and route match information. 2381 * 2382 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2383 */ 2384 record SseCommentRouteKey(@NonNull RouteType routeType, 2385 @Nullable ResourcePathDeclaration route, 2386 SseComment.@NonNull CommentType commentType) { 2387 public SseCommentRouteKey { 2388 requireNonNull(routeType); 2389 requireNonNull(commentType); 2390 if (routeType == RouteType.MATCHED && route == null) 2391 throw new IllegalArgumentException("Route must be provided when RouteType is MATCHED"); 2392 if (routeType == RouteType.UNMATCHED && route != null) 2393 throw new IllegalArgumentException("Route must be null when RouteType is UNMATCHED"); 2394 } 2395 } 2396 2397 /** 2398 * Key for metrics grouped by Server-Sent Event route match information. 2399 * 2400 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2401 */ 2402 record SseEventRouteKey(@NonNull RouteType routeType, 2403 @Nullable ResourcePathDeclaration route) { 2404 public SseEventRouteKey { 2405 requireNonNull(routeType); 2406 if (routeType == RouteType.MATCHED && route == null) 2407 throw new IllegalArgumentException("Route must be provided when RouteType is MATCHED"); 2408 if (routeType == RouteType.UNMATCHED && route != null) 2409 throw new IllegalArgumentException("Route must be null when RouteType is UNMATCHED"); 2410 } 2411 } 2412 2413 /** 2414 * Key for metrics grouped by Server-Sent Event route match information and handshake failure reason. 2415 * 2416 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2417 */ 2418 record SseEventRouteHandshakeFailureKey(@NonNull RouteType routeType, 2419 @Nullable ResourcePathDeclaration route, 2420 SseConnection.@NonNull HandshakeFailureReason handshakeFailureReason) { 2421 public SseEventRouteHandshakeFailureKey { 2422 requireNonNull(routeType); 2423 if (routeType == RouteType.MATCHED && route == null) 2424 throw new IllegalArgumentException("Route must be provided when RouteType is MATCHED"); 2425 if (routeType == RouteType.UNMATCHED && route != null) 2426 throw new IllegalArgumentException("Route must be null when RouteType is UNMATCHED"); 2427 requireNonNull(handshakeFailureReason); 2428 } 2429 } 2430 2431 /** 2432 * Key for metrics grouped by Server-Sent Event route match information and enqueue outcome. 2433 * 2434 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2435 */ 2436 record SseEventRouteEnqueueOutcomeKey(@NonNull RouteType routeType, 2437 @Nullable ResourcePathDeclaration route, 2438 @NonNull SseEventEnqueueOutcome outcome) { 2439 public SseEventRouteEnqueueOutcomeKey { 2440 requireNonNull(routeType); 2441 if (routeType == RouteType.MATCHED && route == null) 2442 throw new IllegalArgumentException("Route must be provided when RouteType is MATCHED"); 2443 if (routeType == RouteType.UNMATCHED && route != null) 2444 throw new IllegalArgumentException("Route must be null when RouteType is UNMATCHED"); 2445 requireNonNull(outcome); 2446 } 2447 } 2448 2449 /** 2450 * Key for metrics grouped by Server-Sent Event comment type, route match information, and enqueue outcome. 2451 * 2452 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2453 */ 2454 record SseCommentRouteEnqueueOutcomeKey(@NonNull RouteType routeType, 2455 @Nullable ResourcePathDeclaration route, 2456 SseComment.@NonNull CommentType commentType, 2457 @NonNull SseEventEnqueueOutcome outcome) { 2458 public SseCommentRouteEnqueueOutcomeKey { 2459 requireNonNull(routeType); 2460 requireNonNull(commentType); 2461 if (routeType == RouteType.MATCHED && route == null) 2462 throw new IllegalArgumentException("Route must be provided when RouteType is MATCHED"); 2463 if (routeType == RouteType.UNMATCHED && route != null) 2464 throw new IllegalArgumentException("Route must be null when RouteType is UNMATCHED"); 2465 requireNonNull(outcome); 2466 } 2467 } 2468 2469 /** 2470 * Key for metrics grouped by Server-Sent Event route match information and drop reason. 2471 * 2472 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2473 */ 2474 record SseEventRouteDropKey(@NonNull RouteType routeType, 2475 @Nullable ResourcePathDeclaration route, 2476 @NonNull SseEventDropReason dropReason) { 2477 public SseEventRouteDropKey { 2478 requireNonNull(routeType); 2479 if (routeType == RouteType.MATCHED && route == null) 2480 throw new IllegalArgumentException("Route must be provided when RouteType is MATCHED"); 2481 if (routeType == RouteType.UNMATCHED && route != null) 2482 throw new IllegalArgumentException("Route must be null when RouteType is UNMATCHED"); 2483 requireNonNull(dropReason); 2484 } 2485 } 2486 2487 /** 2488 * Key for metrics grouped by Server-Sent Event comment type, route match information, and drop reason. 2489 * 2490 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2491 */ 2492 record SseCommentRouteDropKey(@NonNull RouteType routeType, 2493 @Nullable ResourcePathDeclaration route, 2494 SseComment.@NonNull CommentType commentType, 2495 @NonNull SseEventDropReason dropReason) { 2496 public SseCommentRouteDropKey { 2497 requireNonNull(routeType); 2498 requireNonNull(commentType); 2499 if (routeType == RouteType.MATCHED && route == null) 2500 throw new IllegalArgumentException("Route must be provided when RouteType is MATCHED"); 2501 if (routeType == RouteType.UNMATCHED && route != null) 2502 throw new IllegalArgumentException("Route must be null when RouteType is UNMATCHED"); 2503 requireNonNull(dropReason); 2504 } 2505 } 2506 2507 /** 2508 * Key for metrics grouped by Server-Sent Event stream route match information and termination reason. 2509 * 2510 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2511 */ 2512 record SseStreamRouteTerminationKey(@NonNull RouteType routeType, 2513 @Nullable ResourcePathDeclaration route, 2514 @NonNull StreamTerminationReason terminationReason) { 2515 public SseStreamRouteTerminationKey { 2516 requireNonNull(routeType); 2517 if (routeType == RouteType.MATCHED && route == null) 2518 throw new IllegalArgumentException("Route must be provided when RouteType is MATCHED"); 2519 if (routeType == RouteType.UNMATCHED && route != null) 2520 throw new IllegalArgumentException("Route must be null when RouteType is UNMATCHED"); 2521 requireNonNull(terminationReason); 2522 } 2523 } 2524 2525 /** 2526 * Key for metrics grouped by MCP endpoint class, JSON-RPC method, and request outcome. 2527 * 2528 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2529 */ 2530 record McpEndpointRequestOutcomeKey(@NonNull Class<? extends McpEndpoint> endpointClass, 2531 @NonNull String jsonRpcMethod, 2532 @NonNull McpRequestOutcome requestOutcome) { 2533 public McpEndpointRequestOutcomeKey { 2534 requireNonNull(endpointClass); 2535 requireNonNull(jsonRpcMethod); 2536 requireNonNull(requestOutcome); 2537 } 2538 } 2539 2540 /** 2541 * Key for metrics grouped by MCP endpoint class and session termination reason. 2542 * 2543 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2544 */ 2545 record McpEndpointSessionTerminationKey(@NonNull Class<? extends McpEndpoint> endpointClass, 2546 @NonNull McpSessionTerminationReason terminationReason) { 2547 public McpEndpointSessionTerminationKey { 2548 requireNonNull(endpointClass); 2549 requireNonNull(terminationReason); 2550 } 2551 } 2552 2553 /** 2554 * Key for metrics grouped by MCP endpoint class and SSE stream termination reason. 2555 * 2556 * @author <a href="https://www.revetkn.com">Mark Allen</a> 2557 */ 2558 record McpEndpointSseStreamTerminationKey(@NonNull Class<? extends McpEndpoint> endpointClass, 2559 @NonNull StreamTerminationReason terminationReason) { 2560 public McpEndpointSseStreamTerminationKey { 2561 requireNonNull(endpointClass); 2562 requireNonNull(terminationReason); 2563 } 2564 } 2565 2566 /** 2567 * Acquires a threadsafe {@link MetricsCollector} instance with sensible defaults. 2568 * <p> 2569 * This method is guaranteed to return a new instance. 2570 * 2571 * @return a {@code MetricsCollector} with default settings 2572 */ 2573 @NonNull 2574 static MetricsCollector defaultInstance() { 2575 return DefaultMetricsCollector.defaultInstance(); 2576 } 2577 2578 /** 2579 * Acquires a threadsafe {@link MetricsCollector} instance that performs no work. 2580 * <p> 2581 * This method is useful when you want to explicitly disable metrics collection without writing your own implementation. 2582 * <p> 2583 * The returned instance is guaranteed to be a JVM-wide singleton. 2584 * 2585 * @return a no-op {@code MetricsCollector} 2586 */ 2587 @NonNull 2588 static MetricsCollector disabledInstance() { 2589 return DisabledMetricsCollector.defaultInstance(); 2590 } 2591}