This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/skywalking.git
The following commit(s) were added to refs/heads/master by this push:
new 4226f14e90 Add POST /inspect/values admin API to read foreign metric
values (#13927)
4226f14e90 is described below
commit 4226f14e908462d92139ae0974164b5fb0ab52d0
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Wed Jun 24 23:36:38 2026 +0800
Add POST /inspect/values admin API to read foreign metric values (#13927)
A new admin-only POST /inspect/values reads the VALUES of a metric this OAP
does not
define locally (persisted by another OAP), by running the real MQE engine
over
caller-supplied {valueColumn, valueType} — the value-reading companion to
the merged
GET /inspect/entities foreign path (#13926).
A request-scoped InspectQueryContext ThreadLocal makes the foreign metric
look
registered to every read path (provide-if-absent — the local catalog always
wins):
ValueColumnMetadata resolves its value column / type / scope, and the
storage location
registries resolve where it lives — MetadataRegistry synthesizes a BanyanDB
measure
schema, IndexController resolves the ES metrics-all index, TableHelper
probes the JDBC
function tables. The storage read paths need no per-DAO special-casing; the
response is
the native MQE ExpressionResult. Verified end-to-end across
banyandb/es/postgresql.
---
docs/en/changes/changes.md | 1 +
docs/en/setup/backend/admin-api/inspect.md | 85 +++++++++++++--
docs/en/setup/backend/admin-api/readme.md | 3 +
oap-server/server-admin/inspect/pom.xml | 6 ++
.../admin/inspect/handler/InspectRestHandler.java | 114 +++++++++++++++++++++
.../inspect/request/InspectValuesRequest.java | 69 +++++++++++++
.../core/storage/annotation/ForeignMetricMeta.java | 49 +++++++++
.../storage/annotation/InspectQueryContext.java | 64 ++++++++++++
.../storage/annotation/ValueColumnMetadata.java | 29 +++++-
.../oap/query/graphql/mqe/rt/MQEExecutor.java | 111 ++++++++++++++++++++
.../storage/plugin/banyandb/MetadataRegistry.java | 38 ++++++-
.../banyandb/measure/BanyanDBMetricsQueryDAO.java | 3 +
.../plugin/elasticsearch/base/IndexController.java | 15 ++-
.../elasticsearch/query/MetricsQueryEsDAO.java | 34 ++++--
.../storage/plugin/jdbc/common/TableHelper.java | 13 +++
.../jdbc/common/dao/JDBCMetricsQueryDAO.java | 77 ++++++++------
test/e2e-v2/cases/inspect/README.md | 14 +--
test/e2e-v2/cases/inspect/inspect-foreign-flow.sh | 14 +++
test/e2e-v2/script/env | 2 +-
19 files changed, 684 insertions(+), 57 deletions(-)
diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md
index fde18400e2..82debb79ee 100644
--- a/docs/en/changes/changes.md
+++ b/docs/en/changes/changes.md
@@ -3,6 +3,7 @@
#### Project
* Extend the `GET /inspect/entities` admin API to inspect a metric persisted
by **any** OAP, even one this node does not define locally. When the metric is
unknown to the local registry, the caller supplies `valueColumn` + `valueType`
and the storage backend resolves the physical index/table/group from its own
running config (no DB schema/table-metadata read): ES uses the merged
`metrics-all` index + `metric_table` discriminator, JDBC probes the node's
function tables by the `table_name` [...]
+* Add the `POST /inspect/values` admin API — read the value series of a metric
persisted by **another** OAP (one this node does not define locally) by
supplying its `{valueColumn, valueType}`. The real MQE engine runs over a
request-scoped `InspectQueryContext` overlay (provide-if-absent — the local
catalog always wins) that makes the foreign metric look registered to every
read path: `ValueColumnMetadata` resolves its value column / type / scope, and
the storage location registries reso [...]
* Remove the always-on alarm-to-event conversion (`EventHookCallback`). A
triggered alarm is no longer synthesized into the events pipeline as an
`Alarm`/`AlarmRecovery` event; events now originate only from real event
sources (agents, SkyWalking CLI, Kubernetes Event Exporter). Alarms remain
available through the alarm store (`getAlarm`/`queryAlarms`) and the configured
alarm hooks. This drops a documented "Known Event" and removes 1-2 synthetic
event records per alarm fire.
* **New `queryAlarms` GraphQL query — entity / layer / rule filters for
alarms.** Adds
a comprehensive alarm query API alongside the legacy `getAlarm`. The new
diff --git a/docs/en/setup/backend/admin-api/inspect.md
b/docs/en/setup/backend/admin-api/inspect.md
index c43dea12c9..1fe958ee66 100644
--- a/docs/en/setup/backend/admin-api/inspect.md
+++ b/docs/en/setup/backend/admin-api/inspect.md
@@ -1,17 +1,18 @@
# Inspect API
-The Inspect API lives on `admin-server` and exposes two browse endpoints that
-let operators answer two questions without writing exploratory MQE:
+The Inspect API lives on `admin-server` and exposes three endpoints that
+let operators answer three questions without writing exploratory MQE:
-1. *Which metrics has OAP registered, and at what downsampling?*
-2. *For metric `X` in time range `T`, which entities currently hold values?*
+1. *Which metrics has OAP registered, and at what downsampling?* — `GET
/inspect/metrics`
+2. *For metric `X` in time range `T`, which entities currently hold values?* —
`GET /inspect/entities`
+3. *For metric `X` + entity `E`, what are the values?* — `POST /inspect/values`
For a locally-defined metric, the output of (2) carries a ready-to-paste
`mqeEntity` payload, so the follow-up MQE call against the public GraphQL
`execExpression` mutation is copy-paste from the inspect response. A metric
persisted by **another OAP** that this node does not define can also be
-inspected with caller-supplied metadata (see
-[Foreign metrics](#foreign-metrics-not-defined-on-this-oap)).
+inspected with caller-supplied metadata — both its entities (2) and its values
+(3) — see [Foreign metrics](#foreign-metrics-not-defined-on-this-oap).
## Enabling
@@ -227,6 +228,73 @@ curl
'http://oap-admin:17128/inspect/entities?metric=meter_custom_x&valueColumn=
}
```
+### `POST /inspect/values`
+
+Reads the **values** of a metric this OAP does not define locally, by running
the
+real MQE engine over caller-supplied metadata. Where `GET /inspect/entities`
+answers *which entities hold values*, this answers *what those values are* —
the
+native MQE `ExpressionResult` (the same shape the UI renders for a catalog
metric),
+for a metric that is otherwise foreign to this node.
+
+Because it trusts caller-supplied metadata and forces a read of a metric this
OAP
+cannot validate, it is **admin-only** (it never mirrors onto the public REST /
+GraphQL surface) and takes a request **body**: an MQE expression plus one
metadata
+entry per foreign metric the expression references.
+
+Request body (`application/json`):
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `expression` | yes | The MQE expression to evaluate — a single foreign
metric name, or an expression combining foreign and/or catalog metrics. |
+| `entity` | yes | The MQE query entity; its `scope` binds every foreign
metric. e.g. `{ "scope": "Service", "serviceName": "X", "normal": true }` (use
`serviceInstanceName` / `endpointName` for the deeper scopes). |
+| `start` / `end` | yes | Time range, same format as
[`/inspect/entities`](#get-inspectentities). |
+| `step` | yes | One of `MINUTE` / `HOUR` / `DAY`. |
+| `foreignMetrics` | yes | One entry per metric in `expression` that this OAP
does not define: `{ "name": "...", "valueColumn": "value", "valueType": "LONG"
}`. `valueColumn` is the post-override physical column; `valueType` is one of
`LONG` / `INT` / `DOUBLE` / `LABELED`. A locally-defined metric must **not** be
listed here (query it via the public GraphQL `execExpression`). |
+
+The metadata is overlaid **provide-if-absent** (the local catalog always wins)
onto
+the same registries the engine already consults, so a foreign metric looks
registered
+for the duration of the request: `ValueColumnMetadata` resolves its value
column / type
+/ scope, and the storage location registries resolve its index / table /
measure exactly
+as described in [Foreign metrics](#foreign-metrics-not-defined-on-this-oap).
The overlay
+is request-scoped to the calling thread and removed when the read completes;
the public
+query path never sets it.
+
+Only scalar (`LONG` / `INT` / `DOUBLE`) and labeled (best-effort) value series
are
+supported. An expression that resolves to `top_n` / records / heatmaps needs a
local
+model and surfaces as an error. Under ES `logicSharding=true` a foreign value
read is
+unsupported (the physical index derives from the metric's stream class),
returning `500`.
+
+Example — read the value series of `meter_custom_x`, defined on another OAP:
+
+```bash
+curl -X POST 'http://oap-admin:17128/inspect/values' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "expression": "meter_custom_x",
+ "entity": { "scope": "Service", "serviceName": "payment", "normal": true },
+ "start": "2026-05-10 1230", "end": "2026-05-10 1240", "step": "MINUTE",
+ "foreignMetrics": [
+ { "name": "meter_custom_x", "valueColumn": "value", "valueType": "LONG" }
+ ]
+ }'
+```
+
+```json
+{
+ "type": "TIME_SERIES_VALUES",
+ "results": [
+ {
+ "metric": { "labels": [] },
+ "values": [
+ { "id": "1778416200000", "value": "42" },
+ { "id": "1778416260000", "value": "42" }
+ ]
+ }
+ ],
+ "error": null
+}
+```
+
## Discovering the OAP REST URL for the MQE follow-up
To keep the surface minimal, the inspect API does not introduce a separate
@@ -248,6 +316,11 @@ session start is enough.
| 400 | `{"error":"metric type SAMPLED_RECORD is out of scope for
/inspect/entities"}` | Metric is `SAMPLED_RECORD`. |
| 400 | `{"error":"process scope is out of scope"}` | Scope is `Process` /
`ProcessRelation`. |
| 400 | `{"error":"limit must be between 1 and 300"}` | `limit` out of range. |
+| 400 | `{"error":"foreignMetrics is required; a locally-defined metric should
be queried via the public GraphQL execExpression"}` | `POST /inspect/values`
body had no `foreignMetrics`. |
+| 400 | `{"error":"metric foo is defined locally; query it via the GraphQL
execExpression and drop it from foreignMetrics"}` | A `foreignMetrics` entry
names a metric this OAP already defines. |
+| 400 | `{"error":"valueColumn is invalid: …"}` | A `foreignMetrics`
`valueColumn` is not a bare identifier. |
+| 400 | `{"error":"<MQE error>"}` | `POST /inspect/values` expression resolved
to an unsupported shape (e.g. `top_n` / record / heatmap) for a foreign metric.
|
+| 500 | `{"error":"<storage error>"}` | A wrong `valueColumn` / `valueType`,
or ES `logicSharding=true`, surfaced at the storage layer during a value read. |
## Limits
diff --git a/docs/en/setup/backend/admin-api/readme.md
b/docs/en/setup/backend/admin-api/readme.md
index de50384bd5..c75dad0d6d 100644
--- a/docs/en/setup/backend/admin-api/readme.md
+++ b/docs/en/setup/backend/admin-api/readme.md
@@ -86,6 +86,9 @@ Common operations:
- `GET /inspect/metrics` — metric catalog with type / scope / supported
downsamplings.
- `GET /inspect/entities?metric=&start=&end=&step=` — capped (≤300) list of
entities holding values, decoded into MQE-ready form.
+- `POST /inspect/values` — read the value series of a metric this OAP does not
+ define locally (foreign metric), by supplying its `{valueColumn, valueType}`;
+ returns the native MQE result.
Operator reference: [Inspect API](inspect.md).
diff --git a/oap-server/server-admin/inspect/pom.xml
b/oap-server/server-admin/inspect/pom.xml
index 05da1ae654..a51f99245d 100644
--- a/oap-server/server-admin/inspect/pom.xml
+++ b/oap-server/server-admin/inspect/pom.xml
@@ -39,6 +39,12 @@
<artifactId>admin-server</artifactId>
<version>${project.version}</version>
</dependency>
+ <!-- MQEExecutor: run the MQE engine synchronously for the
foreign-metric value path. -->
+ <dependency>
+ <groupId>org.apache.skywalking</groupId>
+ <artifactId>query-graphql-plugin</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
diff --git
a/oap-server/server-admin/inspect/src/main/java/org/apache/skywalking/oap/server/admin/inspect/handler/InspectRestHandler.java
b/oap-server/server-admin/inspect/src/main/java/org/apache/skywalking/oap/server/admin/inspect/handler/InspectRestHandler.java
index 1d358542ad..7b8f5abab3 100644
---
a/oap-server/server-admin/inspect/src/main/java/org/apache/skywalking/oap/server/admin/inspect/handler/InspectRestHandler.java
+++
b/oap-server/server-admin/inspect/src/main/java/org/apache/skywalking/oap/server/admin/inspect/handler/InspectRestHandler.java
@@ -18,11 +18,15 @@
package org.apache.skywalking.oap.server.admin.inspect.handler;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.linecorp.armeria.common.HttpData;
import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.HttpStatus;
import com.linecorp.armeria.common.MediaType;
+import com.linecorp.armeria.server.annotation.Blocking;
import com.linecorp.armeria.server.annotation.Get;
import com.linecorp.armeria.server.annotation.Param;
+import com.linecorp.armeria.server.annotation.Post;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
@@ -36,7 +40,9 @@ import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
+import org.apache.skywalking.oap.query.graphql.mqe.rt.MQEExecutor;
import org.apache.skywalking.oap.server.admin.inspect.decoder.EntityDecoder;
+import
org.apache.skywalking.oap.server.admin.inspect.request.InspectValuesRequest;
import
org.apache.skywalking.oap.server.admin.inspect.response.EntitiesResponse;
import org.apache.skywalking.oap.server.admin.inspect.response.EntityRow;
import org.apache.skywalking.oap.server.admin.inspect.response.ErrorResponse;
@@ -51,10 +57,12 @@ import
org.apache.skywalking.oap.server.core.query.enumeration.MetricsType;
import org.apache.skywalking.oap.server.core.query.enumeration.Scope;
import org.apache.skywalking.oap.server.core.query.enumeration.Step;
import org.apache.skywalking.oap.server.core.query.input.Duration;
+import org.apache.skywalking.oap.server.core.query.mqe.ExpressionResult;
import org.apache.skywalking.oap.server.core.query.type.Service;
import org.apache.skywalking.oap.server.core.source.DefaultScopeDefine;
import org.apache.skywalking.oap.server.core.storage.StorageModule;
import org.apache.skywalking.oap.server.core.storage.annotation.Column;
+import
org.apache.skywalking.oap.server.core.storage.annotation.ForeignMetricMeta;
import
org.apache.skywalking.oap.server.core.storage.annotation.ValueColumnMetadata;
import org.apache.skywalking.oap.server.core.storage.model.IModelManager;
import org.apache.skywalking.oap.server.core.storage.model.Model;
@@ -77,6 +85,9 @@ public class InspectRestHandler {
/** Value types a caller may declare for a foreign (locally-undefined)
metric. */
private static final Set<String> ACCEPTED_FOREIGN_VALUE_TYPES =
Set.of("LONG", "INT", "DOUBLE", "LABELED");
+ /** A value column is interpolated into JDBC SQL on the read path;
restrict it to a bare identifier. */
+ private static final Pattern VALUE_COLUMN_PATTERN =
Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$");
+ private static final ObjectMapper VALUES_MAPPER = new ObjectMapper();
private final ModuleManager moduleManager;
@@ -398,6 +409,109 @@ public class InspectRestHandler {
return HttpResponse.ofJson(MediaType.JSON_UTF_8, body);
}
+ /**
+ * Read the VALUES of metric(s) persisted by another OAP that this node
does not define. The body
+ * carries an MQE expression plus, in {@code foreignMetrics}, the metadata
for each foreign metric
+ * it references (value column + type). The same MQE engine the public
GraphQL surface uses is run
+ * synchronously with that metadata overlaid PROVIDE-IF-ABSENT (the
catalog always wins), returning
+ * the native {@code ExpressionResult}. Marked {@code @Blocking}: the eval
+ storage read are
+ * synchronous and must not run on the event loop. Only scalar
(LONG/INT/DOUBLE) and labeled
+ * (best-effort) value series are supported; {@code top_n} and
record/heatmap shapes need a local
+ * model and surface as an error.
+ */
+ @Blocking
+ @Post("/inspect/values")
+ public HttpResponse listValues(final HttpData requestBody) {
+ final InspectValuesRequest req;
+ try {
+ req = VALUES_MAPPER.readValue(requestBody.toStringUtf8(),
InspectValuesRequest.class);
+ } catch (Exception e) {
+ return error(HttpStatus.BAD_REQUEST, "invalid request body: " +
e.getMessage());
+ }
+ if (req.getExpression() == null || req.getExpression().isBlank()) {
+ return error(HttpStatus.BAD_REQUEST, "expression is required");
+ }
+ if (req.getEntity() == null || req.getEntity().getScope() == null) {
+ return error(HttpStatus.BAD_REQUEST, "entity (with a scope) is
required");
+ }
+ // The scope alone is not enough: the entity must carry the name
fields its scope needs
+ // (e.g. serviceName + normal for Service), or buildId() yields a
bogus id that the read
+ // silently misses — surface that as a 400 instead of an empty 200.
+ if (!req.getEntity().isValid()) {
+ return error(HttpStatus.BAD_REQUEST,
+ "entity is missing required fields for scope " +
req.getEntity().getScope()
+ + " (Service needs serviceName + normal;
ServiceInstance/Endpoint also need "
+ + "serviceInstanceName / endpointName)");
+ }
+ if (req.getForeignMetrics() == null ||
req.getForeignMetrics().isEmpty()) {
+ return error(HttpStatus.BAD_REQUEST,
+ "foreignMetrics is required; a locally-defined metric should
be queried via the public "
+ + "GraphQL execExpression");
+ }
+
+ final Step step;
+ try {
+ step = Step.valueOf(String.valueOf(req.getStep()).toUpperCase());
+ } catch (Exception e) {
+ return error(HttpStatus.BAD_REQUEST,
+ "step must be one of MINUTE / HOUR / DAY (got " +
req.getStep() + ")");
+ }
+ if (step == Step.SECOND) {
+ return error(HttpStatus.BAD_REQUEST, "step must be one of MINUTE /
HOUR / DAY (got SECOND)");
+ }
+
+ final int scopeId = req.getEntity().getScope().getScopeId();
+ final List<ForeignMetricMeta> foreign = new ArrayList<>();
+ for (final InspectValuesRequest.ForeignMetricInput fm :
req.getForeignMetrics()) {
+ if (fm.getName() == null || fm.getName().isBlank()) {
+ return error(HttpStatus.BAD_REQUEST, "each foreignMetrics
entry needs a name");
+ }
+ if (fm.getValueColumn() == null ||
!VALUE_COLUMN_PATTERN.matcher(fm.getValueColumn()).matches()) {
+ return error(HttpStatus.BAD_REQUEST, "valueColumn is invalid:
" + fm.getValueColumn());
+ }
+ final String type = fm.getValueType() == null ? "" :
fm.getValueType().toUpperCase();
+ if (!ACCEPTED_FOREIGN_VALUE_TYPES.contains(type)) {
+ return error(HttpStatus.BAD_REQUEST,
+ "valueType must be one of LONG / INT / DOUBLE / LABELED
(got " + fm.getValueType() + ")");
+ }
+ if
(ValueColumnMetadata.INSTANCE.readValueColumnDefinition(fm.getName()).isPresent())
{
+ return error(HttpStatus.BAD_REQUEST,
+ "metric " + fm.getName() + " is defined locally; query it
via the GraphQL "
+ + "execExpression and drop it from foreignMetrics");
+ }
+ foreign.add(new ForeignMetricMeta(fm.getName(),
fm.getValueColumn(), type, scopeId, 0));
+ }
+
+ final Duration duration = new Duration();
+ duration.setStart(req.getStart());
+ duration.setEnd(req.getEnd());
+ duration.setStep(step);
+ try {
+ duration.getStartTimeBucket();
+ duration.getEndTimeBucket();
+ } catch (IllegalArgumentException | UnexpectedException e) {
+ return error(HttpStatus.BAD_REQUEST,
+ "start / end must follow the step's date format (DAY:
yyyy-MM-dd, HOUR: yyyy-MM-dd HH, "
+ + "MINUTE: yyyy-MM-dd HHmm): " + e.getMessage());
+ }
+
+ final ExpressionResult result;
+ try {
+ result = new MQEExecutor(moduleManager)
+ .execute(req.getExpression(), req.getEntity(), duration,
foreign);
+ } catch (Exception e) {
+ // Optimistic read: a foreign top_n / record shape, or a wrong
valueColumn/valueType,
+ // surfaces here rather than as garbage.
+ log.warn("inspect values execute failed for expression={}",
req.getExpression(), e);
+ return error(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
+ }
+ if (result.getError() != null) {
+ // e.g. an unsupported shape resolved to UNKNOWN — never put that
on the wire as a 200.
+ return error(HttpStatus.BAD_REQUEST, result.getError());
+ }
+ return HttpResponse.ofJson(MediaType.JSON_UTF_8, result);
+ }
+
/**
* Mirror of the {@code /inspect/entities} type acceptance set. Kept in
one place so
* the {@code mqeQueryable=true} filter on {@code /inspect/metrics} and
the actual
diff --git
a/oap-server/server-admin/inspect/src/main/java/org/apache/skywalking/oap/server/admin/inspect/request/InspectValuesRequest.java
b/oap-server/server-admin/inspect/src/main/java/org/apache/skywalking/oap/server/admin/inspect/request/InspectValuesRequest.java
new file mode 100644
index 0000000000..4d9e075c04
--- /dev/null
+++
b/oap-server/server-admin/inspect/src/main/java/org/apache/skywalking/oap/server/admin/inspect/request/InspectValuesRequest.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.admin.inspect.request;
+
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.skywalking.oap.server.core.query.input.Entity;
+
+/**
+ * Body of {@code POST /inspect/values}: an MQE expression evaluated over one
or more metrics this OAP
+ * does not define locally, whose metadata the caller supplies in {@link
#foreignMetrics}.
+ */
+@Getter
+@Setter
+public class InspectValuesRequest {
+ /**
+ * The MQE expression to evaluate (e.g. a single foreign metric name, or
an expression combining
+ * foreign and/or catalog metrics).
+ */
+ private String expression;
+ /**
+ * The query entity; its {@code scope} is used to bind every foreign
metric.
+ */
+ private Entity entity;
+ private String start;
+ private String end;
+ /**
+ * One of {@code MINUTE} / {@code HOUR} / {@code DAY}.
+ */
+ private String step;
+ /**
+ * Metadata for the foreign metrics referenced by {@link #expression}.
+ */
+ private List<ForeignMetricInput> foreignMetrics;
+
+ /**
+ * Caller-supplied metadata for a single foreign metric.
+ */
+ @Getter
+ @Setter
+ public static class ForeignMetricInput {
+ private String name;
+ /**
+ * Physical value column (post reserved-word override, e.g. {@code
value_} on MySQL/PostgreSQL).
+ */
+ private String valueColumn;
+ /**
+ * One of {@code LONG} / {@code INT} / {@code DOUBLE} (scalar) or
{@code LABELED}.
+ */
+ private String valueType;
+ }
+}
diff --git
a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/ForeignMetricMeta.java
b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/ForeignMetricMeta.java
new file mode 100644
index 0000000000..914743767b
--- /dev/null
+++
b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/ForeignMetricMeta.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.storage.annotation;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * Caller-supplied metadata for a metric this OAP does not define locally
("foreign"), used only by
+ * the admin inspect value path. It carries exactly what the read path cannot
recover from the metric
+ * name: the value column, its data type, the scope, and the empty-bucket
default. The scope is the
+ * metric's, not the queried entity's; it is derived from the query Entity by
the caller side.
+ *
+ * <p>It is supplied per-request via {@link InspectQueryContext} and consulted
PROVIDE-IF-ABSENT by
+ * {@link ValueColumnMetadata} — it can only fill gaps for unregistered
metrics, never shadow a
+ * registered one.
+ */
+@Getter
+@RequiredArgsConstructor
+public class ForeignMetricMeta {
+ private final String metricName;
+ /**
+ * Physical value column (post reserved-word override, e.g. {@code value_}
on MySQL/PostgreSQL).
+ */
+ private final String valueColumn;
+ /**
+ * One of {@code LONG} / {@code INT} / {@code DOUBLE} (scalar) or {@code
LABELED} (DataTable).
+ * Distinguishes both the MQE branch (COMMON vs LABELED) and the storage
decode (long vs double).
+ */
+ private final String valueType;
+ private final int scopeId;
+ private final int defaultValue;
+}
diff --git
a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/InspectQueryContext.java
b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/InspectQueryContext.java
new file mode 100644
index 0000000000..746db4dfcd
--- /dev/null
+++
b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/InspectQueryContext.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.storage.annotation;
+
+import java.util.Map;
+
+/**
+ * Request-scoped overlay of {@link ForeignMetricMeta}, holding metadata for
the foreign metrics
+ * referenced by ONE inspect value query, keyed by metric name.
+ *
+ * <p>This is NOT a behaviour side-channel: the metadata enters the system as
an explicit parameter
+ * to the synchronous MQE execute entry, which republishes it here and removes
it in a {@code finally}
+ * on the SAME thread that runs the (entirely synchronous) MQE eval + storage
read. It is consulted by
+ * {@link ValueColumnMetadata} (so the MQE resolution sees the foreign metric)
and by the storage
+ * value-read paths (which need {@link ForeignMetricMeta#getValueType()} to
decode). It mirrors how
+ * {@code DebuggingTraceContext.TRACE_CONTEXT} already rides the same eval→DAO
thread. The public MQE /
+ * GraphQL path never sets it, so it is {@code null} for every normal query.
+ */
+public final class InspectQueryContext {
+ private static final ThreadLocal<Map<String, ForeignMetricMeta>> CONTEXT =
new ThreadLocal<>();
+
+ private InspectQueryContext() {
+ }
+
+ /**
+ * Open the overlay for the current thread. Must be paired with {@link
#clear()} in a
+ * {@code finally} on the same thread.
+ *
+ * @param metaByMetric immutable map of metric name to its caller-supplied
metadata
+ */
+ public static void set(final Map<String, ForeignMetricMeta> metaByMetric) {
+ CONTEXT.set(Map.copyOf(metaByMetric));
+ }
+
+ public static void clear() {
+ CONTEXT.remove();
+ }
+
+ /**
+ * @param metricName the metric being resolved
+ * @return the caller-supplied metadata, or {@code null} when no overlay
is active or the metric
+ * is not in it (i.e. it is a locally-registered metric)
+ */
+ public static ForeignMetricMeta get(final String metricName) {
+ final Map<String, ForeignMetricMeta> map = CONTEXT.get();
+ return map == null ? null : map.get(metricName);
+ }
+}
diff --git
a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/ValueColumnMetadata.java
b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/ValueColumnMetadata.java
index de62c44738..69031feda7 100644
---
a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/ValueColumnMetadata.java
+++
b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/ValueColumnMetadata.java
@@ -85,7 +85,32 @@ public enum ValueColumnMetadata {
* @return metric metadata if found
*/
public Optional<ValueColumn> readValueColumnDefinition(String metricsName)
{
- return Optional.ofNullable(mapping.get(metricsName));
+ return Optional.ofNullable(resolve(metricsName));
+ }
+
+ /**
+ * Resolve a metric's value column: the locally-registered catalog wins;
only when a metric is
+ * absent locally does the request-scoped {@link InspectQueryContext} fill
the gap (the admin
+ * inspect value path). The overlay is therefore provide-if-absent — it
can never shadow or
+ * override a registered metric. Returns {@code null} when neither has it.
+ *
+ * @param metricsName the metric to resolve
+ * @return its value column, or {@code null}
+ */
+ private ValueColumn resolve(String metricsName) {
+ final ValueColumn registered = mapping.get(metricsName);
+ if (registered != null) {
+ return registered;
+ }
+ final ForeignMetricMeta foreign = InspectQueryContext.get(metricsName);
+ return foreign == null ? null : toForeignValueColumn(foreign);
+ }
+
+ private ValueColumn toForeignValueColumn(ForeignMetricMeta foreign) {
+ final Column.ValueDataType dataType =
"LABELED".equals(foreign.getValueType())
+ ? Column.ValueDataType.LABELED_VALUE :
Column.ValueDataType.COMMON_VALUE;
+ return new ValueColumn(
+ foreign.getValueColumn(), dataType, foreign.getDefaultValue(),
foreign.getScopeId(), false);
}
/**
@@ -100,7 +125,7 @@ public enum ValueColumnMetadata {
}
private ValueColumn findColumn(String metricsName) {
- ValueColumn column = mapping.get(metricsName);
+ ValueColumn column = resolve(metricsName);
if (column == null) {
throw new RuntimeException("Metrics:" + metricsName + " doesn't
have value column definition");
}
diff --git
a/oap-server/server-query-plugin/query-graphql-plugin/src/main/java/org/apache/skywalking/oap/query/graphql/mqe/rt/MQEExecutor.java
b/oap-server/server-query-plugin/query-graphql-plugin/src/main/java/org/apache/skywalking/oap/query/graphql/mqe/rt/MQEExecutor.java
new file mode 100644
index 0000000000..38277f08a7
--- /dev/null
+++
b/oap-server/server-query-plugin/query-graphql-plugin/src/main/java/org/apache/skywalking/oap/query/graphql/mqe/rt/MQEExecutor.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.query.graphql.mqe.rt;
+
+import java.text.DecimalFormat;
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.antlr.v4.runtime.CharStreams;
+import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.misc.ParseCancellationException;
+import org.antlr.v4.runtime.tree.ParseTree;
+import org.apache.skywalking.mqe.rt.exception.ParseErrorListener;
+import org.apache.skywalking.mqe.rt.grammar.MQELexer;
+import org.apache.skywalking.mqe.rt.grammar.MQEParser;
+import org.apache.skywalking.oap.server.core.query.input.Duration;
+import org.apache.skywalking.oap.server.core.query.input.Entity;
+import org.apache.skywalking.oap.server.core.query.mqe.ExpressionResult;
+import org.apache.skywalking.oap.server.core.query.mqe.ExpressionResultType;
+import
org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingTraceContext;
+import
org.apache.skywalking.oap.server.core.storage.annotation.InspectQueryContext;
+import
org.apache.skywalking.oap.server.core.storage.annotation.ForeignMetricMeta;
+import org.apache.skywalking.oap.server.library.module.ModuleManager;
+
+import static
org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingTraceContext.TRACE_CONTEXT;
+
+/**
+ * Synchronous MQE evaluation entry, factored out of {@code
MetricsExpressionQuery} so the admin
+ * inspect value path can run the SAME engine without the resolver's {@code
queryAsync} ForkJoinPool
+ * hop. Optionally overlays caller-supplied metadata for foreign metrics
(metrics this OAP does not
+ * define) via {@link InspectQueryContext} for the duration of the synchronous
eval; the overlay is
+ * provide-if-absent and removed in a {@code finally} on the same thread that
runs the eval and the
+ * storage read (the eval is fully synchronous, mirroring how {@code
TRACE_CONTEXT} already rides that
+ * thread into the DAO).
+ */
+public class MQEExecutor {
+ private final ModuleManager moduleManager;
+ private final DecimalFormat valueFormat = new DecimalFormat();
+
+ public MQEExecutor(final ModuleManager moduleManager) {
+ this.moduleManager = moduleManager;
+ this.valueFormat.setGroupingUsed(false);
+ }
+
+ /**
+ * Evaluate an MQE expression synchronously.
+ *
+ * @param expression the MQE expression
+ * @param entity the query entity; its scope is used to bind any
foreign metric
+ * @param duration the query time range
+ * @param foreign metadata for foreign metrics referenced by the
expression; {@code null} or
+ * empty for a purely catalog query (the public GraphQL
path passes null)
+ * @return the native expression result
+ */
+ public ExpressionResult execute(final String expression, final Entity
entity, final Duration duration,
+ final List<ForeignMetricMeta> foreign) {
+ final boolean hasForeign = foreign != null && !foreign.isEmpty();
+ if (hasForeign) {
+ InspectQueryContext.set(foreign.stream().collect(
+ Collectors.toMap(ForeignMetricMeta::getMetricName,
Function.identity(), (a, b) -> a)));
+ }
+ final DebuggingTraceContext traceContext = new DebuggingTraceContext(
+ "Inspect MQE: " + expression + ", Entity: " + entity + ",
Duration: " + duration, false, false);
+ TRACE_CONTEXT.set(traceContext);
+ try {
+ final MQEVisitor visitor = new MQEVisitor(moduleManager, entity,
duration);
+ final MQELexer lexer = new
MQELexer(CharStreams.fromString(expression));
+ lexer.addErrorListener(new ParseErrorListener());
+ final MQEParser parser = new MQEParser(new
CommonTokenStream(lexer));
+ parser.addErrorListener(new ParseErrorListener());
+ final ParseTree tree;
+ try {
+ tree = parser.expression();
+ } catch (ParseCancellationException e) {
+ final ExpressionResult errorResult = new ExpressionResult();
+ errorResult.setType(ExpressionResultType.UNKNOWN);
+ errorResult.setError(e.getMessage());
+ return errorResult;
+ }
+ final ExpressionResult result = visitor.visit(tree);
+ result.getResults().forEach(mqeValues ->
mqeValues.getValues().forEach(mqeValue -> {
+ if (!mqeValue.isEmptyValue()) {
+
mqeValue.setValue(valueFormat.format(mqeValue.getDoubleValue()));
+ }
+ }));
+ return result;
+ } finally {
+ traceContext.stopTrace();
+ TRACE_CONTEXT.remove();
+ if (hasForeign) {
+ InspectQueryContext.clear();
+ }
+ }
+ }
+}
diff --git
a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/MetadataRegistry.java
b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/MetadataRegistry.java
index 415e183a88..d14fb4055d 100644
---
a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/MetadataRegistry.java
+++
b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/MetadataRegistry.java
@@ -62,6 +62,8 @@ import
org.apache.skywalking.oap.server.core.query.type.KeyValue;
import org.apache.skywalking.oap.server.core.storage.StorageException;
import org.apache.skywalking.oap.server.core.storage.annotation.BanyanDB;
import org.apache.skywalking.oap.server.core.storage.annotation.Column;
+import
org.apache.skywalking.oap.server.core.storage.annotation.ForeignMetricMeta;
+import
org.apache.skywalking.oap.server.core.storage.annotation.InspectQueryContext;
import
org.apache.skywalking.oap.server.core.storage.annotation.ValueColumnMetadata;
import org.apache.skywalking.oap.server.core.storage.model.Model;
import org.apache.skywalking.oap.server.core.storage.model.ModelColumn;
@@ -422,7 +424,41 @@ public enum MetadataRegistry {
* Find metadata with down-sampling
*/
public Schema findMetricMetadata(final String modelName, DownSampling
downSampling) {
- return this.registry.get(SchemaMetadata.formatName(modelName,
downSampling));
+ final Schema schema =
this.registry.get(SchemaMetadata.formatName(modelName, downSampling));
+ if (schema != null) {
+ return schema;
+ }
+ // Provide-if-absent: a locally-registered metric always wins (above).
Only when this OAP has no
+ // schema for the metric AND the inspect overlay is active on THIS
THREAD do we synthesize a
+ // read-only foreign schema. The overlay is a ThreadLocal set only on
the admin inspect request
+ // thread (never a write thread), so writes — even those that reach
here via findMetadata() —
+ // never observe it.
+ final ForeignMetricMeta foreign = InspectQueryContext.get(modelName);
+ if (foreign != null) {
+ return synthesizeForeignMetricSchema(
+ modelName, stepFromDownSampling(downSampling),
foreign.getValueColumn(), foreign.getValueType());
+ }
+ return null;
+ }
+
+ /**
+ * Inverse of {@link #deriveFromStep(Step)}: map a {@link DownSampling}
back to the {@link Step}
+ * that {@link #synthesizeForeignMetricSchema} expects.
+ *
+ * @param downSampling the down-sampling to map back
+ * @return the corresponding query step
+ */
+ private Step stepFromDownSampling(DownSampling downSampling) {
+ switch (downSampling) {
+ case Day:
+ return Step.DAY;
+ case Hour:
+ return Step.HOUR;
+ case Second:
+ return Step.SECOND;
+ default:
+ return Step.MINUTE;
+ }
}
/**
diff --git
a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/measure/BanyanDBMetricsQueryDAO.java
b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/measure/BanyanDBMetricsQueryDAO.java
index 4139141774..ac3d95c767 100644
---
a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/measure/BanyanDBMetricsQueryDAO.java
+++
b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/measure/BanyanDBMetricsQueryDAO.java
@@ -59,6 +59,9 @@ public class BanyanDBMetricsQueryDAO extends
AbstractBanyanDBDAO implements IMet
@Override
public MetricsValues readMetricsValues(MetricsCondition condition, String
valueColumnName, Duration duration) throws IOException {
String modelName = condition.getName();
+ // findMetricMetadata is overlay-aware: for a foreign metric (the
admin inspect value path) it
+ // synthesizes a read-only schema from InspectQueryContext; a local
metric returns the registered
+ // one. So this read path no longer special-cases foreign metrics.
MetadataRegistry.Schema schema =
MetadataRegistry.INSTANCE.findMetricMetadata(modelName, duration.getStep());
if (schema == null) {
throw new IOException("schema is not registered");
diff --git
a/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/base/IndexController.java
b/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/base/IndexController.java
index d41ebf7554..3dfd46a130 100644
---
a/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/base/IndexController.java
+++
b/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/base/IndexController.java
@@ -24,6 +24,8 @@ import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.skywalking.oap.server.core.Const;
import org.apache.skywalking.oap.server.core.analysis.FunctionCategory;
+import
org.apache.skywalking.oap.server.core.storage.annotation.ForeignMetricMeta;
+import
org.apache.skywalking.oap.server.core.storage.annotation.InspectQueryContext;
import org.apache.skywalking.oap.server.core.storage.model.Model;
import org.apache.skywalking.oap.server.core.storage.model.ModelColumn;
import org.apache.skywalking.oap.server.library.util.CollectionUtils;
@@ -33,7 +35,6 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.Optional;
/**
* The metrics data, that generated by OAL or MAL, would be partitioned to
storage by the functions of the OAL or MAL.
@@ -159,7 +160,17 @@ public enum IndexController {
public static final String MANAGEMENT_TABLE_NAME = "management_table";
public static String getPhysicalTableName(String logicName) {
- return
Optional.ofNullable(LOGIC_INDICES_CATALOG.get(logicName)).orElse(logicName);
+ final String catalogEntry = LOGIC_INDICES_CATALOG.get(logicName);
+ if (catalogEntry != null) {
+ return catalogEntry;
+ }
+ // Provide-if-absent: the catalog wins (above). A foreign metric
(inspect overlay active on
+ // this thread, no local registry entry) lives in the single
merged metrics index instead of
+ // a per-metric index that does not exist; isMergedTable() derives
true from this. The same
+ // overlay gates the foreign handling in MetricsQueryEsDAO.
logicSharding is rejected upstream
+ // in the DAO, where the request context is understood.
+ final ForeignMetricMeta foreign =
InspectQueryContext.get(logicName);
+ return foreign != null ? METRICS_LOGIC_TABLE_NAME : logicName;
}
public static void registerRelation(Model model, String physicalName) {
diff --git
a/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/query/MetricsQueryEsDAO.java
b/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/query/MetricsQueryEsDAO.java
index b4c44e0be2..4d3cc2f68a 100644
---
a/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/query/MetricsQueryEsDAO.java
+++
b/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/query/MetricsQueryEsDAO.java
@@ -47,6 +47,7 @@ import
org.apache.skywalking.oap.server.core.query.type.IntValues;
import org.apache.skywalking.oap.server.core.query.type.KVInt;
import org.apache.skywalking.oap.server.core.query.type.KeyValue;
import org.apache.skywalking.oap.server.core.query.type.MetricsValues;
+import
org.apache.skywalking.oap.server.core.storage.annotation.InspectQueryContext;
import
org.apache.skywalking.oap.server.core.storage.annotation.ValueColumnMetadata;
import org.apache.skywalking.oap.server.core.storage.query.IMetricsQueryDAO;
import
org.apache.skywalking.oap.server.library.client.elasticsearch.ElasticSearchClient;
@@ -64,16 +65,26 @@ public class MetricsQueryEsDAO extends EsDAO implements
IMetricsQueryDAO {
@Override
public MetricsValues readMetricsValues(final MetricsCondition condition,
final String valueColumnName,
- final Duration duration) {
+ final Duration duration) throws
IOException {
+ // getPhysicalTableName / isMergedTable are overlay-aware: a foreign
metric (admin inspect value
+ // path) resolves to the merged METRICS_LOGIC_TABLE_NAME index and
reports mergedTable=true with
+ // no explicit branch here. The one case the overlay cannot cover is
logicSharding=true, where the
+ // physical index derives from the metric's (absent) stream class —
reject that up front.
+ if (InspectQueryContext.get(condition.getName()) != null &&
IndexController.INSTANCE.isLogicSharding()) {
+ throw new IOException(
+ "inspecting a foreign metric is unsupported under ES
logicSharding=true: the physical "
+ + "index is derived from the metric's stream class, which
this OAP does not have for "
+ + condition.getName());
+ }
final String realValueColumn =
IndexController.LogicIndicesRegister.getPhysicalColumnName(condition.getName(),
valueColumnName);
- String tableName =
-
IndexController.LogicIndicesRegister.getPhysicalTableName(condition.getName());
+ final boolean mergedTable =
IndexController.LogicIndicesRegister.isMergedTable(condition.getName());
+ final String tableName =
IndexController.LogicIndicesRegister.getPhysicalTableName(condition.getName());
final List<PointOfTime> pointOfTimes =
duration.assembleDurationPoints();
Map<String, List<String>> indexIdsGroup = new HashMap<>();
final List<String> ids = pointOfTimes.stream().map(pointOfTime -> {
String id = pointOfTime.id(condition.getEntity().buildId());
- if
(IndexController.LogicIndicesRegister.isMergedTable(condition.getName())) {
+ if (mergedTable) {
id =
IndexController.INSTANCE.generateDocId(condition.getName(), id);
}
String indexName = TimeSeriesUtils.queryIndexName(
@@ -116,11 +127,20 @@ public class MetricsQueryEsDAO extends EsDAO implements
IMetricsQueryDAO {
public List<MetricsValues> readLabeledMetricsValues(final MetricsCondition
condition,
final String
valueColumnName,
final List<KeyValue>
labels,
- final Duration
duration) {
+ final Duration
duration) throws IOException {
+ // getPhysicalTableName is overlay-aware: a foreign metric (admin
inspect value path) resolves
+ // to the merged METRICS_LOGIC_TABLE_NAME index, so aggregationMode
derives true and the doc-id
+ // is metric-name-prefixed without an explicit foreign branch.
logicSharding cannot be resolved
+ // for a foreign metric (index derives from the absent stream class) —
reject it up front.
+ if (InspectQueryContext.get(condition.getName()) != null &&
IndexController.INSTANCE.isLogicSharding()) {
+ throw new IOException(
+ "inspecting a foreign metric is unsupported under ES
logicSharding=true: the physical "
+ + "index is derived from the metric's stream class, which
this OAP does not have for "
+ + condition.getName());
+ }
final String realValueColumn =
IndexController.LogicIndicesRegister.getPhysicalColumnName(condition.getName(),
valueColumnName);
final List<PointOfTime> pointOfTimes =
duration.assembleDurationPoints();
- String tableName =
-
IndexController.LogicIndicesRegister.getPhysicalTableName(condition.getName());
+ final String tableName =
IndexController.LogicIndicesRegister.getPhysicalTableName(condition.getName());
Map<String, List<String>> indexIdsGroup = new HashMap<>();
boolean aggregationMode = !tableName.equals(condition.getName());
diff --git
a/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/TableHelper.java
b/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/TableHelper.java
index 7d02b4afce..e770ef6d34 100644
---
a/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/TableHelper.java
+++
b/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/TableHelper.java
@@ -32,6 +32,7 @@ import
org.apache.skywalking.oap.server.core.analysis.DownSampling;
import org.apache.skywalking.oap.server.core.analysis.FunctionCategory;
import org.apache.skywalking.oap.server.core.analysis.TimeBucket;
import org.apache.skywalking.oap.server.core.config.ConfigService;
+import
org.apache.skywalking.oap.server.core.storage.annotation.InspectQueryContext;
import org.apache.skywalking.oap.server.core.storage.model.Model;
import
org.apache.skywalking.oap.server.library.client.jdbc.hikaricp.JDBCClient;
import org.apache.skywalking.oap.server.library.module.ModuleManager;
@@ -39,6 +40,7 @@ import
org.apache.skywalking.oap.server.library.util.StringUtil;
import org.apache.skywalking.oap.server.storage.plugin.jdbc.TableMetaInfo;
import java.time.Duration;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
@@ -107,6 +109,17 @@ public class TableHelper {
public List<String> getTablesForRead(String modelName, long
timeBucketStart, long timeBucketEnd) {
final var model = TableMetaInfo.get(modelName);
+ if (model == null && InspectQueryContext.get(modelName) != null) {
+ // A foreign metric (admin inspect value path: InspectQueryContext
active on this thread)
+ // has no local model, so its physical function table is unknown.
Probe every metric
+ // function table; the metric-prefixed row ids (generateId) keep
only this metric's rows.
+ // A non-overlay miss is a genuinely unknown metric — fall through
and let it surface.
+ final List<String> tables = new ArrayList<>();
+ for (final var rawTable : getMetricRawTables()) {
+ tables.addAll(getExistingDayTables(rawTable, timeBucketStart,
timeBucketEnd));
+ }
+ return tables;
+ }
final var rawTableName = getTableName(model);
if (!model.isTimeSeries()) {
diff --git
a/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/dao/JDBCMetricsQueryDAO.java
b/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/dao/JDBCMetricsQueryDAO.java
index 5aa18fc9e2..9277422c7d 100644
---
a/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/dao/JDBCMetricsQueryDAO.java
+++
b/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/dao/JDBCMetricsQueryDAO.java
@@ -30,6 +30,7 @@ import
org.apache.skywalking.oap.server.core.query.type.HeatMap;
import org.apache.skywalking.oap.server.core.query.type.KVInt;
import org.apache.skywalking.oap.server.core.query.type.KeyValue;
import org.apache.skywalking.oap.server.core.query.type.MetricsValues;
+import
org.apache.skywalking.oap.server.core.storage.annotation.InspectQueryContext;
import
org.apache.skywalking.oap.server.core.storage.annotation.ValueColumnMetadata;
import org.apache.skywalking.oap.server.core.storage.query.IMetricsQueryDAO;
import
org.apache.skywalking.oap.server.library.client.jdbc.hikaricp.JDBCClient;
@@ -59,11 +60,9 @@ public class JDBCMetricsQueryDAO extends JDBCSQLExecutor
implements IMetricsQuer
// Label is null, because in readMetricsValues, no label parameter.
final var intValues = metricsValues.getValues();
+ final var foreign = InspectQueryContext.get(condition.getName()) !=
null;
final var tables = tableHelper.getTablesForRead(
- condition.getName(),
- duration.getStartTimeBucket(),
- duration.getEndTimeBucket()
- );
+ condition.getName(), duration.getStartTimeBucket(),
duration.getEndTimeBucket());
final var pointOfTimes = duration.assembleDurationPoints();
final var entityId = condition.getEntity().buildId();
@@ -82,18 +81,26 @@ public class JDBCMetricsQueryDAO extends JDBCSQLExecutor
implements IMetricsQuer
.collect(Collectors.joining(", ", "(", ")"))
);
- jdbcClient.executeQuery(
- sql.toString(),
- resultSet -> {
- while (resultSet.next()) {
- final var kv = new KVInt();
- kv.setId(resultSet.getString("id"));
- kv.setValue(resultSet.getLong(valueColumnName));
- intValues.addKVInt(kv);
- }
- return null;
- },
- ids.toArray(new Object[0]));
+ try {
+ jdbcClient.executeQuery(
+ sql.toString(),
+ resultSet -> {
+ while (resultSet.next()) {
+ final var kv = new KVInt();
+ kv.setId(resultSet.getString("id"));
+ kv.setValue(resultSet.getLong(valueColumnName));
+ intValues.addKVInt(kv);
+ }
+ return null;
+ },
+ ids.toArray(new Object[0]));
+ } catch (Exception e) {
+ if (!foreign) {
+ throw e;
+ }
+ // Foreign probe spans every metric function table; this one
does not carry the
+ // caller's value column. The metric-prefixed ids match only
its real table, so skip.
+ }
}
metricsValues.setValues(
@@ -109,11 +116,9 @@ public class JDBCMetricsQueryDAO extends JDBCSQLExecutor
implements IMetricsQuer
final List<KeyValue>
labels,
final Duration
duration) {
final var idMap = new HashMap<String, DataTable>();
+ final var foreign = InspectQueryContext.get(condition.getName()) !=
null;
final var tables = tableHelper.getTablesForRead(
- condition.getName(),
- duration.getStartTimeBucket(),
- duration.getEndTimeBucket()
- );
+ condition.getName(), duration.getStartTimeBucket(),
duration.getEndTimeBucket());
final var pointOfTimes = duration.assembleDurationPoints();
final var entityId = condition.getEntity().buildId();
@@ -131,20 +136,28 @@ public class JDBCMetricsQueryDAO extends JDBCSQLExecutor
implements IMetricsQuer
.collect(Collectors.joining(", ", "(", ")"))
);
- jdbcClient.executeQuery(
- sql.toString(),
- resultSet -> {
- while (resultSet.next()) {
- String id = resultSet.getString("id");
+ try {
+ jdbcClient.executeQuery(
+ sql.toString(),
+ resultSet -> {
+ while (resultSet.next()) {
+ String id = resultSet.getString("id");
- DataTable multipleValues = new DataTable(5);
-
multipleValues.toObject(resultSet.getString(valueColumnName));
+ DataTable multipleValues = new DataTable(5);
+
multipleValues.toObject(resultSet.getString(valueColumnName));
- idMap.put(id, multipleValues);
- }
- return null;
- },
- ids.toArray(new Object[0]));
+ idMap.put(id, multipleValues);
+ }
+ return null;
+ },
+ ids.toArray(new Object[0]));
+ } catch (Exception e) {
+ if (!foreign) {
+ throw e;
+ }
+ // Foreign probe spans every metric function table; this one
does not carry the
+ // caller's value column. The metric-prefixed ids match only
its real table, so skip.
+ }
}
return Util.sortValues(
Util.composeLabelValue(condition.getName(), labels, ids, idMap),
diff --git a/test/e2e-v2/cases/inspect/README.md
b/test/e2e-v2/cases/inspect/README.md
index 32ff07a740..f498087a34 100644
--- a/test/e2e-v2/cases/inspect/README.md
+++ b/test/e2e-v2/cases/inspect/README.md
@@ -14,17 +14,19 @@ Two independent OAPs share one storage backend (no cluster):
| aware (existing) | oap-a | `/inspect/metrics` lists it; `/inspect/entities`
returns `inspect-e2e-svc` with an `mqeEntity` |
| — | oap-b | `/inspect/metrics` excludes it |
| aware, no metadata | oap-b | `/inspect/entities` → `400 metric unknown
locally …` |
-| **foreign (new)** | oap-b | `/inspect/entities --value-column --value-type`
returns the same entity, `scope:null`, no `mqeEntity` |
+| **foreign entity (new)** | oap-b | `/inspect/entities --value-column
--value-type` returns the same entity, `scope:null`, no `mqeEntity` |
+| **foreign value (new)** | oap-b | `POST /inspect/values` with
`foreignMetrics` returns the metric's value series (`42`) |
Covered storages: `banyandb/`, `elasticsearch/`, `postgresql/`.
## CI wiring (gated on skywalking-cli)
-The foreign assertion calls `swctl admin inspect entities --value-column /
--value-type`,
-flags added in [skywalking-cli
#230](https://github.com/apache/skywalking-cli/pull/230).
-The e2e builds swctl from `SW_CTL_COMMIT` (`test/e2e-v2/script/env`), which is
pinned to a
-cli commit that includes those flags, and the three storage variants are wired
into the
-`e2e` matrix in `.github/workflows/skywalking.yaml`.
+The foreign assertions call `swctl admin inspect entities --value-column /
--value-type`
+(flags from [skywalking-cli
#230](https://github.com/apache/skywalking-cli/pull/230)) and
+`swctl admin inspect values --foreign-metric` (command from
+[skywalking-cli #232](https://github.com/apache/skywalking-cli/pull/232)). The
e2e builds swctl
+from `SW_CTL_COMMIT` (`test/e2e-v2/script/env`), pinned to a cli commit that
includes both, and the
+three storage variants are wired into the `e2e` matrix in
`.github/workflows/skywalking.yaml`.
To validate locally, build swctl from that commit (or newer) and run any
variant's
`e2e.yaml` with `skywalking-infra-e2e`; all three storages pass.
diff --git a/test/e2e-v2/cases/inspect/inspect-foreign-flow.sh
b/test/e2e-v2/cases/inspect/inspect-foreign-flow.sh
index f86a130f20..67ee561b3c 100755
--- a/test/e2e-v2/cases/inspect/inspect-foreign-flow.sh
+++ b/test/e2e-v2/cases/inspect/inspect-foreign-flow.sh
@@ -29,6 +29,8 @@
# 3. OAP-B /inspect/entities WITHOUT valueColumn/valueType → "unknown
locally".
# 4. OAP-B /inspect/entities WITH valueColumn/valueType (FOREIGN/new path)
returns
# the SAME entity, scope=null, no mqeEntity.
+# 5. OAP-B POST /inspect/values WITH foreignMetrics (FOREIGN VALUE/new path)
returns
+# the metric's value series (42).
set -euo pipefail
A_REST="${A_REST:-http://127.0.0.1:17128}"
@@ -103,4 +105,16 @@ echo "${b_rows}" | jq -e '.rows[] |
select(.decoded.serviceName=="'"${SVC}"'") |
|| fail "OAP-B foreign row should carry no mqeEntity: ${b_rows}"
log " ✓ OAP-B FOREIGN /inspect/entities returns ${SVC}, scope=null, no
mqeEntity (new path)"
+# --- 5. OAP-B foreign VALUE read (admin inspect values) returns the metric
value ---
+# Uses the cli `admin inspect values` command (skywalking-cli #232, pinned via
SW_CTL_COMMIT) →
+# POST /inspect/values. MINUTE step: the per-minute value of
meter_inspect_e2e_pool (=
+# e2e_rr_pool_size summed over one service) is a constant 42; the DAY
downsampling is not persisted
+# this early in the run. Relative -30m/0m window so no host date-arithmetic is
needed.
+b_values="$(b_inspect values --expression "${METRIC}" --service-name "${SVC}" \
+ --foreign-metric "${METRIC},${VC},LONG" --start "-30m" --end "0m" --step
MINUTE)" \
+ || fail "OAP-B admin inspect values errored"
+echo "${b_values}" | jq -e '[.results[]?.values[]? | select(.value=="42")] |
length > 0' >/dev/null \
+ || fail "OAP-B admin inspect values returned no '42' value series:
${b_values}"
+log " ✓ OAP-B FOREIGN admin inspect values returns the metric value 42 (new
path)"
+
log "=== inspect-foreign-flow.sh PASSED ==="
diff --git a/test/e2e-v2/script/env b/test/e2e-v2/script/env
index 373df11394..80df253051 100644
--- a/test/e2e-v2/script/env
+++ b/test/e2e-v2/script/env
@@ -27,7 +27,7 @@ SW_BANYANDB_COMMIT=c2d925e4eae4d77edda94e1fd438243483960150
SW_AGENT_PHP_COMMIT=d1114e7be5d89881eec76e5b56e69ff844691e35
SW_PREDICTOR_COMMIT=54a0197654a3781a6f73ce35146c712af297c994
-SW_CTL_COMMIT=90365c4bc59de3704ff81b4cefe55d09f706d00d
+SW_CTL_COMMIT=85e5afdb3d55c6e5af66a472c3fe8ac024d11690
# Third-party image versions used by e2e infrastructure (not skywalking
# components). Pinned here so the matrix is reproducible.