Skip to content

Commit 4226f14

Browse files
authored
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.
1 parent fe55b90 commit 4226f14

19 files changed

Lines changed: 684 additions & 57 deletions

File tree

docs/en/changes/changes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#### Project
44

55
* 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` discriminator, and BanyanDB synthesizes a read-only measure schema. Scope is no longer required — the `entity_id` is decoded structurally (service / 2nd-level / relations) with a generic `name` leaf. Locally-defined metrics keep the exact field names, scope, and `mqeEntity` as before.
6+
* 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 resolve where it lives (`MetadataRegistry` synthesizes a BanyanDB measure schema, `IndexController` resolves the ES `metrics-all` index, `TableHelper` probes the JDBC function tables), so the read returns the native MQE `ExpressionResult` with no per-DAO special-casing. Admin-only (a forced read this OAP cannot validate); not mirrored onto the public REST / GraphQL surface. See the [Inspect API](../setup/backend/admin-api/inspect.md).
67
* 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.
78
* **New `queryAlarms` GraphQL query — entity / layer / rule filters for alarms.** Adds
89
a comprehensive alarm query API alongside the legacy `getAlarm`. The new

docs/en/setup/backend/admin-api/inspect.md

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
# Inspect API
22

3-
The Inspect API lives on `admin-server` and exposes two browse endpoints that
4-
let operators answer two questions without writing exploratory MQE:
3+
The Inspect API lives on `admin-server` and exposes three endpoints that
4+
let operators answer three questions without writing exploratory MQE:
55

6-
1. *Which metrics has OAP registered, and at what downsampling?*
7-
2. *For metric `X` in time range `T`, which entities currently hold values?*
6+
1. *Which metrics has OAP registered, and at what downsampling?*`GET /inspect/metrics`
7+
2. *For metric `X` in time range `T`, which entities currently hold values?*`GET /inspect/entities`
8+
3. *For metric `X` + entity `E`, what are the values?*`POST /inspect/values`
89

910
For a locally-defined metric, the output of (2) carries a ready-to-paste
1011
`mqeEntity` payload, so the follow-up MQE call against the public GraphQL
1112
`execExpression` mutation is copy-paste from the inspect response. A metric
1213
persisted by **another OAP** that this node does not define can also be
13-
inspected with caller-supplied metadata (see
14-
[Foreign metrics](#foreign-metrics-not-defined-on-this-oap)).
14+
inspected with caller-supplied metadata — both its entities (2) and its values
15+
(3) — see [Foreign metrics](#foreign-metrics-not-defined-on-this-oap).
1516

1617
## Enabling
1718

@@ -227,6 +228,73 @@ curl 'http://oap-admin:17128/inspect/entities?metric=meter_custom_x&valueColumn=
227228
}
228229
```
229230

