Skip to content

Commit 92d3c00

Browse files
committed
feat(observability): add Prometheus metrics endpoint
Signed-off-by: Ho Lim <subhoya@gmail.com>
1 parent 6fb9754 commit 92d3c00

13 files changed

Lines changed: 1075 additions & 16 deletions

File tree

docs/monitoring/monitor-sandbox-activity.mdx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,38 @@ $$nemoclaw <name> logs --follow
5858
The `logs` command shows lifecycle and gateway output.
5959
It does not export the structured per-session agent state that OpenClaw stores under `.openclaw/agents/`.
6060

61+
## Export Prometheus Metrics
62+
63+
NemoClaw can expose lightweight Prometheus-format metrics for blueprint execution, API validation, and sandbox lifecycle operations.
64+
Metrics are disabled by default.
65+
66+
Set the following environment variable before starting the OpenClaw process that loads the NemoClaw plugin:
67+
68+
```bash
69+
export NEMOCLAW_METRICS_ENABLED=true
70+
```
71+
72+
The metrics endpoint listens on `127.0.0.1:9090` by default:
73+
74+
```bash
75+
curl http://127.0.0.1:9090/metrics
76+
```
77+
78+
<Warning title="Secure metrics access">
79+
The `/metrics` endpoint is unauthenticated. If `NEMOCLAW_METRICS_HOST` binds beyond loopback, any host or network that can reach `NEMOCLAW_METRICS_HOST:NEMOCLAW_METRICS_PORT/metrics` may scrape operational metadata about blueprint execution, API validation, and sandbox lifecycle activity. Prefer scraping over a secured network, restrict access with firewall rules, or keep `NEMOCLAW_METRICS_HOST` bound to loopback and expose `/metrics` through a secured proxy.
80+
</Warning>
81+
82+
Use `NEMOCLAW_METRICS_PORT` to select another port, or `NEMOCLAW_METRICS_HOST` to bind to a different interface when your deployment needs remote scraping.
83+
The endpoint serves only `/metrics`; other paths return `404`.
84+
85+
Example metric families include:
86+
87+
```text
88+
blueprint_execution_total{action="apply",profile="default",status="success"} 1
89+
api_validation_total{kind="endpoint_url",source="blueprint",status="success"} 1
90+
sandbox_lifecycle_total{operation="create",status="success"} 1
91+
```
92+
6193
## Inspect Agent Session State
6294

6395
OpenClaw stores structured session state inside the sandbox.

