From a1b349ffe61d8d44d21a7b0ae17158502a1b71be Mon Sep 17 00:00:00 2001 From: Michael Peterson Date: Wed, 5 Aug 2026 14:51:09 -0400 Subject: [PATCH 01/10] Add project routing telemetry infrastructure Adds the core data structures and recording hooks for CPS project routing usage telemetry in GET _cluster/stats: - ProjectRoutingUsageHolder: LongAdder-based counters for _search and ES|QL queries broken down by routing mode (alias_origin, alias_wildcard, custom_tags, named_expression) plus failure counters for recording failed CPS queries. - ProjectRoutingUsageSnapshot: Writeable point-in-time snapshot accumulated across nodes on the coordinator. - ClusterStatsResponse: emits separate top-level `tags` and `project_routing` blocks; `project_routing.queries` is the sum of search and esql totals computed at render time. - TransportSearchAction: records per-search telemetry gated on collectSearchTelemetry and hasLinkedProjects. - Stub infrastructure for Ticket 4: ClusterStatsTagsProvider, TagsConfigSnapshot, ActionPlugin extension point. JSON field names follow the PM spec: `queries`, `queries_project_routing`, `alias_origin`, `alias_wildcard`, `custom_tags`, `named_expression`, `in_SET`, `failures`. Tests cover: wire serialization, add() accumulation, toXContent suppression rules, failure-method no-ops and counter semantics, and ClusterStatsResponse assembly. --- .../elasticsearch/action/ActionModule.java | 7 + .../stats/ClusterStatsNodeResponse.java | 18 +- .../cluster/stats/ClusterStatsResponse.java | 24 +- .../stats/ClusterStatsTagsProvider.java | 30 ++ .../stats/ProjectRoutingUsageHolder.java | 140 +++++++++ .../stats/ProjectRoutingUsageSnapshot.java | 280 ++++++++++++++++++ .../cluster/stats/TagsConfigSnapshot.java | 43 +++ .../stats/TransportClusterStatsAction.java | 54 ++-- .../action/search/TransportSearchAction.java | 19 ++ .../elasticsearch/plugins/ActionPlugin.java | 11 + .../ProjectRoutingRequestInfo.java | 32 ++ .../search/crossproject/TargetProjects.java | 18 +- .../org/elasticsearch/usage/UsageService.java | 19 ++ .../referable/project_routing_usage_stats.csv | 1 + .../resources/transport/upper_bounds/9.6.csv | 2 +- .../stats/ProjectRoutingUsageHolderTests.java | 220 ++++++++++++++ .../ProjectRoutingUsageSnapshotTests.java | 269 +++++++++++++++++ .../cluster/stats/VersionStatsTests.java | 3 +- .../ClusterStatsMonitoringDocTests.java | 3 +- 19 files changed, 1165 insertions(+), 28 deletions(-) create mode 100644 server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsTagsProvider.java create mode 100644 server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java create mode 100644 server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java create mode 100644 server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TagsConfigSnapshot.java create mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingRequestInfo.java create mode 100644 server/src/main/resources/transport/definitions/referable/project_routing_usage_stats.csv create mode 100644 server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java create mode 100644 server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshotTests.java diff --git a/server/src/main/java/org/elasticsearch/action/ActionModule.java b/server/src/main/java/org/elasticsearch/action/ActionModule.java index 120232b188c8a..769f42f3e0dc2 100644 --- a/server/src/main/java/org/elasticsearch/action/ActionModule.java +++ b/server/src/main/java/org/elasticsearch/action/ActionModule.java @@ -540,6 +540,13 @@ public ActionModule( ); this.restExtension = restExtension; this.clusterService = clusterService; + + actionPlugins.stream() + .map(ActionPlugin::getClusterStatsTagsProvider) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst() + .ifPresent(usageService::registerTagsProvider); } private static T getRestServerComponent( diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsNodeResponse.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsNodeResponse.java index a41f517b4b333..60253571c71db 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsNodeResponse.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsNodeResponse.java @@ -9,6 +9,7 @@ package org.elasticsearch.action.admin.cluster.stats; +import org.elasticsearch.TransportVersion; import org.elasticsearch.action.admin.cluster.node.info.NodeInfo; import org.elasticsearch.action.admin.cluster.node.stats.NodeStats; import org.elasticsearch.action.admin.indices.stats.ShardStats; @@ -24,6 +25,8 @@ public class ClusterStatsNodeResponse extends BaseNodeResponse { + static final TransportVersion PROJECT_ROUTING_USAGE_STATS = TransportVersion.fromName("project_routing_usage_stats"); + private final NodeInfo nodeInfo; private final NodeStats nodeStats; private final ShardStats[] shardsStats; @@ -32,6 +35,7 @@ public class ClusterStatsNodeResponse extends BaseNodeResponse { private final RepositoryUsageStats repositoryUsageStats; private final CCSTelemetrySnapshot searchCcsMetrics; private final CCSTelemetrySnapshot esqlCcsMetrics; + private final ProjectRoutingUsageSnapshot projectRoutingUsageSnapshot; public ClusterStatsNodeResponse(StreamInput in) throws IOException { super(in); @@ -43,6 +47,9 @@ public ClusterStatsNodeResponse(StreamInput in) throws IOException { repositoryUsageStats = RepositoryUsageStats.readFrom(in); searchCcsMetrics = new CCSTelemetrySnapshot(in); esqlCcsMetrics = new CCSTelemetrySnapshot(in); + projectRoutingUsageSnapshot = in.getTransportVersion().supports(PROJECT_ROUTING_USAGE_STATS) + ? new ProjectRoutingUsageSnapshot(in) + : new ProjectRoutingUsageSnapshot(); } public ClusterStatsNodeResponse( @@ -54,7 +61,8 @@ public ClusterStatsNodeResponse( SearchUsageStats searchUsageStats, RepositoryUsageStats repositoryUsageStats, CCSTelemetrySnapshot ccsTelemetrySnapshot, - CCSTelemetrySnapshot esqlTelemetrySnapshot + CCSTelemetrySnapshot esqlTelemetrySnapshot, + ProjectRoutingUsageSnapshot projectRoutingUsageSnapshot ) { super(node); this.nodeInfo = nodeInfo; @@ -65,6 +73,7 @@ public ClusterStatsNodeResponse( this.repositoryUsageStats = Objects.requireNonNull(repositoryUsageStats); this.searchCcsMetrics = ccsTelemetrySnapshot; this.esqlCcsMetrics = esqlTelemetrySnapshot; + this.projectRoutingUsageSnapshot = Objects.requireNonNull(projectRoutingUsageSnapshot); } public NodeInfo nodeInfo() { @@ -103,6 +112,10 @@ public CCSTelemetrySnapshot getEsqlCcsMetrics() { return esqlCcsMetrics; } + public ProjectRoutingUsageSnapshot getProjectRoutingUsageSnapshot() { + return projectRoutingUsageSnapshot; + } + @Override public void writeTo(StreamOutput out) throws IOException { super.writeTo(out); @@ -114,6 +127,9 @@ public void writeTo(StreamOutput out) throws IOException { repositoryUsageStats.writeTo(out); searchCcsMetrics.writeTo(out); esqlCcsMetrics.writeTo(out); + if (out.getTransportVersion().supports(PROJECT_ROUTING_USAGE_STATS)) { + projectRoutingUsageSnapshot.writeTo(out); + } } } diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponse.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponse.java index 01c3e259a99f9..5901b2ca69687 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponse.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponse.java @@ -19,6 +19,7 @@ import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.common.unit.ByteSizeValue; +import org.elasticsearch.core.Nullable; import org.elasticsearch.xcontent.ToXContentFragment; import org.elasticsearch.xcontent.XContentBuilder; @@ -38,6 +39,9 @@ public class ClusterStatsResponse extends BaseNodesResponse remoteClustersStats; @@ -56,7 +60,8 @@ public ClusterStatsResponse( VersionStats versionStats, ClusterSnapshotStats clusterSnapshotStats, Map remoteClustersStats, - boolean skipMRT + boolean skipMRT, + @Nullable TagsConfigSnapshot tagsConfig ) { super(clusterName, nodes, failures); this.clusterUUID = clusterUUID; @@ -65,6 +70,7 @@ public ClusterStatsResponse( indicesStats = new ClusterStatsIndices(nodes, mappingStats, analysisStats, versionStats); ccsMetrics = new CCSTelemetrySnapshot(skipMRT == false); esqlMetrics = new CCSTelemetrySnapshot(false); + projectRoutingUsageSnapshot = new ProjectRoutingUsageSnapshot(); ClusterHealthStatus status = null; for (ClusterStatsNodeResponse response : nodes) { // only the master node populates the status @@ -76,9 +82,11 @@ public ClusterStatsResponse( nodes.forEach(node -> { ccsMetrics.add(node.getSearchCcsMetrics()); esqlMetrics.add(node.getEsqlCcsMetrics()); + projectRoutingUsageSnapshot.add(node.getProjectRoutingUsageSnapshot()); }); this.status = status; this.clusterSnapshotStats = clusterSnapshotStats; + this.tagsConfig = tagsConfig; this.repositoryUsageStats = nodes.stream() .map(ClusterStatsNodeResponse::repositoryUsageStats) @@ -169,6 +177,20 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.endObject(); + if (tagsConfig != null) { + builder.startObject("tags"); + tagsConfig.toXContent(builder, params); + builder.endObject(); + } + + long totalQueries = projectRoutingUsageSnapshot.getSearchQueriesTotal() + projectRoutingUsageSnapshot.getEsqlQueriesTotal(); + if (totalQueries > 0) { + builder.startObject("project_routing"); + builder.field("queries", totalQueries); + projectRoutingUsageSnapshot.toXContent(builder, params); + builder.endObject(); + } + return builder; } diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsTagsProvider.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsTagsProvider.java new file mode 100644 index 0000000000000..7ced0fee50434 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsTagsProvider.java @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.action.admin.cluster.stats; + +import org.elasticsearch.cluster.ClusterState; +import org.elasticsearch.core.Nullable; + +/** + * Extension point for supplying the {@code tags} configuration snapshot (tag names, named routing expressions, etc.) + * to {@code GET _cluster/stats}. Registered via {@link org.elasticsearch.plugins.ActionPlugin#getClusterStatsTagsProvider()}. + * + *

Needed as an extension point for serverless code. + */ +@FunctionalInterface +public interface ClusterStatsTagsProvider { + + /** + * Returns the current tags configuration snapshot for the request's project, or {@code null} if the configuration + * is unavailable (e.g. CPS is disabled, or the project cannot be resolved from the thread context). + */ + @Nullable + TagsConfigSnapshot getTagsConfig(ClusterState clusterState); +} diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java new file mode 100644 index 0000000000000..fc5c180922269 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.action.admin.cluster.stats; + +import org.elasticsearch.core.Nullable; +import org.elasticsearch.search.crossproject.ProjectRoutingRequestInfo; + +import java.util.concurrent.atomic.LongAdder; + +/** + * Accumulates per-node project-routing telemetry counters. Follows the same pattern as {@link CCSUsageTelemetry}. + * Thread-safe via {@link LongAdder}. Obtain a point-in-time snapshot with {@link #getSnapshot()}. + * + *

All counters are gated on {@code hasLinkedProjects}: they only increment while the project has at least one + * configured linked project. This ensures percentages can be computed from the data + * (e.g. {@code queries_project_routing / queries}). + */ +public class ProjectRoutingUsageHolder { + + // _search endpoint + private final LongAdder searchQueriesTotal = new LongAdder(); + private final LongAdder searchWithProjectRouting = new LongAdder(); + private final LongAdder searchWithAliasOrigin = new LongAdder(); + private final LongAdder searchWithAliasWildcard = new LongAdder(); + private final LongAdder searchWithCustomTags = new LongAdder(); + private final LongAdder searchWithNamedExpression = new LongAdder(); + private final LongAdder searchFailures = new LongAdder(); + + // ES|QL endpoint + private final LongAdder esqlQueriesTotal = new LongAdder(); + private final LongAdder esqlWithProjectRouting = new LongAdder(); + private final LongAdder esqlWithAliasOrigin = new LongAdder(); + private final LongAdder esqlWithAliasWildcard = new LongAdder(); + private final LongAdder esqlWithCustomTags = new LongAdder(); + private final LongAdder esqlWithNamedExpression = new LongAdder(); + private final LongAdder esqlWithSet = new LongAdder(); + private final LongAdder esqlFailures = new LongAdder(); + + /** + * Records a {@code _search} request. {@code queries} is always incremented (subject to the + * {@code hasLinkedProjects} gate). The {@code queries_project_routing} counter and its sub-counters are only + * incremented when {@code info} is non-null, i.e. the request carried a {@code project_routing} expression. + * + * @param info routing metadata from the resolver; null when the request had no {@code project_routing} header, + * or until the resolver is upgraded to populate it (Ticket 2) + * @param hasLinkedProjects true when the project had at least one linked project at the time of the request; + * when false all counters are skipped + */ + public void recordSearch(@Nullable ProjectRoutingRequestInfo info, boolean hasLinkedProjects) { + if (hasLinkedProjects == false) return; + searchQueriesTotal.increment(); + if (info == null) return; + searchWithProjectRouting.increment(); + if (info.usedAliasOrigin()) searchWithAliasOrigin.increment(); + if (info.usedAliasWildcard()) searchWithAliasWildcard.increment(); + if (info.usedNamedExpression()) searchWithNamedExpression.increment(); + if (info.tagsUsedInRouting().stream().anyMatch(t -> t.startsWith("_") == false)) searchWithCustomTags.increment(); + } + + /** + * Records an ES|QL request. {@code queries} is always incremented (subject to the + * {@code hasLinkedProjects} gate). The {@code queries_project_routing} counter and its sub-counters are only + * incremented when {@code info} is non-null, i.e. the request carried a {@code project_routing} expression. + * + * @param info routing metadata from the resolver; null when the request had no {@code project_routing} expression, + * or until the resolver is upgraded to populate it (Ticket 2) + * @param setClauseUsed true when the routing expression came from an in-query {@code SET project_routing = ...} clause + * @param hasLinkedProjects true when the project had at least one linked project at the time of the request; + * when false all counters are skipped + */ + public void recordEsql(@Nullable ProjectRoutingRequestInfo info, boolean setClauseUsed, boolean hasLinkedProjects) { + if (hasLinkedProjects == false) return; + esqlQueriesTotal.increment(); + if (setClauseUsed) esqlWithSet.increment(); + if (info == null) return; + esqlWithProjectRouting.increment(); + if (info.usedAliasOrigin()) esqlWithAliasOrigin.increment(); + if (info.usedAliasWildcard()) esqlWithAliasWildcard.increment(); + if (info.usedNamedExpression()) esqlWithNamedExpression.increment(); + if (info.tagsUsedInRouting().stream().anyMatch(t -> t.startsWith("_") == false)) esqlWithCustomTags.increment(); + } + + /** + * Records a routing failure for a {@code _search}-family request. Increments {@code queries}, + * {@code queries_project_routing}, and {@code failures}. Called by Ticket 5 from + * {@code AuthorizationService.onAuthorizedResourceLoadFailure()}. + * + * @param hasLinkedProjects true when the project had at least one linked project; when false this is a no-op + */ + public void recordSearchFailure(boolean hasLinkedProjects) { + if (hasLinkedProjects == false) return; + searchQueriesTotal.increment(); + searchWithProjectRouting.increment(); + searchFailures.increment(); + } + + /** + * Records a routing failure for an ES|QL request. Increments {@code queries}, + * {@code queries_project_routing}, and {@code failures}. Called by Ticket 5 from + * {@code AuthorizationService.onAuthorizedResourceLoadFailure()}. + * + * @param hasLinkedProjects true when the project had at least one linked project; when false this is a no-op + */ + public void recordEsqlFailure(boolean hasLinkedProjects) { + if (hasLinkedProjects == false) return; + esqlQueriesTotal.increment(); + esqlWithProjectRouting.increment(); + esqlFailures.increment(); + } + + /** + * Returns a point-in-time snapshot of the current counters. + */ + public ProjectRoutingUsageSnapshot getSnapshot() { + return new ProjectRoutingUsageSnapshot( + searchQueriesTotal.sum(), + searchWithProjectRouting.sum(), + searchWithAliasOrigin.sum(), + searchWithAliasWildcard.sum(), + searchWithCustomTags.sum(), + searchWithNamedExpression.sum(), + searchFailures.sum(), + esqlQueriesTotal.sum(), + esqlWithProjectRouting.sum(), + esqlWithAliasOrigin.sum(), + esqlWithAliasWildcard.sum(), + esqlWithCustomTags.sum(), + esqlWithNamedExpression.sum(), + esqlWithSet.sum(), + esqlFailures.sum() + ); + } +} diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java new file mode 100644 index 0000000000000..633b9d969cade --- /dev/null +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java @@ -0,0 +1,280 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.action.admin.cluster.stats; + +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.xcontent.ToXContentFragment; +import org.elasticsearch.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.Objects; + +/** + * A point-in-time snapshot of {@link ProjectRoutingUsageHolder} counters for one node. + * + *

Instances are created on each node, shipped to the coordinator as part of + * {@link ClusterStatsNodeResponse}, and accumulated additively with {@link #add(ProjectRoutingUsageSnapshot)}. + * The accumulated snapshot is then rendered into the {@code project_routing} top-level block of + * {@code GET _cluster/stats}. + */ +public class ProjectRoutingUsageSnapshot implements Writeable, ToXContentFragment { + + private long searchQueriesTotal; + private long searchWithProjectRouting; + private long searchWithAliasOrigin; + private long searchWithAliasWildcard; + private long searchWithCustomTags; + private long searchWithNamedExpression; + private long searchFailures; + + private long esqlQueriesTotal; + private long esqlWithProjectRouting; + private long esqlWithAliasOrigin; + private long esqlWithAliasWildcard; + private long esqlWithCustomTags; + private long esqlWithNamedExpression; + private long esqlWithSet; + private long esqlFailures; + + /** Creates an empty snapshot suitable for accumulating node snapshots into. */ + public ProjectRoutingUsageSnapshot() {} + + /** Creates a snapshot from the provided counter values (called by {@link ProjectRoutingUsageHolder#getSnapshot()}). */ + public ProjectRoutingUsageSnapshot( + long searchQueriesTotal, + long searchWithProjectRouting, + long searchWithAliasOrigin, + long searchWithAliasWildcard, + long searchWithCustomTags, + long searchWithNamedExpression, + long searchFailures, + long esqlQueriesTotal, + long esqlWithProjectRouting, + long esqlWithAliasOrigin, + long esqlWithAliasWildcard, + long esqlWithCustomTags, + long esqlWithNamedExpression, + long esqlWithSet, + long esqlFailures + ) { + this.searchQueriesTotal = searchQueriesTotal; + this.searchWithProjectRouting = searchWithProjectRouting; + this.searchWithAliasOrigin = searchWithAliasOrigin; + this.searchWithAliasWildcard = searchWithAliasWildcard; + this.searchWithCustomTags = searchWithCustomTags; + this.searchWithNamedExpression = searchWithNamedExpression; + this.searchFailures = searchFailures; + this.esqlQueriesTotal = esqlQueriesTotal; + this.esqlWithProjectRouting = esqlWithProjectRouting; + this.esqlWithAliasOrigin = esqlWithAliasOrigin; + this.esqlWithAliasWildcard = esqlWithAliasWildcard; + this.esqlWithCustomTags = esqlWithCustomTags; + this.esqlWithNamedExpression = esqlWithNamedExpression; + this.esqlWithSet = esqlWithSet; + this.esqlFailures = esqlFailures; + } + + public ProjectRoutingUsageSnapshot(StreamInput in) throws IOException { + searchQueriesTotal = in.readVLong(); + searchWithProjectRouting = in.readVLong(); + searchWithAliasOrigin = in.readVLong(); + searchWithAliasWildcard = in.readVLong(); + searchWithCustomTags = in.readVLong(); + searchWithNamedExpression = in.readVLong(); + searchFailures = in.readVLong(); + esqlQueriesTotal = in.readVLong(); + esqlWithProjectRouting = in.readVLong(); + esqlWithAliasOrigin = in.readVLong(); + esqlWithAliasWildcard = in.readVLong(); + esqlWithCustomTags = in.readVLong(); + esqlWithNamedExpression = in.readVLong(); + esqlWithSet = in.readVLong(); + esqlFailures = in.readVLong(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeVLong(searchQueriesTotal); + out.writeVLong(searchWithProjectRouting); + out.writeVLong(searchWithAliasOrigin); + out.writeVLong(searchWithAliasWildcard); + out.writeVLong(searchWithCustomTags); + out.writeVLong(searchWithNamedExpression); + out.writeVLong(searchFailures); + out.writeVLong(esqlQueriesTotal); + out.writeVLong(esqlWithProjectRouting); + out.writeVLong(esqlWithAliasOrigin); + out.writeVLong(esqlWithAliasWildcard); + out.writeVLong(esqlWithCustomTags); + out.writeVLong(esqlWithNamedExpression); + out.writeVLong(esqlWithSet); + out.writeVLong(esqlFailures); + } + + /** + * Additively merges {@code other} into this snapshot. Called on the coordinator to combine node responses. + */ + public void add(ProjectRoutingUsageSnapshot other) { + if (other == null) return; + searchQueriesTotal += other.searchQueriesTotal; + searchWithProjectRouting += other.searchWithProjectRouting; + searchWithAliasOrigin += other.searchWithAliasOrigin; + searchWithAliasWildcard += other.searchWithAliasWildcard; + searchWithCustomTags += other.searchWithCustomTags; + searchWithNamedExpression += other.searchWithNamedExpression; + searchFailures += other.searchFailures; + esqlQueriesTotal += other.esqlQueriesTotal; + esqlWithProjectRouting += other.esqlWithProjectRouting; + esqlWithAliasOrigin += other.esqlWithAliasOrigin; + esqlWithAliasWildcard += other.esqlWithAliasWildcard; + esqlWithCustomTags += other.esqlWithCustomTags; + esqlWithNamedExpression += other.esqlWithNamedExpression; + esqlWithSet += other.esqlWithSet; + esqlFailures += other.esqlFailures; + } + + public long getSearchQueriesTotal() { + return searchQueriesTotal; + } + + public long getSearchWithProjectRouting() { + return searchWithProjectRouting; + } + + public long getSearchWithAliasOrigin() { + return searchWithAliasOrigin; + } + + public long getSearchWithAliasWildcard() { + return searchWithAliasWildcard; + } + + public long getSearchWithCustomTags() { + return searchWithCustomTags; + } + + public long getSearchWithNamedExpression() { + return searchWithNamedExpression; + } + + public long getSearchFailures() { + return searchFailures; + } + + public long getEsqlQueriesTotal() { + return esqlQueriesTotal; + } + + public long getEsqlWithProjectRouting() { + return esqlWithProjectRouting; + } + + public long getEsqlWithAliasOrigin() { + return esqlWithAliasOrigin; + } + + public long getEsqlWithAliasWildcard() { + return esqlWithAliasWildcard; + } + + public long getEsqlWithCustomTags() { + return esqlWithCustomTags; + } + + public long getEsqlWithNamedExpression() { + return esqlWithNamedExpression; + } + + public long getEsqlWithSet() { + return esqlWithSet; + } + + public long getEsqlFailures() { + return esqlFailures; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProjectRoutingUsageSnapshot other = (ProjectRoutingUsageSnapshot) o; + return searchQueriesTotal == other.searchQueriesTotal + && searchWithProjectRouting == other.searchWithProjectRouting + && searchWithAliasOrigin == other.searchWithAliasOrigin + && searchWithAliasWildcard == other.searchWithAliasWildcard + && searchWithCustomTags == other.searchWithCustomTags + && searchWithNamedExpression == other.searchWithNamedExpression + && searchFailures == other.searchFailures + && esqlQueriesTotal == other.esqlQueriesTotal + && esqlWithProjectRouting == other.esqlWithProjectRouting + && esqlWithAliasOrigin == other.esqlWithAliasOrigin + && esqlWithAliasWildcard == other.esqlWithAliasWildcard + && esqlWithCustomTags == other.esqlWithCustomTags + && esqlWithNamedExpression == other.esqlWithNamedExpression + && esqlWithSet == other.esqlWithSet + && esqlFailures == other.esqlFailures; + } + + @Override + public int hashCode() { + return Objects.hash( + searchQueriesTotal, + searchWithProjectRouting, + searchWithAliasOrigin, + searchWithAliasWildcard, + searchWithCustomTags, + searchWithNamedExpression, + searchFailures, + esqlQueriesTotal, + esqlWithProjectRouting, + esqlWithAliasOrigin, + esqlWithAliasWildcard, + esqlWithCustomTags, + esqlWithNamedExpression, + esqlWithSet, + esqlFailures + ); + } + + /** + * Emits the {@code search} and {@code esql} sub-objects inside the {@code project_routing} block. + * The caller is responsible for opening and closing the {@code project_routing} object and for emitting + * the top-level {@code queries} sum. Subsections with zero counts are suppressed. + */ + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + if (searchQueriesTotal > 0) { + builder.startObject("search"); + builder.field("queries", searchQueriesTotal); + builder.field("queries_project_routing", searchWithProjectRouting); + builder.field("alias_origin", searchWithAliasOrigin); + builder.field("alias_wildcard", searchWithAliasWildcard); + builder.field("custom_tags", searchWithCustomTags); + builder.field("named_expression", searchWithNamedExpression); + builder.field("failures", searchFailures); + builder.endObject(); + } + if (esqlQueriesTotal > 0) { + builder.startObject("esql"); + builder.field("queries", esqlQueriesTotal); + builder.field("queries_project_routing", esqlWithProjectRouting); + builder.field("alias_origin", esqlWithAliasOrigin); + builder.field("alias_wildcard", esqlWithAliasWildcard); + builder.field("custom_tags", esqlWithCustomTags); + builder.field("named_expression", esqlWithNamedExpression); + builder.field("in_SET", esqlWithSet); + builder.field("failures", esqlFailures); + builder.endObject(); + } + return builder; + } +} diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TagsConfigSnapshot.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TagsConfigSnapshot.java new file mode 100644 index 0000000000000..b7b3592ec5267 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TagsConfigSnapshot.java @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.action.admin.cluster.stats; + +import org.elasticsearch.xcontent.ToXContentFragment; +import org.elasticsearch.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.List; + +/** + * A snapshot of the project's tag configuration for inclusion in {@code GET _cluster/stats}. + * Populated by the serverless cross-project module (Ticket 4). Emits the static config fields + * ({@code total}, {@code total_custom}, {@code names}, {@code named_routing_expressions}) inside + * the top-level {@code tags} object. + */ +public record TagsConfigSnapshot( + int total, + int totalCustom, + List names, + int namedRoutingExpressionsTotal, + List namedRoutingExpressionNames +) implements ToXContentFragment { + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.field("total", total); + builder.field("total_custom", totalCustom); + builder.array("names", names.toArray(new String[0])); + builder.startObject("named_routing_expressions"); + builder.field("total", namedRoutingExpressionsTotal); + builder.array("names", namedRoutingExpressionNames.toArray(new String[0])); + builder.endObject(); + return builder; + } +} diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java index 7dad9fb0edb4a..66de771360f03 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java @@ -112,6 +112,7 @@ public class TransportClusterStatsAction extends TransportNodesAction< private final SearchUsageHolder searchUsageHolder; private final CCSUsageTelemetry ccsUsageHolder; private final CCSUsageTelemetry esqlUsageHolder; + private final UsageService usageService; private final Executor clusterStateStatsExecutor; private final MetadataStatsCache mappingStatsCache; @@ -147,6 +148,7 @@ public TransportClusterStatsAction( this.searchUsageHolder = usageService.getSearchUsageHolder(); this.ccsUsageHolder = usageService.getCcsUsageHolder(); this.esqlUsageHolder = usageService.getEsqlUsageHolder(); + this.usageService = usageService; this.clusterStateStatsExecutor = threadPool.executor(ThreadPool.Names.MANAGEMENT); this.mappingStatsCache = new MetadataStatsCache<>(threadPool.getThreadContext(), MappingStats::of); this.analysisStatsCache = new MetadataStatsCache<>(threadPool.getThreadContext(), AnalysisStats::of); @@ -187,10 +189,10 @@ protected void newResponseAsync( ); assert ThreadPool.assertCurrentThreadPool(ThreadPool.Names.MANAGEMENT); - additionalStatsListener.andThenApply( - additionalStats -> request.isRemoteStats() - // Return stripped down stats for remote clusters - ? new ClusterStatsResponse( + additionalStatsListener.andThenApply(additionalStats -> { + if (request.isRemoteStats()) { + // Return stripped down stats for remote clusters — no tags block needed + return new ClusterStatsResponse( System.currentTimeMillis(), clusterService.state().metadata().clusterUUID(), clusterService.getClusterName(), @@ -201,22 +203,30 @@ protected void newResponseAsync( null, null, Map.of(), - false - ) - : new ClusterStatsResponse( - System.currentTimeMillis(), - additionalStats.clusterUUID(), - clusterService.getClusterName(), - responses, - failures, - additionalStats.mappingStats(), - additionalStats.analysisStats(), - VersionStats.of(clusterService.state().metadata(), responses), - additionalStats.clusterSnapshotStats(), - additionalStats.getRemoteStats(), - request.isCPS() - ) - ).addListener(listener); + false, + null + ); + } + TagsConfigSnapshot tagsConfig = null; + ClusterStatsTagsProvider tagsProvider = usageService.getTagsProvider(); + if (tagsProvider != null) { + tagsConfig = tagsProvider.getTagsConfig(clusterService.state()); + } + return new ClusterStatsResponse( + System.currentTimeMillis(), + additionalStats.clusterUUID(), + clusterService.getClusterName(), + responses, + failures, + additionalStats.mappingStats(), + additionalStats.analysisStats(), + VersionStats.of(clusterService.state().metadata(), responses), + additionalStats.clusterSnapshotStats(), + additionalStats.getRemoteStats(), + request.isCPS(), + tagsConfig + ); + }).addListener(listener); } @Override @@ -320,6 +330,7 @@ protected ClusterStatsNodeResponse nodeOperation(ClusterStatsNodeRequest nodeReq final RepositoryUsageStats repositoryUsageStats = repositoriesService.getUsageStats(); final CCSTelemetrySnapshot ccsTelemetry = ccsUsageHolder.getCCSTelemetrySnapshot(); final CCSTelemetrySnapshot esqlTelemetry = esqlUsageHolder.getCCSTelemetrySnapshot(); + final ProjectRoutingUsageSnapshot projectRoutingUsage = usageService.getProjectRoutingUsageHolder().getSnapshot(); return new ClusterStatsNodeResponse( nodeInfo.getNode(), @@ -330,7 +341,8 @@ protected ClusterStatsNodeResponse nodeOperation(ClusterStatsNodeRequest nodeReq searchUsageStats, repositoryUsageStats, ccsTelemetry, - esqlTelemetry + esqlTelemetry, + projectRoutingUsage ); } diff --git a/server/src/main/java/org/elasticsearch/action/search/TransportSearchAction.java b/server/src/main/java/org/elasticsearch/action/search/TransportSearchAction.java index 3cca046eecf71..f0734cb73271c 100644 --- a/server/src/main/java/org/elasticsearch/action/search/TransportSearchAction.java +++ b/server/src/main/java/org/elasticsearch/action/search/TransportSearchAction.java @@ -93,8 +93,10 @@ import org.elasticsearch.search.builder.SearchSourceBuilder; import org.elasticsearch.search.crossproject.CrossProjectIndexResolutionValidator; import org.elasticsearch.search.crossproject.CrossProjectModeDecider; +import org.elasticsearch.search.crossproject.ProjectRoutingRequestInfo; import org.elasticsearch.search.crossproject.ProjectRoutingResolver; import org.elasticsearch.search.crossproject.SearchPlanningPhaseResolutionResult; +import org.elasticsearch.search.crossproject.TargetProjects; import org.elasticsearch.search.internal.AliasFilter; import org.elasticsearch.search.internal.SearchContext; import org.elasticsearch.search.internal.ShardSearchContextId; @@ -561,6 +563,23 @@ public void onFailure(Exception e) { searchResponseActionListener = delegate; } + // CPS project routing telemetry — queries counts all searches while the project has links; + // queries_project_routing and sub-counters only increment for requests that carry a project_routing expression. + // PIT opens (collectSearchTelemetry=false) are excluded — they are resource allocation, not queries. + if (collectSearchTelemetry) { + TargetProjects targetProjects = rewritten.getResolvedTargetProjects(); + boolean hasLinkedProjects = targetProjects != null && targetProjects.hasLinkedProjects(); + // Non-null routingInfo signals to the holder that this request carried a project_routing expression, + // triggering queries_project_routing and its sub-counters in addition to queries. + String projectRouting = rewritten.getProjectRouting(); + ProjectRoutingRequestInfo routingInfo = Strings.isNullOrEmpty(projectRouting) == false + ? (targetProjects != null && targetProjects.projectRoutingRequestInfo() != null + ? targetProjects.projectRoutingRequestInfo() + : ProjectRoutingRequestInfo.NONE) + : null; + usageService.getProjectRoutingUsageHolder().recordSearch(routingInfo, hasLinkedProjects); + } + if (resolvedIndices.getRemoteClusterIndices().isEmpty()) { if (resolvesCrossProject && rewritten.getResolvedIndexExpressions() != null) { ElasticsearchException ex = CrossProjectIndexResolutionValidator.validate( diff --git a/server/src/main/java/org/elasticsearch/plugins/ActionPlugin.java b/server/src/main/java/org/elasticsearch/plugins/ActionPlugin.java index 8c216d27891c8..449494bcac8f6 100644 --- a/server/src/main/java/org/elasticsearch/plugins/ActionPlugin.java +++ b/server/src/main/java/org/elasticsearch/plugins/ActionPlugin.java @@ -11,6 +11,7 @@ import org.elasticsearch.action.ActionType; import org.elasticsearch.action.RequestValidators; +import org.elasticsearch.action.admin.cluster.stats.ClusterStatsTagsProvider; import org.elasticsearch.action.admin.indices.alias.IndicesAliasesRequest; import org.elasticsearch.action.admin.indices.mapping.put.PutMappingRequest; import org.elasticsearch.action.support.ActionFilter; @@ -28,6 +29,7 @@ import java.util.Collection; import java.util.Collections; import java.util.Objects; +import java.util.Optional; import java.util.function.Predicate; import java.util.function.Supplier; @@ -142,6 +144,15 @@ default Collection> in return Collections.emptyList(); } + /** + * Optionally supplies a {@link ClusterStatsTagsProvider} that populates the static {@code tags} configuration + * fields (tag names, named routing expressions) in {@code GET _cluster/stats}. At most one plugin may provide + * a non-empty value; {@link org.elasticsearch.action.ActionModule} uses the first it finds. + */ + default Optional getClusterStatsTagsProvider() { + return Optional.empty(); + } + record RestHandlersServices( Settings settings, RestController restController, diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingRequestInfo.java b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingRequestInfo.java new file mode 100644 index 0000000000000..1a8d0c96f24f0 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingRequestInfo.java @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.search.crossproject; + +import java.util.List; + +/** + * Carries per-request project routing metadata from the resolver chain to the transport actions for telemetry recording. + * Populated by the serverless cross-project resolver and attached to {@link TargetProjects}. + * + *

Custom-tag detection is left to consumers: a tag is custom if its name does not start with {@code _}. + * + * @param tagsUsedInRouting all tag names referenced in the resolved expression + * @param usedNamedExpression true when the request used a named-expression ({@code @name}) reference + * @param usedAliasWildcard true when the expression was exactly {@code _alias:*} + * @param usedAliasOrigin true when the expression was exactly {@code _alias:_origin} + */ +public record ProjectRoutingRequestInfo( + List tagsUsedInRouting, + boolean usedNamedExpression, + boolean usedAliasWildcard, + boolean usedAliasOrigin +) { + public static final ProjectRoutingRequestInfo NONE = new ProjectRoutingRequestInfo(List.of(), false, false, false); +} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java index 9195d0468b2ff..858d7284cf312 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java @@ -22,10 +22,20 @@ * @param originProject the origin project, can be null if the request is not cross-project OR it was excluded by * project routing * @param linkedProjects all projects that are linked and authorized, can be empty if the request is not cross-project + * @param projectRoutingRequestInfo per-request routing metadata for telemetry; null when no routing was performed or + * the resolver has not yet been upgraded to populate it + * @param hasLinkedProjects true when the project had at least one configured linked project at the time of routing; + * set by the serverless resolver and used for telemetry gating. Unlike {@code linkedProjects} + * (which reflects post-routing state), this preserves the pre-routing truth — e.g. + * {@code _alias:_origin} queries resolve to an empty {@code linkedProjects} list even when + * links were configured. This field cannot be added to projectRoutingRequestInfo, as that + * object can be null, but we still need to increment telemetry counters based on this value. */ public record TargetProjects( @Nullable ProjectRoutingInfo originProject, // null when CPS is disabled or the local project is excluded by routing - @Nullable List linkedProjects // null when CPS is disabled + @Nullable List linkedProjects, // null when CPS is disabled + @Nullable ProjectRoutingRequestInfo projectRoutingRequestInfo, + boolean hasLinkedProjects ) { // Constant for representing no target project at all. Note this has a non-null empty linkedProjects field. public static final TargetProjects EMPTY = new TargetProjects(null, List.of()); @@ -44,8 +54,12 @@ public record TargetProjects( assert LOCAL_ONLY_FOR_CPS_DISABLED.crossProject() == false; } + public TargetProjects(ProjectRoutingInfo originProject, List linkedProjects) { + this(originProject, linkedProjects, null, false); + } + public TargetProjects(ProjectRoutingInfo originProject) { - this(originProject, List.of()); + this(originProject, List.of(), null, false); } @Nullable diff --git a/server/src/main/java/org/elasticsearch/usage/UsageService.java b/server/src/main/java/org/elasticsearch/usage/UsageService.java index 5b4fa0f27bf48..734933aa13b39 100644 --- a/server/src/main/java/org/elasticsearch/usage/UsageService.java +++ b/server/src/main/java/org/elasticsearch/usage/UsageService.java @@ -11,6 +11,9 @@ import org.elasticsearch.action.admin.cluster.node.usage.NodeUsage; import org.elasticsearch.action.admin.cluster.stats.CCSUsageTelemetry; +import org.elasticsearch.action.admin.cluster.stats.ClusterStatsTagsProvider; +import org.elasticsearch.action.admin.cluster.stats.ProjectRoutingUsageHolder; +import org.elasticsearch.core.Nullable; import org.elasticsearch.rest.BaseRestHandler; import java.util.HashMap; @@ -27,12 +30,15 @@ public class UsageService { private final SearchUsageHolder searchUsageHolder; private final CCSUsageTelemetry ccsUsageHolder; private final CCSUsageTelemetry esqlUsageHolder; + private final ProjectRoutingUsageHolder projectRoutingUsageHolder; + private volatile ClusterStatsTagsProvider tagsProvider = null; public UsageService() { this.handlers = new HashMap<>(); this.searchUsageHolder = new SearchUsageHolder(); this.ccsUsageHolder = new CCSUsageTelemetry(); this.esqlUsageHolder = new CCSUsageTelemetry(false); + this.projectRoutingUsageHolder = new ProjectRoutingUsageHolder(); } /** @@ -95,4 +101,17 @@ public CCSUsageTelemetry getCcsUsageHolder() { public CCSUsageTelemetry getEsqlUsageHolder() { return esqlUsageHolder; } + + public ProjectRoutingUsageHolder getProjectRoutingUsageHolder() { + return projectRoutingUsageHolder; + } + + public void registerTagsProvider(ClusterStatsTagsProvider provider) { + this.tagsProvider = Objects.requireNonNull(provider); + } + + @Nullable + public ClusterStatsTagsProvider getTagsProvider() { + return tagsProvider; + } } diff --git a/server/src/main/resources/transport/definitions/referable/project_routing_usage_stats.csv b/server/src/main/resources/transport/definitions/referable/project_routing_usage_stats.csv new file mode 100644 index 0000000000000..8cad359d8e895 --- /dev/null +++ b/server/src/main/resources/transport/definitions/referable/project_routing_usage_stats.csv @@ -0,0 +1 @@ +9491000 diff --git a/server/src/main/resources/transport/upper_bounds/9.6.csv b/server/src/main/resources/transport/upper_bounds/9.6.csv index 0b01cf70fbc12..2a705dbb14669 100644 --- a/server/src/main/resources/transport/upper_bounds/9.6.csv +++ b/server/src/main/resources/transport/upper_bounds/9.6.csv @@ -1 +1 @@ -esql_view_description,9490000 +project_routing_usage_stats,9491000 diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java new file mode 100644 index 0000000000000..c9c0fa461302e --- /dev/null +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java @@ -0,0 +1,220 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.action.admin.cluster.stats; + +import org.elasticsearch.search.crossproject.ProjectRoutingRequestInfo; +import org.elasticsearch.test.ESTestCase; + +import java.util.List; + +import static org.hamcrest.Matchers.equalTo; + +public class ProjectRoutingUsageHolderTests extends ESTestCase { + + private static ProjectRoutingRequestInfo info(boolean aliasOrigin, boolean aliasWildcard, boolean namedExpr, String... tags) { + return new ProjectRoutingRequestInfo(List.of(tags), namedExpr, aliasWildcard, aliasOrigin); + } + + // ----------------------------------------------------------------------- + // hasLinkedProjects = false → all calls are no-ops + // ----------------------------------------------------------------------- + + public void testNoLinkedProjects_searchIsNoOp() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearch(info(true, true, true, "mytag"), false); + holder.recordSearch(null, false); + + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getSearchQueriesTotal(), equalTo(0L)); + assertThat(snap.getSearchWithProjectRouting(), equalTo(0L)); + } + + public void testNoLinkedProjects_esqlIsNoOp() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordEsql(info(true, true, true, "mytag"), true, false); + holder.recordEsql(null, true, false); + + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getEsqlQueriesTotal(), equalTo(0L)); + assertThat(snap.getEsqlWithProjectRouting(), equalTo(0L)); + assertThat(snap.getEsqlWithSet(), equalTo(0L)); + } + + // ----------------------------------------------------------------------- + // null info → only total_queries increments, no sub-counters + // ----------------------------------------------------------------------- + + public void testNullInfo_searchOnlyIncrementsTotalQueries() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearch(null, true); + holder.recordSearch(null, true); + + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getSearchQueriesTotal(), equalTo(2L)); + assertThat(snap.getSearchWithProjectRouting(), equalTo(0L)); + assertThat(snap.getSearchWithAliasOrigin(), equalTo(0L)); + assertThat(snap.getSearchWithAliasWildcard(), equalTo(0L)); + assertThat(snap.getSearchWithCustomTags(), equalTo(0L)); + assertThat(snap.getSearchWithNamedExpression(), equalTo(0L)); + } + + public void testNullInfo_esqlOnlyIncrementsTotalQueries() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordEsql(null, false, true); + + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getEsqlQueriesTotal(), equalTo(1L)); + assertThat(snap.getEsqlWithProjectRouting(), equalTo(0L)); + assertThat(snap.getEsqlWithAliasOrigin(), equalTo(0L)); + assertThat(snap.getEsqlWithAliasWildcard(), equalTo(0L)); + assertThat(snap.getEsqlWithCustomTags(), equalTo(0L)); + assertThat(snap.getEsqlWithNamedExpression(), equalTo(0L)); + assertThat(snap.getEsqlWithSet(), equalTo(0L)); + } + + // ----------------------------------------------------------------------- + // with_project_routing and sub-counter flags — _search + // ----------------------------------------------------------------------- + + public void testSearch_noneInfoIncrementsWithProjectRoutingOnly() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearch(ProjectRoutingRequestInfo.NONE, true); + + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getSearchQueriesTotal(), equalTo(1L)); + assertThat(snap.getSearchWithProjectRouting(), equalTo(1L)); + assertThat(snap.getSearchWithAliasOrigin(), equalTo(0L)); + assertThat(snap.getSearchWithAliasWildcard(), equalTo(0L)); + assertThat(snap.getSearchWithCustomTags(), equalTo(0L)); + assertThat(snap.getSearchWithNamedExpression(), equalTo(0L)); + } + + public void testSearch_aliasOriginFlag() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearch(info(true, false, false, "_alias"), true); + + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getSearchWithProjectRouting(), equalTo(1L)); + assertThat(snap.getSearchWithAliasOrigin(), equalTo(1L)); + assertThat(snap.getSearchWithAliasWildcard(), equalTo(0L)); + } + + public void testSearch_aliasWildcardFlag() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearch(info(false, true, false, "_alias"), true); + + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getSearchWithAliasWildcard(), equalTo(1L)); + assertThat(snap.getSearchWithAliasOrigin(), equalTo(0L)); + } + + public void testSearch_namedExpressionFlag() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearch(info(false, false, true, "_alias"), true); + + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getSearchWithNamedExpression(), equalTo(1L)); + assertThat(snap.getSearchWithCustomTags(), equalTo(0L)); + } + + // ----------------------------------------------------------------------- + // custom-tag detection: names starting with '_' are predefined + // ----------------------------------------------------------------------- + + public void testSearch_predefinedTagsOnly() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearch(info(false, false, false, "_alias", "_region", "_csp"), true); + + assertThat(holder.getSnapshot().getSearchWithCustomTags(), equalTo(0L)); + } + + public void testSearch_singleCustomTag() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearch(info(false, false, false, "mytag"), true); + + assertThat(holder.getSnapshot().getSearchWithCustomTags(), equalTo(1L)); + } + + public void testSearch_mixedPredefinedAndCustom() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearch(info(false, false, false, "_alias", "mytag"), true); + + assertThat(holder.getSnapshot().getSearchWithCustomTags(), equalTo(1L)); + } + + public void testSearch_emptyTagList() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearch(info(false, false, false /* no tags */), true); + + assertThat(holder.getSnapshot().getSearchWithCustomTags(), equalTo(0L)); + } + + // ----------------------------------------------------------------------- + // ES|QL: with_SET increments independently of info nullness + // ----------------------------------------------------------------------- + + public void testEsql_setClauseWithNullInfo() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordEsql(null, true, true); + + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getEsqlQueriesTotal(), equalTo(1L)); + assertThat(snap.getEsqlWithSet(), equalTo(1L)); + assertThat(snap.getEsqlWithProjectRouting(), equalTo(0L)); + } + + public void testEsql_setClauseWithInfo() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordEsql(info(false, false, false, "_alias"), true, true); + + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getEsqlWithSet(), equalTo(1L)); + assertThat(snap.getEsqlWithProjectRouting(), equalTo(1L)); + } + + public void testEsql_noSetClause() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordEsql(info(true, false, false, "_alias"), false, true); + + assertThat(holder.getSnapshot().getEsqlWithSet(), equalTo(0L)); + } + + public void testEsql_subCounterFlags() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordEsql(info(true, false, true, "_alias", "custom"), false, true); + + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getEsqlWithProjectRouting(), equalTo(1L)); + assertThat(snap.getEsqlWithAliasOrigin(), equalTo(1L)); + assertThat(snap.getEsqlWithAliasWildcard(), equalTo(0L)); + assertThat(snap.getEsqlWithNamedExpression(), equalTo(1L)); + assertThat(snap.getEsqlWithCustomTags(), equalTo(1L)); + } + + // ----------------------------------------------------------------------- + // Accumulation across multiple calls + // ----------------------------------------------------------------------- + + public void testSearch_accumulatesCorrectly() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearch(null, true); // total only + holder.recordSearch(info(true, false, false, "_alias"), true); // + with_project_routing, alias_origin + holder.recordSearch(info(false, false, true, "_alias"), true); // + with_project_routing, named_expr + holder.recordSearch(info(false, false, false, "custom"), false); // gated out — hasLinkedProjects=false + + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getSearchQueriesTotal(), equalTo(3L)); + assertThat(snap.getSearchWithProjectRouting(), equalTo(2L)); + assertThat(snap.getSearchWithAliasOrigin(), equalTo(1L)); + assertThat(snap.getSearchWithNamedExpression(), equalTo(1L)); + assertThat(snap.getSearchWithAliasWildcard(), equalTo(0L)); + assertThat(snap.getSearchWithCustomTags(), equalTo(0L)); + } +} diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshotTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshotTests.java new file mode 100644 index 0000000000000..ee937c7cdec49 --- /dev/null +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshotTests.java @@ -0,0 +1,269 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.action.admin.cluster.stats; + +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.test.AbstractWireSerializingTestCase; +import org.elasticsearch.xcontent.ToXContent; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xcontent.XContentFactory; + +import java.io.IOException; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +public class ProjectRoutingUsageSnapshotTests extends AbstractWireSerializingTestCase { + + @Override + protected Writeable.Reader instanceReader() { + return ProjectRoutingUsageSnapshot::new; + } + + @Override + protected ProjectRoutingUsageSnapshot createTestInstance() { + if (randomBoolean()) { + return new ProjectRoutingUsageSnapshot(); + } + return randomSnapshot(); + } + + static ProjectRoutingUsageSnapshot randomSnapshot() { + return new ProjectRoutingUsageSnapshot( + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong() + ); + } + + @Override + protected ProjectRoutingUsageSnapshot mutateInstance(ProjectRoutingUsageSnapshot instance) { + // Pick one field to increment so the result is guaranteed to differ + int field = randomIntBetween(0, 14); + return new ProjectRoutingUsageSnapshot( + field == 0 ? instance.getSearchQueriesTotal() + 1 : instance.getSearchQueriesTotal(), + field == 1 ? instance.getSearchWithProjectRouting() + 1 : instance.getSearchWithProjectRouting(), + field == 2 ? instance.getSearchWithAliasOrigin() + 1 : instance.getSearchWithAliasOrigin(), + field == 3 ? instance.getSearchWithAliasWildcard() + 1 : instance.getSearchWithAliasWildcard(), + field == 4 ? instance.getSearchWithCustomTags() + 1 : instance.getSearchWithCustomTags(), + field == 5 ? instance.getSearchWithNamedExpression() + 1 : instance.getSearchWithNamedExpression(), + field == 6 ? instance.getSearchFailures() + 1 : instance.getSearchFailures(), + field == 7 ? instance.getEsqlQueriesTotal() + 1 : instance.getEsqlQueriesTotal(), + field == 8 ? instance.getEsqlWithProjectRouting() + 1 : instance.getEsqlWithProjectRouting(), + field == 9 ? instance.getEsqlWithAliasOrigin() + 1 : instance.getEsqlWithAliasOrigin(), + field == 10 ? instance.getEsqlWithAliasWildcard() + 1 : instance.getEsqlWithAliasWildcard(), + field == 11 ? instance.getEsqlWithCustomTags() + 1 : instance.getEsqlWithCustomTags(), + field == 12 ? instance.getEsqlWithNamedExpression() + 1 : instance.getEsqlWithNamedExpression(), + field == 13 ? instance.getEsqlWithSet() + 1 : instance.getEsqlWithSet(), + field == 14 ? instance.getEsqlFailures() + 1 : instance.getEsqlFailures() + ); + } + + // ----------------------------------------------------------------------- + // add() accumulation + // ----------------------------------------------------------------------- + + public void testAdd_emptyPlusNonEmpty() { + ProjectRoutingUsageSnapshot empty = new ProjectRoutingUsageSnapshot(); + ProjectRoutingUsageSnapshot full = randomSnapshot(); + empty.add(full); + assertThat(empty, equalTo(full)); + } + + public void testAdd_doubling() { + ProjectRoutingUsageSnapshot snap = randomSnapshot(); + ProjectRoutingUsageSnapshot acc = new ProjectRoutingUsageSnapshot(); + acc.add(snap); + acc.add(snap); + + assertThat(acc.getSearchQueriesTotal(), equalTo(snap.getSearchQueriesTotal() * 2)); + assertThat(acc.getSearchWithProjectRouting(), equalTo(snap.getSearchWithProjectRouting() * 2)); + assertThat(acc.getSearchWithAliasOrigin(), equalTo(snap.getSearchWithAliasOrigin() * 2)); + assertThat(acc.getSearchWithAliasWildcard(), equalTo(snap.getSearchWithAliasWildcard() * 2)); + assertThat(acc.getSearchWithCustomTags(), equalTo(snap.getSearchWithCustomTags() * 2)); + assertThat(acc.getSearchWithNamedExpression(), equalTo(snap.getSearchWithNamedExpression() * 2)); + assertThat(acc.getSearchFailures(), equalTo(snap.getSearchFailures() * 2)); + assertThat(acc.getEsqlQueriesTotal(), equalTo(snap.getEsqlQueriesTotal() * 2)); + assertThat(acc.getEsqlWithProjectRouting(), equalTo(snap.getEsqlWithProjectRouting() * 2)); + assertThat(acc.getEsqlWithAliasOrigin(), equalTo(snap.getEsqlWithAliasOrigin() * 2)); + assertThat(acc.getEsqlWithAliasWildcard(), equalTo(snap.getEsqlWithAliasWildcard() * 2)); + assertThat(acc.getEsqlWithCustomTags(), equalTo(snap.getEsqlWithCustomTags() * 2)); + assertThat(acc.getEsqlWithNamedExpression(), equalTo(snap.getEsqlWithNamedExpression() * 2)); + assertThat(acc.getEsqlWithSet(), equalTo(snap.getEsqlWithSet() * 2)); + assertThat(acc.getEsqlFailures(), equalTo(snap.getEsqlFailures() * 2)); + } + + public void testAdd_null_isNoop() { + ProjectRoutingUsageSnapshot snap = randomSnapshot(); + ProjectRoutingUsageSnapshot copy = new ProjectRoutingUsageSnapshot(); + copy.add(snap); + copy.add(null); + assertThat(copy, equalTo(snap)); + } + + public void testAdd_twoSnapshots() { + ProjectRoutingUsageSnapshot a = randomSnapshot(); + ProjectRoutingUsageSnapshot b = randomSnapshot(); + ProjectRoutingUsageSnapshot acc = new ProjectRoutingUsageSnapshot(); + acc.add(a); + acc.add(b); + + assertThat(acc.getSearchQueriesTotal(), equalTo(a.getSearchQueriesTotal() + b.getSearchQueriesTotal())); + assertThat(acc.getSearchFailures(), equalTo(a.getSearchFailures() + b.getSearchFailures())); + assertThat(acc.getEsqlWithSet(), equalTo(a.getEsqlWithSet() + b.getEsqlWithSet())); + assertThat(acc.getEsqlFailures(), equalTo(a.getEsqlFailures() + b.getEsqlFailures())); + } + + // ----------------------------------------------------------------------- + // ProjectRoutingUsageHolder failure-recording methods + // ----------------------------------------------------------------------- + + public void testRecordSearchFailure_noOp_when_hasLinkedProjects_false() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearchFailure(false); + assertThat(holder.getSnapshot(), equalTo(new ProjectRoutingUsageSnapshot())); + } + + public void testRecordSearchFailure_increments_queries_and_queries_project_routing_and_failures() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearchFailure(true); + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getSearchQueriesTotal(), equalTo(1L)); + assertThat(snap.getSearchWithProjectRouting(), equalTo(1L)); + assertThat(snap.getSearchFailures(), equalTo(1L)); + // mode sub-counters must remain at zero + assertThat(snap.getSearchWithAliasOrigin(), equalTo(0L)); + assertThat(snap.getSearchWithAliasWildcard(), equalTo(0L)); + assertThat(snap.getSearchWithCustomTags(), equalTo(0L)); + assertThat(snap.getSearchWithNamedExpression(), equalTo(0L)); + // esql counters untouched + assertThat(snap.getEsqlQueriesTotal(), equalTo(0L)); + assertThat(snap.getEsqlFailures(), equalTo(0L)); + } + + public void testRecordEsqlFailure_noOp_when_hasLinkedProjects_false() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordEsqlFailure(false); + assertThat(holder.getSnapshot(), equalTo(new ProjectRoutingUsageSnapshot())); + } + + public void testRecordEsqlFailure_increments_queries_and_queries_project_routing_and_failures() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordEsqlFailure(true); + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getEsqlQueriesTotal(), equalTo(1L)); + assertThat(snap.getEsqlWithProjectRouting(), equalTo(1L)); + assertThat(snap.getEsqlFailures(), equalTo(1L)); + // mode sub-counters must remain at zero + assertThat(snap.getEsqlWithAliasOrigin(), equalTo(0L)); + assertThat(snap.getEsqlWithAliasWildcard(), equalTo(0L)); + assertThat(snap.getEsqlWithCustomTags(), equalTo(0L)); + assertThat(snap.getEsqlWithNamedExpression(), equalTo(0L)); + assertThat(snap.getEsqlWithSet(), equalTo(0L)); + // search counters untouched + assertThat(snap.getSearchQueriesTotal(), equalTo(0L)); + assertThat(snap.getSearchFailures(), equalTo(0L)); + } + + // ----------------------------------------------------------------------- + // toXContent suppression rules + // ----------------------------------------------------------------------- + + public void testToXContent_allZero_emitsNothing() throws IOException { + ProjectRoutingUsageSnapshot snap = new ProjectRoutingUsageSnapshot(); + String json = toJson(snap); + assertThat(json, not(containsString("search"))); + assertThat(json, not(containsString("esql"))); + } + + public void testToXContent_searchOnly_emitsSearchNotEsql() throws IOException { + ProjectRoutingUsageSnapshot snap = new ProjectRoutingUsageSnapshot( + 5L, + 3L, + 1L, + 0L, + 0L, + 0L, + 2L, // search: total=5, with_pr=3, alias_origin=1, failures=2 + 0L, + 0L, + 0L, + 0L, + 0L, + 0L, + 0L, + 0L // esql: all zero + ); + String json = toJson(snap); + assertThat(json, containsString("\"search\"")); + assertThat(json, containsString("\"queries\":5")); + assertThat(json, containsString("\"queries_project_routing\":3")); + assertThat(json, containsString("\"alias_origin\":1")); + assertThat(json, containsString("\"failures\":2")); + assertThat(json, not(containsString("\"esql\""))); + } + + public void testToXContent_esqlOnly_emitsEsqlNotSearch() throws IOException { + ProjectRoutingUsageSnapshot snap = new ProjectRoutingUsageSnapshot( + 0L, + 0L, + 0L, + 0L, + 0L, + 0L, + 0L, // search: all zero + 8L, + 4L, + 0L, + 2L, + 0L, + 0L, + 3L, + 1L // esql: total=8, with_pr=4, alias_wildcard=2, in_SET=3, failures=1 + ); + String json = toJson(snap); + assertThat(json, not(containsString("\"search\""))); + assertThat(json, containsString("\"esql\"")); + assertThat(json, containsString("\"queries\":8")); + assertThat(json, containsString("\"queries_project_routing\":4")); + assertThat(json, containsString("\"alias_wildcard\":2")); + assertThat(json, containsString("\"in_SET\":3")); + assertThat(json, containsString("\"failures\":1")); + } + + public void testToXContent_bothPresent() throws IOException { + ProjectRoutingUsageSnapshot snap = new ProjectRoutingUsageSnapshot(10L, 5L, 0L, 0L, 0L, 0L, 0L, 7L, 3L, 0L, 0L, 0L, 0L, 1L, 0L); + String json = toJson(snap); + assertThat(json, containsString("\"search\"")); + assertThat(json, containsString("\"esql\"")); + } + + private static String toJson(ProjectRoutingUsageSnapshot snap) throws IOException { + XContentBuilder builder = XContentFactory.jsonBuilder(); + builder.startObject(); + snap.toXContent(builder, ToXContent.EMPTY_PARAMS); + builder.endObject(); + return Strings.toString(builder); + } +} diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/VersionStatsTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/VersionStatsTests.java index 075675eb9f7ca..4eebf7badead6 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/VersionStatsTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/VersionStatsTests.java @@ -132,7 +132,8 @@ public void testCreation() { new SearchUsageStats(), RepositoryUsageStats.EMPTY, null, - null + null, + new ProjectRoutingUsageSnapshot() ); stats = VersionStats.of(metadata, Collections.singletonList(nodeResponse)); diff --git a/x-pack/plugin/monitoring/src/test/java/org/elasticsearch/xpack/monitoring/collector/cluster/ClusterStatsMonitoringDocTests.java b/x-pack/plugin/monitoring/src/test/java/org/elasticsearch/xpack/monitoring/collector/cluster/ClusterStatsMonitoringDocTests.java index 74eafca465971..d11ed511c6be7 100644 --- a/x-pack/plugin/monitoring/src/test/java/org/elasticsearch/xpack/monitoring/collector/cluster/ClusterStatsMonitoringDocTests.java +++ b/x-pack/plugin/monitoring/src/test/java/org/elasticsearch/xpack/monitoring/collector/cluster/ClusterStatsMonitoringDocTests.java @@ -438,7 +438,8 @@ public void testToXContent() throws IOException { VersionStats.of(metadata, singletonList(mockNodeResponse)), ClusterSnapshotStats.EMPTY, null, - false + false, + null ); final MonitoringDoc.Node node = new MonitoringDoc.Node("_uuid", "_host", "_addr", "_ip", "_name", 1504169190855L); From af6076cee1f97846693617f984f0ac22a43de722 Mon Sep 17 00:00:00 2001 From: Michael Peterson Date: Wed, 5 Aug 2026 15:51:32 -0400 Subject: [PATCH 02/10] Additional test and some variable renames --- .../stats/ProjectRoutingUsageHolder.java | 18 +-- .../stats/ProjectRoutingUsageSnapshot.java | 40 ++--- ...usterStatsResponseProjectRoutingTests.java | 146 ++++++++++++++++++ .../ProjectRoutingUsageSnapshotTests.java | 31 ++-- 4 files changed, 192 insertions(+), 43 deletions(-) create mode 100644 server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponseProjectRoutingTests.java diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java index fc5c180922269..0fe248c9262ea 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java @@ -24,14 +24,14 @@ */ public class ProjectRoutingUsageHolder { - // _search endpoint + // _search, _async_search, _msearch (per sub-request), _search/template, _msearch/template private final LongAdder searchQueriesTotal = new LongAdder(); private final LongAdder searchWithProjectRouting = new LongAdder(); private final LongAdder searchWithAliasOrigin = new LongAdder(); private final LongAdder searchWithAliasWildcard = new LongAdder(); private final LongAdder searchWithCustomTags = new LongAdder(); private final LongAdder searchWithNamedExpression = new LongAdder(); - private final LongAdder searchFailures = new LongAdder(); + private final LongAdder searchProjectRoutingFailures = new LongAdder(); // ES|QL endpoint private final LongAdder esqlQueriesTotal = new LongAdder(); @@ -41,7 +41,7 @@ public class ProjectRoutingUsageHolder { private final LongAdder esqlWithCustomTags = new LongAdder(); private final LongAdder esqlWithNamedExpression = new LongAdder(); private final LongAdder esqlWithSet = new LongAdder(); - private final LongAdder esqlFailures = new LongAdder(); + private final LongAdder esqlProjectRoutingFailures = new LongAdder(); /** * Records a {@code _search} request. {@code queries} is always incremented (subject to the @@ -94,11 +94,11 @@ public void recordEsql(@Nullable ProjectRoutingRequestInfo info, boolean setClau * * @param hasLinkedProjects true when the project had at least one linked project; when false this is a no-op */ - public void recordSearchFailure(boolean hasLinkedProjects) { + public void recordSearchProjectRoutingFailure(boolean hasLinkedProjects) { if (hasLinkedProjects == false) return; searchQueriesTotal.increment(); searchWithProjectRouting.increment(); - searchFailures.increment(); + searchProjectRoutingFailures.increment(); } /** @@ -108,11 +108,11 @@ public void recordSearchFailure(boolean hasLinkedProjects) { * * @param hasLinkedProjects true when the project had at least one linked project; when false this is a no-op */ - public void recordEsqlFailure(boolean hasLinkedProjects) { + public void recordEsqlProjectRoutingFailure(boolean hasLinkedProjects) { if (hasLinkedProjects == false) return; esqlQueriesTotal.increment(); esqlWithProjectRouting.increment(); - esqlFailures.increment(); + esqlProjectRoutingFailures.increment(); } /** @@ -126,7 +126,7 @@ public ProjectRoutingUsageSnapshot getSnapshot() { searchWithAliasWildcard.sum(), searchWithCustomTags.sum(), searchWithNamedExpression.sum(), - searchFailures.sum(), + searchProjectRoutingFailures.sum(), esqlQueriesTotal.sum(), esqlWithProjectRouting.sum(), esqlWithAliasOrigin.sum(), @@ -134,7 +134,7 @@ public ProjectRoutingUsageSnapshot getSnapshot() { esqlWithCustomTags.sum(), esqlWithNamedExpression.sum(), esqlWithSet.sum(), - esqlFailures.sum() + esqlProjectRoutingFailures.sum() ); } } diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java index 633b9d969cade..49268a606863a 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java @@ -34,7 +34,7 @@ public class ProjectRoutingUsageSnapshot implements Writeable, ToXContentFragmen private long searchWithAliasWildcard; private long searchWithCustomTags; private long searchWithNamedExpression; - private long searchFailures; + private long searchProjectRoutingFailures; private long esqlQueriesTotal; private long esqlWithProjectRouting; @@ -43,7 +43,7 @@ public class ProjectRoutingUsageSnapshot implements Writeable, ToXContentFragmen private long esqlWithCustomTags; private long esqlWithNamedExpression; private long esqlWithSet; - private long esqlFailures; + private long esqlProjectRoutingFailures; /** Creates an empty snapshot suitable for accumulating node snapshots into. */ public ProjectRoutingUsageSnapshot() {} @@ -56,7 +56,7 @@ public ProjectRoutingUsageSnapshot( long searchWithAliasWildcard, long searchWithCustomTags, long searchWithNamedExpression, - long searchFailures, + long searchProjectRoutingFailures, long esqlQueriesTotal, long esqlWithProjectRouting, long esqlWithAliasOrigin, @@ -64,7 +64,7 @@ public ProjectRoutingUsageSnapshot( long esqlWithCustomTags, long esqlWithNamedExpression, long esqlWithSet, - long esqlFailures + long esqlProjectRoutingFailures ) { this.searchQueriesTotal = searchQueriesTotal; this.searchWithProjectRouting = searchWithProjectRouting; @@ -72,7 +72,7 @@ public ProjectRoutingUsageSnapshot( this.searchWithAliasWildcard = searchWithAliasWildcard; this.searchWithCustomTags = searchWithCustomTags; this.searchWithNamedExpression = searchWithNamedExpression; - this.searchFailures = searchFailures; + this.searchProjectRoutingFailures = searchProjectRoutingFailures; this.esqlQueriesTotal = esqlQueriesTotal; this.esqlWithProjectRouting = esqlWithProjectRouting; this.esqlWithAliasOrigin = esqlWithAliasOrigin; @@ -80,7 +80,7 @@ public ProjectRoutingUsageSnapshot( this.esqlWithCustomTags = esqlWithCustomTags; this.esqlWithNamedExpression = esqlWithNamedExpression; this.esqlWithSet = esqlWithSet; - this.esqlFailures = esqlFailures; + this.esqlProjectRoutingFailures = esqlProjectRoutingFailures; } public ProjectRoutingUsageSnapshot(StreamInput in) throws IOException { @@ -90,7 +90,7 @@ public ProjectRoutingUsageSnapshot(StreamInput in) throws IOException { searchWithAliasWildcard = in.readVLong(); searchWithCustomTags = in.readVLong(); searchWithNamedExpression = in.readVLong(); - searchFailures = in.readVLong(); + searchProjectRoutingFailures = in.readVLong(); esqlQueriesTotal = in.readVLong(); esqlWithProjectRouting = in.readVLong(); esqlWithAliasOrigin = in.readVLong(); @@ -98,7 +98,7 @@ public ProjectRoutingUsageSnapshot(StreamInput in) throws IOException { esqlWithCustomTags = in.readVLong(); esqlWithNamedExpression = in.readVLong(); esqlWithSet = in.readVLong(); - esqlFailures = in.readVLong(); + esqlProjectRoutingFailures = in.readVLong(); } @Override @@ -109,7 +109,7 @@ public void writeTo(StreamOutput out) throws IOException { out.writeVLong(searchWithAliasWildcard); out.writeVLong(searchWithCustomTags); out.writeVLong(searchWithNamedExpression); - out.writeVLong(searchFailures); + out.writeVLong(searchProjectRoutingFailures); out.writeVLong(esqlQueriesTotal); out.writeVLong(esqlWithProjectRouting); out.writeVLong(esqlWithAliasOrigin); @@ -117,7 +117,7 @@ public void writeTo(StreamOutput out) throws IOException { out.writeVLong(esqlWithCustomTags); out.writeVLong(esqlWithNamedExpression); out.writeVLong(esqlWithSet); - out.writeVLong(esqlFailures); + out.writeVLong(esqlProjectRoutingFailures); } /** @@ -131,7 +131,7 @@ public void add(ProjectRoutingUsageSnapshot other) { searchWithAliasWildcard += other.searchWithAliasWildcard; searchWithCustomTags += other.searchWithCustomTags; searchWithNamedExpression += other.searchWithNamedExpression; - searchFailures += other.searchFailures; + searchProjectRoutingFailures += other.searchProjectRoutingFailures; esqlQueriesTotal += other.esqlQueriesTotal; esqlWithProjectRouting += other.esqlWithProjectRouting; esqlWithAliasOrigin += other.esqlWithAliasOrigin; @@ -139,7 +139,7 @@ public void add(ProjectRoutingUsageSnapshot other) { esqlWithCustomTags += other.esqlWithCustomTags; esqlWithNamedExpression += other.esqlWithNamedExpression; esqlWithSet += other.esqlWithSet; - esqlFailures += other.esqlFailures; + esqlProjectRoutingFailures += other.esqlProjectRoutingFailures; } public long getSearchQueriesTotal() { @@ -167,7 +167,7 @@ public long getSearchWithNamedExpression() { } public long getSearchFailures() { - return searchFailures; + return searchProjectRoutingFailures; } public long getEsqlQueriesTotal() { @@ -199,7 +199,7 @@ public long getEsqlWithSet() { } public long getEsqlFailures() { - return esqlFailures; + return esqlProjectRoutingFailures; } @Override @@ -213,7 +213,7 @@ public boolean equals(Object o) { && searchWithAliasWildcard == other.searchWithAliasWildcard && searchWithCustomTags == other.searchWithCustomTags && searchWithNamedExpression == other.searchWithNamedExpression - && searchFailures == other.searchFailures + && searchProjectRoutingFailures == other.searchProjectRoutingFailures && esqlQueriesTotal == other.esqlQueriesTotal && esqlWithProjectRouting == other.esqlWithProjectRouting && esqlWithAliasOrigin == other.esqlWithAliasOrigin @@ -221,7 +221,7 @@ public boolean equals(Object o) { && esqlWithCustomTags == other.esqlWithCustomTags && esqlWithNamedExpression == other.esqlWithNamedExpression && esqlWithSet == other.esqlWithSet - && esqlFailures == other.esqlFailures; + && esqlProjectRoutingFailures == other.esqlProjectRoutingFailures; } @Override @@ -233,7 +233,7 @@ public int hashCode() { searchWithAliasWildcard, searchWithCustomTags, searchWithNamedExpression, - searchFailures, + searchProjectRoutingFailures, esqlQueriesTotal, esqlWithProjectRouting, esqlWithAliasOrigin, @@ -241,7 +241,7 @@ public int hashCode() { esqlWithCustomTags, esqlWithNamedExpression, esqlWithSet, - esqlFailures + esqlProjectRoutingFailures ); } @@ -260,7 +260,7 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.field("alias_wildcard", searchWithAliasWildcard); builder.field("custom_tags", searchWithCustomTags); builder.field("named_expression", searchWithNamedExpression); - builder.field("failures", searchFailures); + builder.field("failures", searchProjectRoutingFailures); builder.endObject(); } if (esqlQueriesTotal > 0) { @@ -272,7 +272,7 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.field("custom_tags", esqlWithCustomTags); builder.field("named_expression", esqlWithNamedExpression); builder.field("in_SET", esqlWithSet); - builder.field("failures", esqlFailures); + builder.field("failures", esqlProjectRoutingFailures); builder.endObject(); } return builder; diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponseProjectRoutingTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponseProjectRoutingTests.java new file mode 100644 index 0000000000000..ea35d9b825ce7 --- /dev/null +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponseProjectRoutingTests.java @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.action.admin.cluster.stats; + +import org.elasticsearch.Build; +import org.elasticsearch.TransportVersion; +import org.elasticsearch.action.admin.cluster.node.info.NodeInfo; +import org.elasticsearch.action.admin.cluster.node.info.PluginsAndModules; +import org.elasticsearch.action.admin.cluster.node.stats.NodeStatsTests; +import org.elasticsearch.action.admin.indices.stats.ShardStats; +import org.elasticsearch.cluster.ClusterName; +import org.elasticsearch.cluster.ClusterSnapshotStats; +import org.elasticsearch.cluster.health.ClusterHealthStatus; +import org.elasticsearch.cluster.metadata.Metadata; +import org.elasticsearch.cluster.node.DiscoveryNodeUtils; +import org.elasticsearch.cluster.version.CompatibilityVersions; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.transport.BoundTransportAddress; +import org.elasticsearch.common.transport.TransportAddress; +import org.elasticsearch.common.unit.Processors; +import org.elasticsearch.index.IndexVersion; +import org.elasticsearch.monitor.jvm.JvmInfo; +import org.elasticsearch.monitor.os.OsInfo; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.transport.TransportInfo; + +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; + +/** + * Tests the project_routing and tags block assembly in ClusterStatsResponse.toXContent(). + * Focused on the gating logic (totalQueries > 0, tagsConfig != null) and the + * top-level queries sum that is computed at render time rather than stored. + */ +public class ClusterStatsResponseProjectRoutingTests extends ESTestCase { + + public void testProjectRoutingBlock_absent_when_allCountersZero() throws Exception { + ClusterStatsResponse response = buildResponse(new ProjectRoutingUsageSnapshot(), null); + String json = Strings.toString(response); + assertThat(json, not(containsString("\"project_routing\""))); + } + + public void testProjectRoutingBlock_present_with_correct_queries_sum() throws Exception { + // searchQueriesTotal=5, esqlQueriesTotal=3 → queries=8 + ProjectRoutingUsageSnapshot snapshot = new ProjectRoutingUsageSnapshot(5L, 0L, 0L, 0L, 0L, 0L, 0L, 3L, 0L, 0L, 0L, 0L, 0L, 0L, 0L); + ClusterStatsResponse response = buildResponse(snapshot, null); + String json = Strings.toString(response); + assertThat(json, containsString("\"project_routing\"")); + assertThat(json, containsString("\"queries\":8")); + assertThat(json, not(containsString("\"tags\""))); + } + + public void testTagsBlock_present_and_independent_of_project_routing() throws Exception { + // zero-query snapshot — project_routing block must be absent + ProjectRoutingUsageSnapshot snapshot = new ProjectRoutingUsageSnapshot(); + TagsConfigSnapshot tagsConfig = new TagsConfigSnapshot(2, 1, List.of("_alias", "mytag"), 0, List.of()); + ClusterStatsResponse response = buildResponse(snapshot, tagsConfig); + String json = Strings.toString(response); + assertThat(json, containsString("\"tags\"")); + assertThat(json, containsString("\"total\":2")); + assertThat(json, not(containsString("\"project_routing\""))); + } + + public void testBothBlocks_present_when_both_non_empty() throws Exception { + ProjectRoutingUsageSnapshot snapshot = new ProjectRoutingUsageSnapshot(10L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L); + TagsConfigSnapshot tagsConfig = new TagsConfigSnapshot(1, 0, List.of("_alias"), 0, List.of()); + ClusterStatsResponse response = buildResponse(snapshot, tagsConfig); + String json = Strings.toString(response); + assertThat(json, containsString("\"tags\"")); + assertThat(json, containsString("\"project_routing\"")); + assertThat(json, containsString("\"queries\":10")); + } + + // ----------------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------------- + + private static ClusterStatsResponse buildResponse(ProjectRoutingUsageSnapshot snapshot, TagsConfigSnapshot tagsConfig) { + ClusterStatsNodeResponse nodeResponse = buildNodeResponse(snapshot); + List nodes = List.of(nodeResponse); + return new ClusterStatsResponse( + 0L, + "test-uuid", + new ClusterName("test"), + nodes, + List.of(), + MappingStats.of(Metadata.EMPTY_METADATA, () -> {}), + AnalysisStats.of(Metadata.EMPTY_METADATA, () -> {}), + VersionStats.of(Metadata.EMPTY_METADATA, nodes), + ClusterSnapshotStats.EMPTY, + Map.of(), + false, + tagsConfig + ); + } + + private static ClusterStatsNodeResponse buildNodeResponse(ProjectRoutingUsageSnapshot snapshot) { + var node = DiscoveryNodeUtils.create("node1", buildNewFakeTransportAddress()); + TransportAddress addr = buildNewFakeTransportAddress(); + var boundAddr = new BoundTransportAddress(new TransportAddress[] { addr }, addr); + var osInfo = new OsInfo(0L, 1, Processors.of(1.0), "test", "test", "test", "test"); + var nodeInfo = new NodeInfo( + Build.current().version(), + new CompatibilityVersions(TransportVersion.current(), Map.of()), + IndexVersion.current(), + Map.of(), + Build.current(), + node, + Settings.EMPTY, + osInfo, + null, + JvmInfo.jvmInfo(), + null, + new TransportInfo(boundAddr, Map.of()), + null, + null, + new PluginsAndModules(List.of(), List.of()), + null, + null, + null + ); + return new ClusterStatsNodeResponse( + node, + ClusterHealthStatus.GREEN, + nodeInfo, + NodeStatsTests.createNodeStats(), + new ShardStats[0], + new SearchUsageStats(), + RepositoryUsageStats.EMPTY, + null, + null, + snapshot + ); + } +} diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshotTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshotTests.java index ee937c7cdec49..e449093b7fb3c 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshotTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshotTests.java @@ -68,7 +68,7 @@ protected ProjectRoutingUsageSnapshot mutateInstance(ProjectRoutingUsageSnapshot field == 3 ? instance.getSearchWithAliasWildcard() + 1 : instance.getSearchWithAliasWildcard(), field == 4 ? instance.getSearchWithCustomTags() + 1 : instance.getSearchWithCustomTags(), field == 5 ? instance.getSearchWithNamedExpression() + 1 : instance.getSearchWithNamedExpression(), - field == 6 ? instance.getSearchFailures() + 1 : instance.getSearchFailures(), + field == 6 ? instance.getSearchProjectRoutingFailures() + 1 : instance.getSearchProjectRoutingFailures(), field == 7 ? instance.getEsqlQueriesTotal() + 1 : instance.getEsqlQueriesTotal(), field == 8 ? instance.getEsqlWithProjectRouting() + 1 : instance.getEsqlWithProjectRouting(), field == 9 ? instance.getEsqlWithAliasOrigin() + 1 : instance.getEsqlWithAliasOrigin(), @@ -76,7 +76,7 @@ protected ProjectRoutingUsageSnapshot mutateInstance(ProjectRoutingUsageSnapshot field == 11 ? instance.getEsqlWithCustomTags() + 1 : instance.getEsqlWithCustomTags(), field == 12 ? instance.getEsqlWithNamedExpression() + 1 : instance.getEsqlWithNamedExpression(), field == 13 ? instance.getEsqlWithSet() + 1 : instance.getEsqlWithSet(), - field == 14 ? instance.getEsqlFailures() + 1 : instance.getEsqlFailures() + field == 14 ? instance.getEsqlProjectRoutingFailures() + 1 : instance.getEsqlProjectRoutingFailures() ); } @@ -103,7 +103,7 @@ public void testAdd_doubling() { assertThat(acc.getSearchWithAliasWildcard(), equalTo(snap.getSearchWithAliasWildcard() * 2)); assertThat(acc.getSearchWithCustomTags(), equalTo(snap.getSearchWithCustomTags() * 2)); assertThat(acc.getSearchWithNamedExpression(), equalTo(snap.getSearchWithNamedExpression() * 2)); - assertThat(acc.getSearchFailures(), equalTo(snap.getSearchFailures() * 2)); + assertThat(acc.getSearchProjectRoutingFailures(), equalTo(snap.getSearchProjectRoutingFailures() * 2)); assertThat(acc.getEsqlQueriesTotal(), equalTo(snap.getEsqlQueriesTotal() * 2)); assertThat(acc.getEsqlWithProjectRouting(), equalTo(snap.getEsqlWithProjectRouting() * 2)); assertThat(acc.getEsqlWithAliasOrigin(), equalTo(snap.getEsqlWithAliasOrigin() * 2)); @@ -111,7 +111,7 @@ public void testAdd_doubling() { assertThat(acc.getEsqlWithCustomTags(), equalTo(snap.getEsqlWithCustomTags() * 2)); assertThat(acc.getEsqlWithNamedExpression(), equalTo(snap.getEsqlWithNamedExpression() * 2)); assertThat(acc.getEsqlWithSet(), equalTo(snap.getEsqlWithSet() * 2)); - assertThat(acc.getEsqlFailures(), equalTo(snap.getEsqlFailures() * 2)); + assertThat(acc.getEsqlProjectRoutingFailures(), equalTo(snap.getEsqlProjectRoutingFailures() * 2)); } public void testAdd_null_isNoop() { @@ -130,9 +130,12 @@ public void testAdd_twoSnapshots() { acc.add(b); assertThat(acc.getSearchQueriesTotal(), equalTo(a.getSearchQueriesTotal() + b.getSearchQueriesTotal())); - assertThat(acc.getSearchFailures(), equalTo(a.getSearchFailures() + b.getSearchFailures())); + assertThat( + acc.getSearchProjectRoutingFailures(), + equalTo(a.getSearchProjectRoutingFailures() + b.getSearchProjectRoutingFailures()) + ); assertThat(acc.getEsqlWithSet(), equalTo(a.getEsqlWithSet() + b.getEsqlWithSet())); - assertThat(acc.getEsqlFailures(), equalTo(a.getEsqlFailures() + b.getEsqlFailures())); + assertThat(acc.getEsqlProjectRoutingFailures(), equalTo(a.getEsqlProjectRoutingFailures() + b.getEsqlProjectRoutingFailures())); } // ----------------------------------------------------------------------- @@ -141,17 +144,17 @@ public void testAdd_twoSnapshots() { public void testRecordSearchFailure_noOp_when_hasLinkedProjects_false() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordSearchFailure(false); + holder.recordSearchProjectRoutingFailure(false); assertThat(holder.getSnapshot(), equalTo(new ProjectRoutingUsageSnapshot())); } public void testRecordSearchFailure_increments_queries_and_queries_project_routing_and_failures() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordSearchFailure(true); + holder.recordSearchProjectRoutingFailure(true); ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); assertThat(snap.getSearchQueriesTotal(), equalTo(1L)); assertThat(snap.getSearchWithProjectRouting(), equalTo(1L)); - assertThat(snap.getSearchFailures(), equalTo(1L)); + assertThat(snap.getSearchProjectRoutingFailures(), equalTo(1L)); // mode sub-counters must remain at zero assertThat(snap.getSearchWithAliasOrigin(), equalTo(0L)); assertThat(snap.getSearchWithAliasWildcard(), equalTo(0L)); @@ -159,22 +162,22 @@ public void testRecordSearchFailure_increments_queries_and_queries_project_routi assertThat(snap.getSearchWithNamedExpression(), equalTo(0L)); // esql counters untouched assertThat(snap.getEsqlQueriesTotal(), equalTo(0L)); - assertThat(snap.getEsqlFailures(), equalTo(0L)); + assertThat(snap.getEsqlProjectRoutingFailures(), equalTo(0L)); } public void testRecordEsqlFailure_noOp_when_hasLinkedProjects_false() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordEsqlFailure(false); + holder.recordEsqlProjectRoutingFailure(false); assertThat(holder.getSnapshot(), equalTo(new ProjectRoutingUsageSnapshot())); } public void testRecordEsqlFailure_increments_queries_and_queries_project_routing_and_failures() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordEsqlFailure(true); + holder.recordEsqlProjectRoutingFailure(true); ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); assertThat(snap.getEsqlQueriesTotal(), equalTo(1L)); assertThat(snap.getEsqlWithProjectRouting(), equalTo(1L)); - assertThat(snap.getEsqlFailures(), equalTo(1L)); + assertThat(snap.getEsqlProjectRoutingFailures(), equalTo(1L)); // mode sub-counters must remain at zero assertThat(snap.getEsqlWithAliasOrigin(), equalTo(0L)); assertThat(snap.getEsqlWithAliasWildcard(), equalTo(0L)); @@ -183,7 +186,7 @@ public void testRecordEsqlFailure_increments_queries_and_queries_project_routing assertThat(snap.getEsqlWithSet(), equalTo(0L)); // search counters untouched assertThat(snap.getSearchQueriesTotal(), equalTo(0L)); - assertThat(snap.getSearchFailures(), equalTo(0L)); + assertThat(snap.getSearchProjectRoutingFailures(), equalTo(0L)); } // ----------------------------------------------------------------------- From 61213a768db766ee525fcac07f933fad8b515f07 Mon Sep 17 00:00:00 2001 From: Michael Peterson Date: Wed, 5 Aug 2026 16:15:33 -0400 Subject: [PATCH 03/10] Fixed compilation error --- .../admin/cluster/stats/ProjectRoutingUsageSnapshot.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java index 49268a606863a..2bce3f4cb26e1 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java @@ -166,7 +166,7 @@ public long getSearchWithNamedExpression() { return searchWithNamedExpression; } - public long getSearchFailures() { + public long getSearchProjectRoutingFailures() { return searchProjectRoutingFailures; } @@ -198,7 +198,7 @@ public long getEsqlWithSet() { return esqlWithSet; } - public long getEsqlFailures() { + public long getEsqlProjectRoutingFailures() { return esqlProjectRoutingFailures; } From d586afc79158aac2ade214d1dfd7243d183662d5 Mon Sep 17 00:00:00 2001 From: Michael Peterson Date: Thu, 6 Aug 2026 15:30:21 -0400 Subject: [PATCH 04/10] Update server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java Co-authored-by: Stanislav Malyshev --- .../admin/cluster/stats/TransportClusterStatsAction.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java index 66de771360f03..631b616804c12 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java @@ -207,11 +207,8 @@ protected void newResponseAsync( null ); } - TagsConfigSnapshot tagsConfig = null; ClusterStatsTagsProvider tagsProvider = usageService.getTagsProvider(); - if (tagsProvider != null) { - tagsConfig = tagsProvider.getTagsConfig(clusterService.state()); - } + TagsConfigSnapshot tagsConfig = (tagsProvider != null) ? tagsProvider.getTagsConfig(clusterService.state()) : null; return new ClusterStatsResponse( System.currentTimeMillis(), additionalStats.clusterUUID(), From 6e278575a33bf4d15a744f8ff3971213083ea2d3 Mon Sep 17 00:00:00 2001 From: Michael Peterson Date: Thu, 6 Aug 2026 15:58:11 -0400 Subject: [PATCH 05/10] PR changes --- .../cluster/stats/TagsConfigSnapshot.java | 18 ++++++----------- .../action/search/TransportSearchAction.java | 20 ++++++++++--------- ...usterStatsResponseProjectRoutingTests.java | 4 ++-- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TagsConfigSnapshot.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TagsConfigSnapshot.java index b7b3592ec5267..018f7b95c562c 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TagsConfigSnapshot.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TagsConfigSnapshot.java @@ -21,22 +21,16 @@ * ({@code total}, {@code total_custom}, {@code names}, {@code named_routing_expressions}) inside * the top-level {@code tags} object. */ -public record TagsConfigSnapshot( - int total, - int totalCustom, - List names, - int namedRoutingExpressionsTotal, - List namedRoutingExpressionNames -) implements ToXContentFragment { +public record TagsConfigSnapshot(List names, List namedRoutingExpressionNames) implements ToXContentFragment { @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { - builder.field("total", total); - builder.field("total_custom", totalCustom); - builder.array("names", names.toArray(new String[0])); + builder.field("total", names.size()); + builder.field("total_custom", names.stream().filter(n -> n.startsWith("_") == false).count()); + builder.stringListField("names", names); builder.startObject("named_routing_expressions"); - builder.field("total", namedRoutingExpressionsTotal); - builder.array("names", namedRoutingExpressionNames.toArray(new String[0])); + builder.field("total", namedRoutingExpressionNames.size()); + builder.stringListField("names", namedRoutingExpressionNames); builder.endObject(); return builder; } diff --git a/server/src/main/java/org/elasticsearch/action/search/TransportSearchAction.java b/server/src/main/java/org/elasticsearch/action/search/TransportSearchAction.java index f0734cb73271c..9f398530655ea 100644 --- a/server/src/main/java/org/elasticsearch/action/search/TransportSearchAction.java +++ b/server/src/main/java/org/elasticsearch/action/search/TransportSearchAction.java @@ -569,15 +569,17 @@ public void onFailure(Exception e) { if (collectSearchTelemetry) { TargetProjects targetProjects = rewritten.getResolvedTargetProjects(); boolean hasLinkedProjects = targetProjects != null && targetProjects.hasLinkedProjects(); - // Non-null routingInfo signals to the holder that this request carried a project_routing expression, - // triggering queries_project_routing and its sub-counters in addition to queries. - String projectRouting = rewritten.getProjectRouting(); - ProjectRoutingRequestInfo routingInfo = Strings.isNullOrEmpty(projectRouting) == false - ? (targetProjects != null && targetProjects.projectRoutingRequestInfo() != null - ? targetProjects.projectRoutingRequestInfo() - : ProjectRoutingRequestInfo.NONE) - : null; - usageService.getProjectRoutingUsageHolder().recordSearch(routingInfo, hasLinkedProjects); + if (hasLinkedProjects) { + // Non-null routingInfo signals to the holder that this request carried a project_routing expression, + // triggering queries_project_routing and its sub-counters in addition to queries. + String projectRouting = rewritten.getProjectRouting(); + ProjectRoutingRequestInfo routingInfo = Strings.isNullOrEmpty(projectRouting) == false + ? (targetProjects.projectRoutingRequestInfo() != null + ? targetProjects.projectRoutingRequestInfo() + : ProjectRoutingRequestInfo.NONE) + : null; + usageService.getProjectRoutingUsageHolder().recordSearch(routingInfo, hasLinkedProjects); + } } if (resolvedIndices.getRemoteClusterIndices().isEmpty()) { diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponseProjectRoutingTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponseProjectRoutingTests.java index ea35d9b825ce7..1095096dacfd4 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponseProjectRoutingTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponseProjectRoutingTests.java @@ -64,7 +64,7 @@ public void testProjectRoutingBlock_present_with_correct_queries_sum() throws Ex public void testTagsBlock_present_and_independent_of_project_routing() throws Exception { // zero-query snapshot — project_routing block must be absent ProjectRoutingUsageSnapshot snapshot = new ProjectRoutingUsageSnapshot(); - TagsConfigSnapshot tagsConfig = new TagsConfigSnapshot(2, 1, List.of("_alias", "mytag"), 0, List.of()); + TagsConfigSnapshot tagsConfig = new TagsConfigSnapshot(List.of("_alias", "mytag"), List.of()); ClusterStatsResponse response = buildResponse(snapshot, tagsConfig); String json = Strings.toString(response); assertThat(json, containsString("\"tags\"")); @@ -74,7 +74,7 @@ public void testTagsBlock_present_and_independent_of_project_routing() throws Ex public void testBothBlocks_present_when_both_non_empty() throws Exception { ProjectRoutingUsageSnapshot snapshot = new ProjectRoutingUsageSnapshot(10L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L); - TagsConfigSnapshot tagsConfig = new TagsConfigSnapshot(1, 0, List.of("_alias"), 0, List.of()); + TagsConfigSnapshot tagsConfig = new TagsConfigSnapshot(List.of("_alias"), List.of()); ClusterStatsResponse response = buildResponse(snapshot, tagsConfig); String json = Strings.toString(response); assertThat(json, containsString("\"tags\"")); From 8341614aaac7eb2036522b36fd70fee17ce8342b Mon Sep 17 00:00:00 2001 From: Michael Peterson Date: Fri, 7 Aug 2026 10:04:33 -0400 Subject: [PATCH 06/10] PR changes --- .../stats/ProjectRoutingUsageHolder.java | 125 +++++++++++------- .../stats/TransportClusterStatsAction.java | 13 +- .../ProjectRoutingRequestInfo.java | 10 +- .../stats/ProjectRoutingUsageHolderTests.java | 5 +- 4 files changed, 83 insertions(+), 70 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java index 0fe248c9262ea..08a3923b481f4 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java @@ -21,27 +21,68 @@ *

All counters are gated on {@code hasLinkedProjects}: they only increment while the project has at least one * configured linked project. This ensures percentages can be computed from the data * (e.g. {@code queries_project_routing / queries}). + * + *

Common per-endpoint counters are grouped in {@link RoutingCounters}. Adding a new endpoint (e.g. EQL, SQL) + * requires adding a new {@link RoutingCounters} instance and a corresponding {@code record*()} method. + * The ES|QL-specific {@code in_SET} counter ({@link #esqlWithSet}) is tracked separately. */ public class ProjectRoutingUsageHolder { - // _search, _async_search, _msearch (per sub-request), _search/template, _msearch/template - private final LongAdder searchQueriesTotal = new LongAdder(); - private final LongAdder searchWithProjectRouting = new LongAdder(); - private final LongAdder searchWithAliasOrigin = new LongAdder(); - private final LongAdder searchWithAliasWildcard = new LongAdder(); - private final LongAdder searchWithCustomTags = new LongAdder(); - private final LongAdder searchWithNamedExpression = new LongAdder(); - private final LongAdder searchProjectRoutingFailures = new LongAdder(); + /** + * Groups the counters that are common across all tracked endpoints. Each endpoint ({@code _search}, + * {@code _esql}, and any future additions) gets its own instance. + */ + private static class RoutingCounters { + final LongAdder total = new LongAdder(); + final LongAdder withProjectRouting = new LongAdder(); + final LongAdder withAliasOrigin = new LongAdder(); + final LongAdder withAliasWildcard = new LongAdder(); + final LongAdder withCustomTags = new LongAdder(); + final LongAdder withNamedExpression = new LongAdder(); + final LongAdder failures = new LongAdder(); + + /** + * Records a query. Always increments {@code total}. When {@code info} is non-null (the request + * carried a {@code project_routing} expression), also increments {@code withProjectRouting} and + * any applicable sub-counters. + */ + void record(@Nullable ProjectRoutingRequestInfo info) { + total.increment(); + if (info == null) { + return; + } + withProjectRouting.increment(); + if (info.usedAliasOrigin()) { + withAliasOrigin.increment(); + } + if (info.usedAliasWildcard()) { + withAliasWildcard.increment(); + } + if (info.usedNamedExpression()) { + withNamedExpression.increment(); + } + if (info.usedCustomTags()) { + withCustomTags.increment(); + } + } + + /** + * Records a routing failure. Increments {@code total}, {@code withProjectRouting}, and + * {@code failures}. Called by Ticket 5 from {@code AuthorizationService.onAuthorizedResourceLoadFailure()}. + */ + void recordFailure() { + total.increment(); + withProjectRouting.increment(); + failures.increment(); + } + } + + // _search, _async_search, _msearch (per sub-request), _search/template, _msearch/template, _count, _cat/count + private final RoutingCounters search = new RoutingCounters(); // ES|QL endpoint - private final LongAdder esqlQueriesTotal = new LongAdder(); - private final LongAdder esqlWithProjectRouting = new LongAdder(); - private final LongAdder esqlWithAliasOrigin = new LongAdder(); - private final LongAdder esqlWithAliasWildcard = new LongAdder(); - private final LongAdder esqlWithCustomTags = new LongAdder(); - private final LongAdder esqlWithNamedExpression = new LongAdder(); - private final LongAdder esqlWithSet = new LongAdder(); - private final LongAdder esqlProjectRoutingFailures = new LongAdder(); + private final RoutingCounters esql = new RoutingCounters(); + private final LongAdder esqlWithSet = new LongAdder(); // in_SET: routing came from SET clause, not request body /** * Records a {@code _search} request. {@code queries} is always incremented (subject to the @@ -55,13 +96,7 @@ public class ProjectRoutingUsageHolder { */ public void recordSearch(@Nullable ProjectRoutingRequestInfo info, boolean hasLinkedProjects) { if (hasLinkedProjects == false) return; - searchQueriesTotal.increment(); - if (info == null) return; - searchWithProjectRouting.increment(); - if (info.usedAliasOrigin()) searchWithAliasOrigin.increment(); - if (info.usedAliasWildcard()) searchWithAliasWildcard.increment(); - if (info.usedNamedExpression()) searchWithNamedExpression.increment(); - if (info.tagsUsedInRouting().stream().anyMatch(t -> t.startsWith("_") == false)) searchWithCustomTags.increment(); + search.record(info); } /** @@ -77,14 +112,8 @@ public void recordSearch(@Nullable ProjectRoutingRequestInfo info, boolean hasLi */ public void recordEsql(@Nullable ProjectRoutingRequestInfo info, boolean setClauseUsed, boolean hasLinkedProjects) { if (hasLinkedProjects == false) return; - esqlQueriesTotal.increment(); if (setClauseUsed) esqlWithSet.increment(); - if (info == null) return; - esqlWithProjectRouting.increment(); - if (info.usedAliasOrigin()) esqlWithAliasOrigin.increment(); - if (info.usedAliasWildcard()) esqlWithAliasWildcard.increment(); - if (info.usedNamedExpression()) esqlWithNamedExpression.increment(); - if (info.tagsUsedInRouting().stream().anyMatch(t -> t.startsWith("_") == false)) esqlWithCustomTags.increment(); + esql.record(info); } /** @@ -96,9 +125,7 @@ public void recordEsql(@Nullable ProjectRoutingRequestInfo info, boolean setClau */ public void recordSearchProjectRoutingFailure(boolean hasLinkedProjects) { if (hasLinkedProjects == false) return; - searchQueriesTotal.increment(); - searchWithProjectRouting.increment(); - searchProjectRoutingFailures.increment(); + search.recordFailure(); } /** @@ -110,9 +137,7 @@ public void recordSearchProjectRoutingFailure(boolean hasLinkedProjects) { */ public void recordEsqlProjectRoutingFailure(boolean hasLinkedProjects) { if (hasLinkedProjects == false) return; - esqlQueriesTotal.increment(); - esqlWithProjectRouting.increment(); - esqlProjectRoutingFailures.increment(); + esql.recordFailure(); } /** @@ -120,21 +145,21 @@ public void recordEsqlProjectRoutingFailure(boolean hasLinkedProjects) { */ public ProjectRoutingUsageSnapshot getSnapshot() { return new ProjectRoutingUsageSnapshot( - searchQueriesTotal.sum(), - searchWithProjectRouting.sum(), - searchWithAliasOrigin.sum(), - searchWithAliasWildcard.sum(), - searchWithCustomTags.sum(), - searchWithNamedExpression.sum(), - searchProjectRoutingFailures.sum(), - esqlQueriesTotal.sum(), - esqlWithProjectRouting.sum(), - esqlWithAliasOrigin.sum(), - esqlWithAliasWildcard.sum(), - esqlWithCustomTags.sum(), - esqlWithNamedExpression.sum(), + search.total.sum(), + search.withProjectRouting.sum(), + search.withAliasOrigin.sum(), + search.withAliasWildcard.sum(), + search.withCustomTags.sum(), + search.withNamedExpression.sum(), + search.failures.sum(), + esql.total.sum(), + esql.withProjectRouting.sum(), + esql.withAliasOrigin.sum(), + esql.withAliasWildcard.sum(), + esql.withCustomTags.sum(), + esql.withNamedExpression.sum(), esqlWithSet.sum(), - esqlProjectRoutingFailures.sum() + esql.failures.sum() ); } } diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java index 631b616804c12..61f2d9d8ba531 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java @@ -66,7 +66,6 @@ import org.elasticsearch.transport.RemoteConnectionInfo; import org.elasticsearch.transport.TransportService; import org.elasticsearch.transport.Transports; -import org.elasticsearch.usage.SearchUsageHolder; import org.elasticsearch.usage.UsageService; import java.io.IOException; @@ -109,9 +108,6 @@ public class TransportClusterStatsAction extends TransportNodesAction< private final IndicesService indicesService; private final RepositoriesService repositoriesService; private final ProjectResolver projectResolver; - private final SearchUsageHolder searchUsageHolder; - private final CCSUsageTelemetry ccsUsageHolder; - private final CCSUsageTelemetry esqlUsageHolder; private final UsageService usageService; private final Executor clusterStateStatsExecutor; @@ -145,9 +141,6 @@ public TransportClusterStatsAction( this.indicesService = indicesService; this.repositoriesService = repositoriesService; this.projectResolver = projectResolver; - this.searchUsageHolder = usageService.getSearchUsageHolder(); - this.ccsUsageHolder = usageService.getCcsUsageHolder(); - this.esqlUsageHolder = usageService.getEsqlUsageHolder(); this.usageService = usageService; this.clusterStateStatsExecutor = threadPool.executor(ThreadPool.Names.MANAGEMENT); this.mappingStatsCache = new MetadataStatsCache<>(threadPool.getThreadContext(), MappingStats::of); @@ -322,11 +315,11 @@ protected ClusterStatsNodeResponse nodeOperation(ClusterStatsNodeRequest nodeReq ? new ClusterStateHealth(clusterState, project.getConcreteAllIndices(), project.id()).getStatus() : null; - final SearchUsageStats searchUsageStats = searchUsageHolder.getSearchUsageStats(); + final SearchUsageStats searchUsageStats = usageService.getSearchUsageHolder().getSearchUsageStats(); final RepositoryUsageStats repositoryUsageStats = repositoriesService.getUsageStats(); - final CCSTelemetrySnapshot ccsTelemetry = ccsUsageHolder.getCCSTelemetrySnapshot(); - final CCSTelemetrySnapshot esqlTelemetry = esqlUsageHolder.getCCSTelemetrySnapshot(); + final CCSTelemetrySnapshot ccsTelemetry = usageService.getCcsUsageHolder().getCCSTelemetrySnapshot(); + final CCSTelemetrySnapshot esqlTelemetry = usageService.getEsqlUsageHolder().getCCSTelemetrySnapshot(); final ProjectRoutingUsageSnapshot projectRoutingUsage = usageService.getProjectRoutingUsageHolder().getSnapshot(); return new ClusterStatsNodeResponse( diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingRequestInfo.java b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingRequestInfo.java index 1a8d0c96f24f0..bce7c3b0e9e9e 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingRequestInfo.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingRequestInfo.java @@ -9,24 +9,20 @@ package org.elasticsearch.search.crossproject; -import java.util.List; - /** * Carries per-request project routing metadata from the resolver chain to the transport actions for telemetry recording. * Populated by the serverless cross-project resolver and attached to {@link TargetProjects}. * - *

Custom-tag detection is left to consumers: a tag is custom if its name does not start with {@code _}. - * - * @param tagsUsedInRouting all tag names referenced in the resolved expression + * @param usedCustomTags true when the routing expression referenced at least one custom tag (a tag whose name does not start with {@code _}) * @param usedNamedExpression true when the request used a named-expression ({@code @name}) reference * @param usedAliasWildcard true when the expression was exactly {@code _alias:*} * @param usedAliasOrigin true when the expression was exactly {@code _alias:_origin} */ public record ProjectRoutingRequestInfo( - List tagsUsedInRouting, + boolean usedCustomTags, boolean usedNamedExpression, boolean usedAliasWildcard, boolean usedAliasOrigin ) { - public static final ProjectRoutingRequestInfo NONE = new ProjectRoutingRequestInfo(List.of(), false, false, false); + public static final ProjectRoutingRequestInfo NONE = new ProjectRoutingRequestInfo(false, false, false, false); } diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java index c9c0fa461302e..555bc981237db 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java @@ -12,14 +12,13 @@ import org.elasticsearch.search.crossproject.ProjectRoutingRequestInfo; import org.elasticsearch.test.ESTestCase; -import java.util.List; - import static org.hamcrest.Matchers.equalTo; public class ProjectRoutingUsageHolderTests extends ESTestCase { private static ProjectRoutingRequestInfo info(boolean aliasOrigin, boolean aliasWildcard, boolean namedExpr, String... tags) { - return new ProjectRoutingRequestInfo(List.of(tags), namedExpr, aliasWildcard, aliasOrigin); + boolean usedCustomTags = java.util.Arrays.stream(tags).anyMatch(t -> t.startsWith("_") == false); + return new ProjectRoutingRequestInfo(usedCustomTags, namedExpr, aliasWildcard, aliasOrigin); } // ----------------------------------------------------------------------- From 6c956e30cf099153bce1823604a5a36c048140df Mon Sep 17 00:00:00 2001 From: Michael Peterson Date: Fri, 7 Aug 2026 10:40:00 -0400 Subject: [PATCH 07/10] Changed to loadSingletonServiceProvider for ClusterStatsTagsProvider --- .../elasticsearch/action/ActionModule.java | 6 ------ .../stats/ClusterStatsTagsProvider.java | 3 ++- .../stats/ProjectRoutingUsageHolder.java | 20 ++++++++++++++----- .../stats/ProjectRoutingUsageSnapshot.java | 12 ++++++++--- .../elasticsearch/node/NodeConstruction.java | 4 +++- .../elasticsearch/plugins/ActionPlugin.java | 11 ---------- .../org/elasticsearch/usage/UsageService.java | 12 ++++++----- 7 files changed, 36 insertions(+), 32 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ActionModule.java b/server/src/main/java/org/elasticsearch/action/ActionModule.java index 769f42f3e0dc2..58483b69f37e9 100644 --- a/server/src/main/java/org/elasticsearch/action/ActionModule.java +++ b/server/src/main/java/org/elasticsearch/action/ActionModule.java @@ -541,12 +541,6 @@ public ActionModule( this.restExtension = restExtension; this.clusterService = clusterService; - actionPlugins.stream() - .map(ActionPlugin::getClusterStatsTagsProvider) - .filter(Optional::isPresent) - .map(Optional::get) - .findFirst() - .ifPresent(usageService::registerTagsProvider); } private static T getRestServerComponent( diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsTagsProvider.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsTagsProvider.java index 7ced0fee50434..d3dfea95e8616 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsTagsProvider.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsTagsProvider.java @@ -14,7 +14,8 @@ /** * Extension point for supplying the {@code tags} configuration snapshot (tag names, named routing expressions, etc.) - * to {@code GET _cluster/stats}. Registered via {@link org.elasticsearch.plugins.ActionPlugin#getClusterStatsTagsProvider()}. + * to {@code GET _cluster/stats}. Registered via SPI ({@code META-INF/services/}) and loaded by + * {@code NodeConstruction} using {@code loadSingletonServiceProvider}. * *

Needed as an extension point for serverless code. */ diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java index 08a3923b481f4..231c93cce97fc 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java @@ -95,7 +95,9 @@ void recordFailure() { * when false all counters are skipped */ public void recordSearch(@Nullable ProjectRoutingRequestInfo info, boolean hasLinkedProjects) { - if (hasLinkedProjects == false) return; + if (hasLinkedProjects == false) { + return; + } search.record(info); } @@ -111,8 +113,12 @@ public void recordSearch(@Nullable ProjectRoutingRequestInfo info, boolean hasLi * when false all counters are skipped */ public void recordEsql(@Nullable ProjectRoutingRequestInfo info, boolean setClauseUsed, boolean hasLinkedProjects) { - if (hasLinkedProjects == false) return; - if (setClauseUsed) esqlWithSet.increment(); + if (hasLinkedProjects == false) { + return; + } + if (setClauseUsed) { + esqlWithSet.increment(); + } esql.record(info); } @@ -124,7 +130,9 @@ public void recordEsql(@Nullable ProjectRoutingRequestInfo info, boolean setClau * @param hasLinkedProjects true when the project had at least one linked project; when false this is a no-op */ public void recordSearchProjectRoutingFailure(boolean hasLinkedProjects) { - if (hasLinkedProjects == false) return; + if (hasLinkedProjects == false) { + return; + } search.recordFailure(); } @@ -136,7 +144,9 @@ public void recordSearchProjectRoutingFailure(boolean hasLinkedProjects) { * @param hasLinkedProjects true when the project had at least one linked project; when false this is a no-op */ public void recordEsqlProjectRoutingFailure(boolean hasLinkedProjects) { - if (hasLinkedProjects == false) return; + if (hasLinkedProjects == false) { + return; + } esql.recordFailure(); } diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java index 2bce3f4cb26e1..baf6275b31107 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshot.java @@ -124,7 +124,9 @@ public void writeTo(StreamOutput out) throws IOException { * Additively merges {@code other} into this snapshot. Called on the coordinator to combine node responses. */ public void add(ProjectRoutingUsageSnapshot other) { - if (other == null) return; + if (other == null) { + return; + } searchQueriesTotal += other.searchQueriesTotal; searchWithProjectRouting += other.searchWithProjectRouting; searchWithAliasOrigin += other.searchWithAliasOrigin; @@ -204,8 +206,12 @@ public long getEsqlProjectRoutingFailures() { @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } ProjectRoutingUsageSnapshot other = (ProjectRoutingUsageSnapshot) o; return searchQueriesTotal == other.searchQueriesTotal && searchWithProjectRouting == other.searchWithProjectRouting diff --git a/server/src/main/java/org/elasticsearch/node/NodeConstruction.java b/server/src/main/java/org/elasticsearch/node/NodeConstruction.java index a37a6c1b3a313..df5b4aad740f8 100644 --- a/server/src/main/java/org/elasticsearch/node/NodeConstruction.java +++ b/server/src/main/java/org/elasticsearch/node/NodeConstruction.java @@ -18,6 +18,7 @@ import org.elasticsearch.action.ActionModule; import org.elasticsearch.action.ActionType; import org.elasticsearch.action.admin.cluster.repositories.reservedstate.ReservedRepositoryAction; +import org.elasticsearch.action.admin.cluster.stats.ClusterStatsTagsProvider; import org.elasticsearch.action.admin.indices.template.reservedstate.ReservedComposableIndexTemplateAction; import org.elasticsearch.action.bulk.FailureStoreMetrics; import org.elasticsearch.action.bulk.IncrementalBulkService; @@ -1563,7 +1564,8 @@ private ClusterService createClusterService( } private UsageService createUsageService() { - UsageService usageService = new UsageService(); + ClusterStatsTagsProvider tagsProvider = pluginsService.loadSingletonServiceProvider(ClusterStatsTagsProvider.class, () -> null); + UsageService usageService = new UsageService(tagsProvider); modules.bindToInstance(UsageService.class, usageService); return usageService; } diff --git a/server/src/main/java/org/elasticsearch/plugins/ActionPlugin.java b/server/src/main/java/org/elasticsearch/plugins/ActionPlugin.java index 449494bcac8f6..8c216d27891c8 100644 --- a/server/src/main/java/org/elasticsearch/plugins/ActionPlugin.java +++ b/server/src/main/java/org/elasticsearch/plugins/ActionPlugin.java @@ -11,7 +11,6 @@ import org.elasticsearch.action.ActionType; import org.elasticsearch.action.RequestValidators; -import org.elasticsearch.action.admin.cluster.stats.ClusterStatsTagsProvider; import org.elasticsearch.action.admin.indices.alias.IndicesAliasesRequest; import org.elasticsearch.action.admin.indices.mapping.put.PutMappingRequest; import org.elasticsearch.action.support.ActionFilter; @@ -29,7 +28,6 @@ import java.util.Collection; import java.util.Collections; import java.util.Objects; -import java.util.Optional; import java.util.function.Predicate; import java.util.function.Supplier; @@ -144,15 +142,6 @@ default Collection> in return Collections.emptyList(); } - /** - * Optionally supplies a {@link ClusterStatsTagsProvider} that populates the static {@code tags} configuration - * fields (tag names, named routing expressions) in {@code GET _cluster/stats}. At most one plugin may provide - * a non-empty value; {@link org.elasticsearch.action.ActionModule} uses the first it finds. - */ - default Optional getClusterStatsTagsProvider() { - return Optional.empty(); - } - record RestHandlersServices( Settings settings, RestController restController, diff --git a/server/src/main/java/org/elasticsearch/usage/UsageService.java b/server/src/main/java/org/elasticsearch/usage/UsageService.java index 734933aa13b39..58a182e8a94fb 100644 --- a/server/src/main/java/org/elasticsearch/usage/UsageService.java +++ b/server/src/main/java/org/elasticsearch/usage/UsageService.java @@ -31,14 +31,20 @@ public class UsageService { private final CCSUsageTelemetry ccsUsageHolder; private final CCSUsageTelemetry esqlUsageHolder; private final ProjectRoutingUsageHolder projectRoutingUsageHolder; - private volatile ClusterStatsTagsProvider tagsProvider = null; + @Nullable + private final ClusterStatsTagsProvider tagsProvider; public UsageService() { + this(null); + } + + public UsageService(@Nullable ClusterStatsTagsProvider tagsProvider) { this.handlers = new HashMap<>(); this.searchUsageHolder = new SearchUsageHolder(); this.ccsUsageHolder = new CCSUsageTelemetry(); this.esqlUsageHolder = new CCSUsageTelemetry(false); this.projectRoutingUsageHolder = new ProjectRoutingUsageHolder(); + this.tagsProvider = tagsProvider; } /** @@ -106,10 +112,6 @@ public ProjectRoutingUsageHolder getProjectRoutingUsageHolder() { return projectRoutingUsageHolder; } - public void registerTagsProvider(ClusterStatsTagsProvider provider) { - this.tagsProvider = Objects.requireNonNull(provider); - } - @Nullable public ClusterStatsTagsProvider getTagsProvider() { return tagsProvider; From e61f04eb22f0c9fde978ada5ba3d91d804d1b76b Mon Sep 17 00:00:00 2001 From: Michael Peterson Date: Fri, 7 Aug 2026 10:52:47 -0400 Subject: [PATCH 08/10] changes based on PR feedback --- .../stats/TransportClusterStatsAction.java | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java index 61f2d9d8ba531..a8276f5258a77 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TransportClusterStatsAction.java @@ -43,6 +43,7 @@ import org.elasticsearch.common.util.CancellableSingleObjectCache; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.core.FixForMultiProject; +import org.elasticsearch.core.Nullable; import org.elasticsearch.index.IndexService; import org.elasticsearch.index.engine.CommitStats; import org.elasticsearch.index.seqno.RetentionLeaseStats; @@ -66,6 +67,7 @@ import org.elasticsearch.transport.RemoteConnectionInfo; import org.elasticsearch.transport.TransportService; import org.elasticsearch.transport.Transports; +import org.elasticsearch.usage.SearchUsageHolder; import org.elasticsearch.usage.UsageService; import java.io.IOException; @@ -108,7 +110,12 @@ public class TransportClusterStatsAction extends TransportNodesAction< private final IndicesService indicesService; private final RepositoriesService repositoriesService; private final ProjectResolver projectResolver; - private final UsageService usageService; + private final SearchUsageHolder searchUsageHolder; + private final CCSUsageTelemetry ccsUsageHolder; + private final CCSUsageTelemetry esqlUsageHolder; + private final ProjectRoutingUsageHolder projectRoutingUsageHolder; + @Nullable + private final ClusterStatsTagsProvider tagsProvider; private final Executor clusterStateStatsExecutor; private final MetadataStatsCache mappingStatsCache; @@ -141,7 +148,11 @@ public TransportClusterStatsAction( this.indicesService = indicesService; this.repositoriesService = repositoriesService; this.projectResolver = projectResolver; - this.usageService = usageService; + this.searchUsageHolder = usageService.getSearchUsageHolder(); + this.ccsUsageHolder = usageService.getCcsUsageHolder(); + this.esqlUsageHolder = usageService.getEsqlUsageHolder(); + this.projectRoutingUsageHolder = usageService.getProjectRoutingUsageHolder(); + this.tagsProvider = usageService.getTagsProvider(); this.clusterStateStatsExecutor = threadPool.executor(ThreadPool.Names.MANAGEMENT); this.mappingStatsCache = new MetadataStatsCache<>(threadPool.getThreadContext(), MappingStats::of); this.analysisStatsCache = new MetadataStatsCache<>(threadPool.getThreadContext(), AnalysisStats::of); @@ -200,7 +211,6 @@ protected void newResponseAsync( null ); } - ClusterStatsTagsProvider tagsProvider = usageService.getTagsProvider(); TagsConfigSnapshot tagsConfig = (tagsProvider != null) ? tagsProvider.getTagsConfig(clusterService.state()) : null; return new ClusterStatsResponse( System.currentTimeMillis(), @@ -315,12 +325,12 @@ protected ClusterStatsNodeResponse nodeOperation(ClusterStatsNodeRequest nodeReq ? new ClusterStateHealth(clusterState, project.getConcreteAllIndices(), project.id()).getStatus() : null; - final SearchUsageStats searchUsageStats = usageService.getSearchUsageHolder().getSearchUsageStats(); + final SearchUsageStats searchUsageStats = searchUsageHolder.getSearchUsageStats(); final RepositoryUsageStats repositoryUsageStats = repositoriesService.getUsageStats(); - final CCSTelemetrySnapshot ccsTelemetry = usageService.getCcsUsageHolder().getCCSTelemetrySnapshot(); - final CCSTelemetrySnapshot esqlTelemetry = usageService.getEsqlUsageHolder().getCCSTelemetrySnapshot(); - final ProjectRoutingUsageSnapshot projectRoutingUsage = usageService.getProjectRoutingUsageHolder().getSnapshot(); + final CCSTelemetrySnapshot ccsTelemetry = ccsUsageHolder.getCCSTelemetrySnapshot(); + final CCSTelemetrySnapshot esqlTelemetry = esqlUsageHolder.getCCSTelemetrySnapshot(); + final ProjectRoutingUsageSnapshot projectRoutingUsage = projectRoutingUsageHolder.getSnapshot(); return new ClusterStatsNodeResponse( nodeInfo.getNode(), From 95263f749107c3f9904fd90fdd11c2415d145b16 Mon Sep 17 00:00:00 2001 From: Michael Peterson Date: Fri, 7 Aug 2026 11:28:46 -0400 Subject: [PATCH 09/10] Changes based on cursor-agent review of code --- .../elasticsearch/action/ActionModule.java | 1 - .../ProjectRoutingRequestInfo.java | 3 +- .../stats/ProjectRoutingUsageHolderTests.java | 104 ++++++++++++------ .../ProjectRoutingUsageSnapshotTests.java | 51 --------- 4 files changed, 72 insertions(+), 87 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ActionModule.java b/server/src/main/java/org/elasticsearch/action/ActionModule.java index 58483b69f37e9..120232b188c8a 100644 --- a/server/src/main/java/org/elasticsearch/action/ActionModule.java +++ b/server/src/main/java/org/elasticsearch/action/ActionModule.java @@ -540,7 +540,6 @@ public ActionModule( ); this.restExtension = restExtension; this.clusterService = clusterService; - } private static T getRestServerComponent( diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingRequestInfo.java b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingRequestInfo.java index bce7c3b0e9e9e..b58e0a091662a 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingRequestInfo.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingRequestInfo.java @@ -13,7 +13,8 @@ * Carries per-request project routing metadata from the resolver chain to the transport actions for telemetry recording. * Populated by the serverless cross-project resolver and attached to {@link TargetProjects}. * - * @param usedCustomTags true when the routing expression referenced at least one custom tag (a tag whose name does not start with {@code _}) + * @param usedCustomTags true when the routing expression referenced at least one custom tag (a tag whose name does not + * start with {@code _}) * @param usedNamedExpression true when the request used a named-expression ({@code @name}) reference * @param usedAliasWildcard true when the expression was exactly {@code _alias:*} * @param usedAliasOrigin true when the expression was exactly {@code _alias:_origin} diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java index 555bc981237db..ee1ef922c548f 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java @@ -16,9 +16,8 @@ public class ProjectRoutingUsageHolderTests extends ESTestCase { - private static ProjectRoutingRequestInfo info(boolean aliasOrigin, boolean aliasWildcard, boolean namedExpr, String... tags) { - boolean usedCustomTags = java.util.Arrays.stream(tags).anyMatch(t -> t.startsWith("_") == false); - return new ProjectRoutingRequestInfo(usedCustomTags, namedExpr, aliasWildcard, aliasOrigin); + private static ProjectRoutingRequestInfo info(boolean aliasOrigin, boolean aliasWildcard, boolean namedExpr, boolean customTags) { + return new ProjectRoutingRequestInfo(customTags, namedExpr, aliasWildcard, aliasOrigin); } // ----------------------------------------------------------------------- @@ -27,7 +26,7 @@ private static ProjectRoutingRequestInfo info(boolean aliasOrigin, boolean alias public void testNoLinkedProjects_searchIsNoOp() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordSearch(info(true, true, true, "mytag"), false); + holder.recordSearch(info(true, true, true, true), false); holder.recordSearch(null, false); ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); @@ -37,7 +36,7 @@ public void testNoLinkedProjects_searchIsNoOp() { public void testNoLinkedProjects_esqlIsNoOp() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordEsql(info(true, true, true, "mytag"), true, false); + holder.recordEsql(info(true, true, true, true), true, false); holder.recordEsql(null, true, false); ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); @@ -97,7 +96,7 @@ public void testSearch_noneInfoIncrementsWithProjectRoutingOnly() { public void testSearch_aliasOriginFlag() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordSearch(info(true, false, false, "_alias"), true); + holder.recordSearch(info(true, false, false, false), true); ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); assertThat(snap.getSearchWithProjectRouting(), equalTo(1L)); @@ -107,7 +106,7 @@ public void testSearch_aliasOriginFlag() { public void testSearch_aliasWildcardFlag() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordSearch(info(false, true, false, "_alias"), true); + holder.recordSearch(info(false, true, false, false), true); ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); assertThat(snap.getSearchWithAliasWildcard(), equalTo(1L)); @@ -116,7 +115,7 @@ public void testSearch_aliasWildcardFlag() { public void testSearch_namedExpressionFlag() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordSearch(info(false, false, true, "_alias"), true); + holder.recordSearch(info(false, false, true, false), true); ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); assertThat(snap.getSearchWithNamedExpression(), equalTo(1L)); @@ -124,37 +123,23 @@ public void testSearch_namedExpressionFlag() { } // ----------------------------------------------------------------------- - // custom-tag detection: names starting with '_' are predefined + // custom-tag flag // ----------------------------------------------------------------------- - public void testSearch_predefinedTagsOnly() { + public void testSearch_noCustomTags() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordSearch(info(false, false, false, "_alias", "_region", "_csp"), true); + holder.recordSearch(info(false, false, false, false), true); assertThat(holder.getSnapshot().getSearchWithCustomTags(), equalTo(0L)); } - public void testSearch_singleCustomTag() { + public void testSearch_withCustomTags() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordSearch(info(false, false, false, "mytag"), true); + holder.recordSearch(info(false, false, false, true), true); assertThat(holder.getSnapshot().getSearchWithCustomTags(), equalTo(1L)); } - public void testSearch_mixedPredefinedAndCustom() { - ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordSearch(info(false, false, false, "_alias", "mytag"), true); - - assertThat(holder.getSnapshot().getSearchWithCustomTags(), equalTo(1L)); - } - - public void testSearch_emptyTagList() { - ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordSearch(info(false, false, false /* no tags */), true); - - assertThat(holder.getSnapshot().getSearchWithCustomTags(), equalTo(0L)); - } - // ----------------------------------------------------------------------- // ES|QL: with_SET increments independently of info nullness // ----------------------------------------------------------------------- @@ -171,7 +156,7 @@ public void testEsql_setClauseWithNullInfo() { public void testEsql_setClauseWithInfo() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordEsql(info(false, false, false, "_alias"), true, true); + holder.recordEsql(info(false, false, false, false), true, true); ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); assertThat(snap.getEsqlWithSet(), equalTo(1L)); @@ -180,14 +165,14 @@ public void testEsql_setClauseWithInfo() { public void testEsql_noSetClause() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordEsql(info(true, false, false, "_alias"), false, true); + holder.recordEsql(info(true, false, false, false), false, true); assertThat(holder.getSnapshot().getEsqlWithSet(), equalTo(0L)); } public void testEsql_subCounterFlags() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordEsql(info(true, false, true, "_alias", "custom"), false, true); + holder.recordEsql(info(true, false, true, true), false, true); ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); assertThat(snap.getEsqlWithProjectRouting(), equalTo(1L)); @@ -197,16 +182,67 @@ public void testEsql_subCounterFlags() { assertThat(snap.getEsqlWithCustomTags(), equalTo(1L)); } + // ----------------------------------------------------------------------- + // Failure-recording methods + // ----------------------------------------------------------------------- + + public void testRecordSearchFailure_noOp_when_hasLinkedProjects_false() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearchProjectRoutingFailure(false); + assertThat(holder.getSnapshot(), equalTo(new ProjectRoutingUsageSnapshot())); + } + + public void testRecordSearchFailure_increments_queries_and_queries_project_routing_and_failures() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordSearchProjectRoutingFailure(true); + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getSearchQueriesTotal(), equalTo(1L)); + assertThat(snap.getSearchWithProjectRouting(), equalTo(1L)); + assertThat(snap.getSearchProjectRoutingFailures(), equalTo(1L)); + // mode sub-counters must remain at zero + assertThat(snap.getSearchWithAliasOrigin(), equalTo(0L)); + assertThat(snap.getSearchWithAliasWildcard(), equalTo(0L)); + assertThat(snap.getSearchWithCustomTags(), equalTo(0L)); + assertThat(snap.getSearchWithNamedExpression(), equalTo(0L)); + // esql counters untouched + assertThat(snap.getEsqlQueriesTotal(), equalTo(0L)); + assertThat(snap.getEsqlProjectRoutingFailures(), equalTo(0L)); + } + + public void testRecordEsqlFailure_noOp_when_hasLinkedProjects_false() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordEsqlProjectRoutingFailure(false); + assertThat(holder.getSnapshot(), equalTo(new ProjectRoutingUsageSnapshot())); + } + + public void testRecordEsqlFailure_increments_queries_and_queries_project_routing_and_failures() { + ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); + holder.recordEsqlProjectRoutingFailure(true); + ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); + assertThat(snap.getEsqlQueriesTotal(), equalTo(1L)); + assertThat(snap.getEsqlWithProjectRouting(), equalTo(1L)); + assertThat(snap.getEsqlProjectRoutingFailures(), equalTo(1L)); + // mode sub-counters must remain at zero + assertThat(snap.getEsqlWithAliasOrigin(), equalTo(0L)); + assertThat(snap.getEsqlWithAliasWildcard(), equalTo(0L)); + assertThat(snap.getEsqlWithCustomTags(), equalTo(0L)); + assertThat(snap.getEsqlWithNamedExpression(), equalTo(0L)); + assertThat(snap.getEsqlWithSet(), equalTo(0L)); + // search counters untouched + assertThat(snap.getSearchQueriesTotal(), equalTo(0L)); + assertThat(snap.getSearchProjectRoutingFailures(), equalTo(0L)); + } + // ----------------------------------------------------------------------- // Accumulation across multiple calls // ----------------------------------------------------------------------- public void testSearch_accumulatesCorrectly() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordSearch(null, true); // total only - holder.recordSearch(info(true, false, false, "_alias"), true); // + with_project_routing, alias_origin - holder.recordSearch(info(false, false, true, "_alias"), true); // + with_project_routing, named_expr - holder.recordSearch(info(false, false, false, "custom"), false); // gated out — hasLinkedProjects=false + holder.recordSearch(null, true); // total only + holder.recordSearch(info(true, false, false, false), true); // + with_project_routing, alias_origin + holder.recordSearch(info(false, false, true, false), true); // + with_project_routing, named_expr + holder.recordSearch(info(false, false, false, true), false); // gated out — hasLinkedProjects=false ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); assertThat(snap.getSearchQueriesTotal(), equalTo(3L)); diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshotTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshotTests.java index e449093b7fb3c..a61225690f379 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshotTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageSnapshotTests.java @@ -138,57 +138,6 @@ public void testAdd_twoSnapshots() { assertThat(acc.getEsqlProjectRoutingFailures(), equalTo(a.getEsqlProjectRoutingFailures() + b.getEsqlProjectRoutingFailures())); } - // ----------------------------------------------------------------------- - // ProjectRoutingUsageHolder failure-recording methods - // ----------------------------------------------------------------------- - - public void testRecordSearchFailure_noOp_when_hasLinkedProjects_false() { - ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordSearchProjectRoutingFailure(false); - assertThat(holder.getSnapshot(), equalTo(new ProjectRoutingUsageSnapshot())); - } - - public void testRecordSearchFailure_increments_queries_and_queries_project_routing_and_failures() { - ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordSearchProjectRoutingFailure(true); - ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); - assertThat(snap.getSearchQueriesTotal(), equalTo(1L)); - assertThat(snap.getSearchWithProjectRouting(), equalTo(1L)); - assertThat(snap.getSearchProjectRoutingFailures(), equalTo(1L)); - // mode sub-counters must remain at zero - assertThat(snap.getSearchWithAliasOrigin(), equalTo(0L)); - assertThat(snap.getSearchWithAliasWildcard(), equalTo(0L)); - assertThat(snap.getSearchWithCustomTags(), equalTo(0L)); - assertThat(snap.getSearchWithNamedExpression(), equalTo(0L)); - // esql counters untouched - assertThat(snap.getEsqlQueriesTotal(), equalTo(0L)); - assertThat(snap.getEsqlProjectRoutingFailures(), equalTo(0L)); - } - - public void testRecordEsqlFailure_noOp_when_hasLinkedProjects_false() { - ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordEsqlProjectRoutingFailure(false); - assertThat(holder.getSnapshot(), equalTo(new ProjectRoutingUsageSnapshot())); - } - - public void testRecordEsqlFailure_increments_queries_and_queries_project_routing_and_failures() { - ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); - holder.recordEsqlProjectRoutingFailure(true); - ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); - assertThat(snap.getEsqlQueriesTotal(), equalTo(1L)); - assertThat(snap.getEsqlWithProjectRouting(), equalTo(1L)); - assertThat(snap.getEsqlProjectRoutingFailures(), equalTo(1L)); - // mode sub-counters must remain at zero - assertThat(snap.getEsqlWithAliasOrigin(), equalTo(0L)); - assertThat(snap.getEsqlWithAliasWildcard(), equalTo(0L)); - assertThat(snap.getEsqlWithCustomTags(), equalTo(0L)); - assertThat(snap.getEsqlWithNamedExpression(), equalTo(0L)); - assertThat(snap.getEsqlWithSet(), equalTo(0L)); - // search counters untouched - assertThat(snap.getSearchQueriesTotal(), equalTo(0L)); - assertThat(snap.getSearchProjectRoutingFailures(), equalTo(0L)); - } - // ----------------------------------------------------------------------- // toXContent suppression rules // ----------------------------------------------------------------------- From aff7bdcaf0424fe34452fa0706ca50308da3a61a Mon Sep 17 00:00:00 2001 From: Michael Peterson Date: Mon, 10 Aug 2026 15:24:28 -0400 Subject: [PATCH 10/10] PR feedback --- .../stats/ProjectRoutingUsageHolder.java | 4 +- .../cluster/stats/TagsConfigSnapshot.java | 4 +- ...usterStatsResponseProjectRoutingTests.java | 49 +++++++++++-------- .../stats/ProjectRoutingUsageHolderTests.java | 4 +- 4 files changed, 34 insertions(+), 27 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java index 231c93cce97fc..eba05f8b41d73 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolder.java @@ -116,10 +116,10 @@ public void recordEsql(@Nullable ProjectRoutingRequestInfo info, boolean setClau if (hasLinkedProjects == false) { return; } - if (setClauseUsed) { + esql.record(info); + if (setClauseUsed && info != null) { esqlWithSet.increment(); } - esql.record(info); } /** diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TagsConfigSnapshot.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TagsConfigSnapshot.java index 018f7b95c562c..0e3242af88fab 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TagsConfigSnapshot.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/stats/TagsConfigSnapshot.java @@ -18,8 +18,8 @@ /** * A snapshot of the project's tag configuration for inclusion in {@code GET _cluster/stats}. * Populated by the serverless cross-project module (Ticket 4). Emits the static config fields - * ({@code total}, {@code total_custom}, {@code names}, {@code named_routing_expressions}) inside - * the top-level {@code tags} object. + * ({@code total}, {@code total_custom}, {@code names}, {@code named_routing_expressions}) as a + * fragment; the caller is responsible for opening and closing the enclosing {@code tags} object. */ public record TagsConfigSnapshot(List names, List namedRoutingExpressionNames) implements ToXContentFragment { diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponseProjectRoutingTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponseProjectRoutingTests.java index 1095096dacfd4..1358e066987b6 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponseProjectRoutingTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ClusterStatsResponseProjectRoutingTests.java @@ -21,7 +21,7 @@ import org.elasticsearch.cluster.metadata.Metadata; import org.elasticsearch.cluster.node.DiscoveryNodeUtils; import org.elasticsearch.cluster.version.CompatibilityVersions; -import org.elasticsearch.common.Strings; +import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.transport.BoundTransportAddress; import org.elasticsearch.common.transport.TransportAddress; @@ -30,13 +30,17 @@ import org.elasticsearch.monitor.jvm.JvmInfo; import org.elasticsearch.monitor.os.OsInfo; import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.rest.ObjectPath; import org.elasticsearch.transport.TransportInfo; +import org.elasticsearch.xcontent.ToXContent; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xcontent.XContentType; import java.util.List; import java.util.Map; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; /** * Tests the project_routing and tags block assembly in ClusterStatsResponse.toXContent(). @@ -46,46 +50,49 @@ public class ClusterStatsResponseProjectRoutingTests extends ESTestCase { public void testProjectRoutingBlock_absent_when_allCountersZero() throws Exception { - ClusterStatsResponse response = buildResponse(new ProjectRoutingUsageSnapshot(), null); - String json = Strings.toString(response); - assertThat(json, not(containsString("\"project_routing\""))); + ObjectPath json = toObjectPath(buildResponse(new ProjectRoutingUsageSnapshot(), null)); + assertThat(json.evaluate("project_routing"), nullValue()); } public void testProjectRoutingBlock_present_with_correct_queries_sum() throws Exception { // searchQueriesTotal=5, esqlQueriesTotal=3 → queries=8 ProjectRoutingUsageSnapshot snapshot = new ProjectRoutingUsageSnapshot(5L, 0L, 0L, 0L, 0L, 0L, 0L, 3L, 0L, 0L, 0L, 0L, 0L, 0L, 0L); - ClusterStatsResponse response = buildResponse(snapshot, null); - String json = Strings.toString(response); - assertThat(json, containsString("\"project_routing\"")); - assertThat(json, containsString("\"queries\":8")); - assertThat(json, not(containsString("\"tags\""))); + ObjectPath json = toObjectPath(buildResponse(snapshot, null)); + assertThat(json.evaluate("project_routing.queries"), equalTo(8)); + assertThat(json.evaluate("tags"), nullValue()); } public void testTagsBlock_present_and_independent_of_project_routing() throws Exception { // zero-query snapshot — project_routing block must be absent ProjectRoutingUsageSnapshot snapshot = new ProjectRoutingUsageSnapshot(); TagsConfigSnapshot tagsConfig = new TagsConfigSnapshot(List.of("_alias", "mytag"), List.of()); - ClusterStatsResponse response = buildResponse(snapshot, tagsConfig); - String json = Strings.toString(response); - assertThat(json, containsString("\"tags\"")); - assertThat(json, containsString("\"total\":2")); - assertThat(json, not(containsString("\"project_routing\""))); + ObjectPath json = toObjectPath(buildResponse(snapshot, tagsConfig)); + assertThat(json.evaluate("tags.total"), equalTo(2)); + assertThat(json.evaluate("project_routing"), nullValue()); } public void testBothBlocks_present_when_both_non_empty() throws Exception { ProjectRoutingUsageSnapshot snapshot = new ProjectRoutingUsageSnapshot(10L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L); TagsConfigSnapshot tagsConfig = new TagsConfigSnapshot(List.of("_alias"), List.of()); - ClusterStatsResponse response = buildResponse(snapshot, tagsConfig); - String json = Strings.toString(response); - assertThat(json, containsString("\"tags\"")); - assertThat(json, containsString("\"project_routing\"")); - assertThat(json, containsString("\"queries\":10")); + ObjectPath json = toObjectPath(buildResponse(snapshot, tagsConfig)); + assertThat(json.evaluate("project_routing.queries"), equalTo(10)); + assertThat(json.evaluate("tags.total"), equalTo(1)); } // ----------------------------------------------------------------------- // helpers // ----------------------------------------------------------------------- + private static ObjectPath toObjectPath(ClusterStatsResponse response) throws Exception { + XContentType xContentType = XContentType.JSON; + try (XContentBuilder builder = XContentBuilder.builder(xContentType.xContent())) { + builder.startObject(); + response.toXContent(builder, ToXContent.EMPTY_PARAMS); + builder.endObject(); + return ObjectPath.createFromXContent(xContentType.xContent(), BytesReference.bytes(builder)); + } + } + private static ClusterStatsResponse buildResponse(ProjectRoutingUsageSnapshot snapshot, TagsConfigSnapshot tagsConfig) { ClusterStatsNodeResponse nodeResponse = buildNodeResponse(snapshot); List nodes = List.of(nodeResponse); diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java index ee1ef922c548f..9e604e43fd600 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/stats/ProjectRoutingUsageHolderTests.java @@ -144,13 +144,13 @@ public void testSearch_withCustomTags() { // ES|QL: with_SET increments independently of info nullness // ----------------------------------------------------------------------- - public void testEsql_setClauseWithNullInfo() { + public void testEsql_setClauseWithNullInfo_doesNotIncrementWithSet() { ProjectRoutingUsageHolder holder = new ProjectRoutingUsageHolder(); holder.recordEsql(null, true, true); ProjectRoutingUsageSnapshot snap = holder.getSnapshot(); assertThat(snap.getEsqlQueriesTotal(), equalTo(1L)); - assertThat(snap.getEsqlWithSet(), equalTo(1L)); + assertThat(snap.getEsqlWithSet(), equalTo(0L)); assertThat(snap.getEsqlWithProjectRouting(), equalTo(0L)); }