231+
### `POST /inspect/values`
232+
233+
Reads the **values** of a metric this OAP does not define locally, by running the
234+
real MQE engine over caller-supplied metadata. Where `GET /inspect/entities`
235+
answers *which entities hold values*, this answers *what those values are* — the
236+
native MQE `ExpressionResult` (the same shape the UI renders for a catalog metric),
237+
for a metric that is otherwise foreign to this node.
238+
239+
Because it trusts caller-supplied metadata and forces a read of a metric this OAP
240+
cannot validate, it is **admin-only** (it never mirrors onto the public REST /
241+
GraphQL surface) and takes a request **body**: an MQE expression plus one metadata
242+
entry per foreign metric the expression references.
243+
244+
Request body (`application/json`):
245+
246+
| Field | Required | Description |
247+
|-------|----------|-------------|
248+
| `expression` | yes | The MQE expression to evaluate — a single foreign metric name, or an expression combining foreign and/or catalog metrics. |
249+
| `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). |
250+
| `start` / `end` | yes | Time range, same format as [`/inspect/entities`](#get-inspectentities). |
251+
| `step` | yes | One of `MINUTE` / `HOUR` / `DAY`. |
252+
| `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`). |
253+
254+
The metadata is overlaid **provide-if-absent** (the local catalog always wins) onto
255+
the same registries the engine already consults, so a foreign metric looks registered
256+
for the duration of the request: `ValueColumnMetadata` resolves its value column / type
257+
/ scope, and the storage location registries resolve its index / table / measure exactly
258+
as described in [Foreign metrics](#foreign-metrics-not-defined-on-this-oap). The overlay
259+
is request-scoped to the calling thread and removed when the read completes; the public
260+
query path never sets it.
261+
262+
Only scalar (`LONG` / `INT` / `DOUBLE`) and labeled (best-effort) value series are
263+
supported. An expression that resolves to `top_n` / records / heatmaps needs a local
264+
model and surfaces as an error. Under ES `logicSharding=true` a foreign value read is
265+
unsupported (the physical index derives from the metric's stream class), returning `500`.
266+
267+
Example — read the value series of `meter_custom_x`, defined on another OAP:
268+
269+
```bash
270+
curl -X POST 'http://oap-admin:17128/inspect/values' \
271+
-H 'Content-Type: application/json' \
272+
-d '{
273+
"expression": "meter_custom_x",
274+
"entity": { "scope": "Service", "serviceName": "payment", "normal": true },
275+
"start": "2026-05-10 1230", "end": "2026-05-10 1240", "step": "MINUTE",
276+
"foreignMetrics": [
277+
{ "name": "meter_custom_x", "valueColumn": "value", "valueType": "LONG" }
278+
]
279+
}'
280+
```
281+
282+
```json
283+
{
284+
"type": "TIME_SERIES_VALUES",
285+
"results": [
286+
{
287+
"metric": { "labels": [] },
288+
"values": [
289+
{ "id": "1778416200000", "value": "42" },
290+
{ "id": "1778416260000", "value": "42" }
291+
]
292+
}
293+
],
294+
"error": null
295+
}
296+
```
297+
230298
## Discovering the OAP REST URL for the MQE follow-up
231299

232300
To keep the surface minimal, the inspect API does not introduce a separate
@@ -248,6 +316,11 @@ session start is enough.
248316
| 400 | `{"error":"metric type SAMPLED_RECORD is out of scope for /inspect/entities"}` | Metric is `SAMPLED_RECORD`. |
249317
| 400 | `{"error":"process scope is out of scope"}` | Scope is `Process` / `ProcessRelation`. |
250318
| 400 | `{"error":"limit must be between 1 and 300"}` | `limit` out of range. |
319+
| 400 | `{"error":"foreignMetrics is required; a locally-defined metric should be queried via the public GraphQL execExpression"}` | `POST /inspect/values` body had no `foreignMetrics`. |
320+
| 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. |
321+
| 400 | `{"error":"valueColumn is invalid: …"}` | A `foreignMetrics` `valueColumn` is not a bare identifier. |
322+
| 400 | `{"error":"<MQE error>"}` | `POST /inspect/values` expression resolved to an unsupported shape (e.g. `top_n` / record / heatmap) for a foreign metric. |
323+
| 500 | `{"error":"<storage error>"}` | A wrong `valueColumn` / `valueType`, or ES `logicSharding=true`, surfaced at the storage layer during a value read. |
251324

252325
## Limits
253326

docs/en/setup/backend/admin-api/readme.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ Common operations:
8686
- `GET /inspect/metrics` — metric catalog with type / scope / supported downsamplings.
8787
- `GET /inspect/entities?metric=&start=&end=&step=` — capped (≤300) list of
8888
entities holding values, decoded into MQE-ready form.
89+
- `POST /inspect/values` — read the value series of a metric this OAP does not
90+
define locally (foreign metric), by supplying its `{valueColumn, valueType}`;
91+
returns the native MQE result.
8992

9093
Operator reference: [Inspect API](inspect.md).
9194

oap-server/server-admin/inspect/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@
3939
<artifactId>admin-server</artifactId>
4040
<version>${project.version}</version>
4141
</dependency>
42+
<!-- MQEExecutor: run the MQE engine synchronously for the foreign-metric value path. -->
43+
<dependency>
44+
<groupId>org.apache.skywalking</groupId>
45+
<artifactId>query-graphql-plugin</artifactId>
46+
<version>${project.version}</version>
47+
</dependency>
4248
<dependency>
4349
<groupId>org.junit.jupiter</groupId>
4450
<artifactId>junit-jupiter</artifactId>

oap-server/server-admin/inspect/src/main/java/org/apache/skywalking/oap/server/admin/inspect/handler/InspectRestHandler.java

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@
1818

1919
package org.apache.skywalking.oap.server.admin.inspect.handler;
2020

21+
import com.fasterxml.jackson.databind.ObjectMapper;
22+
import com.linecorp.armeria.common.HttpData;
2123
import com.linecorp.armeria.common.HttpResponse;
2224
import com.linecorp.armeria.common.HttpStatus;
2325
import com.linecorp.armeria.common.MediaType;
26+
import com.linecorp.armeria.server.annotation.Blocking;
2427
import com.linecorp.armeria.server.annotation.Get;
2528
import com.linecorp.armeria.server.annotation.Param;
29+
import com.linecorp.armeria.server.annotation.Post;
2630
import java.io.IOException;
2731
import java.util.ArrayList;
2832
import java.util.Collections;
@@ -36,7 +40,9 @@
3640
import java.util.regex.PatternSyntaxException;
3741
import java.util.stream.Collectors;
3842
import lombok.extern.slf4j.Slf4j;
43+
import org.apache.skywalking.oap.query.graphql.mqe.rt.MQEExecutor;
3944
import org.apache.skywalking.oap.server.admin.inspect.decoder.EntityDecoder;
45+
import org.apache.skywalking.oap.server.admin.inspect.request.InspectValuesRequest;
4046
import org.apache.skywalking.oap.server.admin.inspect.response.EntitiesResponse;
4147
import org.apache.skywalking.oap.server.admin.inspect.response.EntityRow;
4248
import org.apache.skywalking.oap.server.admin.inspect.response.ErrorResponse;
@@ -51,10 +57,12 @@
5157
import org.apache.skywalking.oap.server.core.query.enumeration.Scope;
5258
import org.apache.skywalking.oap.server.core.query.enumeration.Step;
5359
import org.apache.skywalking.oap.server.core.query.input.Duration;
60+
import org.apache.skywalking.oap.server.core.query.mqe.ExpressionResult;
5461
import org.apache.skywalking.oap.server.core.query.type.Service;
5562
import org.apache.skywalking.oap.server.core.source.DefaultScopeDefine;
5663
import org.apache.skywalking.oap.server.core.storage.StorageModule;
5764
import org.apache.skywalking.oap.server.core.storage.annotation.Column;
65+
import org.apache.skywalking.oap.server.core.storage.annotation.ForeignMetricMeta;
5866
import org.apache.skywalking.oap.server.core.storage.annotation.ValueColumnMetadata;
5967
import org.apache.skywalking.oap.server.core.storage.model.IModelManager;
6068
import org.apache.skywalking.oap.server.core.storage.model.Model;
@@ -77,6 +85,9 @@ public class InspectRestHandler {
7785
/** Value types a caller may declare for a foreign (locally-undefined) metric. */
7886
private static final Set<String> ACCEPTED_FOREIGN_VALUE_TYPES =
7987
Set.of("LONG", "INT", "DOUBLE", "LABELED");
88+
/** A value column is interpolated into JDBC SQL on the read path; restrict it to a bare identifier. */
89+
private static final Pattern VALUE_COLUMN_PATTERN = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$");
90+
private static final ObjectMapper VALUES_MAPPER = new ObjectMapper();
8091

8192
private final ModuleManager moduleManager;
8293

@@ -398,6 +409,109 @@ private HttpResponse listForeignEntities(final String metric,
398409
return HttpResponse.ofJson(MediaType.JSON_UTF_8, body);
399410
}
400411

412+
/**
413+
* Read the VALUES of metric(s) persisted by another OAP that this node does not define. The body
414+
* carries an MQE expression plus, in {@code foreignMetrics}, the metadata for each foreign metric
415+
* it references (value column + type). The same MQE engine the public GraphQL surface uses is run
416+
* synchronously with that metadata overlaid PROVIDE-IF-ABSENT (the catalog always wins), returning
417+
* the native {@code ExpressionResult}. Marked {@code @Blocking}: the eval + storage read are
418+
* synchronous and must not run on the event loop. Only scalar (LONG/INT/DOUBLE) and labeled
419+
* (best-effort) value series are supported; {@code top_n} and record/heatmap shapes need a local
420+
* model and surface as an error.
421+
*/
422+
@Blocking
423+
@Post("/inspect/values")
424+
public HttpResponse listValues(final HttpData requestBody) {
425+
final InspectValuesRequest req;
426+
try {
427+
req = VALUES_MAPPER.readValue(requestBody.toStringUtf8(), InspectValuesRequest.class);
428+
} catch (Exception e) {
429+
return error(HttpStatus.BAD_REQUEST, "invalid request body: " + e.getMessage());
430+
}
431+
if (req.getExpression() == null || req.getExpression().isBlank()) {
432+
return error(HttpStatus.BAD_REQUEST, "expression is required");
433+
}
434+
if (req.getEntity() == null || req.getEntity().getScope() == null) {
435+
return error(HttpStatus.BAD_REQUEST, "entity (with a scope) is required");
436+
}
437+
// The scope alone is not enough: the entity must carry the name fields its scope needs
438+
// (e.g. serviceName + normal for Service), or buildId() yields a bogus id that the read
439+
// silently misses — surface that as a 400 instead of an empty 200.
440+
if (!req.getEntity().isValid()) {
441+
return error(HttpStatus.BAD_REQUEST,
442+
"entity is missing required fields for scope " + req.getEntity().getScope()
443+
+ " (Service needs serviceName + normal; ServiceInstance/Endpoint also need "
444+
+ "serviceInstanceName / endpointName)");
445+
}
446+
if (req.getForeignMetrics() == null || req.getForeignMetrics().isEmpty()) {
447+
return error(HttpStatus.BAD_REQUEST,
448+
"foreignMetrics is required; a locally-defined metric should be queried via the public "
449+
+ "GraphQL execExpression");
450+
}
451+
452+
final Step step;
453+
try {
454+
step = Step.valueOf(String.valueOf(req.getStep()).toUpperCase());
455+
} catch (Exception e) {
456+
return error(HttpStatus.BAD_REQUEST,
457+
"step must be one of MINUTE / HOUR / DAY (got " + req.getStep() + ")");
458+
}
459+
if (step == Step.SECOND) {
460+
return error(HttpStatus.BAD_REQUEST, "step must be one of MINUTE / HOUR / DAY (got SECOND)");
461+
}
462+
463+
final int scopeId = req.getEntity().getScope().getScopeId();
464+
final List<ForeignMetricMeta> foreign = new ArrayList<>();
465+
for (final InspectValuesRequest.ForeignMetricInput fm : req.getForeignMetrics()) {
466+
if (fm.getName() == null || fm.getName().isBlank()) {
467+
return error(HttpStatus.BAD_REQUEST, "each foreignMetrics entry needs a name");
468+
}
469+
if (fm.getValueColumn() == null || !VALUE_COLUMN_PATTERN.matcher(fm.getValueColumn()).matches()) {
470+
return error(HttpStatus.BAD_REQUEST, "valueColumn is invalid: " + fm.getValueColumn());
471+
}
472+
final String type = fm.getValueType() == null ? "" : fm.getValueType().toUpperCase();
473+
if (!ACCEPTED_FOREIGN_VALUE_TYPES.contains(type)) {
474+
return error(HttpStatus.BAD_REQUEST,
475+
"valueType must be one of LONG / INT / DOUBLE / LABELED (got " + fm.getValueType() + ")");
476+
}
477+
if (ValueColumnMetadata.INSTANCE.readValueColumnDefinition(fm.getName()).isPresent()) {
478+
return error(HttpStatus.BAD_REQUEST,
479+
"metric " + fm.getName() + " is defined locally; query it via the GraphQL "
480+
+ "execExpression and drop it from foreignMetrics");
481+
}
482+
foreign.add(new ForeignMetricMeta(fm.getName(), fm.getValueColumn(), type, scopeId, 0));
483+
}
484+
485+
final Duration duration = new Duration();
486+
duration.setStart(req.getStart());
487+
duration.setEnd(req.getEnd());
488+
duration.setStep(step);
489+
try {
490+
duration.getStartTimeBucket();
491+
duration.getEndTimeBucket();
492+
} catch (IllegalArgumentException | UnexpectedException e) {
493+
return error(HttpStatus.BAD_REQUEST,
494+
"start / end must follow the step's date format (DAY: yyyy-MM-dd, HOUR: yyyy-MM-dd HH, "
495+
+ "MINUTE: yyyy-MM-dd HHmm): " + e.getMessage());
496+
}
497+
498+
final ExpressionResult result;
499+
try {
500+
result = new MQEExecutor(moduleManager)
501+
.execute(req.getExpression(), req.getEntity(), duration, foreign);
502+
} catch (Exception e) {
503+
// Optimistic read: a foreign top_n / record shape, or a wrong valueColumn/valueType,
504+
// surfaces here rather than as garbage.
505+
log.warn("inspect values execute failed for expression={}", req.getExpression(), e);
506+
return error(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
507+
}
508+
if (result.getError() != null) {
509+
// e.g. an unsupported shape resolved to UNKNOWN — never put that on the wire as a 200.
510+
return error(HttpStatus.BAD_REQUEST, result.getError());
511+
}
512+
return HttpResponse.ofJson(MediaType.JSON_UTF_8, result);
513+
}
514+
401515
/**
402516
* Mirror of the {@code /inspect/entities} type acceptance set. Kept in one place so
403517
* the {@code mqeQueryable=true} filter on {@code /inspect/metrics} and the actual

0 commit comments

Comments
 (0)