This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 4822e9d1d4de1fa15bf19b6006761b300d920077 Author: Wu Sheng <[email protected]> AuthorDate: Tue May 12 09:54:13 2026 +0800 api-client: port wire types and rest clients --- packages/api-client/src/dsl-debugging.ts | 712 +++++++++++++++++++++++++++++++ packages/api-client/src/index.ts | 102 ++++- packages/api-client/src/inspect.ts | 370 ++++++++++++++++ packages/api-client/src/oal.ts | 182 ++++++++ packages/api-client/src/runtime-rule.ts | 244 +++++++++++ packages/api-client/src/status.ts | 90 ++++ packages/api-client/src/types.ts | 224 ++++++++++ 7 files changed, 1922 insertions(+), 2 deletions(-) diff --git a/packages/api-client/src/dsl-debugging.ts b/packages/api-client/src/dsl-debugging.ts new file mode 100644 index 0000000..1ff4f9c --- /dev/null +++ b/packages/api-client/src/dsl-debugging.ts @@ -0,0 +1,712 @@ +/* + * 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. + */ + +/** + * DSL live-debugger client — `/dsl-debugging/*` endpoints, typed against + * the **as-built** wire emitted by `DSLDebuggingRestHandler.java` on the + * `swip-13-dsl-debugger` branch. The wire shape was rewritten upstream + * (commits 4275f61df5 / 0e4058614c / 6f2db069a0): records now carry an + * envelope (`startedAtMs`, `dsl`, `rule`) plus a per-execution + * `samples[]` array, and the per-DSL stage vocabulary collapsed to five + * unified sample types (`input | filter | function | aggregation | + * output`). + * + * POST /dsl-debugging/session?catalog=&name=&ruleName=&clientId= + * [&granularity=] + * body (optional JSON): + * { recordCap?, retentionMillis?, + * granularity? } + * GET /dsl-debugging/session/{id} + * POST /dsl-debugging/session/{id}/stop + * GET /dsl-debugging/sessions — JSON object, not NDJSON + * GET /dsl-debugging/status — 5-field health snapshot + * + * Notable wire facts: + * - Inputs are query params; the optional JSON body carries + * `recordCap` / `retentionMillis` / `granularity` overrides. + * `granularity` query param wins over body. + * - The session response carries top-level `ruleKey` + `capturedAt`, + * per-record `startedAtMs` + `dsl` + `rule` envelope, and per-record + * `samples[]` whose entries discriminate via `type`. + * - Per-record `contentHash` was removed; the verbatim `dsl` string + * carries hot-update awareness instead. + * - `priorCleanup` is now a structured object `{local, peers[]}`, not a + * flat array. + * - Peer install ack values are UPPERCASE: `INSTALLED | NOT_LOCAL | + * FAILED`. + * - Session-start can fail with `cluster_view_split` (HTTP 421) when + * the cluster's view of the rule disagrees across nodes. + */ + +import { RuntimeRuleApiError, type ApplyResult } from './types.js'; +import type { FetchLike } from './runtime-rule.js'; + +// ── Catalogs ─────────────────────────────────────────────────────── + +/** Debug-session catalog — every wire name accepted by the handler. + * Matches `Catalog.java` enum's `wireName` values. */ +export type DebugCatalog = 'otel-rules' | 'log-mal-rules' | 'telegraf-rules' | 'lal' | 'oal'; + +export const DEBUG_CATALOGS: readonly DebugCatalog[] = [ + 'otel-rules', + 'log-mal-rules', + 'telegraf-rules', + 'lal', + 'oal', +] as const; + +export function isDebugCatalog(value: unknown): value is DebugCatalog { + return typeof value === 'string' && (DEBUG_CATALOGS as readonly string[]).includes(value); +} + +// ── Session start ────────────────────────────────────────────────── + +/** Query-param inputs (mandatory) for `POST /dsl-debugging/session`. */ +export interface StartSessionQuery { + /** Stable per-debug-context UUID. UI mints one per browser tab / + * debugger widget into sessionStorage and reuses it across polls. */ + clientId: string; + catalog: DebugCatalog; + /** For LAL: rule file name — the upstream allows either with or + * without an extension; runtime-rule-applied LAL uses the rule + * name directly. + * For MAL: the rule's `name` from `/runtime/rule/list`. + * For OAL: source class name (e.g. `Endpoint`, `ServiceRelation`). */ + name: string; + /** For LAL: the rule within the file (or the rule name for + * runtime-rule LAL). + * For MAL: the metric's full name (matches the holder lookup key). + * For OAL: the source class name (same value as `name`). */ + ruleName: string; + /** Per-DSL capture granularity. Currently only LAL distinguishes + * block vs statement; MAL/OAL ignore the flag server-side. + * Defaults to `block`. */ + granularity?: Granularity; +} + +/** Mirror of OAP's `SessionLimits.MAX_RECORD_CAP` — both default and + * hard ceiling for `recordCap` on `POST /dsl-debugging/session`. + * Studio's BFF rejects requests above this with `400 + * invalid_recordCap` before the OAP round-trip; UI inputs bound to + * the same value. Keep in sync with `SessionLimits.java` upstream. */ +export const MAX_RECORD_CAP = 100; + +/** Mirror of OAP's `SessionLimits.MAX_RETENTION_MILLIS` — 1 hour + * hard cap on per-session retention windows. */ +export const MAX_RETENTION_MILLIS = 60 * 60 * 1000; + +/** Optional JSON body for `POST /dsl-debugging/session`. */ +export interface StartSessionBody { + /** Default + hard cap 100 (`SessionLimits.MAX_RECORD_CAP`). Session + * moves to `captured` once this many records have been appended. + * OAP rejects `recordCap > 100` with `400 invalid_limits`; the BFF + * short-circuits the same way before the OAP round-trip. */ + recordCap?: number; + /** Default 5 min, max 1 h (3 600 000 ms). Wall-clock retention + * before the session is reaped. */ + retentionMillis?: number; + /** Body fallback for granularity — query param wins when set. */ + granularity?: Granularity; +} + +/** LAL-only knob — does the recorder emit per-statement records or + * just block-level ones. Server query param wins over body. */ +export type Granularity = 'block' | 'statement'; + +export const GRANULARITIES: readonly Granularity[] = ['block', 'statement'] as const; + +export function isGranularity(v: unknown): v is Granularity { + return v === 'block' || v === 'statement'; +} + +export type StartSessionArgs = StartSessionQuery & StartSessionBody; + +/** Wire-side ack from the `InstallDebugSession` fan-out. The first + * five come from the proto enum (`InstallState`); `FAILED` is + * appended by the receiving node when the peer call itself failed + * (timeout, RPC error). */ +export type PeerInstallAckState = + | 'INSTALLED' + | 'NOT_LOCAL' + | 'ALREADY_INSTALLED' + | 'REJECTED' + | 'TOO_MANY_SESSIONS' + | 'FAILED'; + +/** Per-peer install ack. Values are UPPERCASE in the wire. */ +export interface PeerInstallAck { + peer: string; + /** Set when the peer responded. */ + nodeId?: string; + ack: PeerInstallAckState; + detail?: string; +} + +/** At-a-glance "session live on N of M OAPs" summary attached to the + * start-session response. `total` counts the receiving node + every + * reachable peer; `created` counts those that returned `INSTALLED` + * or `ALREADY_INSTALLED`. Per-peer detail lives in `peers[]`. */ +export interface InstallSummary { + created: number; + total: number; +} + +/** Local node's prior-cleanup outcome — the broadcast also runs locally + * and the receiving node reports the count of prior sessions it + * terminated for the same `clientId`. */ +export interface LocalPriorCleanup { + nodeId: string; + stoppedCount: number; + stoppedSessionIds: string[]; +} + +/** Per-peer prior-cleanup outcome from the `StopByClientId` fan-out. */ +export interface PeerPriorCleanup { + peer: string; + /** Set on success. */ + nodeId?: string; + stoppedCount?: number; + stoppedSessionIds?: string[]; + /** Set instead of the success fields when the peer call failed. */ + ack?: 'failed'; + detail?: string; +} + +/** Cluster-wide prior-cleanup report. The local slice is always present; + * `peers[]` is one entry per known peer (may be empty in single-node). */ +export interface PriorCleanup { + local: LocalPriorCleanup; + peers: PeerPriorCleanup[]; +} + +export interface RuleKey { + catalog: DebugCatalog; + name: string; + ruleName: string; +} + +export interface StartSessionResponse { + sessionId: string; + clientId: string; + ruleKey: RuleKey; + /** Unix-ms. */ + createdAt: number; + /** Unix-ms. The session is reaped at this wall-clock. */ + retentionDeadline: number; + /** Echoed back from the request (or the server default `block`). */ + granularity: Granularity; + /** True when the receiving node had the rule loaded and bound a + * recorder locally. False on a router-only node where the install + * was a pure fan-out. */ + localInstalled: boolean; + /** "session live on N of M OAPs" rollup — `created` counts nodes + * whose ack was `INSTALLED` / `ALREADY_INSTALLED`; `total` is + * receiving node + every reachable peer (1 + peers.length). */ + installed: InstallSummary; + peers: PeerInstallAck[]; + priorCleanup: PriorCleanup; +} + +// ── Sample-level shape (the new per-execution capture) ───────────── + +/** The five unified sample types. The rule's static stage vocabulary + * (per-DSL block names like `meterEmit`, `extractor`, `aggregation`) + * collapsed into these on the wire — each DSL only emits a subset: + * + * - MAL: `input | filter | function | output` + * - LAL: `input | filter | function | output` + * - OAL: `input | filter | function | aggregation | output` */ +export type SampleType = 'input' | 'filter' | 'function' | 'aggregation' | 'output'; + +export const SAMPLE_TYPES: readonly SampleType[] = [ + 'input', + 'filter', + 'function', + 'aggregation', + 'output', +] as const; + +// ─── MAL payload shapes ──────────────────────────────────────────── + +/** A single MAL `Sample` row inside a SampleFamily — what the upstream + * ELSampleFamilyDebugDump.toJson surfaces per row. */ +export interface MalSampleRow { + name: string; + labels: Record<string, string>; + /** Numeric value as JSON number (Long / Double / counter). */ + value: number; + /** Unix-ms. */ + timestamp: number; +} + +/** MAL non-output payload — the captured `SampleFamily.toJson()`. + * `families` only appears on the file-level filter probe, which + * captures the full multi-family input map. The flat case (`samples` + + * `items`) is the per-stage shape every chain method emits. */ +export interface MalSamplesPayload { + /** Set on the file-level filter probe only — count of source + * families that fed this filter. */ + families?: number; + /** Sample-row count. `0` together with `empty: true` means the + * family was empty (probe still captured for visibility). */ + samples?: number; + empty?: boolean; + /** Either flat sample rows (chain-method probes) or nested + * per-family arrays (file-level filter probe). The recorder picks + * the shape based on which probe fired; clients must check. */ + items?: MalSampleRow[] | MalSamplesPayload[]; +} + +/** MAL `output`-type payload (the `appendMeterEmit` probe) — the + * materialised metric ready for L1 push. Per OAP's + * `MALDebugRecorderImpl.meterEmitPayload`: always `metric`, `entity`, + * `valueType`, `timeBucket`; `value` only when the holder type is + * recognised (LongValueHolder / IntValueHolder / DoubleValueHolder / + * LabeledValueHolder). Studio renders whatever's present and never + * infers from upstream stages. */ +export interface MalOutputPayload { + metric: string; + /** `MeterEntity#toString()` — operator-readable form of the entity + * the metric is bound to (scope, service / instance / endpoint + * names, layer, attrs). */ + entity: string; + /** Resolved meter function name (e.g. `sum`, `avg`, `histogram`, + * `avgLabeled`) — surfaced via the `@MeterFunction` annotation + * walk so the operator doesn't see the generated subclass name. */ + valueType: string; + /** Time bucket of the emit (yyyyMMddHHmm). */ + timeBucket: number; + /** Materialised reading. Three shapes per the recorder's holder + * switch: + * - `number` — scalar holders (Sum / Avg / Max / Min / Latest / + * SumPerMin / CPM …; finite doubles). + * - `string` — non-finite doubles serialised as `"NaN"`, + * `"Infinity"`, `"-Infinity"` so the wire stays valid JSON. + * - `Record<string, number>` — labeled metrics (`*Labeled`) and + * histogram-percentile functions emit a `DataTable`; keys are + * label combos for `*Labeled`, `p=<rank>` entries for percentile + * functions. */ + value?: number | string | Record<string, number>; +} + +// ─── LAL payload shapes ──────────────────────────────────────────── + +/** LAL `LogData.toJson()` — what the input probe sees on the way in. */ +export interface LalLogDataInput { + type: 'LogData'; + timestamp?: number; + service?: string; + serviceInstance?: string; + endpoint?: string; + layer?: string; + tags?: { key: string; value: string }[]; + body?: { + contentType?: string; + format?: 'TEXT' | 'YAML' | 'JSON'; + text?: string; + }; + /** Trace identifiers — only when the agent attached them. */ + traceId?: string; + segmentId?: string; + spanId?: number; + /** Open shape — Envoy access-log path materialises as `Message` / + * alternative inputs that the framework's typed dispatcher + * serialises. */ + [key: string]: unknown; +} + +/** LAL `Message`-class input (gRPC envelope). The framework's typed + * dispatcher captures whatever the rule's input type is — we model + * the open-payload case here. */ +export interface LalMessageInput { + type: 'Message'; + /** Class name + serialisable proto fields, captured opaquely. */ + [key: string]: unknown; +} + +/** Tagged-union LAL input. `[type=LogData]` is the common case. */ +export type LalInput = LalLogDataInput | LalMessageInput | { type: string; [k: string]: unknown }; + +/** LAL `LogBuilder.outputToJson()` — the DB-bound row the rule has + * built so far. Stable shape across stages because the builder is + * cached on `bindInput`. */ +export interface LalLogBuilderOutput { + type: 'LogBuilder'; + name?: string; + service?: string; + serviceInstance?: string; + endpoint?: string; + layer?: string; + traceId?: string; + segmentId?: string; + spanId?: number; + timestamp?: number; + contentType?: string; + content?: string; + /** Merged-tag view: `original` from input, `lal-added` from the + * rule, `lal-override` when the rule's key collides with an input + * tag (runtime concatenates rather than replaces). */ + tags?: LalLogBuilderTag[]; + /** Open shape — EnvoyAccessLogBuilder etc. add custom fields. */ + [key: string]: unknown; +} + +export interface LalLogBuilderTag { + key: string; + value: string; + status?: 'original' | 'lal-added' | 'lal-override'; +} + +/** LAL `Message`-typed builder (e.g. EnvoyAccessLogBuilder). */ +export interface LalMessageBuilderOutput { + type: string; + [key: string]: unknown; +} + +export type LalOutput = LalLogBuilderOutput | LalMessageBuilderOutput; + +/** LAL sample payload — every stage carries the same envelope. The + * input probe populates `input`; bindInput-onwards probes populate + * `output`. Either may be present (rare double-bind cases). */ +export interface LalSamplePayload { + /** True when the rule body called `abort()`. */ + aborted?: boolean; + /** True when `parsed` slots have been populated by parser probes. */ + hasParsed?: boolean; + /** Convenience list of keys the parser ran (extractor reads). */ + parsedKeys?: string[]; + input?: LalInput; + output?: LalOutput; +} + +// ─── OAL payload shapes ──────────────────────────────────────────── + +/** OAL input / filter payload — the source object's columns. The + * upstream recorder calls `source.toJson()`; the column set is per + * source class. */ +export interface OalSourcePayload { + type: string; + /** Column-bag — source-class-specific. The first row is `scope: + * number` (the OAL scope ordinal); other rows are operator-readable + * fields like `entityId`, `timeBucket`, `sourceServiceName`, etc. */ + fields: { scope?: number; entityId?: string; timeBucket?: number; [key: string]: unknown }; +} + +/** OAL function / aggregation / output payload — the materialised + * metric class's columns at this probe. The shape is `Metrics#toJson` + * so the field set is per-metric (CPM has `count/total/value`, + * histogram-style metrics have buckets, etc.). */ +export interface OalMetricsPayload { + type: string; + timeBucket?: number; + lastUpdateTimestamp?: number; + id?: string; + total?: number; + value?: number; + /** Open shape — per-metric extra columns (count, summation, + * histogram dataset, …). */ + [key: string]: unknown; +} + +/** Union of every per-DSL payload a sample's `payload` can carry. */ +export type SamplePayload = + | MalSamplesPayload + | MalOutputPayload + | LalSamplePayload + | OalSourcePayload + | OalMetricsPayload + | Record<string, unknown>; + +/** A single captured probe sample — one execution step of the DSL + * pipeline. Multiple samples sit inside one `SessionRecord` and + * represent the in-order trace of one rule execution. + * + * - `type`: the unified five-state lifecycle position. + * - `sourceText`: verbatim DSL fragment from ANTLR (or empty for + * LAL probes that don't correspond to a single text slice). + * - `continueOn`: did the pipeline continue past this probe? `false` + * on rejected filter branches, on `abort()` calls, on builder + * failures. + * - `payload`: per-DSL shape — see the per-DSL types above. + * - `sourceLine`: 1-based line number in the rule body. Omitted when + * 0 / not applicable (block-level LAL probes, MAL chain stages on + * a one-liner rule). */ +export interface SessionSample { + type: SampleType; + sourceText: string; + continueOn: boolean; + payload: SamplePayload; + sourceLine?: number; +} + +// ── Per-record envelope ──────────────────────────────────────────── + +/** Catalog-specific structured rule metadata. The recorder fills in + * whatever it has — common fields: + * - all DSLs: `ruleName` + * - OAL: `sourceLine` (the OAL source statement's line in the + * .oal file) + * - MAL: `metricPrefix`, `name`, `filter`, `exp`, `expSuffix` + * - LAL: `ruleName` only + * Open string-keyed bag — Studio reads selectively. */ +export interface SessionRecordRule { + ruleName: string; + sourceLine?: string; + /** Open: MAL emits multi-field rule metadata (filter, exp, …). */ + [key: string]: string | undefined; +} + +/** One captured execution of the rule. The verbatim `dsl` is the + * rule source as it stood at capture time — Studio renders this + * directly in the per-record card and the foldable LAL source pane, + * so hot-update edits show up record-by-record without an extra + * fetch (each record carries its own snapshot). */ +export interface SessionRecord { + /** Unix-ms when the execution started on the receiving node. */ + startedAtMs: number; + /** Verbatim rule source — multi-line, exactly as the holder owned + * it when this execution fired. */ + dsl: string; + rule: SessionRecordRule; + samples: SessionSample[]; +} + +// ── Per-node slice + session response ────────────────────────────── + +export type NodeStatus = 'ok' | 'captured' | 'not_local' | 'unreachable'; + +export interface NodeSlice { + /** Set on the local slice and on successful peer slices. */ + nodeId?: string; + /** Set on peer slices (the gRPC peer address); always present for + * unreachable peers, may also appear on healthy ones. */ + peer?: string; + status: NodeStatus; + captured?: boolean; + totalBytes?: number; + records: SessionRecord[]; + /** Set when `status === 'unreachable'`. */ + detail?: string; +} + +export interface SessionResponse { + sessionId: string; + /** Unix-ms when the receiving node snapshotted the slice. */ + capturedAt: number; + /** Echo of the install-time `RuleKey` — omitted when the local + * slice is null (post-stop polls / unknown session). */ + ruleKey?: RuleKey; + nodes: NodeSlice[]; +} + +// ── Stop / list / status ─────────────────────────────────────────── + +export interface StopPeerOutcome { + peer: string; + nodeId?: string; + stopped?: boolean; + ack?: 'failed'; + detail?: string; +} + +export interface StopSessionResponse { + sessionId: string; + localStopped: boolean; + peers: StopPeerOutcome[]; +} + +export interface ActiveSessionRow { + sessionId: string; + clientId: string; + ruleKey: RuleKey; + createdAt: number; + retentionDeadline: number; + captured: boolean; + totalBytes: number; +} + +export interface ActiveSessionsResponse { + sessions: ActiveSessionRow[]; + count: number; +} + +export interface DslDebuggingStatus { + module: string; + phase: string; + nodeId: string; + injectionEnabled: boolean; + activeSessions: number; +} + +// ── Error envelope ───────────────────────────────────────────────── + +/** Known `code` values the handler emits. Open string for forward + * compatibility — the server may extend this enum. */ +export type DebugErrorCode = + | 'injection_disabled' + | 'invalid_catalog' + | 'invalid_limits' + | 'invalid_granularity' + | 'missing_param' + | 'rule_not_found' + | 'session_not_found' + | 'registry_misconfigured' + | 'source_not_found' + | 'missing_source' + | 'too_many_sessions' + | 'cluster_view_split' + | (string & {}); + +export interface DebugErrorBody { + status: 'error'; + code: DebugErrorCode; + message: string; +} + +// ── Client ───────────────────────────────────────────────────────── + +export interface DslDebuggingClientOptions { + adminUrl: string; + fetch?: FetchLike; + headers?: Record<string, string>; + timeoutMs?: number; +} + +export class DslDebuggingClient { + private readonly fetchImpl: FetchLike; + private readonly base: string; + private readonly defaultHeaders: Record<string, string>; + private readonly timeoutMs: number; + + constructor(options: DslDebuggingClientOptions) { + this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis); + this.base = options.adminUrl.replace(/\/$/, ''); + this.defaultHeaders = options.headers ?? {}; + this.timeoutMs = options.timeoutMs ?? 0; + } + + /** `POST /dsl-debugging/session?catalog=&name=&ruleName=&clientId= + * [&granularity=]`, optional JSON body with `recordCap` / + * `retentionMillis` / `granularity`. The query param wins over the + * body for granularity (matches upstream resolution order). */ + async startSession(args: StartSessionArgs): Promise<StartSessionResponse> { + const params = new URLSearchParams({ + catalog: args.catalog, + name: args.name, + ruleName: args.ruleName, + clientId: args.clientId, + }); + if (args.granularity !== undefined) params.set('granularity', args.granularity); + const url = `${this.base}/dsl-debugging/session?${params.toString()}`; + const body: StartSessionBody = {}; + if (args.recordCap !== undefined) body.recordCap = args.recordCap; + if (args.retentionMillis !== undefined) body.retentionMillis = args.retentionMillis; + const hasBody = Object.keys(body).length > 0; + const init: RequestInit = { + method: 'POST', + ...(hasBody + ? { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + : {}), + }; + const res = await this.send(url, init); + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as StartSessionResponse; + } + + /** `GET /dsl-debugging/session/{id}`. Returns `null` on `404 + * session_not_found`; live polls return a normal envelope where + * the local slice may be `status: "not_local"` after a stop. */ + async getSession(id: string): Promise<SessionResponse | null> { + const url = `${this.base}/dsl-debugging/session/${encodeURIComponent(id)}`; + const res = await this.send(url, { method: 'GET' }); + if (res.status === 404) return null; + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as SessionResponse; + } + + /** `POST /dsl-debugging/session/{id}/stop` — idempotent. */ + async stopSession(id: string): Promise<StopSessionResponse> { + const url = `${this.base}/dsl-debugging/session/${encodeURIComponent(id)}/stop`; + const res = await this.send(url, { method: 'POST' }); + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as StopSessionResponse; + } + + /** `GET /dsl-debugging/sessions` — JSON object, not NDJSON. */ + async listSessions(): Promise<ActiveSessionsResponse> { + const url = `${this.base}/dsl-debugging/sessions`; + const res = await this.send(url, { method: 'GET' }); + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as ActiveSessionsResponse; + } + + /** `GET /dsl-debugging/status` — per-node 5-field health snapshot. */ + async getStatus(): Promise<DslDebuggingStatus> { + const url = `${this.base}/dsl-debugging/status`; + const res = await this.send(url, { method: 'GET' }); + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as DslDebuggingStatus; + } + + // ── private helpers ───────────────────────────────────────────── + + private async send(url: string, init: RequestInit): Promise<Response> { + const headers: Record<string, string> = { + Accept: 'application/json', + ...this.defaultHeaders, + ...((init.headers as Record<string, string>) ?? {}), + }; + const finalInit: RequestInit = { ...init, headers }; + if (this.timeoutMs > 0) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await this.fetchImpl(url, { ...finalInit, signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } + return this.fetchImpl(url, finalInit); + } + + private async toError(res: Response, url: string): Promise<RuntimeRuleApiError> { + const text = await res.text(); + let parsed: ApplyResult | string = text; + try { + const json = JSON.parse(text) as Record<string, unknown>; + // Accept either the legacy `{ applyStatus, message }` envelope + // (runtime-rule pipeline) or the `{ status, code, message }` + // envelope (dsl-debugging / runtime-oal). Downstream `outcomeOf` + // helpers switch on `code` or `applyStatus`. + if (typeof json.applyStatus === 'string' && typeof json.message === 'string') { + parsed = json as unknown as ApplyResult; + } else if ( + json.status === 'error' && + typeof json.code === 'string' && + typeof json.message === 'string' + ) { + parsed = json as unknown as ApplyResult; + } + } catch { + // not JSON; keep the raw text. + } + return new RuntimeRuleApiError(res.status, parsed, url); + } +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index f577a05..4913ff5 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -15,5 +15,103 @@ * limitations under the License. */ -// Stage 1.3 will populate this with REST clients ported from vantage-studio. -export {}; +export * from './types.js'; +export { + RuntimeRuleClient, + type RuntimeRuleClientOptions, + type AddOrUpdateArgs, + type GetRuleArgs, + type FetchLike, +} from './runtime-rule.js'; +export { StatusClient, type StatusClientOptions, type NormalisedClusterNode } from './status.js'; +export { + OalClient, + type OalClientOptions, + type OalFilesResponse, + type OalRulesResponse, + type OalSourceListing, + type OalSourceDetail, + type OalSourceMetricDetail, +} from './oal.js'; +export { + InspectClient, + InspectApiError, + INSPECT_STEPS, + INSPECT_ENTITY_LIMIT_MAX, + formatInspectDate, + isInspectDate, + type InspectClientOptions, + type InspectCatalog, + type InspectMetricType, + type InspectScope, + type InspectStep, + type ListMetricsArgs, + type ListEntitiesArgs, + type MetricRow, + type MetricsResponse, + type EntityRow, + type EntitiesResponse, + type MqeEntity, + type DecodedEntity, + type InspectErrorBody, + type ExpressionResultType, + type ExpressionResult, + type MqeValues, + type MqeValue, + type MqeMetadata, + type MqeKeyValue, + type MqeOwner, + type InspectExecRequest, +} from './inspect.js'; +export { + DslDebuggingClient, + DEBUG_CATALOGS, + GRANULARITIES, + SAMPLE_TYPES, + MAX_RECORD_CAP, + MAX_RETENTION_MILLIS, + isDebugCatalog, + isGranularity, + type DslDebuggingClientOptions, + type DebugCatalog, + type Granularity, + type StartSessionArgs, + type StartSessionQuery, + type StartSessionBody, + type StartSessionResponse, + type PeerInstallAck, + type PeerInstallAckState, + type InstallSummary, + type LocalPriorCleanup, + type PeerPriorCleanup, + type PriorCleanup, + type RuleKey, + type SampleType, + type SessionSample, + type SessionRecord, + type SessionRecordRule, + type SamplePayload, + type MalSampleRow, + type MalSamplesPayload, + type MalOutputPayload, + type LalSamplePayload, + type LalInput, + type LalLogDataInput, + type LalMessageInput, + type LalOutput, + type LalLogBuilderOutput, + type LalLogBuilderTag, + type LalMessageBuilderOutput, + type OalSourcePayload, + type OalMetricsPayload, + type SessionResponse, + type NodeSlice, + type NodeStatus, + type StopSessionResponse, + type StopPeerOutcome, + type ActiveSessionRow, + type ActiveSessionsResponse, + type DslDebuggingStatus, + type DebugErrorBody, + type DebugErrorCode, +} from './dsl-debugging.js'; diff --git a/packages/api-client/src/inspect.ts b/packages/api-client/src/inspect.ts new file mode 100644 index 0000000..18f08e4 --- /dev/null +++ b/packages/api-client/src/inspect.ts @@ -0,0 +1,370 @@ +/* + * 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. + */ + +/** + * SWIP-14 Inspect API — read-only catalog + entity enumeration on + * admin-server's port 17128. The endpoints are: + * + * GET /inspect/metrics + * GET /inspect/entities?metric=…&start=…&end=…&step=…&limit=… + * + * Both are admin-only and are off by default — the operator must set + * `SW_INSPECT=default` on OAP. Wire shapes match the implementation on + * `swip-14-inspect-api` (final, post-review-pass-2). See + * `oap-server/server-admin/inspect/src/main/java/.../response/`. + * + * The MQE values themselves live behind OAP's regular GraphQL + * `execExpression` surface; the Inspect API only returns the catalog + * and MQE-ready Entity shapes you can paste into that mutation. + */ + +import type { FetchLike } from './runtime-rule.js'; + +// ── Catalog / metrics ────────────────────────────────────────────── + +/** MetricsType emitted by OAP's `MetricsType.java`; values match + * `Column.ValueDataType` after the metadata mapping. */ +export type InspectMetricType = 'REGULAR_VALUE' | 'LABELED_VALUE' | 'HEATMAP' | 'SAMPLED_RECORD'; + +/** Catalog string from `DefaultScopeDefine` — uppercase, underscore- + * separated. Used for the `/inspect/metrics?catalog=` filter. */ +export type InspectCatalog = + | 'SERVICE' + | 'SERVICE_INSTANCE' + | 'ENDPOINT' + | 'SERVICE_RELATION' + | 'SERVICE_INSTANCE_RELATION' + | 'ENDPOINT_RELATION' + | (string & {}); + +/** Scope name (e.g. `Service`, `ServiceInstance`, `Endpoint`, + * `ServiceRelation`, `ServiceInstanceRelation`, `EndpointRelation`). + * Comes straight from `Scope.Finder.valueOf(scopeId).name()`. */ +export type InspectScope = + | 'Service' + | 'ServiceInstance' + | 'Endpoint' + | 'ServiceRelation' + | 'ServiceInstanceRelation' + | 'EndpointRelation' + | (string & {}); + +export type InspectStep = 'MINUTE' | 'HOUR' | 'DAY'; +export const INSPECT_STEPS: readonly InspectStep[] = ['MINUTE', 'HOUR', 'DAY'] as const; + +/** Server-side hard cap on `/inspect/entities?limit=`. */ +export const INSPECT_ENTITY_LIMIT_MAX = 300; + +export interface MetricRow { + name: string; + type: InspectMetricType; + catalog: InspectCatalog; + scopeId: number; + scope: InspectScope; + valueColumnName: string; + downsamplings: InspectStep[]; +} + +export interface MetricsResponse { + metrics: MetricRow[]; +} + +// ── Entities ─────────────────────────────────────────────────────── + +/** MQE Entity input shape — field names match SkyWalking's GraphQL + * `Entity` input verbatim so the operator can paste this block + * straight into a `mutation execExpression(…, entity: …)`. The OAP + * side serialises with `@JsonInclude(NON_NULL)` so any field that + * doesn't apply to the scope is omitted from the JSON. */ +export interface MqeEntity { + scope: InspectScope; + serviceName?: string; + normal?: boolean; + serviceInstanceName?: string; + endpointName?: string; + destServiceName?: string; + destNormal?: boolean; + destServiceInstanceName?: string; + destEndpointName?: string; +} + +/** Decoded entity-id payload — scope-dependent shape. For single + * scopes (`Service`, `ServiceInstance`, `Endpoint`) it carries + * service/instance/endpoint fields at the top level; for *Relation + * scopes it nests under `source` / `destination`. Modelled here as + * an open record because the JSON shape is fixed per scope but the + * union is wide. */ +export type DecodedEntity = Record<string, unknown>; + +export interface EntityRow { + entityId: string; + decoded: DecodedEntity; + /** Set for service-bearing rows; one row per registered Layer. + * Omitted (Java `null` → field absent thanks to `NON_NULL`) when + * the service is missing from the metadata cache. */ + layer?: string; + mqeEntity: MqeEntity; +} + +export interface EntitiesResponse { + metric: string; + scope: InspectScope; + step: InspectStep; + /** Echo of the `start` query param in the step-specific format. */ + start: string; + /** Echo of the `end` query param in the step-specific format. */ + end: string; + rows: EntityRow[]; +} + +// ── Request args ─────────────────────────────────────────────────── + +export interface ListMetricsArgs { + /** Java regex.Pattern over metric name. No filter when omitted. */ + regex?: string; + /** Repeatable; matches `MetricsType` enum names. */ + type?: InspectMetricType[]; + /** Repeatable; matches `DefaultScopeDefine` catalog names. */ + catalog?: InspectCatalog[]; + /** If true, narrows to MQE-queryable types (`REGULAR_VALUE` + + * `LABELED_VALUE`). */ + mqeQueryable?: boolean; +} + +export interface ListEntitiesArgs { + metric: string; + /** Date string per step format. Use `formatInspectDate(date, step)` + * to build it. */ + start: string; + end: string; + step: InspectStep; + /** 1–300, default 300 server-side. Studio defaults to a smaller + * number per widget. */ + limit?: number; +} + +// ── MQE result shape ─────────────────────────────────────────────── +// +// What `mutation execExpression(...)` returns on the public GraphQL +// surface — `data.execExpression`. Studio's BFF unwraps the GraphQL +// envelope and forwards this shape verbatim to the SPA. + +export type ExpressionResultType = + | 'UNKNOWN' + | 'SINGLE_VALUE' + | 'TIME_SERIES_VALUES' + | 'SORTED_LIST' + | 'RECORD_LIST'; + +export interface MqeOwner { + scope?: string | null; + serviceID?: string | null; + serviceName?: string | null; + normal?: boolean | null; + serviceInstanceID?: string | null; + serviceInstanceName?: string | null; + endpointID?: string | null; + endpointName?: string | null; +} + +export interface MqeKeyValue { + key: string; + value: string; +} + +export interface MqeMetadata { + labels: MqeKeyValue[]; +} + +export interface MqeValue { + id?: string | null; + owner?: MqeOwner | null; + /** Stringified number or `null` when absent. */ + value: string | null; + traceID?: string | null; +} + +export interface MqeValues { + metric: MqeMetadata; + values: MqeValue[]; +} + +export interface ExpressionResult { + type: ExpressionResultType; + results: MqeValues[]; + error?: string | null; +} + +/** Wire shape for Studio's `POST /api/inspect/exec`. The BFF will + * translate this into a GraphQL `mutation execExpression(...)` call + * against the resolved MQE base. */ +export interface InspectExecRequest { + expression: string; + entity: MqeEntity; + duration: { + start: string; + end: string; + step: InspectStep; + /** Cold-stage flag, BanyanDB-only. Default false. */ + coldStage?: boolean; + }; + /** Forwarded to GraphQL as `debug: Boolean`. Off by default. */ + debug?: boolean; +} + +// ── Date format ──────────────────────────────────────────────────── + +/** Format a `Date` into the date string OAP expects for the given + * step. Mirrors `Duration.getStartTimeBucket` / `getEndTimeBucket`'s + * accepted shapes: + * + * DAY → `yyyy-MM-dd` + * HOUR → `yyyy-MM-dd HH` + * MINUTE → `yyyy-MM-dd HHmm` + * + * All values are zero-padded; the date is interpreted in the OAP + * server's local timezone, so prefer feeding through `UTC` if your + * OAP is configured for UTC (the default for containerised deploys). + */ +export function formatInspectDate(d: Date, step: InspectStep): string { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + const day = String(d.getUTCDate()).padStart(2, '0'); + const date = `${y}-${m}-${day}`; + if (step === 'DAY') return date; + const h = String(d.getUTCHours()).padStart(2, '0'); + if (step === 'HOUR') return `${date} ${h}`; + const min = String(d.getUTCMinutes()).padStart(2, '0'); + return `${date} ${h}${min}`; +} + +/** True iff `s` parses as a valid date string for the given step. */ +export function isInspectDate(s: string, step: InspectStep): boolean { + if (step === 'DAY') return /^\d{4}-\d{2}-\d{2}$/.test(s); + if (step === 'HOUR') return /^\d{4}-\d{2}-\d{2} \d{2}$/.test(s); + return /^\d{4}-\d{2}-\d{2} \d{4}$/.test(s); +} + +// ── Errors ───────────────────────────────────────────────────────── + +/** OAP's inspect error envelope: `{ "error": "string" }`. */ +export interface InspectErrorBody { + error: string; +} + +export class InspectApiError extends Error { + constructor( + public readonly status: number, + public readonly body: InspectErrorBody | string, + public readonly url: string, + ) { + const detail = typeof body === 'string' ? body : body.error; + super(`${status} on ${url} — ${detail}`); + this.name = 'InspectApiError'; + } +} + +// ── Client ───────────────────────────────────────────────────────── + +export interface InspectClientOptions { + /** OAP admin port URL, e.g. `http://oap:17128`. No trailing slash. */ + adminUrl: string; + fetch?: FetchLike; + headers?: Record<string, string>; + /** Default per-call timeout in ms. `0` disables. */ + timeoutMs?: number; +} + +export class InspectClient { + private readonly fetchImpl: FetchLike; + private readonly base: string; + private readonly defaultHeaders: Record<string, string>; + private readonly timeoutMs: number; + + constructor(options: InspectClientOptions) { + this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis); + this.base = options.adminUrl.replace(/\/$/, ''); + this.defaultHeaders = options.headers ?? {}; + this.timeoutMs = options.timeoutMs ?? 0; + } + + /** `GET /inspect/metrics` with optional `regex` / `type` / `catalog` + * / `mqeQueryable` filters. Returns the full catalog when no + * filters are passed. */ + async listMetrics(args: ListMetricsArgs = {}): Promise<MetricsResponse> { + const params = new URLSearchParams(); + if (args.regex !== undefined) params.set('regex', args.regex); + if (args.mqeQueryable === true) params.set('mqeQueryable', 'true'); + for (const t of args.type ?? []) params.append('type', t); + for (const c of args.catalog ?? []) params.append('catalog', c); + const qs = params.toString(); + const url = `${this.base}/inspect/metrics${qs ? `?${qs}` : ''}`; + const res = await this.send(url, { method: 'GET' }); + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as MetricsResponse; + } + + /** `GET /inspect/entities` — `metric` + time range + step + limit. */ + async listEntities(args: ListEntitiesArgs): Promise<EntitiesResponse> { + const params = new URLSearchParams({ + metric: args.metric, + start: args.start, + end: args.end, + step: args.step, + }); + if (args.limit !== undefined) params.set('limit', String(args.limit)); + const url = `${this.base}/inspect/entities?${params.toString()}`; + const res = await this.send(url, { method: 'GET' }); + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as EntitiesResponse; + } + + // ── private helpers ───────────────────────────────────────────── + + private async send(url: string, init: RequestInit): Promise<Response> { + const headers: Record<string, string> = { + Accept: 'application/json', + ...this.defaultHeaders, + ...((init.headers as Record<string, string>) ?? {}), + }; + const finalInit: RequestInit = { ...init, headers }; + if (this.timeoutMs > 0) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await this.fetchImpl(url, { ...finalInit, signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } + return this.fetchImpl(url, finalInit); + } + + private async toError(res: Response, url: string): Promise<InspectApiError> { + const text = await res.text(); + let parsed: InspectErrorBody | string = text; + try { + const json = JSON.parse(text) as Record<string, unknown>; + if (typeof json.error === 'string') { + parsed = json as unknown as InspectErrorBody; + } + } catch { + // not JSON; keep the raw text. + } + return new InspectApiError(res.status, parsed, url); + } +} diff --git a/packages/api-client/src/oal.ts b/packages/api-client/src/oal.ts new file mode 100644 index 0000000..3b6609f --- /dev/null +++ b/packages/api-client/src/oal.ts @@ -0,0 +1,182 @@ +/* + * 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. + */ + +/** + * Read-only OAL listing — `GET /runtime/oal/*` introduced by SWIP-13. + * + * The actual implementation on the `swip-13-dsl-debugger` branch ships a + * **per-source-dispatcher** listing, not a per-rule one — the OAL debugger + * targets one source (e.g. `Endpoint`) and every metric routed off that + * source captures together. So the listing is shaped to match: one row per + * dispatcher, with the dispatcher's full metric set inline. + * + * File metadata is also intentionally minimal — `/files` returns just file + * names; `/files/{name}` returns the raw `.oal` text as `text/plain`. + * + * Read-only by design. OAL hot-update is upstream-deferred; the path + * prefix `/runtime/oal/*` is reserved for future write endpoints. + */ + +import { RuntimeRuleApiError, type ApplyResult } from './types.js'; +import type { FetchLike } from './runtime-rule.js'; + +export interface OalFilesResponse { + /** OAL file names (with extension), e.g. `core.oal`. */ + files: string[]; + count: number; +} + +/** One row per dispatcher in the running OAP. The OAL debugger targets a + * source — every metric on that source captures together. The listing + * endpoint emits metric names only; per-metric `status` requires the + * per-source detail call (`/runtime/oal/rules/{source}`). */ +export interface OalSourceListing { + /** OAL source class name without `Dispatcher` suffix, e.g. `Endpoint`. */ + source: string; + /** Fully-qualified dispatcher class name. */ + dispatcher: string; + /** Metric names routed off this source. */ + metrics: string[]; +} + +export interface OalRulesResponse { + sources: OalSourceListing[]; + count: number; +} + +/** Per-metric gate state from the per-source detail endpoint. + * `status: "live"` means the dispatcher's `DebugHolderProvider` has a + * holder ready and a session install on this metric will succeed. + * `no_holder` means the codegen knew the metric but no holder was + * bound (test mocks or dispatchers compiled before SWIP-13). */ +export interface OalSourceMetricDetail { + name: string; + status: 'live' | 'no_holder'; +} + +/** Per-source detail — same `source` + `dispatcher` as the listing, + * but `metrics` carries per-metric `{name, status}` rather than bare + * strings. The wire has no source-level `status`; the rollup is + * derived per metric. */ +export interface OalSourceDetail { + source: string; + dispatcher: string; + metrics: OalSourceMetricDetail[]; +} + +export interface OalClientOptions { + /** OAP admin-server URL, e.g. `http://oap:17128`. No trailing slash. */ + adminUrl: string; + fetch?: FetchLike; + headers?: Record<string, string>; + timeoutMs?: number; +} + +/** + * Typed wrapper for the four read-only OAL endpoints. + */ +export class OalClient { + private readonly fetchImpl: FetchLike; + private readonly base: string; + private readonly defaultHeaders: Record<string, string>; + private readonly timeoutMs: number; + + constructor(options: OalClientOptions) { + this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis); + this.base = options.adminUrl.replace(/\/$/, ''); + this.defaultHeaders = options.headers ?? {}; + this.timeoutMs = options.timeoutMs ?? 0; + } + + /** `GET /runtime/oal/files` — bare file-name listing. */ + async listFiles(): Promise<OalFilesResponse> { + const url = `${this.base}/runtime/oal/files`; + const res = await this.send(url, { method: 'GET' }); + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as OalFilesResponse; + } + + /** `GET /runtime/oal/files/{name}` — returns raw `.oal` text + * (`text/plain`). Returns `null` when the file isn't loaded. */ + async getFileContent(name: string): Promise<string | null> { + const url = `${this.base}/runtime/oal/files/${encodeURIComponent(name)}`; + const res = await this.send(url, { method: 'GET' }); + if (res.status === 404) return null; + if (!res.ok) throw await this.toError(res, url); + return await res.text(); + } + + /** `GET /runtime/oal/rules` — per-dispatcher listing for the picker. */ + async listSources(): Promise<OalRulesResponse> { + const url = `${this.base}/runtime/oal/rules`; + const res = await this.send(url, { method: 'GET' }); + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as OalRulesResponse; + } + + /** `GET /runtime/oal/rules/{source}` — single-source detail with + * holder-bound status. Path param is the source class name. Returns + * `null` when no dispatcher owns that source. */ + async getSource(source: string): Promise<OalSourceDetail | null> { + const url = `${this.base}/runtime/oal/rules/${encodeURIComponent(source)}`; + const res = await this.send(url, { method: 'GET' }); + if (res.status === 404) return null; + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as OalSourceDetail; + } + + // ── private helpers ───────────────────────────────────────────── + + private async send(url: string, init: RequestInit): Promise<Response> { + const headers: Record<string, string> = { + Accept: 'application/json, text/plain', + ...this.defaultHeaders, + ...((init.headers as Record<string, string>) ?? {}), + }; + const finalInit: RequestInit = { ...init, headers }; + if (this.timeoutMs > 0) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await this.fetchImpl(url, { ...finalInit, signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } + return this.fetchImpl(url, finalInit); + } + + private async toError(res: Response, url: string): Promise<RuntimeRuleApiError> { + const text = await res.text(); + let parsed: ApplyResult | string = text; + try { + const json = JSON.parse(text) as Record<string, unknown>; + if (typeof json.applyStatus === 'string' && typeof json.message === 'string') { + parsed = json as unknown as ApplyResult; + } else if ( + json.status === 'error' && + typeof json.code === 'string' && + typeof json.message === 'string' + ) { + parsed = json as unknown as ApplyResult; + } + } catch { + // not JSON; keep the raw text. + } + return new RuntimeRuleApiError(res.status, parsed, url); + } +} diff --git a/packages/api-client/src/runtime-rule.ts b/packages/api-client/src/runtime-rule.ts new file mode 100644 index 0000000..e8e3914 --- /dev/null +++ b/packages/api-client/src/runtime-rule.ts @@ -0,0 +1,244 @@ +/* + * 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. + */ + +import type { + ApplyResult, + BundledEntry, + Catalog, + DeleteMode, + ListEnvelope, + NotModified, + RuleResponse, + RuleSource, + RuleStatus, +} from './types.js'; +import { RuntimeRuleApiError } from './types.js'; + +export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>; + +export interface RuntimeRuleClientOptions { + /** OAP admin port URL, e.g. `http://oap:17128`. No trailing slash. */ + adminUrl: string; + /** Optional fetch implementation. Defaults to the global `fetch`. */ + fetch?: FetchLike; + /** Optional default headers (e.g. for a forward-RPC token). */ + headers?: Record<string, string>; + /** Default per-call timeout in ms. `0` disables. */ + timeoutMs?: number; +} + +export interface AddOrUpdateArgs { + catalog: Catalog; + name: string; + /** Raw YAML. */ + body: string; + /** Default false. Required when the edit moves storage identity. */ + allowStorageChange?: boolean; + /** Default false. Re-runs apply on byte-identical content; subsumes + * the prior `/fix` route. */ + force?: boolean; +} + +export interface GetRuleArgs { + catalog: Catalog; + name: string; + /** Default `runtime` — DAO row first, static fallback. `bundled` + * bypasses the DAO and returns only the static twin. */ + source?: RuleSource; + /** Conditional fetch — if the server's ETag matches, the client + * returns a {@link NotModified} sentinel and saves the body. */ + ifNoneMatch?: string; +} + +/** + * Typed wrapper for the eight runtime-rule REST endpoints v1 binds to. + * + * Each method speaks the canonical route — the `/runtime/mal/otel/...`, + * `/runtime/mal/log/...`, `/runtime/lal/...` shortcut variants are + * sugar for scripted ops and aren't surfaced here. + */ +export class RuntimeRuleClient { + private readonly fetchImpl: FetchLike; + private readonly base: string; + private readonly defaultHeaders: Record<string, string>; + private readonly timeoutMs: number; + + constructor(options: RuntimeRuleClientOptions) { + this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis); + this.base = options.adminUrl.replace(/\/$/, ''); + this.defaultHeaders = options.headers ?? {}; + this.timeoutMs = options.timeoutMs ?? 0; + } + + /** `GET /runtime/rule/list[?catalog=]` */ + async list(catalog?: Catalog): Promise<ListEnvelope> { + const url = this.url('/runtime/rule/list', catalog ? { catalog } : undefined); + const res = await this.send(url, { method: 'GET' }); + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as ListEnvelope; + } + + /** `GET /runtime/rule/bundled?catalog=[&withContent=]` */ + async listBundled(catalog: Catalog, withContent = true): Promise<BundledEntry[]> { + const url = this.url('/runtime/rule/bundled', { + catalog, + withContent: String(withContent), + }); + const res = await this.send(url, { method: 'GET' }); + if (!res.ok) throw await this.toError(res, url); + return (await res.json()) as BundledEntry[]; + } + + /** `GET /runtime/rule?catalog=&name=[&source=]`. Returns {@link + * NotModified} when the server replies 304 to a conditional fetch. */ + async get(args: GetRuleArgs): Promise<RuleResponse | NotModified> { + const params: Record<string, string> = { + catalog: args.catalog, + name: args.name, + }; + if (args.source) params.source = args.source; + const url = this.url('/runtime/rule', params); + + const headers: Record<string, string> = { Accept: 'application/x-yaml' }; + if (args.ifNoneMatch) headers['If-None-Match'] = args.ifNoneMatch; + + const res = await this.send(url, { method: 'GET', headers }); + + if (res.status === 304) { + return { + notModified: true, + etag: res.headers.get('ETag') ?? '', + contentHash: res.headers.get('X-Sw-Content-Hash') ?? '', + status: (res.headers.get('X-Sw-Status') as RuleStatus) ?? 'n/a', + }; + } + if (!res.ok) throw await this.toError(res, url); + + const body = await res.text(); + return { + status: (res.headers.get('X-Sw-Status') as RuleResponse['status']) ?? 'n/a', + source: (res.headers.get('X-Sw-Source') as RuleResponse['source']) ?? 'runtime', + contentHash: res.headers.get('X-Sw-Content-Hash') ?? '', + updateTime: Number(res.headers.get('X-Sw-Update-Time') ?? '0'), + etag: res.headers.get('ETag') ?? '', + content: body, + }; + } + + /** `POST /runtime/rule/addOrUpdate?catalog=&name=[&allowStorageChange=][&force=]` */ + async addOrUpdate(args: AddOrUpdateArgs): Promise<ApplyResult> { + const params: Record<string, string> = { + catalog: args.catalog, + name: args.name, + }; + if (args.allowStorageChange) params.allowStorageChange = 'true'; + if (args.force) params.force = 'true'; + const url = this.url('/runtime/rule/addOrUpdate', params); + + const res = await this.send(url, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: args.body, + }); + + return this.expectApplyResult(res, url); + } + + /** `POST /runtime/rule/inactivate?catalog=&name=` (Design A — see + * `reference_runtime_rule_api.md`). */ + async inactivate(catalog: Catalog, name: string): Promise<ApplyResult> { + const url = this.url('/runtime/rule/inactivate', { catalog, name }); + const res = await this.send(url, { method: 'POST' }); + return this.expectApplyResult(res, url); + } + + /** `POST /runtime/rule/delete?catalog=&name=[&mode=revertToBundled]`. + * The two-step gate is server-side: a 409 `requires_inactivate_first` + * is surfaced via {@link RuntimeRuleApiError}. */ + async delete(catalog: Catalog, name: string, mode: DeleteMode = ''): Promise<ApplyResult> { + const params: Record<string, string> = { catalog, name }; + if (mode) params.mode = mode; + const url = this.url('/runtime/rule/delete', params); + const res = await this.send(url, { method: 'POST' }); + return this.expectApplyResult(res, url); + } + + /** `GET /runtime/rule/dump` or `/dump/{catalog}` — streams `tar.gz`. */ + async dump(catalog?: Catalog): Promise<Response> { + const path = catalog + ? `/runtime/rule/dump/${encodeURIComponent(catalog)}` + : '/runtime/rule/dump'; + const url = this.url(path); + const res = await this.send(url, { method: 'GET' }); + if (!res.ok) throw await this.toError(res, url); + return res; + } + + // ─── private helpers ───────────────────────────────────────────── + + private url(path: string, params?: Record<string, string>): string { + const u = new URL(this.base + path); + if (params) { + for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v); + } + return u.toString(); + } + + private async send(url: string, init: RequestInit): Promise<Response> { + const headers: Record<string, string> = { + ...this.defaultHeaders, + ...((init.headers as Record<string, string>) ?? {}), + }; + const finalInit: RequestInit = { ...init, headers }; + if (this.timeoutMs > 0) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await this.fetchImpl(url, { ...finalInit, signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } + return this.fetchImpl(url, finalInit); + } + + /** Handle the apply-result-or-error response shape that addOrUpdate / + * inactivate / delete share. 2xx returns the parsed JSON; everything + * else throws a {@link RuntimeRuleApiError} with the parsed body so + * callers can switch on `applyStatus` (e.g. the 409 + * `storage_change_requires_explicit_approval` path). */ + private async expectApplyResult(res: Response, url: string): Promise<ApplyResult> { + if (res.status >= 200 && res.status < 300) { + return (await res.json()) as ApplyResult; + } + throw await this.toError(res, url); + } + + private async toError(res: Response, url: string): Promise<RuntimeRuleApiError> { + const text = await res.text(); + let parsed: ApplyResult | string = text; + try { + const json = JSON.parse(text) as Partial<ApplyResult>; + if (typeof json.applyStatus === 'string' && typeof json.message === 'string') { + parsed = json as ApplyResult; + } + } catch { + // not JSON; keep the raw text. + } + return new RuntimeRuleApiError(res.status, parsed, url); + } +} diff --git a/packages/api-client/src/status.ts b/packages/api-client/src/status.ts new file mode 100644 index 0000000..ae99aba --- /dev/null +++ b/packages/api-client/src/status.ts @@ -0,0 +1,90 @@ +/* + * 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. + */ + +import type { ClusterNode, ClusterNodesResponse } from './types.js'; +import type { FetchLike } from './runtime-rule.js'; + +export interface StatusClientOptions { + /** OAP query/status URL, default port 12800. No trailing slash. */ + statusUrl: string; + fetch?: FetchLike; + headers?: Record<string, string>; + /** Per-call timeout in ms. 0 disables. Default 0. */ + timeoutMs?: number; +} + +/** Cluster node with both wire spellings of the self-flag normalised + * into a single `self: boolean` field. */ +export interface NormalisedClusterNode { + host: string; + port: number; + self: boolean; +} + +/** + * Read-only client for the upstream `/status/*` plugin endpoints v1 + * needs. Today this is a single endpoint — cluster member discovery + * for the BFF's per-rule × per-node fan-out. + */ +export class StatusClient { + private readonly fetchImpl: FetchLike; + private readonly base: string; + private readonly defaultHeaders: Record<string, string>; + private readonly timeoutMs: number; + + constructor(options: StatusClientOptions) { + this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis); + this.base = options.statusUrl.replace(/\/$/, ''); + this.defaultHeaders = options.headers ?? {}; + this.timeoutMs = options.timeoutMs ?? 0; + } + + /** `GET /status/cluster/nodes` — returns the OAP cluster member list. */ + async clusterNodes(): Promise<NormalisedClusterNode[]> { + const url = `${this.base}/status/cluster/nodes`; + const init: RequestInit = { + method: 'GET', + headers: { Accept: 'application/json', ...this.defaultHeaders }, + }; + let res: Response; + if (this.timeoutMs > 0) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + res = await this.fetchImpl(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } else { + res = await this.fetchImpl(url, init); + } + if (!res.ok) { + const body = await res.text(); + throw new Error(`StatusClient.clusterNodes: ${res.status} on ${url} — ${body}`); + } + const json = (await res.json()) as ClusterNodesResponse; + return (json.nodes ?? []).map(normaliseNode); + } +} + +function normaliseNode(n: ClusterNode): NormalisedClusterNode { + return { + host: n.host, + port: n.port, + self: n.self ?? n.isSelf ?? false, + }; +} diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts new file mode 100644 index 0000000..b0dbb6e --- /dev/null +++ b/packages/api-client/src/types.ts @@ -0,0 +1,224 @@ +/* + * 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. + */ + +/** + * Wire types for the runtime-rule REST surface and the cluster-status + * query. Mirrors `reference_runtime_rule_api.md` in Claude memory; the + * authoritative source is the upstream + * `RuntimeRuleRestHandler.java` + `RuntimeRuleService.java` on + * `feature/runtime-rule-hot-update` (squashed tip in + * `~/github/skywalking`). + * + * v1 binds to MAL + LAL only. OAL is permanently excluded from + * hot-update. + */ + +// ── Catalogs ─────────────────────────────────────────────────────── + +export const CATALOGS = ['otel-rules', 'log-mal-rules', 'telegraf-rules', 'lal'] as const; +export type Catalog = (typeof CATALOGS)[number]; + +export function isCatalog(value: unknown): value is Catalog { + return typeof value === 'string' && (CATALOGS as readonly string[]).includes(value); +} + +// ── Status / loader enums ────────────────────────────────────────── + +export type RuleStatus = 'ACTIVE' | 'INACTIVE' | 'BUNDLED' | 'n/a'; +export type LocalState = 'RUNNING' | 'SUSPENDED' | 'NOT_LOADED'; +export type SuspendOrigin = 'NONE' | 'LOCAL_APPLY' | 'PEER_BROADCAST'; +export type LoaderGc = 'LIVE' | 'PENDING' | 'COLLECTED'; +export type LoaderKind = 'RUNTIME' | 'BUNDLED' | 'NONE'; + +// ── /runtime/rule/list ───────────────────────────────────────────── + +export interface LoaderStats { + active: number; + pending: number; +} + +interface ListRowBase { + catalog: Catalog; + name: string; + localState: LocalState; + loaderGc: LoaderGc; + loaderKind: LoaderKind; + /** Format: `<kind>:<catalog>/<rule>@<MMdd-HHmmss>`, e.g. + * `runtime:otel-rules/vm@0429-101900`. Empty string when no per-file + * loader exists (typical for bundled-only rules served from the + * default loader). */ + loaderName: string; + /** SHA-256 hex of the currently-served content. */ + contentHash: string; + /** True iff a static twin exists on disk for this `(catalog, name)`. */ + bundled: boolean; + /** SHA-256 hex of the static twin; absent when `bundled === false`. */ + bundledContentHash?: string; +} + +/** Operator-pushed runtime row. */ +export interface OperatorRow extends ListRowBase { + status: 'ACTIVE' | 'INACTIVE'; + suspendOrigin: SuspendOrigin; + updateTime: number; + lastApplyError: string; + pendingUnregister: false; +} + +/** Bundled-only row — shipped on disk, no operator override. */ +export interface BundledRow extends ListRowBase { + status: 'BUNDLED'; + pendingUnregister: false; +} + +/** Orphan row — DAO row was deleted, dslManager hasn't swept yet. + * Transient — gone within one tick. */ +export interface OrphanRow extends ListRowBase { + status: 'n/a'; + pendingUnregister: true; +} + +export type ListRow = OperatorRow | BundledRow | OrphanRow; + +export interface ListEnvelope { + generatedAt: number; + loaderStats: LoaderStats; + rules: ListRow[]; +} + +// ── /runtime/rule/bundled ────────────────────────────────────────── + +export interface BundledEntry { + name: string; + /** mal | lal — derived from catalog when relevant. */ + kind: string; + contentHash: string; + /** Present iff `withContent=true` was requested (the default). */ + content?: string; + /** True if a runtime override row exists for this name. */ + overridden: boolean; +} + +// ── /runtime/rule (single-rule fetch) ────────────────────────────── + +export type RuleSource = 'runtime' | 'bundled'; + +/** The JSON envelope returned when the client passes + * `Accept: application/json`. Default mode returns raw YAML; the + * client wrapper normalises both into a {@link RuleResponse}. */ +export interface RuleJsonEnvelope { + catalog: string; + name: string; + /** Note: legacy `STATIC` may appear on older OAP builds; new ones + * return `BUNDLED` consistently with `/list`. */ + status: RuleStatus | 'STATIC'; + source: 'runtime' | 'static'; + contentHash: string; + updateTime: number; + content: string; +} + +export interface RuleResponse { + status: RuleStatus | 'STATIC'; + source: 'runtime' | 'static'; + contentHash: string; + updateTime: number; + /** Echo of `ETag` response header, with surrounding quotes. */ + etag: string; + /** Raw YAML body. */ + content: string; +} + +/** Returned by `RuntimeRuleClient.get()` when the server replies `304 + * Not Modified` to a conditional request — the client did not waste + * bandwidth on an unchanged body. */ +export interface NotModified { + notModified: true; + etag: string; + contentHash: string; + status: RuleStatus | 'STATIC'; +} + +// ── /runtime/rule/addOrUpdate ────────────────────────────────────── + +/** + * Open-ended for forward compatibility: OAP may add new applyStatus + * codes. The values here are the ones documented today. + */ +export type ApplyStatus = + | 'no_change' + | 'filter_only_applied' + | 'structural_applied' + | 'persisted_apply_pending' + | 'compile_failed' + | 'empty_body' + | 'invalid_catalog' + | 'invalid_name' + | 'invalid_delete_mode' + | 'no_bundled_twin' + | 'storage_change_requires_explicit_approval' + | 'requires_inactivate_first' + | 'ddl_verify_failed' + | 'apply_failed' + | 'persist_failed'; + +export interface ApplyResult { + /** One of {@link ApplyStatus}; typed as `string` so a server-side + * addition doesn't break the client deserialiser. */ + applyStatus: ApplyStatus | (string & {}); + catalog: string; + name: string; + message: string; +} + +// ── /runtime/rule/delete ─────────────────────────────────────────── + +export const DELETE_MODES = ['', 'revertToBundled'] as const; +export type DeleteMode = (typeof DELETE_MODES)[number]; + +// ── /status/cluster/nodes (port 12800) ───────────────────────────── + +export interface ClusterNode { + host: string; + port: number; + /** One of these two carries the boolean — Java field is `isSelf` and + * Gson serialisation may emit either. The wrapper normalises to the + * `self` field on its public type. */ + self?: boolean; + isSelf?: boolean; +} + +export interface ClusterNodesResponse { + nodes: ClusterNode[]; +} + +// ── Errors ───────────────────────────────────────────────────────── + +/** Thrown by the client for any HTTP response outside the expected + * set. Exposes the parsed body so callers can switch on + * `applyStatus`. */ +export class RuntimeRuleApiError extends Error { + constructor( + public readonly status: number, + public readonly body: ApplyResult | string, + public readonly url: string, + ) { + const detail = typeof body === 'string' ? body : `${body.applyStatus}: ${body.message}`; + super(`${status} on ${url} — ${detail}`); + this.name = 'RuntimeRuleApiError'; + } +}
