blokctl 0.6.21 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/modular-observability.capstone.e2e.test.js +72 -0
- package/dist/commands/create/node.js +46 -66
- package/dist/commands/create/project.js +55 -9
- package/dist/commands/create/utils/Examples.d.ts +8 -20
- package/dist/commands/create/utils/Examples.js +138 -412
- package/dist/commands/dev/index.js +40 -1
- package/dist/commands/generate/NodeGenerator.d.ts +0 -2
- package/dist/commands/generate/NodeGenerator.js +0 -20
- package/dist/commands/generate/RuntimeGenerator.d.ts +0 -2
- package/dist/commands/generate/RuntimeGenerator.js +0 -19
- package/dist/commands/generate/RuntimeGenerator.test.js +0 -29
- package/dist/commands/generate/TriggerGenerator.d.ts +0 -2
- package/dist/commands/generate/TriggerGenerator.js +0 -19
- package/dist/commands/generate/WorkflowGenerator.d.ts +0 -2
- package/dist/commands/generate/WorkflowGenerator.js +0 -19
- package/dist/commands/generate/e2e/NodeGenerator.e2e.test.js +0 -12
- package/dist/commands/generate/e2e/RuntimeGenerator.e2e.test.js +0 -12
- package/dist/commands/generate/e2e/TriggerGenerator.e2e.test.js +0 -14
- package/dist/commands/monitor/monitor-component.js +5 -5
- package/dist/commands/observability/add.d.ts +2 -0
- package/dist/commands/observability/add.js +113 -0
- package/dist/commands/observability/alerting-module.test.js +43 -0
- package/dist/commands/observability/apply.d.ts +10 -0
- package/dist/commands/observability/apply.js +11 -0
- package/dist/commands/observability/descriptor.d.ts +37 -0
- package/dist/commands/observability/descriptor.js +203 -0
- package/dist/commands/observability/descriptor.test.d.ts +1 -0
- package/dist/commands/observability/descriptor.test.js +40 -0
- package/dist/commands/observability/index.d.ts +1 -0
- package/dist/commands/observability/index.js +53 -0
- package/dist/commands/observability/list.d.ts +2 -0
- package/dist/commands/observability/list.js +45 -0
- package/dist/commands/observability/logging-module.test.d.ts +1 -0
- package/dist/commands/observability/logging-module.test.js +43 -0
- package/dist/commands/observability/obs-stack-module.test.d.ts +1 -0
- package/dist/commands/observability/obs-stack-module.test.js +33 -0
- package/dist/commands/observability/remove.d.ts +2 -0
- package/dist/commands/observability/remove.js +62 -0
- package/dist/commands/observability/shared.d.ts +6 -0
- package/dist/commands/observability/shared.js +23 -0
- package/dist/commands/observability/status.d.ts +2 -0
- package/dist/commands/observability/status.js +36 -0
- package/dist/commands/observability/tracing-module.test.d.ts +1 -0
- package/dist/commands/observability/tracing-module.test.js +42 -0
- package/dist/commands/profile/index.js +7 -10
- package/dist/commands/watch/format.d.ts +23 -0
- package/dist/commands/watch/format.js +60 -0
- package/dist/commands/watch/index.d.ts +1 -0
- package/dist/commands/watch/index.js +53 -0
- package/dist/commands/watch/sse.d.ts +16 -0
- package/dist/commands/watch/sse.js +82 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/services/obs-setup.d.ts +5 -0
- package/dist/services/obs-setup.js +68 -0
- package/dist/services/obs-setup.test.d.ts +1 -0
- package/dist/services/obs-setup.test.js +71 -0
- package/dist/services/obs-tiers.d.ts +9 -0
- package/dist/services/obs-tiers.js +16 -0
- package/dist/services/observability-mutations.d.ts +4 -0
- package/dist/services/observability-mutations.js +46 -0
- package/dist/services/observability-mutations.test.d.ts +1 -0
- package/dist/services/observability-mutations.test.js +57 -0
- package/dist/services/runtime-setup.d.ts +12 -1
- package/dist/services/runtime-setup.js +274 -14
- package/dist/studio-dist/assets/{index-BD8_9YPN.js → index-CnFqCRQe.js} +17 -17
- package/dist/studio-dist/index.html +1 -1
- package/package.json +3 -3
- package/dist/commands/generate/GenerationAnalytics.d.ts +0 -61
- package/dist/commands/generate/GenerationAnalytics.js +0 -163
- package/dist/commands/generate/GenerationAnalytics.test.js +0 -407
- package/dist/commands/generate/PromptVersioning.d.ts +0 -25
- package/dist/commands/generate/PromptVersioning.js +0 -71
- package/dist/commands/generate/PromptVersioning.test.js +0 -120
- /package/dist/{commands/generate/GenerationAnalytics.test.d.ts → __tests__/modular-observability.capstone.e2e.test.d.ts} +0 -0
- /package/dist/commands/{generate/PromptVersioning.test.d.ts → observability/alerting-module.test.d.ts} +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { getObservabilityModule } from "./descriptor.js";
|
|
6
|
+
const tracing = getObservabilityModule("tracing");
|
|
7
|
+
describe("tracing module (MO-TRACING)", () => {
|
|
8
|
+
it("ships inert — every OTEL_EXPORTER_OTLP_ENDPOINT line is commented out", () => {
|
|
9
|
+
const env = tracing.envBlock({ projectDir: "/tmp/x" });
|
|
10
|
+
expect(env).toContain("OTEL_EXPORTER_OTLP_ENDPOINT");
|
|
11
|
+
for (const line of env.split("\n")) {
|
|
12
|
+
if (/OTEL_EXPORTER_OTLP_ENDPOINT=/.test(line))
|
|
13
|
+
expect(line.trim().startsWith("#")).toBe(true);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
it("declares tempo as its compose service, with no deps + no infra files", () => {
|
|
17
|
+
expect(tracing.composeServices).toEqual(["tempo"]);
|
|
18
|
+
expect(tracing.packageDeps).toEqual({});
|
|
19
|
+
expect(tracing.infraFiles).toEqual([]);
|
|
20
|
+
expect(tracing.dependencies).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
describe("verify()", () => {
|
|
23
|
+
let tmp;
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "blok-tracing-"));
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
it("reports inert when the endpoint is unset/commented", async () => {
|
|
31
|
+
fs.writeFileSync(path.join(tmp, ".env.local"), "PORT=4000\n# OTEL_EXPORTER_OTLP_ENDPOINT=http://x\n");
|
|
32
|
+
const r = await tracing.verify?.(tmp);
|
|
33
|
+
expect(r?.ok).toBe(true);
|
|
34
|
+
expect(r?.message).toMatch(/inert/);
|
|
35
|
+
});
|
|
36
|
+
it("reports active when the endpoint is uncommented", async () => {
|
|
37
|
+
fs.writeFileSync(path.join(tmp, ".env.local"), "OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4318\n");
|
|
38
|
+
const r = await tracing.verify?.(tmp);
|
|
39
|
+
expect(r?.message).toContain("exporting spans to http://tempo:4318");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -43,19 +43,16 @@ program
|
|
|
43
43
|
const host = options.host || "http://localhost:9090";
|
|
44
44
|
const token = options.token;
|
|
45
45
|
const topN = Number.parseInt(options.top, 10) || 10;
|
|
46
|
-
const [nodeTimeResults,
|
|
47
|
-
queryPrometheus("
|
|
48
|
-
queryPrometheus("
|
|
49
|
-
queryPrometheus("node_memory", host, token),
|
|
50
|
-
queryPrometheus("node_cpu", host, token),
|
|
51
|
-
queryPrometheus("node_errors_total", host, token),
|
|
46
|
+
const [nodeTimeResults, nodeMemResults] = await Promise.all([
|
|
47
|
+
queryPrometheus("(blok_node_duration_seconds_sum / blok_node_duration_seconds_count) * 1000", host, token),
|
|
48
|
+
queryPrometheus("blok_node_memory_bytes / 1000000", host, token),
|
|
52
49
|
]);
|
|
53
50
|
const PerformanceProfiler = await getPerformanceProfiler();
|
|
54
51
|
const profiler = new PerformanceProfiler({ topN });
|
|
55
52
|
let hasData = false;
|
|
56
53
|
for (const result of nodeTimeResults) {
|
|
57
|
-
const wf = result.metric.workflow || "unknown";
|
|
58
|
-
const node = result.metric.node || result.metric.name || "unknown";
|
|
54
|
+
const wf = result.metric.workflow_name || result.metric.workflow || "unknown";
|
|
55
|
+
const node = result.metric.node_name || result.metric.node || result.metric.name || "unknown";
|
|
59
56
|
if (workflowName && wf !== workflowName)
|
|
60
57
|
continue;
|
|
61
58
|
const timeMs = Number.parseFloat(result.value[1]) || 0;
|
|
@@ -65,8 +62,8 @@ program
|
|
|
65
62
|
}
|
|
66
63
|
}
|
|
67
64
|
for (const result of nodeMemResults) {
|
|
68
|
-
const wf = result.metric.workflow || "unknown";
|
|
69
|
-
const node = result.metric.node || result.metric.name || "unknown";
|
|
65
|
+
const wf = result.metric.workflow_name || result.metric.workflow || "unknown";
|
|
66
|
+
const node = result.metric.node_name || result.metric.node || result.metric.name || "unknown";
|
|
70
67
|
if (workflowName && wf !== workflowName)
|
|
71
68
|
continue;
|
|
72
69
|
const memMb = Number.parseFloat(result.value[1]) || 0;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface WatchRunEvent {
|
|
2
|
+
id: string;
|
|
3
|
+
type: string;
|
|
4
|
+
runId: string;
|
|
5
|
+
workflowName: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
nodeName?: string;
|
|
8
|
+
nodeId?: string;
|
|
9
|
+
payload?: {
|
|
10
|
+
durationMs?: number;
|
|
11
|
+
error?: {
|
|
12
|
+
message?: string;
|
|
13
|
+
code?: number | string;
|
|
14
|
+
} | unknown;
|
|
15
|
+
reason?: string;
|
|
16
|
+
[k: string]: unknown;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface FormatOptions {
|
|
20
|
+
color?: boolean;
|
|
21
|
+
verbose?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function formatEvent(ev: WatchRunEvent, opts?: FormatOptions): string | null;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createColors } from "picocolors";
|
|
2
|
+
const NODE_INDENT = " ";
|
|
3
|
+
function shortRun(runId) {
|
|
4
|
+
return runId.length > 12 ? runId.slice(0, 12) : runId;
|
|
5
|
+
}
|
|
6
|
+
function durationMs(payload) {
|
|
7
|
+
const d = payload?.durationMs;
|
|
8
|
+
return typeof d === "number" && Number.isFinite(d) ? `${Math.round(d)}ms` : "";
|
|
9
|
+
}
|
|
10
|
+
function errorText(payload) {
|
|
11
|
+
const e = payload?.error;
|
|
12
|
+
if (!e || typeof e !== "object")
|
|
13
|
+
return "";
|
|
14
|
+
const code = e.code !== undefined && e.code !== null ? `${e.code} ` : "";
|
|
15
|
+
return `${code}${e.message ?? "error"}`.trim();
|
|
16
|
+
}
|
|
17
|
+
export function formatEvent(ev, opts = {}) {
|
|
18
|
+
const c = createColors(opts.color ?? true);
|
|
19
|
+
const verbose = opts.verbose ?? false;
|
|
20
|
+
const run = c.dim(shortRun(ev.runId));
|
|
21
|
+
const wf = c.bold(ev.workflowName || "(workflow)");
|
|
22
|
+
const node = ev.nodeName ?? "";
|
|
23
|
+
const ms = durationMs(ev.payload);
|
|
24
|
+
const err = errorText(ev.payload);
|
|
25
|
+
switch (ev.type) {
|
|
26
|
+
case "RUN_STARTED":
|
|
27
|
+
return `${c.cyan("▶")} ${wf} ${run} ${c.dim("started")}`;
|
|
28
|
+
case "NODE_STARTED":
|
|
29
|
+
return verbose ? `${NODE_INDENT}${c.dim("·")} ${node} ${c.dim("…")}` : null;
|
|
30
|
+
case "NODE_COMPLETED":
|
|
31
|
+
return `${NODE_INDENT}${c.green("✓")} ${node}${ms ? ` ${c.dim(ms)}` : ""}`;
|
|
32
|
+
case "NODE_CACHED":
|
|
33
|
+
return `${NODE_INDENT}${c.blue("◆")} ${node} ${c.dim("cached")}`;
|
|
34
|
+
case "NODE_SKIPPED":
|
|
35
|
+
return verbose ? `${NODE_INDENT}${c.dim("→")} ${node} ${c.dim("skipped")}` : null;
|
|
36
|
+
case "NODE_ATTEMPT_FAILED":
|
|
37
|
+
return `${NODE_INDENT}${c.yellow("↻")} ${node} ${c.yellow("attempt failed")}${err ? ` ${c.dim(err)}` : ""}`;
|
|
38
|
+
case "NODE_FAILED":
|
|
39
|
+
return `${NODE_INDENT}${c.red("✗")} ${node} ${c.red("FAILED")}${err ? ` ${c.dim(err)}` : ""}`;
|
|
40
|
+
case "RUN_COMPLETED":
|
|
41
|
+
return `${c.green("■")} ${wf} ${run} ${c.green("completed")}${ms ? ` ${c.dim(`(${ms})`)}` : ""}`;
|
|
42
|
+
case "RUN_FAILED":
|
|
43
|
+
return `${c.red("■")} ${wf} ${run} ${c.red("FAILED")}${ms ? ` ${c.dim(`(${ms})`)}` : ""}${err ? ` ${c.red(`· ${err}`)}` : ""}`;
|
|
44
|
+
case "RUN_CRASHED":
|
|
45
|
+
return `${c.red("■")} ${wf} ${run} ${c.red("CRASHED")}${err ? ` ${c.red(`· ${err}`)}` : ""}`;
|
|
46
|
+
case "RUN_TIMED_OUT":
|
|
47
|
+
return `${c.red("■")} ${wf} ${run} ${c.red("TIMED OUT")}${ms ? ` ${c.dim(`(${ms})`)}` : ""}`;
|
|
48
|
+
case "RUN_CANCELLED":
|
|
49
|
+
return `${c.yellow("■")} ${wf} ${run} ${c.yellow("cancelled")}`;
|
|
50
|
+
case "RUN_THROTTLED":
|
|
51
|
+
return `${c.yellow("■")} ${wf} ${run} ${c.yellow("throttled")}`;
|
|
52
|
+
case "RUN_QUEUED":
|
|
53
|
+
case "RUN_DELAYED":
|
|
54
|
+
case "RUN_DEBOUNCED":
|
|
55
|
+
case "RUN_EXPIRED":
|
|
56
|
+
return verbose ? `${c.dim("·")} ${wf} ${run} ${c.dim(ev.type.replace("RUN_", "").toLowerCase())}` : null;
|
|
57
|
+
default:
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import color from "picocolors";
|
|
2
|
+
import { program, trackCommandExecution } from "../../services/commander.js";
|
|
3
|
+
import { tokenManager } from "../../services/local-token-manager.js";
|
|
4
|
+
import { formatEvent } from "./format.js";
|
|
5
|
+
import { connectEventStream } from "./sse.js";
|
|
6
|
+
program
|
|
7
|
+
.command("watch")
|
|
8
|
+
.description("Watch workflow executions live in the terminal (streams /__blok/stream)")
|
|
9
|
+
.option("-u, --url <url>", "Blok backend URL", "http://localhost:4000")
|
|
10
|
+
.option("--token <token>", "Auth token for the trace API (required in production)")
|
|
11
|
+
.option("-w, --workflow <names>", "Comma-separated workflow names to watch (default: all)")
|
|
12
|
+
.option("--verbose", "Also show node-started / skipped / scheduling events")
|
|
13
|
+
.option("--no-color", "Disable ANSI colors (pipe-friendly)")
|
|
14
|
+
.action(async (options) => {
|
|
15
|
+
await trackCommandExecution({
|
|
16
|
+
command: "watch",
|
|
17
|
+
args: options,
|
|
18
|
+
execution: async () => {
|
|
19
|
+
const url = options.url || "http://localhost:4000";
|
|
20
|
+
const token = options.token ?? tokenManager.getToken() ?? undefined;
|
|
21
|
+
const useColor = options.color !== false;
|
|
22
|
+
const verbose = Boolean(options.verbose);
|
|
23
|
+
const workflows = options.workflow
|
|
24
|
+
? String(options.workflow)
|
|
25
|
+
.split(",")
|
|
26
|
+
.map((s) => s.trim())
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
: undefined;
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const stop = (code) => {
|
|
31
|
+
controller.abort();
|
|
32
|
+
process.stdout.write("\n");
|
|
33
|
+
process.exit(code);
|
|
34
|
+
};
|
|
35
|
+
process.once("SIGINT", () => stop(0));
|
|
36
|
+
process.once("SIGTERM", () => stop(0));
|
|
37
|
+
const where = workflows ? ` (workflows: ${workflows.join(", ")})` : "";
|
|
38
|
+
process.stdout.write(color.dim(`Watching ${url}/__blok/stream${where} — Ctrl-C to stop\n\n`));
|
|
39
|
+
await connectEventStream(url, { token, workflows, signal: controller.signal }, {
|
|
40
|
+
onEvent: (event) => {
|
|
41
|
+
const line = formatEvent(event, { color: useColor, verbose });
|
|
42
|
+
if (line)
|
|
43
|
+
process.stdout.write(`${line}\n`);
|
|
44
|
+
},
|
|
45
|
+
onError: (err) => {
|
|
46
|
+
process.stderr.write(color.red(`\nstream error: ${err.message}\n`));
|
|
47
|
+
process.stderr.write(color.dim(`Is a Blok server running at ${url}? In production the trace API requires auth — pass --token.\n`));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { WatchRunEvent } from "./format.js";
|
|
2
|
+
export interface ConnectOptions {
|
|
3
|
+
token?: string;
|
|
4
|
+
workflows?: string[];
|
|
5
|
+
signal?: AbortSignal;
|
|
6
|
+
}
|
|
7
|
+
export interface StreamHandlers {
|
|
8
|
+
onOpen?: () => void;
|
|
9
|
+
onEvent: (event: WatchRunEvent) => void;
|
|
10
|
+
onError?: (error: Error) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function parseSseBuffer(buffer: string): {
|
|
13
|
+
events: WatchRunEvent[];
|
|
14
|
+
rest: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function connectEventStream(baseUrl: string, opts: ConnectOptions, handlers: StreamHandlers): Promise<void>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
function parseFrame(frame) {
|
|
2
|
+
let eventType = null;
|
|
3
|
+
const dataLines = [];
|
|
4
|
+
for (const raw of frame.split("\n")) {
|
|
5
|
+
const line = raw.replace(/\r$/, "");
|
|
6
|
+
if (line.length === 0 || line.startsWith(":"))
|
|
7
|
+
continue;
|
|
8
|
+
if (line.startsWith("event:"))
|
|
9
|
+
eventType = line.slice(6).trim();
|
|
10
|
+
else if (line.startsWith("data:"))
|
|
11
|
+
dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
12
|
+
}
|
|
13
|
+
if (dataLines.length === 0)
|
|
14
|
+
return null;
|
|
15
|
+
if (eventType === "connected" || eventType === "stream-end")
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(dataLines.join("\n"));
|
|
19
|
+
if (parsed && typeof parsed === "object" && "runId" in parsed && "type" in parsed) {
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export function parseSseBuffer(buffer) {
|
|
28
|
+
const events = [];
|
|
29
|
+
let rest = buffer.replace(/\r\n/g, "\n");
|
|
30
|
+
let sep = rest.indexOf("\n\n");
|
|
31
|
+
while (sep !== -1) {
|
|
32
|
+
const frame = rest.slice(0, sep);
|
|
33
|
+
rest = rest.slice(sep + 2);
|
|
34
|
+
const event = parseFrame(frame);
|
|
35
|
+
if (event)
|
|
36
|
+
events.push(event);
|
|
37
|
+
sep = rest.indexOf("\n\n");
|
|
38
|
+
}
|
|
39
|
+
return { events, rest };
|
|
40
|
+
}
|
|
41
|
+
export async function connectEventStream(baseUrl, opts, handlers) {
|
|
42
|
+
const url = new URL("/__blok/stream", baseUrl);
|
|
43
|
+
if (opts.workflows && opts.workflows.length > 0) {
|
|
44
|
+
url.searchParams.set("workflows", opts.workflows.join(","));
|
|
45
|
+
}
|
|
46
|
+
const headers = { accept: "text/event-stream" };
|
|
47
|
+
if (opts.token)
|
|
48
|
+
headers.authorization = `Bearer ${opts.token}`;
|
|
49
|
+
let res;
|
|
50
|
+
try {
|
|
51
|
+
res = await fetch(url, { headers, signal: opts.signal });
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (err?.name !== "AbortError")
|
|
55
|
+
handlers.onError?.(err);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!res.ok || !res.body) {
|
|
59
|
+
handlers.onError?.(new Error(`stream connect failed: ${res.status} ${res.statusText || ""}`.trim()));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
handlers.onOpen?.();
|
|
63
|
+
const reader = res.body.getReader();
|
|
64
|
+
const decoder = new TextDecoder();
|
|
65
|
+
let buffer = "";
|
|
66
|
+
try {
|
|
67
|
+
while (true) {
|
|
68
|
+
const { value, done } = await reader.read();
|
|
69
|
+
if (done)
|
|
70
|
+
break;
|
|
71
|
+
buffer += decoder.decode(value, { stream: true });
|
|
72
|
+
const { events, rest } = parseSseBuffer(buffer);
|
|
73
|
+
buffer = rest;
|
|
74
|
+
for (const event of events)
|
|
75
|
+
handlers.onEvent(event);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err?.name !== "AbortError")
|
|
80
|
+
handlers.onError?.(err);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,8 +13,10 @@ import "./commands/nodes/index.js";
|
|
|
13
13
|
import "./commands/config/index.js";
|
|
14
14
|
import "./commands/migrate/index.js";
|
|
15
15
|
import "./commands/runtime/index.js";
|
|
16
|
+
import "./commands/observability/index.js";
|
|
16
17
|
import "./commands/graph/index.js";
|
|
17
18
|
import "./commands/profile/index.js";
|
|
18
19
|
import "./commands/cost/index.js";
|
|
19
20
|
import "./commands/trace/index.js";
|
|
21
|
+
import "./commands/watch/index.js";
|
|
20
22
|
export declare const CLI_NAME = "blokctl";
|
package/dist/index.js
CHANGED
|
@@ -28,10 +28,12 @@ import "./commands/nodes/index.js";
|
|
|
28
28
|
import "./commands/config/index.js";
|
|
29
29
|
import "./commands/migrate/index.js";
|
|
30
30
|
import "./commands/runtime/index.js";
|
|
31
|
+
import "./commands/observability/index.js";
|
|
31
32
|
import "./commands/graph/index.js";
|
|
32
33
|
import "./commands/profile/index.js";
|
|
33
34
|
import "./commands/cost/index.js";
|
|
34
35
|
import "./commands/trace/index.js";
|
|
36
|
+
import "./commands/watch/index.js";
|
|
35
37
|
import { Command } from "commander";
|
|
36
38
|
const version = await getPackageVersion();
|
|
37
39
|
const exec = util.promisify(child_process.exec);
|
|
@@ -89,6 +91,8 @@ async function main() {
|
|
|
89
91
|
.option("-m, --package-manager <value>", "Package manager: npm, yarn, pnpm, bun")
|
|
90
92
|
.option("--pubsub-provider <value>", "Pub/Sub provider: gcp, aws, azure (default: gcp)")
|
|
91
93
|
.option("--queue-provider <value>", "Queue provider: kafka, rabbitmq, sqs, redis (default: kafka)")
|
|
94
|
+
.option("--obs-stack <tier>", "Observability dev stack: none, lite, full (default: none)")
|
|
95
|
+
.option("--observability <list>", "Comma-separated observability modules: metrics,tracing,trace-store,logging,alerting,error-sink")
|
|
92
96
|
.option("--examples", "Install example workflows and nodes")
|
|
93
97
|
.action(async (options) => {
|
|
94
98
|
await analytics.trackCommandExecution({
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ObsStackTier } from "./obs-tiers.js";
|
|
2
|
+
export declare function setupObservabilityStack(repoSource: string, projectDir: string, tier: ObsStackTier): {
|
|
3
|
+
copied: string[];
|
|
4
|
+
};
|
|
5
|
+
export declare function trimComposeServices(composePath: string, keep: string[]): void;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fsExtra from "fs-extra";
|
|
3
|
+
import { parse, stringify } from "yaml";
|
|
4
|
+
import { TIER_DEFINITIONS } from "./obs-tiers.js";
|
|
5
|
+
export function setupObservabilityStack(repoSource, projectDir, tier) {
|
|
6
|
+
const def = TIER_DEFINITIONS[tier];
|
|
7
|
+
if (tier === "none")
|
|
8
|
+
return { copied: [] };
|
|
9
|
+
const src = path.join(repoSource, "infra", "metrics");
|
|
10
|
+
const dest = path.join(projectDir, "infra", "metrics");
|
|
11
|
+
fsExtra.ensureDirSync(dest);
|
|
12
|
+
const copied = [];
|
|
13
|
+
if (def.files === "*") {
|
|
14
|
+
fsExtra.copySync(src, dest);
|
|
15
|
+
copied.push(...fsExtra.readdirSync(dest));
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
for (const entry of def.files) {
|
|
19
|
+
const from = path.join(src, entry);
|
|
20
|
+
if (fsExtra.existsSync(from)) {
|
|
21
|
+
fsExtra.copySync(from, path.join(dest, entry));
|
|
22
|
+
copied.push(entry);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
trimComposeServices(path.join(dest, "docker-compose.yml"), def.services);
|
|
27
|
+
return { copied };
|
|
28
|
+
}
|
|
29
|
+
export function trimComposeServices(composePath, keep) {
|
|
30
|
+
if (!fsExtra.existsSync(composePath))
|
|
31
|
+
return;
|
|
32
|
+
const doc = parse(fsExtra.readFileSync(composePath, "utf8"));
|
|
33
|
+
if (!doc.services)
|
|
34
|
+
return;
|
|
35
|
+
const keepSet = new Set(keep);
|
|
36
|
+
let changed = false;
|
|
37
|
+
for (const name of Object.keys(doc.services)) {
|
|
38
|
+
if (!keepSet.has(name)) {
|
|
39
|
+
delete doc.services[name];
|
|
40
|
+
changed = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const svc of Object.values(doc.services)) {
|
|
44
|
+
const dep = svc.depends_on;
|
|
45
|
+
if (Array.isArray(dep)) {
|
|
46
|
+
const next = dep.filter((d) => keepSet.has(d));
|
|
47
|
+
if (next.length !== dep.length) {
|
|
48
|
+
changed = true;
|
|
49
|
+
if (next.length > 0)
|
|
50
|
+
svc.depends_on = next;
|
|
51
|
+
else
|
|
52
|
+
svc.depends_on = undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else if (dep && typeof dep === "object") {
|
|
56
|
+
for (const d of Object.keys(dep)) {
|
|
57
|
+
if (!keepSet.has(d)) {
|
|
58
|
+
delete dep[d];
|
|
59
|
+
changed = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (Object.keys(dep).length === 0)
|
|
63
|
+
svc.depends_on = undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (changed)
|
|
67
|
+
fsExtra.writeFileSync(composePath, stringify(doc));
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { parse } from "yaml";
|
|
6
|
+
import { setupObservabilityStack } from "./obs-setup.js";
|
|
7
|
+
import { ALL_OBS_SERVICES } from "./obs-tiers.js";
|
|
8
|
+
function findRepoRoot() {
|
|
9
|
+
let dir = import.meta.dirname;
|
|
10
|
+
for (let i = 0; i < 8; i++) {
|
|
11
|
+
if (fs.existsSync(path.join(dir, "infra", "metrics", "docker-compose.yml")))
|
|
12
|
+
return dir;
|
|
13
|
+
const up = path.dirname(dir);
|
|
14
|
+
if (up === dir)
|
|
15
|
+
break;
|
|
16
|
+
dir = up;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const REPO = findRepoRoot();
|
|
21
|
+
const composeServices = (dir) => {
|
|
22
|
+
const doc = parse(fs.readFileSync(path.join(dir, "infra", "metrics", "docker-compose.yml"), "utf8"));
|
|
23
|
+
return Object.keys(doc.services ?? {});
|
|
24
|
+
};
|
|
25
|
+
describe.skipIf(!REPO)("setupObservabilityStack — tiers (MO-STACK)", () => {
|
|
26
|
+
const repo = REPO;
|
|
27
|
+
let tmp;
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "blok-obs-tier-"));
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
it("none: writes nothing — no infra/metrics dir", () => {
|
|
35
|
+
const res = setupObservabilityStack(repo, tmp, "none");
|
|
36
|
+
expect(res.copied).toEqual([]);
|
|
37
|
+
expect(fs.existsSync(path.join(tmp, "infra", "metrics"))).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
it("lite: exactly prometheus + grafana, and no loki/tempo/alloy", () => {
|
|
40
|
+
setupObservabilityStack(repo, tmp, "lite");
|
|
41
|
+
expect(composeServices(tmp).sort()).toEqual(["grafana", "prometheus"]);
|
|
42
|
+
const m = path.join(tmp, "infra", "metrics");
|
|
43
|
+
expect(fs.existsSync(path.join(m, "prometheus.yml"))).toBe(true);
|
|
44
|
+
expect(fs.existsSync(path.join(m, "loki-config.yaml"))).toBe(false);
|
|
45
|
+
expect(fs.existsSync(path.join(m, "tempo.yaml"))).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
it("lite: no kept service depends_on a trimmed-away service", () => {
|
|
48
|
+
setupObservabilityStack(repo, tmp, "lite");
|
|
49
|
+
const doc = parse(fs.readFileSync(path.join(tmp, "infra", "metrics", "docker-compose.yml"), "utf8"));
|
|
50
|
+
for (const svc of Object.values(doc.services ?? {})) {
|
|
51
|
+
const dep = svc.depends_on;
|
|
52
|
+
const deps = Array.isArray(dep) ? dep : dep ? Object.keys(dep) : [];
|
|
53
|
+
for (const d of deps)
|
|
54
|
+
expect(["prometheus", "grafana"]).toContain(d);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
it("full: all services + the whole file set", () => {
|
|
58
|
+
setupObservabilityStack(repo, tmp, "full");
|
|
59
|
+
expect(composeServices(tmp).sort()).toEqual([...ALL_OBS_SERVICES].sort());
|
|
60
|
+
const m = path.join(tmp, "infra", "metrics");
|
|
61
|
+
expect(fs.existsSync(path.join(m, "loki-config.yaml"))).toBe(true);
|
|
62
|
+
expect(fs.existsSync(path.join(m, "tempo.yaml"))).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
it("is idempotent — re-running lite yields an identical compose file", () => {
|
|
65
|
+
setupObservabilityStack(repo, tmp, "lite");
|
|
66
|
+
const composePath = path.join(tmp, "infra", "metrics", "docker-compose.yml");
|
|
67
|
+
const first = fs.readFileSync(composePath, "utf8");
|
|
68
|
+
setupObservabilityStack(repo, tmp, "lite");
|
|
69
|
+
expect(fs.readFileSync(composePath, "utf8")).toBe(first);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ObsStackTier = "none" | "lite" | "full";
|
|
2
|
+
export declare const OBS_STACK_TIERS: readonly ObsStackTier[];
|
|
3
|
+
export declare const ALL_OBS_SERVICES: readonly ["prometheus", "alertmanager", "grafana", "tempo", "loki", "nginx", "alloy"];
|
|
4
|
+
export interface TierDefinition {
|
|
5
|
+
services: string[];
|
|
6
|
+
files: string[] | "*";
|
|
7
|
+
}
|
|
8
|
+
export declare const TIER_DEFINITIONS: Record<ObsStackTier, TierDefinition>;
|
|
9
|
+
export declare function parseObsTier(value: string): ObsStackTier;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const OBS_STACK_TIERS = ["none", "lite", "full"];
|
|
2
|
+
export const ALL_OBS_SERVICES = ["prometheus", "alertmanager", "grafana", "tempo", "loki", "nginx", "alloy"];
|
|
3
|
+
export const TIER_DEFINITIONS = {
|
|
4
|
+
none: { services: [], files: [] },
|
|
5
|
+
lite: {
|
|
6
|
+
services: ["prometheus", "grafana"],
|
|
7
|
+
files: ["docker-compose.yml", "prometheus.yml", "rules", "datasources.yml", "dashboards", "dashboard.json"],
|
|
8
|
+
},
|
|
9
|
+
full: { services: [...ALL_OBS_SERVICES], files: "*" },
|
|
10
|
+
};
|
|
11
|
+
export function parseObsTier(value) {
|
|
12
|
+
const v = value.trim().toLowerCase();
|
|
13
|
+
if (OBS_STACK_TIERS.includes(v))
|
|
14
|
+
return v;
|
|
15
|
+
throw new Error(`Invalid --obs-stack "${value}". Choose one of: ${OBS_STACK_TIERS.join(", ")}.`);
|
|
16
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ObservabilityModuleConfig, ProjectConfig } from "./runtime-setup.js";
|
|
2
|
+
export declare function withObservabilityModule(config: ProjectConfig, id: string, moduleConfig: ObservabilityModuleConfig): ProjectConfig;
|
|
3
|
+
export declare function withoutObservabilityModule(config: ProjectConfig, id: string): ProjectConfig;
|
|
4
|
+
export declare function rewriteObservabilityEnvBlock(envContent: string, moduleBlocks: string[]): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const OBS_START = "# >>> Blok observability (managed by blokctl) >>>";
|
|
2
|
+
const OBS_END = "# <<< Blok observability (managed by blokctl) <<<";
|
|
3
|
+
export function withObservabilityModule(config, id, moduleConfig) {
|
|
4
|
+
return { ...config, observability: { ...(config.observability ?? {}), [id]: moduleConfig } };
|
|
5
|
+
}
|
|
6
|
+
export function withoutObservabilityModule(config, id) {
|
|
7
|
+
if (!config.observability || !(id in config.observability))
|
|
8
|
+
return config;
|
|
9
|
+
const rest = Object.fromEntries(Object.entries(config.observability).filter(([k]) => k !== id));
|
|
10
|
+
return { ...config, observability: Object.keys(rest).length === 0 ? undefined : rest };
|
|
11
|
+
}
|
|
12
|
+
function stripManagedBlock(envContent) {
|
|
13
|
+
const out = [];
|
|
14
|
+
let inside = false;
|
|
15
|
+
for (const line of envContent.split("\n")) {
|
|
16
|
+
if (line.trim() === OBS_START) {
|
|
17
|
+
inside = true;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (inside) {
|
|
21
|
+
if (line.trim() === OBS_END)
|
|
22
|
+
inside = false;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
out.push(line);
|
|
26
|
+
}
|
|
27
|
+
return out.join("\n");
|
|
28
|
+
}
|
|
29
|
+
export function rewriteObservabilityEnvBlock(envContent, moduleBlocks) {
|
|
30
|
+
for (const block of moduleBlocks) {
|
|
31
|
+
if (/\bBLOK_METRICS_ENABLED\b/.test(block)) {
|
|
32
|
+
throw new Error("BLOK_METRICS_ENABLED is not a supported flag — metrics are ON by default; disable with BLOK_METRICS_DISABLED=1.");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const cleaned = stripManagedBlock(envContent)
|
|
36
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
37
|
+
.replace(/\n+$/, "");
|
|
38
|
+
const body = moduleBlocks
|
|
39
|
+
.map((b) => b.trim())
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.join("\n\n");
|
|
42
|
+
if (!body)
|
|
43
|
+
return cleaned.length > 0 ? `${cleaned}\n` : "";
|
|
44
|
+
const block = `${OBS_START}\n${body}\n${OBS_END}`;
|
|
45
|
+
return cleaned.length > 0 ? `${cleaned}\n\n${block}\n` : `${block}\n`;
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { rewriteObservabilityEnvBlock, withObservabilityModule, withoutObservabilityModule, } from "./observability-mutations.js";
|
|
3
|
+
const mod = (addedAt = "2026-01-01T00:00:00.000Z") => ({ enabled: true, addedAt });
|
|
4
|
+
describe("withObservabilityModule / withoutObservabilityModule", () => {
|
|
5
|
+
it("adds a module while preserving runtimes + triggers", () => {
|
|
6
|
+
const base = { runtimes: { go: { port: 0, startCmd: "", cwd: "", kind: "go", label: "Go" } } };
|
|
7
|
+
const next = withObservabilityModule(base, "metrics", mod());
|
|
8
|
+
expect(next.observability).toEqual({ metrics: mod() });
|
|
9
|
+
expect(next.runtimes).toBe(base.runtimes);
|
|
10
|
+
});
|
|
11
|
+
it("re-adding the same module is a no-op (identical output)", () => {
|
|
12
|
+
const a = withObservabilityModule({}, "tracing", mod());
|
|
13
|
+
const b = withObservabilityModule(a, "tracing", mod());
|
|
14
|
+
expect(b).toEqual(a);
|
|
15
|
+
});
|
|
16
|
+
it("removing the last module drops the observability key entirely", () => {
|
|
17
|
+
const one = withObservabilityModule({}, "metrics", mod());
|
|
18
|
+
const gone = withoutObservabilityModule(one, "metrics");
|
|
19
|
+
expect(gone.observability).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
it("removing an absent module is a no-op", () => {
|
|
22
|
+
const cfg = { observability: { metrics: mod() } };
|
|
23
|
+
expect(withoutObservabilityModule(cfg, "tracing")).toBe(cfg);
|
|
24
|
+
});
|
|
25
|
+
it("removing one of several keeps the rest", () => {
|
|
26
|
+
const cfg = withObservabilityModule(withObservabilityModule({}, "metrics", mod()), "tracing", mod());
|
|
27
|
+
const next = withoutObservabilityModule(cfg, "metrics");
|
|
28
|
+
expect(Object.keys(next.observability ?? {})).toEqual(["tracing"]);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe("rewriteObservabilityEnvBlock", () => {
|
|
32
|
+
it("appends a fenced block and is idempotent (run twice = identical)", () => {
|
|
33
|
+
const blocks = ["BLOK_TRACE_STORE=sqlite", "# BLOK_METRICS_DISABLED=1"];
|
|
34
|
+
const once = rewriteObservabilityEnvBlock("PORT=4000\n", blocks);
|
|
35
|
+
const twice = rewriteObservabilityEnvBlock(once, blocks);
|
|
36
|
+
expect(twice).toBe(once);
|
|
37
|
+
expect(once).toContain("PORT=4000");
|
|
38
|
+
expect(once).toContain("BLOK_TRACE_STORE=sqlite");
|
|
39
|
+
expect(once.match(/managed by blokctl/g)?.length).toBe(2);
|
|
40
|
+
});
|
|
41
|
+
it("preserves unrelated env vars when the module set changes", () => {
|
|
42
|
+
const first = rewriteObservabilityEnvBlock("SECRET=abc\n", ["BLOK_TRACE_STORE=sqlite"]);
|
|
43
|
+
const second = rewriteObservabilityEnvBlock(first, ["CONSOLE_LOG_ACTIVE=true"]);
|
|
44
|
+
expect(second).toContain("SECRET=abc");
|
|
45
|
+
expect(second).toContain("CONSOLE_LOG_ACTIVE=true");
|
|
46
|
+
expect(second).not.toContain("BLOK_TRACE_STORE");
|
|
47
|
+
});
|
|
48
|
+
it("removes the block when no modules remain", () => {
|
|
49
|
+
const withBlock = rewriteObservabilityEnvBlock("SECRET=abc\n", ["BLOK_TRACE_STORE=sqlite"]);
|
|
50
|
+
const cleared = rewriteObservabilityEnvBlock(withBlock, []);
|
|
51
|
+
expect(cleared).toBe("SECRET=abc\n");
|
|
52
|
+
expect(cleared).not.toContain("managed by blokctl");
|
|
53
|
+
});
|
|
54
|
+
it("rejects BLOK_METRICS_ENABLED (only the DISABLED kill-switch is supported)", () => {
|
|
55
|
+
expect(() => rewriteObservabilityEnvBlock("", ["BLOK_METRICS_ENABLED=false"])).toThrow(/BLOK_METRICS_ENABLED/);
|
|
56
|
+
});
|
|
57
|
+
});
|