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.util.Collections; 025import java.util.EnumMap; 026import java.util.LinkedHashMap; 027import java.util.Map; 028import java.util.Objects; 029import java.util.TreeMap; 030 031import static java.util.Objects.requireNonNull; 032 033/** 034 * Immutable aggregate of collected MCP metrics. 035 * <p> 036 * An empty instance permits the shared {@link MetricsCollector.Snapshot} 037 * attachment to remain non-null when no MCP metrics have been observed. The 038 * fixed core families deliberately expose no generic label map; applications 039 * that construct custom event values remain responsible for the 040 * confidentiality and cardinality of those values. 041 * 042 * @author <a href="https://www.revetkn.com">Mark Allen</a> 043 */ 044@ThreadSafe 045public final class McpMetricsSnapshot { 046 @NonNull 047 private static final McpMetricsSnapshot EMPTY = builder().build(); 048 @NonNull 049 private final Long activeHandlerExecutions; 050 @NonNull 051 private final Long handlerQueueDepth; 052 @NonNull 053 private final Long handlerCapacityRejections; 054 @NonNull 055 private final Map<@NonNull ShutdownComponentDisposition, @NonNull Long> 056 serverStops; 057 @NonNull 058 private final Long connectionsAccepted; 059 @NonNull 060 private final Long connectionsRejected; 061 @NonNull 062 private final Map<MetricsCollector.@NonNull TransportFailureReason, 063 @NonNull Long> transportFailures; 064 @NonNull 065 private final Long serverStarts; 066 @NonNull 067 private final Long requestsAccepted; 068 @NonNull 069 private final Long requestsRejected; 070 @NonNull 071 private final Long activeRequests; 072 @NonNull 073 private final Map<@NonNull RequestOutcomeKey, @NonNull Long> requests; 074 @NonNull 075 private final Map<@NonNull RequestOutcomeKey, 076 MetricsCollector.@NonNull HistogramSnapshot> requestDurations; 077 @NonNull 078 private final Long activeRequestStreams; 079 @NonNull 080 private final Map<@NonNull RequestStreamTerminationKey, 081 MetricsCollector.@NonNull HistogramSnapshot> requestStreamDurations; 082 @NonNull 083 private final Long activeSubscriptions; 084 @NonNull 085 private final Map<@NonNull SubscriptionTerminationKey, 086 MetricsCollector.@NonNull HistogramSnapshot> subscriptionDurations; 087 @NonNull 088 private final Map<@NonNull SubscriptionMaintenanceKey, @NonNull Long> 089 subscriptionMaintenance; 090 @NonNull 091 private final Map<@NonNull EndpointMethodKey, @NonNull Long> 092 cancelationsSignaled; 093 @NonNull 094 private final Map<@NonNull EndpointMethodKey, @NonNull Long> progressEmitted; 095 @NonNull 096 private final Long keepAlivesEmitted; 097 @NonNull 098 private final Map<@NonNull Integer, @NonNull Long> protocolErrors; 099 @NonNull 100 private final Map<@NonNull EndpointMethodKey, @NonNull Long> 101 unknownMirroredHeaders; 102 103 private McpMetricsSnapshot(@NonNull Builder builder) { 104 requireNonNull(builder); 105 this.activeHandlerExecutions = builder.activeHandlerExecutions; 106 this.handlerQueueDepth = builder.handlerQueueDepth; 107 this.handlerCapacityRejections = builder.handlerCapacityRejections; 108 this.serverStops = copyServerStops(builder.serverStops); 109 this.connectionsAccepted = builder.connectionsAccepted; 110 this.connectionsRejected = builder.connectionsRejected; 111 this.transportFailures = copyTransportFailures(builder.transportFailures); 112 this.serverStarts = builder.serverStarts; 113 this.requestsAccepted = builder.requestsAccepted; 114 this.requestsRejected = builder.requestsRejected; 115 this.activeRequests = builder.activeRequests; 116 this.requests = copyRequests(builder.requests); 117 this.requestDurations = copyRequestDurations(builder.requestDurations); 118 this.activeRequestStreams = builder.activeRequestStreams; 119 this.requestStreamDurations = 120 copyRequestStreamDurations(builder.requestStreamDurations); 121 this.activeSubscriptions = builder.activeSubscriptions; 122 this.subscriptionDurations = 123 copySubscriptionDurations(builder.subscriptionDurations); 124 this.subscriptionMaintenance = 125 copySubscriptionMaintenanceCounts(builder.subscriptionMaintenance); 126 this.cancelationsSignaled = copyEndpointMethodCounts( 127 builder.cancelationsSignaled, 128 "MCP cancelation-signaled counts must not be negative."); 129 this.progressEmitted = copyEndpointMethodCounts(builder.progressEmitted, 130 "MCP progress-emitted counts must not be negative."); 131 this.keepAlivesEmitted = builder.keepAlivesEmitted; 132 this.protocolErrors = copyProtocolErrorCounts(builder.protocolErrors); 133 this.unknownMirroredHeaders = copyEndpointMethodCounts( 134 builder.unknownMirroredHeaders, 135 "MCP unknown mirrored-header counts must not be negative."); 136 } 137 138 @NonNull 139 private static Long requireNonNegative(@NonNull Long value, 140 @NonNull String diagnostic) { 141 long requiredValue = requireNonNull(value); 142 if (requiredValue < 0L) 143 throw new IllegalArgumentException(requireNonNull(diagnostic)); 144 return requiredValue; 145 } 146 147 @NonNull 148 private static Map<@NonNull ShutdownComponentDisposition, @NonNull Long> 149 copyServerStops(@NonNull Map<@NonNull ShutdownComponentDisposition, 150 @NonNull Long> serverStops) { 151 EnumMap<ShutdownComponentDisposition, Long> copied = 152 new EnumMap<>(ShutdownComponentDisposition.class); 153 requireNonNull(serverStops).forEach((disposition, count) -> { 154 requireNonNull(disposition); 155 requireNonNull(count); 156 if (count < 0L) 157 throw new IllegalArgumentException( 158 "MCP server-stop counts must not be negative."); 159 copied.put(disposition, count); 160 }); 161 return Collections.unmodifiableMap(copied); 162 } 163 164 @NonNull 165 private static Map<MetricsCollector.@NonNull TransportFailureReason, 166 @NonNull Long> copyTransportFailures( 167 @NonNull Map<MetricsCollector.@NonNull TransportFailureReason, 168 @NonNull Long> transportFailures) { 169 EnumMap<MetricsCollector.TransportFailureReason, Long> copied = 170 new EnumMap<>(MetricsCollector.TransportFailureReason.class); 171 requireNonNull(transportFailures).forEach((reason, count) -> { 172 requireNonNull(reason); 173 requireNonNull(count); 174 if (count < 0L) 175 throw new IllegalArgumentException( 176 "MCP transport failure counts must not be negative."); 177 copied.put(reason, count); 178 }); 179 return Collections.unmodifiableMap(copied); 180 } 181 182 @NonNull 183 private static Map<@NonNull RequestOutcomeKey, @NonNull Long> copyRequests( 184 @NonNull Map<@NonNull RequestOutcomeKey, @NonNull Long> requests) { 185 Map<RequestOutcomeKey, Long> copied = new LinkedHashMap<>(); 186 requireNonNull(requests).forEach((key, count) -> { 187 requireNonNull(key); 188 requireNonNull(count); 189 if (count < 0L) 190 throw new IllegalArgumentException( 191 "MCP completed request counts must not be negative."); 192 copied.put(key, count); 193 }); 194 return Collections.unmodifiableMap(copied); 195 } 196 197 @NonNull 198 private static Map<@NonNull RequestOutcomeKey, 199 MetricsCollector.@NonNull HistogramSnapshot> copyRequestDurations( 200 @NonNull Map<@NonNull RequestOutcomeKey, 201 MetricsCollector.@NonNull HistogramSnapshot> requestDurations) { 202 Map<RequestOutcomeKey, MetricsCollector.HistogramSnapshot> copied = 203 new LinkedHashMap<>(); 204 requireNonNull(requestDurations).forEach((key, histogram) -> 205 copied.put(requireNonNull(key), requireNonNull(histogram))); 206 return Collections.unmodifiableMap(copied); 207 } 208 209 @NonNull 210 private static Map<@NonNull RequestStreamTerminationKey, 211 MetricsCollector.@NonNull HistogramSnapshot> 212 copyRequestStreamDurations( 213 @NonNull Map<@NonNull RequestStreamTerminationKey, 214 MetricsCollector.@NonNull HistogramSnapshot> 215 requestStreamDurations) { 216 Map<RequestStreamTerminationKey, MetricsCollector.HistogramSnapshot> 217 copied = new LinkedHashMap<>(); 218 requireNonNull(requestStreamDurations).forEach((key, histogram) -> 219 copied.put(requireNonNull(key), requireNonNull(histogram))); 220 return Collections.unmodifiableMap(copied); 221 } 222 223 @NonNull 224 private static Map<@NonNull SubscriptionTerminationKey, 225 MetricsCollector.@NonNull HistogramSnapshot> 226 copySubscriptionDurations( 227 @NonNull Map<@NonNull SubscriptionTerminationKey, 228 MetricsCollector.@NonNull HistogramSnapshot> 229 subscriptionDurations) { 230 Map<SubscriptionTerminationKey, MetricsCollector.HistogramSnapshot> 231 copied = new LinkedHashMap<>(); 232 requireNonNull(subscriptionDurations).forEach((key, histogram) -> 233 copied.put(requireNonNull(key), requireNonNull(histogram))); 234 return Collections.unmodifiableMap(copied); 235 } 236 237 @NonNull 238 private static Map<@NonNull SubscriptionMaintenanceKey, @NonNull Long> 239 copySubscriptionMaintenanceCounts( 240 @NonNull Map<@NonNull SubscriptionMaintenanceKey, @NonNull Long> counts) { 241 Map<SubscriptionMaintenanceKey, Long> copied = new LinkedHashMap<>(); 242 requireNonNull(counts).forEach((key, count) -> { 243 requireNonNull(key); 244 requireNonNull(count); 245 if (count < 0L) 246 throw new IllegalArgumentException( 247 "MCP subscription-maintenance counts must not be negative."); 248 copied.put(key, count); 249 }); 250 return Collections.unmodifiableMap(copied); 251 } 252 253 @NonNull 254 private static Map<@NonNull EndpointMethodKey, @NonNull Long> 255 copyEndpointMethodCounts( 256 @NonNull Map<@NonNull EndpointMethodKey, @NonNull Long> counts, 257 @NonNull String diagnostic) { 258 Map<EndpointMethodKey, Long> copied = new LinkedHashMap<>(); 259 requireNonNull(counts).forEach((key, count) -> { 260 requireNonNull(key); 261 requireNonNull(count); 262 if (count < 0L) 263 throw new IllegalArgumentException(requireNonNull(diagnostic)); 264 copied.put(key, count); 265 }); 266 return Collections.unmodifiableMap(copied); 267 } 268 269 @NonNull 270 private static Map<@NonNull Integer, @NonNull Long> 271 copyProtocolErrorCounts( 272 @NonNull Map<@NonNull Integer, @NonNull Long> protocolErrors) { 273 Map<Integer, Long> copied = new TreeMap<>(); 274 requireNonNull(protocolErrors).forEach((code, count) -> { 275 requireNonNull(code); 276 requireNonNull(count); 277 if (count < 0L) 278 throw new IllegalArgumentException( 279 "MCP protocol-error counts must not be negative."); 280 copied.put(code, count); 281 }); 282 return Collections.unmodifiableMap(copied); 283 } 284 285 /** 286 * Returns the shared snapshot containing zero or empty MCP metric values. 287 * 288 * @return empty MCP metrics snapshot 289 */ 290 @NonNull 291 public static McpMetricsSnapshot emptyInstance() { 292 return EMPTY; 293 } 294 295 /** 296 * Vends a builder for an MCP metrics snapshot. 297 * 298 * @return MCP metrics snapshot builder 299 */ 300 @NonNull 301 public static Builder builder() { 302 return new Builder(); 303 } 304 305 /** 306 * Returns the number of occupied MCP application-handler execution slots. 307 * 308 * @return active handler executions 309 */ 310 @NonNull 311 public Long getActiveHandlerExecutions() { 312 return this.activeHandlerExecutions; 313 } 314 315 /** 316 * Returns the number of MCP application requests waiting for a handler slot. 317 * 318 * @return handler queue depth 319 */ 320 @NonNull 321 public Long getHandlerQueueDepth() { 322 return this.handlerQueueDepth; 323 } 324 325 /** 326 * Returns the number of MCP application requests rejected because the 327 * bounded handler queue was full. 328 * 329 * @return handler capacity rejections 330 */ 331 @NonNull 332 public Long getHandlerCapacityRejections() { 333 return this.handlerCapacityRejections; 334 } 335 336 /** 337 * Returns nonnegative server-stop counts grouped by fixed shutdown 338 * disposition. 339 * 340 * @return immutable, enum-ordered server-stop counts 341 */ 342 @NonNull 343 public Map<@NonNull ShutdownComponentDisposition, @NonNull Long> 344 getServerStops() { 345 return this.serverStops; 346 } 347 348 /** 349 * Returns the number of successfully accepted MCP connections admitted 350 * within the configured connection-capacity bound. 351 * 352 * @return accepted MCP connections 353 */ 354 @NonNull 355 public Long getConnectionsAccepted() { 356 return this.connectionsAccepted; 357 } 358 359 /** 360 * Returns the number of MCP connections rejected because the configured 361 * connection-capacity bound was full. 362 * 363 * @return rejected MCP connections 364 */ 365 @NonNull 366 public Long getConnectionsRejected() { 367 return this.connectionsRejected; 368 } 369 370 /** 371 * Returns nonnegative MCP transport-failure counts grouped by fixed reason. 372 * 373 * @return immutable, enum-ordered MCP transport-failure counts 374 */ 375 @NonNull 376 public Map<MetricsCollector.@NonNull TransportFailureReason, 377 @NonNull Long> getTransportFailures() { 378 return this.transportFailures; 379 } 380 381 /** 382 * Returns the number of successful MCP server starts. 383 * 384 * @return successful MCP server starts 385 */ 386 @NonNull 387 public Long getServerStarts() { 388 return this.serverStarts; 389 } 390 391 /** 392 * Returns the number of MCP requests accepted by the bounded protocol 393 * processor. Processor acceptance can precede a later pre-admission 394 * rejection, so this count is independent of {@link #getRequestsRejected()}. 395 * 396 * @return accepted MCP requests 397 */ 398 @NonNull 399 public Long getRequestsAccepted() { 400 return this.requestsAccepted; 401 } 402 403 /** 404 * Returns the number of MCP requests rejected before admitted semantic 405 * handling. This count is not the complement of 406 * {@link #getRequestsAccepted()}. 407 * 408 * @return rejected MCP requests 409 */ 410 @NonNull 411 public Long getRequestsRejected() { 412 return this.requestsRejected; 413 } 414 415 /** 416 * Returns the number of currently active admitted MCP requests. 417 * 418 * @return active admitted MCP requests 419 */ 420 @NonNull 421 public Long getActiveRequests() { 422 return this.activeRequests; 423 } 424 425 /** 426 * Returns nonnegative completed-request counts grouped by bounded endpoint, 427 * method, and terminal outcome dimensions. 428 * 429 * @return immutable completed-request counts 430 */ 431 @NonNull 432 public Map<@NonNull RequestOutcomeKey, @NonNull Long> getRequests() { 433 return this.requests; 434 } 435 436 /** 437 * Returns request-duration histograms grouped by bounded endpoint, method, 438 * and terminal outcome dimensions. 439 * 440 * @return immutable request-duration histograms 441 */ 442 @NonNull 443 public Map<@NonNull RequestOutcomeKey, 444 MetricsCollector.@NonNull HistogramSnapshot> getRequestDurations() { 445 return this.requestDurations; 446 } 447 448 /** 449 * Returns the number of currently active MCP request streams. 450 * 451 * @return active MCP request streams 452 */ 453 @NonNull 454 public Long getActiveRequestStreams() { 455 return this.activeRequestStreams; 456 } 457 458 /** 459 * Returns request-stream duration histograms grouped by bounded endpoint, 460 * method, and fixed termination reason dimensions. 461 * 462 * @return immutable request-stream duration histograms 463 */ 464 @NonNull 465 public Map<@NonNull RequestStreamTerminationKey, 466 MetricsCollector.@NonNull HistogramSnapshot> 467 getRequestStreamDurations() { 468 return this.requestStreamDurations; 469 } 470 471 /** 472 * Returns the number of currently active MCP subscriptions. 473 * 474 * @return active MCP subscriptions 475 */ 476 @NonNull 477 public Long getActiveSubscriptions() { 478 return this.activeSubscriptions; 479 } 480 481 /** 482 * Returns subscription-duration histograms grouped by bounded endpoint and 483 * fixed termination reason dimensions. 484 * 485 * @return immutable subscription-duration histograms 486 */ 487 @NonNull 488 public Map<@NonNull SubscriptionTerminationKey, 489 MetricsCollector.@NonNull HistogramSnapshot> getSubscriptionDurations() { 490 return this.subscriptionDurations; 491 } 492 493 /** 494 * Returns delivered subscription-maintenance event counts grouped by bounded 495 * endpoint, fixed work and fixed outcome dimensions across supported MCP 496 * revisions. Counts include coalescing and stale-result discards; they do not 497 * measure unique subscriptions, started attempts, durations or active work. 498 * 499 * @return immutable subscription-maintenance event counts 500 */ 501 @NonNull 502 public Map<@NonNull SubscriptionMaintenanceKey, @NonNull Long> 503 getSubscriptionMaintenance() { 504 return this.subscriptionMaintenance; 505 } 506 507 /** 508 * Returns cooperative request-cancelation signals grouped by bounded 509 * endpoint and method dimensions. 510 * 511 * @return immutable cancelation-signaled counts 512 */ 513 @NonNull 514 public Map<@NonNull EndpointMethodKey, @NonNull Long> 515 getCancelationsSignaled() { 516 return this.cancelationsSignaled; 517 } 518 519 /** 520 * Returns progress notifications accepted for delivery grouped by bounded 521 * endpoint and method dimensions. 522 * 523 * @return immutable progress-emitted counts 524 */ 525 @NonNull 526 public Map<@NonNull EndpointMethodKey, @NonNull Long> getProgressEmitted() { 527 return this.progressEmitted; 528 } 529 530 /** 531 * Returns the number of MCP keep-alive comments accepted for delivery. 532 * 533 * @return keep-alive comments accepted for delivery 534 */ 535 @NonNull 536 public Long getKeepAlivesEmitted() { 537 return this.keepAlivesEmitted; 538 } 539 540 /** 541 * Returns client-visible MCP protocol errors grouped by error code. 542 * 543 * @return immutable protocol-error counts 544 */ 545 @NonNull 546 public Map<@NonNull Integer, @NonNull Long> getProtocolErrors() { 547 return this.protocolErrors; 548 } 549 550 /** 551 * Returns unknown mirrored-header occurrences grouped by bounded endpoint 552 * and method dimensions. 553 * 554 * @return immutable unknown mirrored-header counts 555 */ 556 @NonNull 557 public Map<@NonNull EndpointMethodKey, @NonNull Long> 558 getUnknownMirroredHeaders() { 559 return this.unknownMirroredHeaders; 560 } 561 562 /** 563 * Key for endpoint-and-method counter aggregates. 564 * 565 * @author <a href="https://www.revetkn.com">Mark Allen</a> 566 */ 567 @ThreadSafe 568 public static final class EndpointMethodKey { 569 @NonNull 570 private final String endpointPath; 571 @NonNull 572 private final String jsonRpcMethod; 573 574 /** 575 * Creates an endpoint-and-method aggregate key. 576 * 577 * @param endpointPath registered endpoint-path declaration 578 * @param jsonRpcMethod bounded JSON-RPC method dimension 579 * @return endpoint-and-method aggregate key 580 */ 581 @NonNull 582 public static EndpointMethodKey fromDimensions( 583 @NonNull String endpointPath, @NonNull String jsonRpcMethod) { 584 return new EndpointMethodKey(endpointPath, jsonRpcMethod); 585 } 586 587 private EndpointMethodKey(@NonNull String endpointPath, 588 @NonNull String jsonRpcMethod) { 589 if (requireNonNull(endpointPath).isEmpty()) 590 throw new IllegalArgumentException( 591 "Endpoint path must not be empty."); 592 if (requireNonNull(jsonRpcMethod).isEmpty()) 593 throw new IllegalArgumentException( 594 "JSON-RPC method must not be empty."); 595 this.endpointPath = endpointPath; 596 this.jsonRpcMethod = jsonRpcMethod; 597 } 598 599 /** @return registered endpoint-path declaration */ 600 @NonNull 601 public String getEndpointPath() { 602 return this.endpointPath; 603 } 604 605 /** @return bounded JSON-RPC method dimension */ 606 @NonNull 607 public String getJsonRpcMethod() { 608 return this.jsonRpcMethod; 609 } 610 611 /** @return whether both aggregate dimensions are equal */ 612 @Override 613 public boolean equals(@Nullable Object other) { 614 if (this == other) 615 return true; 616 if (!(other instanceof EndpointMethodKey key)) 617 return false; 618 return this.endpointPath.equals(key.endpointPath) 619 && this.jsonRpcMethod.equals(key.jsonRpcMethod); 620 } 621 622 /** @return value-based hash code */ 623 @Override 624 public int hashCode() { 625 return Objects.hash(this.endpointPath, this.jsonRpcMethod); 626 } 627 628 /** @return dimension-redacted diagnostic rendering */ 629 @Override 630 @NonNull 631 public String toString() { 632 return "EndpointMethodKey{endpointPath=<redacted>, " 633 + "jsonRpcMethod=<redacted>}"; 634 } 635 } 636 637 /** 638 * Key for completed-request and request-duration aggregates. 639 * 640 * @author <a href="https://www.revetkn.com">Mark Allen</a> 641 */ 642 @ThreadSafe 643 public static final class RequestOutcomeKey { 644 @NonNull 645 private final String endpointPath; 646 @NonNull 647 private final String jsonRpcMethod; 648 @NonNull 649 private final McpRequestOutcome outcome; 650 651 /** 652 * Creates a request-outcome aggregate key. 653 * 654 * @param endpointPath registered endpoint-path declaration 655 * @param jsonRpcMethod bounded JSON-RPC method dimension 656 * @param requestOutcome fixed terminal request outcome 657 * @return request-outcome aggregate key 658 */ 659 @NonNull 660 public static RequestOutcomeKey fromDimensions( 661 @NonNull String endpointPath, @NonNull String jsonRpcMethod, 662 @NonNull McpRequestOutcome requestOutcome) { 663 return new RequestOutcomeKey(endpointPath, jsonRpcMethod, 664 requestOutcome); 665 } 666 667 private RequestOutcomeKey(@NonNull String endpointPath, 668 @NonNull String jsonRpcMethod, 669 @NonNull McpRequestOutcome outcome) { 670 if (requireNonNull(endpointPath).isEmpty()) 671 throw new IllegalArgumentException( 672 "Endpoint path must not be empty."); 673 if (requireNonNull(jsonRpcMethod).isEmpty()) 674 throw new IllegalArgumentException( 675 "JSON-RPC method must not be empty."); 676 this.endpointPath = endpointPath; 677 this.jsonRpcMethod = jsonRpcMethod; 678 this.outcome = requireNonNull(outcome); 679 } 680 681 /** @return registered endpoint-path declaration */ 682 @NonNull 683 public String getEndpointPath() { 684 return this.endpointPath; 685 } 686 687 /** @return bounded JSON-RPC method dimension */ 688 @NonNull 689 public String getJsonRpcMethod() { 690 return this.jsonRpcMethod; 691 } 692 693 /** @return fixed terminal request outcome */ 694 @NonNull 695 public McpRequestOutcome getOutcome() { 696 return this.outcome; 697 } 698 699 /** @return whether all aggregate dimensions are equal */ 700 @Override 701 public boolean equals(@Nullable Object other) { 702 if (this == other) 703 return true; 704 if (!(other instanceof RequestOutcomeKey key)) 705 return false; 706 return this.endpointPath.equals(key.endpointPath) 707 && this.jsonRpcMethod.equals(key.jsonRpcMethod) 708 && this.outcome == key.outcome; 709 } 710 711 /** @return value-based hash code */ 712 @Override 713 public int hashCode() { 714 return Objects.hash(this.endpointPath, this.jsonRpcMethod, 715 this.outcome); 716 } 717 718 /** @return diagnostic rendering with application dimensions redacted */ 719 @Override 720 @NonNull 721 public String toString() { 722 return "RequestOutcomeKey{endpointPath=<redacted>, " 723 + "jsonRpcMethod=<redacted>, outcome=" + this.outcome + "}"; 724 } 725 } 726 727 /** 728 * Key for request-stream duration aggregates. 729 * 730 * @author <a href="https://www.revetkn.com">Mark Allen</a> 731 */ 732 @ThreadSafe 733 public static final class RequestStreamTerminationKey { 734 @NonNull 735 private final String endpointPath; 736 @NonNull 737 private final String jsonRpcMethod; 738 @NonNull 739 private final McpStreamTerminationReason reason; 740 741 /** 742 * Creates a request-stream termination aggregate key. 743 * 744 * @param endpointPath registered endpoint-path declaration 745 * @param jsonRpcMethod bounded JSON-RPC method dimension 746 * @param streamTerminationReason fixed request-stream termination reason 747 * @return request-stream termination aggregate key 748 */ 749 @NonNull 750 public static RequestStreamTerminationKey fromDimensions( 751 @NonNull String endpointPath, @NonNull String jsonRpcMethod, 752 @NonNull McpStreamTerminationReason streamTerminationReason) { 753 return new RequestStreamTerminationKey(endpointPath, jsonRpcMethod, 754 streamTerminationReason); 755 } 756 757 private RequestStreamTerminationKey(@NonNull String endpointPath, 758 @NonNull String jsonRpcMethod, 759 @NonNull McpStreamTerminationReason reason) { 760 if (requireNonNull(endpointPath).isEmpty()) 761 throw new IllegalArgumentException( 762 "Endpoint path must not be empty."); 763 if (requireNonNull(jsonRpcMethod).isEmpty()) 764 throw new IllegalArgumentException( 765 "JSON-RPC method must not be empty."); 766 this.endpointPath = endpointPath; 767 this.jsonRpcMethod = jsonRpcMethod; 768 this.reason = requireNonNull(reason); 769 } 770 771 /** @return registered endpoint-path declaration */ 772 @NonNull 773 public String getEndpointPath() { 774 return this.endpointPath; 775 } 776 777 /** @return bounded JSON-RPC method dimension */ 778 @NonNull 779 public String getJsonRpcMethod() { 780 return this.jsonRpcMethod; 781 } 782 783 /** @return fixed request-stream termination reason */ 784 @NonNull 785 public McpStreamTerminationReason getReason() { 786 return this.reason; 787 } 788 789 /** @return whether all aggregate dimensions are equal */ 790 @Override 791 public boolean equals(@Nullable Object other) { 792 if (this == other) 793 return true; 794 if (!(other instanceof RequestStreamTerminationKey key)) 795 return false; 796 return this.endpointPath.equals(key.endpointPath) 797 && this.jsonRpcMethod.equals(key.jsonRpcMethod) 798 && this.reason == key.reason; 799 } 800 801 /** @return value-based hash code */ 802 @Override 803 public int hashCode() { 804 return Objects.hash(this.endpointPath, this.jsonRpcMethod, 805 this.reason); 806 } 807 808 /** @return diagnostic rendering with application dimensions redacted */ 809 @Override 810 @NonNull 811 public String toString() { 812 return "RequestStreamTerminationKey{endpointPath=<redacted>, " 813 + "jsonRpcMethod=<redacted>, reason=" + this.reason + "}"; 814 } 815 } 816 817 /** 818 * Key for subscription-duration aggregates. 819 * 820 * @author <a href="https://www.revetkn.com">Mark Allen</a> 821 */ 822 @ThreadSafe 823 public static final class SubscriptionTerminationKey { 824 @NonNull 825 private final String endpointPath; 826 @NonNull 827 private final McpStreamTerminationReason reason; 828 829 /** 830 * Creates a subscription termination aggregate key. 831 * 832 * @param endpointPath registered endpoint-path declaration 833 * @param streamTerminationReason fixed subscription termination reason 834 * @return subscription termination aggregate key 835 */ 836 @NonNull 837 public static SubscriptionTerminationKey fromDimensions( 838 @NonNull String endpointPath, 839 @NonNull McpStreamTerminationReason streamTerminationReason) { 840 return new SubscriptionTerminationKey(endpointPath, 841 streamTerminationReason); 842 } 843 844 private SubscriptionTerminationKey(@NonNull String endpointPath, 845 @NonNull McpStreamTerminationReason reason) { 846 if (requireNonNull(endpointPath).isEmpty()) 847 throw new IllegalArgumentException( 848 "Endpoint path must not be empty."); 849 this.endpointPath = endpointPath; 850 this.reason = requireNonNull(reason); 851 } 852 853 /** @return registered endpoint-path declaration */ 854 @NonNull 855 public String getEndpointPath() { 856 return this.endpointPath; 857 } 858 859 /** @return fixed subscription termination reason */ 860 @NonNull 861 public McpStreamTerminationReason getReason() { 862 return this.reason; 863 } 864 865 /** @return whether both aggregate dimensions are equal */ 866 @Override 867 public boolean equals(@Nullable Object other) { 868 if (this == other) 869 return true; 870 if (!(other instanceof SubscriptionTerminationKey key)) 871 return false; 872 return this.endpointPath.equals(key.endpointPath) 873 && this.reason == key.reason; 874 } 875 876 /** @return value-based hash code */ 877 @Override 878 public int hashCode() { 879 return Objects.hash(this.endpointPath, this.reason); 880 } 881 882 /** @return diagnostic rendering with the application dimension redacted */ 883 @Override 884 @NonNull 885 public String toString() { 886 return "SubscriptionTerminationKey{endpointPath=<redacted>, reason=" 887 + this.reason + "}"; 888 } 889 } 890 891 /** 892 * Immutable subscription-maintenance aggregate dimensions, shared across 893 * supported MCP revisions. The endpoint is an application-configured path 894 * declaration, never a subscription ID or arbitrary request target. 895 * 896 * @author <a href="https://www.revetkn.com">Mark Allen</a> 897 */ 898 @ThreadSafe 899 public static final class SubscriptionMaintenanceKey { 900 @NonNull 901 private final String endpointPath; 902 private final McpMetricsEvent.SubscriptionMaintenance.@NonNull Work work; 903 private final McpMetricsEvent.SubscriptionMaintenance.@NonNull Outcome outcome; 904 905 /** 906 * Creates a subscription-maintenance aggregate key. 907 * 908 * @param endpointPath registered endpoint-path declaration 909 * @param maintenanceWork fixed maintenance work classification 910 * @param maintenanceOutcome fixed observed maintenance outcome 911 * @return subscription-maintenance aggregate key 912 * @throws IllegalArgumentException if the endpoint path is empty 913 */ 914 @NonNull 915 public static SubscriptionMaintenanceKey fromDimensions( 916 @NonNull String endpointPath, 917 McpMetricsEvent.SubscriptionMaintenance.@NonNull Work maintenanceWork, 918 McpMetricsEvent.SubscriptionMaintenance.@NonNull Outcome maintenanceOutcome) { 919 return new SubscriptionMaintenanceKey(endpointPath, maintenanceWork, 920 maintenanceOutcome); 921 } 922 923 private SubscriptionMaintenanceKey(@NonNull String endpointPath, 924 McpMetricsEvent.SubscriptionMaintenance.@NonNull Work maintenanceWork, 925 McpMetricsEvent.SubscriptionMaintenance.@NonNull Outcome maintenanceOutcome) { 926 if (requireNonNull(endpointPath).isEmpty()) 927 throw new IllegalArgumentException("Endpoint path must not be empty."); 928 this.endpointPath = endpointPath; 929 this.work = requireNonNull(maintenanceWork); 930 this.outcome = requireNonNull(maintenanceOutcome); 931 } 932 933 /** @return registered endpoint-path declaration */ 934 @NonNull 935 public String getEndpointPath() { 936 return this.endpointPath; 937 } 938 939 /** @return fixed maintenance work classification */ 940 public McpMetricsEvent.SubscriptionMaintenance.@NonNull Work getWork() { 941 return this.work; 942 } 943 944 /** @return fixed observed maintenance outcome */ 945 public McpMetricsEvent.SubscriptionMaintenance.@NonNull Outcome getOutcome() { 946 return this.outcome; 947 } 948 949 /** @return whether all three aggregate dimensions are equal */ 950 @Override 951 public boolean equals(@Nullable Object other) { 952 if (this == other) 953 return true; 954 if (!(other instanceof SubscriptionMaintenanceKey key)) 955 return false; 956 return this.endpointPath.equals(key.endpointPath) 957 && this.work == key.work && this.outcome == key.outcome; 958 } 959 960 /** @return value-based hash code */ 961 @Override 962 public int hashCode() { 963 return Objects.hash(this.endpointPath, this.work, this.outcome); 964 } 965 966 /** @return diagnostic rendering with the application dimension redacted */ 967 @Override 968 @NonNull 969 public String toString() { 970 return "SubscriptionMaintenanceKey{endpointPath=<redacted>, work=" 971 + this.work + ", outcome=" + this.outcome + "}"; 972 } 973 } 974 975 /** 976 * Builder for immutable {@link McpMetricsSnapshot} instances. 977 * 978 * @author <a href="https://www.revetkn.com">Mark Allen</a> 979 */ 980 @NotThreadSafe 981 public static final class Builder { 982 @NonNull 983 private Long activeHandlerExecutions; 984 @NonNull 985 private Long handlerQueueDepth; 986 @NonNull 987 private Long handlerCapacityRejections; 988 @NonNull 989 private Map<@NonNull ShutdownComponentDisposition, @NonNull Long> 990 serverStops; 991 @NonNull 992 private Long connectionsAccepted; 993 @NonNull 994 private Long connectionsRejected; 995 @NonNull 996 private Map<MetricsCollector.@NonNull TransportFailureReason, 997 @NonNull Long> transportFailures; 998 @NonNull 999 private Long serverStarts; 1000 @NonNull 1001 private Long requestsAccepted; 1002 @NonNull 1003 private Long requestsRejected; 1004 @NonNull 1005 private Long activeRequests; 1006 @NonNull 1007 private Map<@NonNull RequestOutcomeKey, @NonNull Long> requests; 1008 @NonNull 1009 private Map<@NonNull RequestOutcomeKey, 1010 MetricsCollector.@NonNull HistogramSnapshot> requestDurations; 1011 @NonNull 1012 private Long activeRequestStreams; 1013 @NonNull 1014 private Map<@NonNull RequestStreamTerminationKey, 1015 MetricsCollector.@NonNull HistogramSnapshot> requestStreamDurations; 1016 @NonNull 1017 private Long activeSubscriptions; 1018 @NonNull 1019 private Map<@NonNull SubscriptionTerminationKey, 1020 MetricsCollector.@NonNull HistogramSnapshot> subscriptionDurations; 1021 @NonNull 1022 private Map<@NonNull SubscriptionMaintenanceKey, @NonNull Long> 1023 subscriptionMaintenance; 1024 @NonNull 1025 private Map<@NonNull EndpointMethodKey, @NonNull Long> 1026 cancelationsSignaled; 1027 @NonNull 1028 private Map<@NonNull EndpointMethodKey, @NonNull Long> progressEmitted; 1029 @NonNull 1030 private Long keepAlivesEmitted; 1031 @NonNull 1032 private Map<@NonNull Integer, @NonNull Long> protocolErrors; 1033 @NonNull 1034 private Map<@NonNull EndpointMethodKey, @NonNull Long> 1035 unknownMirroredHeaders; 1036 1037 private Builder() { 1038 this.activeHandlerExecutions = 0L; 1039 this.handlerQueueDepth = 0L; 1040 this.handlerCapacityRejections = 0L; 1041 this.serverStops = Map.of(); 1042 this.connectionsAccepted = 0L; 1043 this.connectionsRejected = 0L; 1044 this.transportFailures = Map.of(); 1045 this.serverStarts = 0L; 1046 this.requestsAccepted = 0L; 1047 this.requestsRejected = 0L; 1048 this.activeRequests = 0L; 1049 this.requests = Map.of(); 1050 this.requestDurations = Map.of(); 1051 this.activeRequestStreams = 0L; 1052 this.requestStreamDurations = Map.of(); 1053 this.activeSubscriptions = 0L; 1054 this.subscriptionDurations = Map.of(); 1055 this.subscriptionMaintenance = Map.of(); 1056 this.cancelationsSignaled = Map.of(); 1057 this.progressEmitted = Map.of(); 1058 this.keepAlivesEmitted = 0L; 1059 this.protocolErrors = Map.of(); 1060 this.unknownMirroredHeaders = Map.of(); 1061 } 1062 1063 /** 1064 * Sets the number of occupied MCP application-handler execution slots. 1065 * 1066 * @param activeHandlerExecutions active handler executions 1067 * @return this builder 1068 * @throws IllegalArgumentException if the count is negative 1069 */ 1070 @NonNull 1071 public Builder activeHandlerExecutions( 1072 @NonNull Long activeHandlerExecutions) { 1073 this.activeHandlerExecutions = requireNonNegative( 1074 activeHandlerExecutions, 1075 "Active MCP handler executions must not be negative."); 1076 return this; 1077 } 1078 1079 /** 1080 * Sets the number of MCP application requests waiting for a handler slot. 1081 * 1082 * @param handlerQueueDepth handler queue depth 1083 * @return this builder 1084 * @throws IllegalArgumentException if the count is negative 1085 */ 1086 @NonNull 1087 public Builder handlerQueueDepth(@NonNull Long handlerQueueDepth) { 1088 this.handlerQueueDepth = requireNonNegative(handlerQueueDepth, 1089 "MCP handler queue depth must not be negative."); 1090 return this; 1091 } 1092 1093 /** 1094 * Sets the number of MCP application requests rejected because the 1095 * bounded handler queue was full. 1096 * 1097 * @param handlerCapacityRejections handler capacity rejections 1098 * @return this builder 1099 * @throws IllegalArgumentException if the count is negative 1100 */ 1101 @NonNull 1102 public Builder handlerCapacityRejections( 1103 @NonNull Long handlerCapacityRejections) { 1104 this.handlerCapacityRejections = requireNonNegative( 1105 handlerCapacityRejections, 1106 "MCP handler capacity rejections must not be negative."); 1107 return this; 1108 } 1109 1110 /** 1111 * Replaces the server-stop counts grouped by fixed shutdown disposition. 1112 * Passing {@code null} or an empty map clears all server-stop counts. 1113 * 1114 * @param serverStops nonnegative server-stop counts, or {@code null} to 1115 * clear them 1116 * @return this builder 1117 * @throws IllegalArgumentException if any count is negative 1118 */ 1119 @NonNull 1120 public Builder serverStops( 1121 @Nullable Map<@NonNull ShutdownComponentDisposition, 1122 @NonNull Long> serverStops) { 1123 this.serverStops = serverStops == null ? Map.of() 1124 : copyServerStops(serverStops); 1125 return this; 1126 } 1127 1128 /** 1129 * Sets the number of successfully accepted MCP connections admitted within 1130 * the configured connection-capacity bound. 1131 * 1132 * @param connectionsAccepted accepted MCP connections 1133 * @return this builder 1134 * @throws IllegalArgumentException if the count is negative 1135 */ 1136 @NonNull 1137 public Builder connectionsAccepted(@NonNull Long connectionsAccepted) { 1138 this.connectionsAccepted = requireNonNegative(connectionsAccepted, 1139 "Accepted MCP connection count must not be negative."); 1140 return this; 1141 } 1142 1143 /** 1144 * Sets the number of MCP connections rejected because the configured 1145 * connection-capacity bound was full. 1146 * 1147 * @param connectionsRejected rejected MCP connections 1148 * @return this builder 1149 * @throws IllegalArgumentException if the count is negative 1150 */ 1151 @NonNull 1152 public Builder connectionsRejected(@NonNull Long connectionsRejected) { 1153 this.connectionsRejected = requireNonNegative(connectionsRejected, 1154 "Rejected MCP connection count must not be negative."); 1155 return this; 1156 } 1157 1158 /** 1159 * Replaces the MCP transport-failure counts grouped by fixed reason. 1160 * Passing {@code null} or an empty map clears all transport-failure counts. 1161 * 1162 * @param transportFailures nonnegative MCP transport-failure counts, or 1163 * {@code null} to clear them 1164 * @return this builder 1165 * @throws IllegalArgumentException if any count is negative 1166 */ 1167 @NonNull 1168 public Builder transportFailures( 1169 @Nullable Map<MetricsCollector.@NonNull TransportFailureReason, 1170 @NonNull Long> transportFailures) { 1171 this.transportFailures = transportFailures == null ? Map.of() 1172 : copyTransportFailures(transportFailures); 1173 return this; 1174 } 1175 1176 /** 1177 * Sets the number of successful MCP server starts. 1178 * 1179 * @param serverStarts successful MCP server starts 1180 * @return this builder 1181 * @throws IllegalArgumentException if the count is negative 1182 */ 1183 @NonNull 1184 public Builder serverStarts(@NonNull Long serverStarts) { 1185 this.serverStarts = requireNonNegative(serverStarts, 1186 "MCP server start count must not be negative."); 1187 return this; 1188 } 1189 1190 /** 1191 * Sets the number of MCP requests accepted by the bounded protocol 1192 * processor. 1193 * 1194 * @param requestsAccepted accepted MCP requests 1195 * @return this builder 1196 * @throws IllegalArgumentException if the count is negative 1197 */ 1198 @NonNull 1199 public Builder requestsAccepted(@NonNull Long requestsAccepted) { 1200 this.requestsAccepted = requireNonNegative(requestsAccepted, 1201 "Accepted MCP request count must not be negative."); 1202 return this; 1203 } 1204 1205 /** 1206 * Sets the number of MCP requests rejected before admitted semantic 1207 * handling. 1208 * 1209 * @param requestsRejected rejected MCP requests 1210 * @return this builder 1211 * @throws IllegalArgumentException if the count is negative 1212 */ 1213 @NonNull 1214 public Builder requestsRejected(@NonNull Long requestsRejected) { 1215 this.requestsRejected = requireNonNegative(requestsRejected, 1216 "Rejected MCP request count must not be negative."); 1217 return this; 1218 } 1219 1220 /** 1221 * Sets the number of currently active admitted MCP requests. 1222 * 1223 * @param activeRequests active admitted MCP requests 1224 * @return this builder 1225 * @throws IllegalArgumentException if the count is negative 1226 */ 1227 @NonNull 1228 public Builder activeRequests(@NonNull Long activeRequests) { 1229 this.activeRequests = requireNonNegative(activeRequests, 1230 "Active MCP request count must not be negative."); 1231 return this; 1232 } 1233 1234 /** 1235 * Replaces completed-request counts grouped by bounded endpoint, method, 1236 * and terminal outcome dimensions. Passing {@code null} or an empty map 1237 * clears all completed-request counts. 1238 * 1239 * @param requests completed-request counts, or {@code null} to clear them 1240 * @return this builder 1241 * @throws IllegalArgumentException if any count is negative 1242 */ 1243 @NonNull 1244 public Builder requests( 1245 @Nullable Map<@NonNull RequestOutcomeKey, @NonNull Long> requests) { 1246 this.requests = requests == null ? Map.of() : copyRequests(requests); 1247 return this; 1248 } 1249 1250 /** 1251 * Replaces request-duration histograms grouped by bounded endpoint, method, 1252 * and terminal outcome dimensions. Passing {@code null} or an empty map 1253 * clears all request-duration histograms. 1254 * 1255 * @param requestDurations request-duration histograms, or {@code null} to 1256 * clear them 1257 * @return this builder 1258 */ 1259 @NonNull 1260 public Builder requestDurations( 1261 @Nullable Map<@NonNull RequestOutcomeKey, 1262 MetricsCollector.@NonNull HistogramSnapshot> requestDurations) { 1263 this.requestDurations = requestDurations == null ? Map.of() 1264 : copyRequestDurations(requestDurations); 1265 return this; 1266 } 1267 1268 /** 1269 * Sets the number of currently active MCP request streams. 1270 * 1271 * @param activeRequestStreams active MCP request streams 1272 * @return this builder 1273 * @throws IllegalArgumentException if the count is negative 1274 */ 1275 @NonNull 1276 public Builder activeRequestStreams(@NonNull Long activeRequestStreams) { 1277 this.activeRequestStreams = requireNonNegative(activeRequestStreams, 1278 "Active MCP request-stream count must not be negative."); 1279 return this; 1280 } 1281 1282 /** 1283 * Replaces request-stream duration histograms grouped by bounded endpoint, 1284 * method, and fixed termination reason dimensions. Passing {@code null} or 1285 * an empty map clears all request-stream duration histograms. 1286 * 1287 * @param requestStreamDurations request-stream duration histograms, or 1288 * {@code null} to clear them 1289 * @return this builder 1290 */ 1291 @NonNull 1292 public Builder requestStreamDurations( 1293 @Nullable Map<@NonNull RequestStreamTerminationKey, 1294 MetricsCollector.@NonNull HistogramSnapshot> 1295 requestStreamDurations) { 1296 this.requestStreamDurations = requestStreamDurations == null 1297 ? Map.of() 1298 : copyRequestStreamDurations(requestStreamDurations); 1299 return this; 1300 } 1301 1302 /** 1303 * Sets the number of currently active MCP subscriptions. 1304 * 1305 * @param activeSubscriptions active MCP subscriptions 1306 * @return this builder 1307 * @throws IllegalArgumentException if the count is negative 1308 */ 1309 @NonNull 1310 public Builder activeSubscriptions(@NonNull Long activeSubscriptions) { 1311 this.activeSubscriptions = requireNonNegative(activeSubscriptions, 1312 "Active MCP subscription count must not be negative."); 1313 return this; 1314 } 1315 1316 /** 1317 * Replaces subscription-duration histograms grouped by bounded endpoint and 1318 * fixed termination reason dimensions. Passing {@code null} or an empty map 1319 * clears all subscription-duration histograms. 1320 * 1321 * @param subscriptionDurations subscription-duration histograms, or 1322 * {@code null} to clear them 1323 * @return this builder 1324 */ 1325 @NonNull 1326 public Builder subscriptionDurations( 1327 @Nullable Map<@NonNull SubscriptionTerminationKey, 1328 MetricsCollector.@NonNull HistogramSnapshot> 1329 subscriptionDurations) { 1330 this.subscriptionDurations = subscriptionDurations == null 1331 ? Map.of() 1332 : copySubscriptionDurations(subscriptionDurations); 1333 return this; 1334 } 1335 1336 /** 1337 * Replaces delivered subscription-maintenance event counts grouped by 1338 * bounded endpoint and fixed work/outcome dimensions. Passing {@code null} 1339 * or an empty map clears these counts. The map is defensively copied; 1340 * explicit zero counts are retained. 1341 * 1342 * @param subscriptionMaintenance subscription-maintenance event counts, 1343 * or {@code null} to clear them 1344 * @return this builder 1345 * @throws IllegalArgumentException if any count is negative 1346 */ 1347 @NonNull 1348 public Builder subscriptionMaintenance( 1349 @Nullable Map<@NonNull SubscriptionMaintenanceKey, @NonNull Long> 1350 subscriptionMaintenance) { 1351 this.subscriptionMaintenance = subscriptionMaintenance == null 1352 ? Map.of() : copySubscriptionMaintenanceCounts(subscriptionMaintenance); 1353 return this; 1354 } 1355 1356 /** 1357 * Replaces cooperative request-cancelation signal counts grouped by bounded 1358 * endpoint and method dimensions. Passing {@code null} or an empty map 1359 * clears all cancelation-signal counts. 1360 * 1361 * @param cancelationsSignaled cancelation-signaled counts, or {@code null} 1362 * to clear them 1363 * @return this builder 1364 * @throws IllegalArgumentException if any count is negative 1365 */ 1366 @NonNull 1367 public Builder cancelationsSignaled( 1368 @Nullable Map<@NonNull EndpointMethodKey, @NonNull Long> 1369 cancelationsSignaled) { 1370 this.cancelationsSignaled = cancelationsSignaled == null 1371 ? Map.of() 1372 : copyEndpointMethodCounts(cancelationsSignaled, 1373 "MCP cancelation-signaled counts must not be negative."); 1374 return this; 1375 } 1376 1377 /** 1378 * Replaces progress-notification counts accepted for delivery grouped by 1379 * bounded endpoint and method dimensions. Passing {@code null} or an empty 1380 * map clears all progress-notification counts. 1381 * 1382 * @param progressEmitted progress-emitted counts, or {@code null} to clear 1383 * them 1384 * @return this builder 1385 * @throws IllegalArgumentException if any count is negative 1386 */ 1387 @NonNull 1388 public Builder progressEmitted( 1389 @Nullable Map<@NonNull EndpointMethodKey, @NonNull Long> 1390 progressEmitted) { 1391 this.progressEmitted = progressEmitted == null ? Map.of() 1392 : copyEndpointMethodCounts(progressEmitted, 1393 "MCP progress-emitted counts must not be negative."); 1394 return this; 1395 } 1396 1397 /** 1398 * Sets the number of MCP keep-alive comments accepted for delivery. 1399 * 1400 * @param keepAlivesEmitted keep-alive comments accepted for delivery 1401 * @return this builder 1402 * @throws IllegalArgumentException if the count is negative 1403 */ 1404 @NonNull 1405 public Builder keepAlivesEmitted(@NonNull Long keepAlivesEmitted) { 1406 this.keepAlivesEmitted = requireNonNegative(keepAlivesEmitted, 1407 "MCP keep-alive emitted count must not be negative."); 1408 return this; 1409 } 1410 1411 /** 1412 * Replaces client-visible MCP protocol-error counts grouped by error code. 1413 * Passing {@code null} or an empty map clears all protocol-error counts. 1414 * 1415 * @param protocolErrors protocol-error counts, or {@code null} to clear them 1416 * @return this builder 1417 * @throws IllegalArgumentException if any count is negative 1418 */ 1419 @NonNull 1420 public Builder protocolErrors( 1421 @Nullable Map<@NonNull Integer, @NonNull Long> protocolErrors) { 1422 this.protocolErrors = protocolErrors == null ? Map.of() 1423 : copyProtocolErrorCounts(protocolErrors); 1424 return this; 1425 } 1426 1427 /** 1428 * Replaces unknown mirrored-header occurrence counts grouped by bounded 1429 * endpoint and method dimensions. Passing {@code null} or an empty map 1430 * clears all unknown mirrored-header counts. 1431 * 1432 * @param unknownMirroredHeaders unknown mirrored-header counts, or 1433 * {@code null} to clear them 1434 * @return this builder 1435 * @throws IllegalArgumentException if any count is negative 1436 */ 1437 @NonNull 1438 public Builder unknownMirroredHeaders( 1439 @Nullable Map<@NonNull EndpointMethodKey, @NonNull Long> 1440 unknownMirroredHeaders) { 1441 this.unknownMirroredHeaders = unknownMirroredHeaders == null 1442 ? Map.of() 1443 : copyEndpointMethodCounts(unknownMirroredHeaders, 1444 "MCP unknown mirrored-header counts must not be negative."); 1445 return this; 1446 } 1447 1448 /** 1449 * Builds an immutable MCP metrics snapshot. 1450 * 1451 * @return built snapshot 1452 */ 1453 @NonNull 1454 public McpMetricsSnapshot build() { 1455 return new McpMetricsSnapshot(this); 1456 } 1457 } 1458}