docs/reference/enterprise-readiness.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ Each row links to the deeper documentation and, where a concrete fix is in progr
7070
| Network policy and denial visibility | Supported with caveats | Live activity appears in `openshell term`; lifecycle and gateway output appear in `$$nemoclaw <sandbox> logs`. Denial log readability is being improved in [#4760](https://github.com/NVIDIA/NemoClaw/issues/4760). Default-policy gaps for plugin installs are tracked in [#4104](https://github.com/NVIDIA/NemoClaw/issues/4104) and [#4015](https://github.com/NVIDIA/NemoClaw/issues/4015), and a `policy-add` YAML defect in [#991](https://github.com/NVIDIA/NemoClaw/issues/991). |
7171
| Model and provider switching | Supported | Switch the active provider or model with the NemoClaw inference commands. Some changes rebuild the sandbox image. See [Switch Inference Providers](../inference/switch-inference-providers) and [Inference Options](../inference/inference-options). |
7272
| Multi-agent and multi-sandbox usage | Supported with caveats | Side-by-side sandboxes run on distinct names and dashboard ports, and each name maps to exactly one agent type. Known multi-instance issues include gateway-port collisions ([#5359](https://github.com/NVIDIA/NemoClaw/issues/5359)) and parallel inference routing fallback ([#5343](https://github.com/NVIDIA/NemoClaw/issues/5343)). A declarative multi-agent manifest is roadmap ([#2853](https://github.com/NVIDIA/NemoClaw/issues/2853)). |
73-
| Monitoring and health | Supported | Use `$$nemoclaw <sandbox> status`, `$$nemoclaw <sandbox> logs --follow`, and `openshell term`. See [Monitor Sandbox Activity](../monitoring/monitor-sandbox-activity). |
74-
| External telemetry and observability export | Roadmap-only | NemoClaw has no built-in metrics or trace export to external observability backends. An observability adapter plugin is tracked in [#3915](https://github.com/NVIDIA/NemoClaw/issues/3915). OpenShell emits structured platform logs (platform-owned). |
73+
| Monitoring and health | Supported | Use `$$nemoclaw <sandbox> status`, `$$nemoclaw <sandbox> logs --follow`, `openshell term`, and optional Prometheus-format metrics. See [Monitor Sandbox Activity](../monitoring/monitor-sandbox-activity). |
74+
| External telemetry and observability export | Supported with caveats | NemoClaw can expose an optional unauthenticated Prometheus-format `/metrics` endpoint for blueprint execution, API validation, and sandbox lifecycle metrics. Keep it bound to loopback or place it behind secured network controls. Trace export and broader observability adapters remain roadmap work tracked in [#3915](https://github.com/NVIDIA/NemoClaw/issues/3915). OpenShell emits structured platform logs (platform-owned). |
7575
| Audit and session records | Supported with caveats | OpenClaw stores per-session JSONL event logs you can export for audit or compliance review; Hermes stores its own runtime state. Export is manual per sandbox. See [Inspect Agent Session State](../monitoring/monitor-sandbox-activity#inspect-agent-session-state). |
7676
| Resource quotas | Supported with caveats | The entrypoint applies best-effort process and file-descriptor limits (`ulimit -u 512`, `ulimit -n 65536`). Set hard limits through the container runtime for fail-closed enforcement. See [Process Controls](../security/best-practices#process-controls). |
7777
| Cost and spend controls | Platform or partner-owned | Deny-by-default egress and routed inference reduce exfiltration and stray endpoints, but NemoClaw does not enforce per-token spend budgets. Set spend limits with your inference provider and monitor unattended agents. |
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import type fs from "node:fs";
6+
7+
interface FsEntry {
8+
type: "file" | "dir";
9+
content?: string;
10+
}
11+
12+
const store = new Map<string, FsEntry>();
13+
const mockExeca = vi.fn();
14+
15+
vi.mock("node:os", () => ({
16+
homedir: () => "/fakehome",
17+
}));
18+
19+
vi.mock("node:crypto", () => ({
20+
randomUUID: () => "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
21+
}));
22+
23+
vi.mock("node:fs", async (importOriginal) => {
24+
const original = await importOriginal<typeof fs>();
25+
return {
26+
...original,
27+
existsSync: (p: string) => store.has(p),
28+
mkdirSync: vi.fn((p: string) => {
29+
store.set(p, { type: "dir" });
30+
}),
31+
readFileSync: (p: string) => {
32+
const entry = store.get(p);
33+
return entry?.type === "file" ? (entry.content ?? "") : throwFsError(p);
34+
},
35+
writeFileSync: vi.fn((p: string, data: string) => {
36+
store.set(p, { type: "file", content: data });
37+
}),
38+
readdirSync: (p: string) => {
39+
const prefix = p.endsWith("/") ? p : `${p}/`;
40+
const entries = new Set<string>();
41+
for (const key of store.keys()) {
42+
const [first] = key.slice(prefix.length).split("/");
43+
key.startsWith(prefix) && first !== undefined && first !== "" && entries.add(first);
44+
}
45+
entries.size === 0 && !store.has(p) && throwFsError(p);
46+
return [...entries].sort();
47+
},
48+
};
49+
});
50+
51+
vi.mock("execa", () => ({
52+
execa: (...args: unknown[]) => mockExeca(...args),
53+
}));
54+
55+
vi.mock("./ssrf.js", () => ({
56+
validateEndpointUrl: vi.fn(async (url: string) => ({ url, pinnedUrl: url })),
57+
}));
58+
59+
const { validateEndpointUrl } = await import("./ssrf.js");
60+
const mockedValidateEndpoint = vi.mocked(validateEndpointUrl);
61+
const { metrics } = await import("../observability/metrics.js");
62+
const { actionApply, actionPlan } = await import("./runner.js");
63+
64+
const stdoutChunks: string[] = [];
65+
66+
function throwFsError(path: string): never {
67+
throw new Error(`ENOENT: ${path}`);
68+
}
69+
70+
function captureStdout(): void {
71+
vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
72+
stdoutChunks.push(String(chunk));
73+
return true;
74+
});
75+
}
76+
77+
function minimalBlueprint(): Record<string, unknown> {
78+
return {
79+
version: "1.0",
80+
components: {
81+
inference: {
82+
profiles: {
83+
default: {
84+
provider_type: "openai",
85+
provider_name: "my-provider",
86+
endpoint: "https://api.example.com/v1",
87+
model: "gpt-4",
88+
credential_env: "MY_API_KEY",
89+
},
90+
},
91+
},
92+
sandbox: {
93+
image: "openclaw",
94+
name: "test-sandbox",
95+
forward_ports: [18789],
96+
},
97+
policy: { additions: {} },
98+
},
99+
};
100+
}
101+
102+
describe("runner metrics", () => {
103+
beforeEach(() => {
104+
store.clear();
105+
stdoutChunks.length = 0;
106+
vi.clearAllMocks();
107+
vi.stubEnv("NEMOCLAW_METRICS_ENABLED", "true");
108+
metrics.reset();
109+
});
110+
111+
afterEach(() => {
112+
vi.restoreAllMocks();
113+
vi.unstubAllEnvs();
114+
metrics.reset();
115+
});
116+
117+
it("records blueprint and endpoint validation metrics for successful plans", async () => {
118+
captureStdout();
119+
mockExeca.mockResolvedValue({ exitCode: 0 });
120+
121+
await actionPlan("default", minimalBlueprint());
122+
123+
const output = metrics.renderPrometheus();
124+
expect(output).toContain(
125+
'blueprint_execution_total{action="plan",profile="default",status="success"} 1',
126+
);
127+
expect(output).toContain(
128+
'blueprint_execution_duration_seconds_count{action="plan",profile="default",status="success"} 1',
129+
);
130+
expect(output).toContain(
131+
'api_validation_total{kind="endpoint_url",source="blueprint",status="success"} 1',
132+
);
133+
});
134+
135+
it("records blueprint and endpoint validation metrics for failed plans", async () => {
136+
captureStdout();
137+
mockExeca.mockResolvedValue({ exitCode: 0 });
138+
mockedValidateEndpoint.mockRejectedValueOnce(new Error("SSRF blocked"));
139+
140+
await expect(actionPlan("default", minimalBlueprint())).rejects.toThrow("SSRF blocked");
141+
142+
const output = metrics.renderPrometheus();
143+
expect(output).toContain(
144+
'blueprint_execution_total{action="plan",profile="default",status="error"} 1',
145+
);
146+
expect(output).toContain(
147+
'api_validation_total{kind="endpoint_url",source="blueprint",status="error"} 1',
148+
);
149+
});
150+
151+
it("records sandbox lifecycle metrics during apply", async () => {
152+
captureStdout();
153+
mockExeca.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
154+
155+
await actionApply("default", minimalBlueprint());
156+
157+
const output = metrics.renderPrometheus();
158+
expect(output).toContain(
159+
'blueprint_execution_total{action="apply",profile="default",status="success"} 1',
160+
);
161+
expect(output).toContain('sandbox_lifecycle_total{operation="create",status="success"} 1');
162+
expect(output).toContain(
163+
'sandbox_lifecycle_duration_seconds_count{operation="create",status="success"} 1',
164+
);
165+
});
166+
});

nemoclaw/src/blueprint/runner.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,7 @@ describe("runner", () => {
620620
expect(plan.router.enabled).toBe(false);
621621
expect(plan.router.port).toBe(4000);
622622
});
623+
623624
});
624625

625626
describe("actionApply", () => {

nemoclaw/src/blueprint/runner.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { join, sep } from "node:path";
2020
import { execa } from "execa";
2121
import YAML from "yaml";
2222

23+
import { metrics } from "../observability/metrics.js";
2324
import { validateEndpointUrl } from "./ssrf.js";
2425
import { buildSubprocessEnv } from "../lib/subprocess-env.js";
2526
import { DASHBOARD_PORT } from "../lib/ports.js";
@@ -409,6 +410,15 @@ async function openshellAvailable(): Promise<boolean> {
409410
return result.exitCode === 0;
410411
}
411412

413+
async function validateEndpointForMetrics(
414+
endpointUrl: string,
415+
source: "override" | "blueprint",
416+
): ReturnType<typeof validateEndpointUrl> {
417+
return await metrics.observeOperation("api_validation", { kind: "endpoint_url", source }, () =>
418+
validateEndpointUrl(endpointUrl),
419+
);
420+
}
421+
412422
/**
413423
* Resolve inference config and sandbox config from a blueprint, applying
414424
* endpoint URL override and SSRF validation if provided.
@@ -431,7 +441,7 @@ async function resolveRunConfig(
431441

432442
let inferenceCfg = { ...inferenceProfiles[profile] };
433443
if (endpointUrl) {
434-
const validated = await validateEndpointUrl(endpointUrl);
444+
const validated = await validateEndpointForMetrics(endpointUrl, "override");
435445
// Use DNS-pinned URL for HTTP (full SSRF/rebinding protection). For HTTPS,
436446
// keep the original hostname — TLS certificate validation prevents rebinding
437447
// since the attacker cannot present a valid cert for the target.
@@ -441,7 +451,7 @@ async function resolveRunConfig(
441451

442452
// Validate the final endpoint (whether from CLI override or blueprint profile)
443453
if (inferenceCfg.endpoint) {
444-
const validated = await validateEndpointUrl(inferenceCfg.endpoint);
454+
const validated = await validateEndpointForMetrics(inferenceCfg.endpoint, "blueprint");
445455
const safe = inferenceCfg.endpoint.startsWith("https:") ? validated.url : validated.pinnedUrl;
446456
inferenceCfg = { ...inferenceCfg, endpoint: safe };
447457
}
@@ -655,6 +665,16 @@ export async function actionPlan(
655665
profile: string,
656666
blueprint: Blueprint,
657667
options?: { dryRun?: boolean; endpointUrl?: string },
668+
): Promise<RunPlan> {
669+
return await metrics.observeOperation("blueprint_execution", { action: "plan", profile }, () =>
670+
actionPlanImpl(profile, blueprint, options),
671+
);
672+
}
673+
674+
async function actionPlanImpl(
675+
profile: string,
676+
blueprint: Blueprint,
677+
options?: { dryRun?: boolean; endpointUrl?: string },
658678
): Promise<RunPlan> {
659679
const rid = emitRunId();
660680
progress(10, "Validating blueprint");
@@ -691,6 +711,16 @@ export async function actionApply(
691711
profile: string,
692712
blueprint: Blueprint,
693713
options?: { planPath?: string; endpointUrl?: string },
714+
): Promise<void> {
715+
await metrics.observeOperation("blueprint_execution", { action: "apply", profile }, () =>
716+
actionApplyImpl(profile, blueprint, options),
717+
);
718+
}
719+
720+
async function actionApplyImpl(
721+
profile: string,
722+
blueprint: Blueprint,
723+
options?: { planPath?: string; endpointUrl?: string },
694724
): Promise<void> {
695725
if (options?.planPath) {
696726
throw new Error(
@@ -727,14 +757,16 @@ export async function actionApply(
727757
createArgs.push("--forward", String(port));
728758
}
729759

730-
const createResult = await runCmd(createArgs, { reject: false });
731-
if (createResult.exitCode !== 0) {
732-
if (createResult.stderr.includes("already exists")) {
733-
log(`Sandbox '${sandboxName}' already exists, reusing.`);
734-
} else {
735-
throw new Error(`Failed to create sandbox: ${createResult.stderr}`);
760+
await metrics.observeOperation("sandbox_lifecycle", { operation: "create" }, async () => {
761+
const createResult = await runCmd(createArgs, { reject: false });
762+
if (createResult.exitCode !== 0) {
763+
if (createResult.stderr.includes("already exists")) {
764+
log(`Sandbox '${sandboxName}' already exists, reusing.`);
765+
} else {
766+
throw new Error(`Failed to create sandbox: ${createResult.stderr}`);
767+
}
736768
}
737-
}
769+
});
738770

739771
progress(50, "Configuring inference provider");
740772
const providerName = inferenceCfg.provider_name ?? "default";

nemoclaw/src/index.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
describeOnboardProvider,
2020
loadOnboardConfig,
2121
} from "./onboard/config.js";
22+
import { isMetricsEnabled, metrics } from "./observability/metrics.js";
23+
import { startMetricsServer, type MetricsServer } from "./observability/server.js";
2224
import { registerRuntimeContext } from "./runtime-context.js";
2325
import { scanForSecrets, isMemoryPath } from "./security/secret-scanner.js";
2426
import { safeResolvePath } from "./security/safe-resolve-path.js";
@@ -352,7 +354,43 @@ export default function register(api: OpenClawPluginApi): void {
352354
handler: (ctx) => handleSlashCommand(ctx, api),
353355
});
354356

355-
// 2. Register nvidia-nim provider from the active OpenClaw config, falling
357+
// 2. Register optional Prometheus-compatible metrics endpoint (#233)
358+
if (isMetricsEnabled()) {
359+
let metricsServer: MetricsServer | undefined;
360+
api.registerService({
361+
id: "nemoclaw-metrics",
362+
start: async ({ logger }) => {
363+
try {
364+
metricsServer = await startMetricsServer({ registry: metrics, logger });
365+
} catch (error) {
366+
logger.warn(
367+
`[OBSERVABILITY] Could not start NemoClaw metrics endpoint: ${
368+
error instanceof Error ? error.message : String(error)
369+
}`,
370+
);
371+
}
372+
},
373+
stop: async ({ logger }) => {
374+
if (!metricsServer) {
375+
return;
376+
}
377+
try {
378+
await metricsServer.close();
379+
logger.info("NemoClaw metrics endpoint stopped");
380+
} catch (error) {
381+
logger.warn(
382+
`[OBSERVABILITY] Could not stop NemoClaw metrics endpoint cleanly: ${
383+
error instanceof Error ? error.message : String(error)
384+
}`,
385+
);
386+
} finally {
387+
metricsServer = undefined;
388+
}
389+
},
390+
});
391+
}
392+
393+
// 3. Register nvidia-nim provider from the active OpenClaw config, falling
356394
// back to the onboard snapshot and then the NemoClaw default.
357395
const onboardCfg = loadOnboardConfig();
358396
const activeModel = readOpenClawPrimaryModel(api.logger) || onboardCfg?.model || "";

0 commit comments

Comments
 (0)