blokctl 0.6.21 → 1.0.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 +60 -9
- package/dist/commands/create/utils/Examples.d.ts +11 -23
- package/dist/commands/create/utils/Examples.js +206 -421
- 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 +4 -23
- package/dist/commands/generate/WorkflowGenerator.test.js +2 -2
- 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/generate/prompts/create-fn-node.system.js +10 -17
- package/dist/commands/generate/prompts/create-workflow.system.js +114 -375
- package/dist/commands/generate/validators/WorkflowValidator.js +80 -5
- package/dist/commands/generate/validators/WorkflowValidator.test.js +66 -0
- 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
|
@@ -29,15 +29,21 @@ export function validateWorkflow(jsonString) {
|
|
|
29
29
|
warnings: [],
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
const v2 = isV2Workflow(workflow.steps);
|
|
33
|
+
validateTopLevel(workflow, v2, errors, warnings);
|
|
33
34
|
if (workflow.trigger && typeof workflow.trigger === "object") {
|
|
34
35
|
validateTrigger(workflow.trigger, errors, warnings);
|
|
35
36
|
}
|
|
36
37
|
const stepNames = new Set();
|
|
37
38
|
if (Array.isArray(workflow.steps)) {
|
|
38
|
-
|
|
39
|
+
if (v2) {
|
|
40
|
+
validateV2Steps(workflow.steps, stepNames, errors, warnings);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
validateSteps(workflow.steps, stepNames, errors, warnings);
|
|
44
|
+
}
|
|
39
45
|
}
|
|
40
|
-
if (workflow.nodes && typeof workflow.nodes === "object") {
|
|
46
|
+
if (!v2 && workflow.nodes && typeof workflow.nodes === "object") {
|
|
41
47
|
validateNodes(workflow.nodes, stepNames, errors, warnings);
|
|
42
48
|
}
|
|
43
49
|
return {
|
|
@@ -46,7 +52,17 @@ export function validateWorkflow(jsonString) {
|
|
|
46
52
|
warnings,
|
|
47
53
|
};
|
|
48
54
|
}
|
|
49
|
-
function
|
|
55
|
+
function isV2Workflow(steps) {
|
|
56
|
+
if (!Array.isArray(steps))
|
|
57
|
+
return false;
|
|
58
|
+
return steps.some((s) => {
|
|
59
|
+
if (!s || typeof s !== "object")
|
|
60
|
+
return false;
|
|
61
|
+
const step = s;
|
|
62
|
+
return step.use !== undefined || step.branch !== undefined || (step.id !== undefined && step.name === undefined);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function validateTopLevel(workflow, v2, errors, warnings) {
|
|
50
66
|
if (!workflow.name || typeof workflow.name !== "string") {
|
|
51
67
|
errors.push('Missing or invalid "name" field (must be a non-empty string)');
|
|
52
68
|
}
|
|
@@ -71,7 +87,7 @@ function validateTopLevel(workflow, errors, warnings) {
|
|
|
71
87
|
else if (workflow.steps.length === 0) {
|
|
72
88
|
errors.push('"steps" array must not be empty');
|
|
73
89
|
}
|
|
74
|
-
if (!workflow.nodes || typeof workflow.nodes !== "object") {
|
|
90
|
+
if (!v2 && (!workflow.nodes || typeof workflow.nodes !== "object")) {
|
|
75
91
|
errors.push('Missing or invalid "nodes" field (must be an object)');
|
|
76
92
|
}
|
|
77
93
|
}
|
|
@@ -205,6 +221,65 @@ function validateSteps(steps, stepNames, errors, warnings) {
|
|
|
205
221
|
stepNames.add(step.name);
|
|
206
222
|
}
|
|
207
223
|
}
|
|
224
|
+
function validateV2Steps(steps, ids, errors, warnings) {
|
|
225
|
+
for (let i = 0; i < steps.length; i++) {
|
|
226
|
+
const step = steps[i];
|
|
227
|
+
if (!step || typeof step !== "object") {
|
|
228
|
+
errors.push(`Step at index ${i} must be an object`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (!step.id || typeof step.id !== "string") {
|
|
232
|
+
errors.push(`Step at index ${i} is missing required "id" field`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (ids.has(step.id)) {
|
|
236
|
+
errors.push(`Duplicate step id "${step.id}" — step ids are flat per workflow (including branch arms)`);
|
|
237
|
+
}
|
|
238
|
+
ids.add(step.id);
|
|
239
|
+
if (step.branch !== undefined) {
|
|
240
|
+
const branch = step.branch;
|
|
241
|
+
if (!branch || typeof branch !== "object") {
|
|
242
|
+
errors.push(`Step "${step.id}" has an invalid "branch" (must be an object)`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (!branch.when || typeof branch.when !== "string") {
|
|
246
|
+
errors.push(`Branch step "${step.id}" is missing a "when" condition string`);
|
|
247
|
+
}
|
|
248
|
+
else if (branch.when.startsWith("js/") || branch.when.startsWith("$.")) {
|
|
249
|
+
errors.push(`Branch step "${step.id}" "when" must be a raw \`ctx.*\` expression (e.g. "ctx.state.x > 10") — never "js/..." or "$...", which throw at runtime`);
|
|
250
|
+
}
|
|
251
|
+
if (!Array.isArray(branch.then) || branch.then.length === 0) {
|
|
252
|
+
errors.push(`Branch step "${step.id}" must have a non-empty "then" array`);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
validateV2Steps(branch.then, ids, errors, warnings);
|
|
256
|
+
}
|
|
257
|
+
if (branch.else !== undefined) {
|
|
258
|
+
if (!Array.isArray(branch.else)) {
|
|
259
|
+
errors.push(`Branch step "${step.id}" "else" must be an array when present`);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
validateV2Steps(branch.else, ids, errors, warnings);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if ((!step.use || typeof step.use !== "string") && typeof step.subworkflow !== "string") {
|
|
268
|
+
errors.push(`Step "${step.id}" is missing required "use" field (the node to run)`);
|
|
269
|
+
}
|
|
270
|
+
if (step.type !== undefined) {
|
|
271
|
+
if (typeof step.type !== "string") {
|
|
272
|
+
errors.push(`Step "${step.id}" "type" must be a string`);
|
|
273
|
+
}
|
|
274
|
+
else if (!VALID_STEP_TYPES.includes(step.type) && !step.type.startsWith("runtime.")) {
|
|
275
|
+
warnings.push(`Step "${step.id}" has unusual type "${step.type}". Common types: ${VALID_STEP_TYPES.join(", ")}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (step.as !== undefined && step.spread !== undefined) {
|
|
279
|
+
errors.push(`Step "${step.id}" sets both "as" and "spread" — they are mutually exclusive`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
208
283
|
function validateNodes(nodes, stepNames, errors, warnings) {
|
|
209
284
|
const allReferencedStepNames = new Set(stepNames);
|
|
210
285
|
for (const [nodeName, nodeConfig] of Object.entries(nodes)) {
|
|
@@ -644,4 +644,70 @@ describe("WorkflowValidator", () => {
|
|
|
644
644
|
expect(result.valid).toBe(true);
|
|
645
645
|
});
|
|
646
646
|
});
|
|
647
|
+
describe("v2 workflows (inline inputs, no nodes map)", () => {
|
|
648
|
+
const base = {
|
|
649
|
+
name: "V2 Flow",
|
|
650
|
+
description: "A v2 workflow",
|
|
651
|
+
version: "1.0.0",
|
|
652
|
+
trigger: { http: { method: "GET", path: "/v2" } },
|
|
653
|
+
};
|
|
654
|
+
it("accepts a v2 workflow with id/use/inputs and no nodes map", () => {
|
|
655
|
+
const result = validateWorkflow(JSON.stringify({
|
|
656
|
+
...base,
|
|
657
|
+
steps: [
|
|
658
|
+
{ id: "fetch", use: "@blokjs/api-call", inputs: { url: "https://x" } },
|
|
659
|
+
{ id: "respond", use: "@blokjs/respond", inputs: { body: "$.state.fetch" }, ephemeral: true },
|
|
660
|
+
],
|
|
661
|
+
}));
|
|
662
|
+
expect(result.valid).toBe(true);
|
|
663
|
+
});
|
|
664
|
+
it("accepts a v2 branch step and validates its arm ids", () => {
|
|
665
|
+
const result = validateWorkflow(JSON.stringify({
|
|
666
|
+
...base,
|
|
667
|
+
steps: [
|
|
668
|
+
{
|
|
669
|
+
id: "route",
|
|
670
|
+
branch: {
|
|
671
|
+
when: "ctx.req.method === 'POST'",
|
|
672
|
+
then: [{ id: "create", use: "@blokjs/api-call", inputs: {} }],
|
|
673
|
+
else: [{ id: "read", use: "@blokjs/api-call", inputs: {} }],
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
],
|
|
677
|
+
}));
|
|
678
|
+
expect(result.valid).toBe(true);
|
|
679
|
+
});
|
|
680
|
+
it("errors on a v2 step missing use", () => {
|
|
681
|
+
const result = validateWorkflow(JSON.stringify({ ...base, steps: [{ id: "x", inputs: {} }] }));
|
|
682
|
+
expect(result.valid).toBe(false);
|
|
683
|
+
expect(result.errors.some((e) => e.includes('missing required "use"'))).toBe(true);
|
|
684
|
+
});
|
|
685
|
+
it("errors on a duplicate id across a branch arm", () => {
|
|
686
|
+
const result = validateWorkflow(JSON.stringify({
|
|
687
|
+
...base,
|
|
688
|
+
steps: [
|
|
689
|
+
{ id: "dup", use: "@blokjs/api-call", inputs: {} },
|
|
690
|
+
{
|
|
691
|
+
id: "b",
|
|
692
|
+
branch: { when: "ctx.state.dup.ok", then: [{ id: "dup", use: "@blokjs/respond", inputs: {} }] },
|
|
693
|
+
},
|
|
694
|
+
],
|
|
695
|
+
}));
|
|
696
|
+
expect(result.valid).toBe(false);
|
|
697
|
+
expect(result.errors.some((e) => e.includes("Duplicate step id"))).toBe(true);
|
|
698
|
+
});
|
|
699
|
+
it("errors on a branch when prefixed with js/ or $.", () => {
|
|
700
|
+
const result = validateWorkflow(JSON.stringify({
|
|
701
|
+
...base,
|
|
702
|
+
steps: [{ id: "b", branch: { when: "js/ctx.state.x", then: [{ id: "t", use: "n", inputs: {} }] } }],
|
|
703
|
+
}));
|
|
704
|
+
expect(result.valid).toBe(false);
|
|
705
|
+
expect(result.errors.some((e) => e.includes("raw"))).toBe(true);
|
|
706
|
+
});
|
|
707
|
+
it("errors when a v2 step sets both as and spread", () => {
|
|
708
|
+
const result = validateWorkflow(JSON.stringify({ ...base, steps: [{ id: "x", use: "n", as: "y", spread: true, inputs: {} }] }));
|
|
709
|
+
expect(result.valid).toBe(false);
|
|
710
|
+
expect(result.errors.some((e) => e.includes("mutually exclusive"))).toBe(true);
|
|
711
|
+
});
|
|
712
|
+
});
|
|
647
713
|
});
|
|
@@ -120,11 +120,11 @@ const fetchPrometheusMetrics = async (host, token) => {
|
|
|
120
120
|
mapWorkflow(wfErrors, "errors", (v) => Math.round(+v));
|
|
121
121
|
mapWorkflow(wfReqs, "requests", (v) => Math.round(+v));
|
|
122
122
|
const nodeMetricsRaw = await Promise.all([
|
|
123
|
-
queryPrometheus("(sum(increase(
|
|
124
|
-
queryPrometheus("sum(increase(
|
|
125
|
-
queryPrometheus("(sum(increase(
|
|
126
|
-
queryPrometheus("sum(increase(
|
|
127
|
-
queryPrometheus("sum(increase(
|
|
123
|
+
queryPrometheus("(sum(increase(blok_node_executions_total[1m])) by (node_name, workflow_path)) > 0", host, token),
|
|
124
|
+
queryPrometheus("sum(increase(blok_node_duration_seconds_sum[1m])) by (node_name, workflow_path)", host, token),
|
|
125
|
+
queryPrometheus("(sum(increase(blok_node_errors_total[1m])) by (node_name, workflow_path)) > 0", host, token),
|
|
126
|
+
queryPrometheus("sum(increase(blok_node_cpu_usage[1m])) by (node_name, workflow_path)", host, token),
|
|
127
|
+
queryPrometheus("sum(increase(blok_node_memory_bytes[1m])) by (node_name, workflow_path)", host, token),
|
|
128
128
|
]);
|
|
129
129
|
const nodeMap = {};
|
|
130
130
|
const setNodeMetric = (list, key, convert) => {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import * as p from "@clack/prompts";
|
|
4
|
+
import color from "picocolors";
|
|
5
|
+
import { isNonInteractive } from "../../services/non-interactive.js";
|
|
6
|
+
import { rewriteObservabilityEnvBlock, withObservabilityModule } from "../../services/observability-mutations.js";
|
|
7
|
+
import { OBSERVABILITY_MODULE_IDS, allObservabilityModules, getObservabilityModule, resolveWithDependencies, } from "./descriptor.js";
|
|
8
|
+
import { ObservabilityCommandError, readConfigSafe, readFrameworkVersion, reportObservabilityError, resolveProjectRoot, } from "./shared.js";
|
|
9
|
+
export async function observabilityAdd(moduleArg, options) {
|
|
10
|
+
try {
|
|
11
|
+
const root = resolveProjectRoot(options.directory);
|
|
12
|
+
const config = readConfigSafe(root);
|
|
13
|
+
const enabled = config.observability ?? {};
|
|
14
|
+
const nonInteractive = isNonInteractive() || options.yes === true;
|
|
15
|
+
let id = moduleArg?.trim().toLowerCase();
|
|
16
|
+
if (!id) {
|
|
17
|
+
if (nonInteractive) {
|
|
18
|
+
throw new ObservabilityCommandError(`Specify a module: blokctl observability add <${OBSERVABILITY_MODULE_IDS.join("|")}>`);
|
|
19
|
+
}
|
|
20
|
+
const choices = allObservabilityModules()
|
|
21
|
+
.filter((m) => !enabled[m.id])
|
|
22
|
+
.map((m) => ({ value: m.id, label: m.label, hint: m.description }));
|
|
23
|
+
if (choices.length === 0) {
|
|
24
|
+
p.intro(color.inverse(" Add observability module "));
|
|
25
|
+
p.outro(color.dim("All observability modules are already enabled."));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const picked = await p.select({ message: "Which observability module do you want to add?", options: choices });
|
|
29
|
+
if (p.isCancel(picked)) {
|
|
30
|
+
p.cancel("Cancelled.");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
id = picked;
|
|
34
|
+
}
|
|
35
|
+
const mod = getObservabilityModule(id);
|
|
36
|
+
if (!mod) {
|
|
37
|
+
throw new ObservabilityCommandError(`Unknown observability module "${id}". Known: ${OBSERVABILITY_MODULE_IDS.join(", ")}.`);
|
|
38
|
+
}
|
|
39
|
+
p.intro(color.inverse(` Add ${mod.label} `));
|
|
40
|
+
if (enabled[mod.id] && options.force !== true) {
|
|
41
|
+
p.outro(color.dim(`${mod.label} is already enabled. Re-run with --force to re-apply its scaffold.`));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const { resolved, added } = resolveWithDependencies([mod.id]);
|
|
45
|
+
const newDeps = added.filter((d) => !enabled[d]);
|
|
46
|
+
if (newDeps.length > 0) {
|
|
47
|
+
const labels = newDeps.map((d) => getObservabilityModule(d)?.label ?? d).join(", ");
|
|
48
|
+
if (nonInteractive) {
|
|
49
|
+
p.log.info(color.dim(`Also enabling required dependencies: ${labels}`));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const ok = await p.confirm({
|
|
53
|
+
message: `${mod.label} requires ${labels}. Enable ${newDeps.length > 1 ? "them" : "it"} too?`,
|
|
54
|
+
initialValue: true,
|
|
55
|
+
});
|
|
56
|
+
if (p.isCancel(ok) || !ok) {
|
|
57
|
+
p.outro(color.dim("Left unchanged."));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const toApply = resolved.filter((rid) => rid === mod.id || !enabled[rid]);
|
|
63
|
+
const addedAt = new Date().toISOString();
|
|
64
|
+
const version = readFrameworkVersion(root);
|
|
65
|
+
const s = p.spinner();
|
|
66
|
+
let nextConfig = config;
|
|
67
|
+
const scaffoldOpts = {
|
|
68
|
+
projectDir: root,
|
|
69
|
+
nonInteractive,
|
|
70
|
+
tier: options.tier,
|
|
71
|
+
localRepo: options.local,
|
|
72
|
+
};
|
|
73
|
+
for (const rid of toApply) {
|
|
74
|
+
const d = getObservabilityModule(rid);
|
|
75
|
+
if (!d)
|
|
76
|
+
continue;
|
|
77
|
+
if (d.validate)
|
|
78
|
+
await d.validate(root);
|
|
79
|
+
if (d.scaffold) {
|
|
80
|
+
s.start(`Scaffolding ${d.label}…`);
|
|
81
|
+
await d.scaffold(scaffoldOpts);
|
|
82
|
+
s.stop(`${d.label} ready`);
|
|
83
|
+
}
|
|
84
|
+
if (d.setup)
|
|
85
|
+
await d.setup(scaffoldOpts);
|
|
86
|
+
const extra = rid === "obs-stack" ? { settings: { tier: options.tier ?? "lite" } } : {};
|
|
87
|
+
nextConfig = withObservabilityModule(nextConfig, rid, { enabled: true, addedAt, version, ...extra });
|
|
88
|
+
}
|
|
89
|
+
fs.mkdirSync(path.join(root, ".blok"), { recursive: true });
|
|
90
|
+
fs.writeFileSync(path.join(root, ".blok", "config.json"), `${JSON.stringify(nextConfig, null, 2)}\n`);
|
|
91
|
+
const enabledIds = Object.keys(nextConfig.observability ?? {});
|
|
92
|
+
const envBlocks = enabledIds.map((eid) => getObservabilityModule(eid)?.envBlock({ projectDir: root }) ?? "");
|
|
93
|
+
const envPath = path.join(root, ".env.local");
|
|
94
|
+
const envContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
|
|
95
|
+
fs.writeFileSync(envPath, rewriteObservabilityEnvBlock(envContent, envBlocks));
|
|
96
|
+
const deps = {};
|
|
97
|
+
for (const rid of toApply)
|
|
98
|
+
Object.assign(deps, getObservabilityModule(rid)?.packageDeps ?? {});
|
|
99
|
+
if (Object.keys(deps).length > 0)
|
|
100
|
+
mergePackageDeps(root, deps);
|
|
101
|
+
p.note(toApply.map((rid) => `${color.green("✓")} ${getObservabilityModule(rid)?.label ?? rid}`).join("\n"), `${mod.label} added`);
|
|
102
|
+
p.outro(color.dim(`Enabled modules: ${enabledIds.join(", ")}.`));
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
reportObservabilityError(err);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function mergePackageDeps(root, deps) {
|
|
109
|
+
const pkgPath = path.join(root, "package.json");
|
|
110
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
111
|
+
pkg.dependencies = { ...(pkg.dependencies ?? {}), ...deps };
|
|
112
|
+
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
113
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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 alerting = getObservabilityModule("alerting");
|
|
7
|
+
const errorSink = getObservabilityModule("error-sink");
|
|
8
|
+
describe("alerting + error-sink modules (MO-ALERTS)", () => {
|
|
9
|
+
it("alerting depends on metrics, declares alertmanager, and enables alerting", () => {
|
|
10
|
+
expect(alerting.dependencies).toEqual(["metrics"]);
|
|
11
|
+
expect(alerting.composeServices).toEqual(["alertmanager"]);
|
|
12
|
+
expect(alerting.envBlock({ projectDir: "/x" })).toContain("BLOK_ALERTING_ENABLED=true");
|
|
13
|
+
});
|
|
14
|
+
it("error-sink ships inert — SENTRY_DSN is commented out", () => {
|
|
15
|
+
for (const line of errorSink.envBlock({ projectDir: "/x" }).split("\n")) {
|
|
16
|
+
if (/SENTRY_DSN=/.test(line))
|
|
17
|
+
expect(line.trim().startsWith("#")).toBe(true);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
describe("verify()", () => {
|
|
21
|
+
let tmp;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "blok-alerts-"));
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
26
|
+
it("error-sink: inert when no SENTRY_DSN", async () => {
|
|
27
|
+
fs.writeFileSync(path.join(tmp, ".env.local"), "# SENTRY_DSN=\n");
|
|
28
|
+
expect((await errorSink.verify?.(tmp))?.message).toMatch(/inert/);
|
|
29
|
+
});
|
|
30
|
+
it("error-sink: flags the missing dep when DSN is set but @sentry/node is absent", async () => {
|
|
31
|
+
fs.writeFileSync(path.join(tmp, ".env.local"), "SENTRY_DSN=https://k@e.test/1\n");
|
|
32
|
+
expect((await errorSink.verify?.(tmp))?.message).toMatch(/@sentry\/node missing/);
|
|
33
|
+
});
|
|
34
|
+
it("alerting: points at obs-stack when the rules file isn't in the project", async () => {
|
|
35
|
+
expect((await alerting.verify?.(tmp))?.message).toMatch(/obs-stack=full/);
|
|
36
|
+
});
|
|
37
|
+
it("alerting: reports rules present when they exist", async () => {
|
|
38
|
+
fs.mkdirSync(path.join(tmp, "infra", "metrics", "rules"), { recursive: true });
|
|
39
|
+
fs.writeFileSync(path.join(tmp, "infra", "metrics", "rules", "blok-alerts.yml"), "groups: []\n");
|
|
40
|
+
expect((await alerting.verify?.(tmp))?.message).toMatch(/alert rules present/);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ObservabilityModuleConfig } from "../../services/runtime-setup.js";
|
|
2
|
+
export declare function resolveObservabilitySelection(moduleIds: string[], opts: {
|
|
3
|
+
addedAt: string;
|
|
4
|
+
version?: string;
|
|
5
|
+
projectDir: string;
|
|
6
|
+
}): {
|
|
7
|
+
configMap: Record<string, ObservabilityModuleConfig>;
|
|
8
|
+
envBlocks: string[];
|
|
9
|
+
added: string[];
|
|
10
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { getObservabilityModule, resolveWithDependencies } from "./descriptor.js";
|
|
2
|
+
export function resolveObservabilitySelection(moduleIds, opts) {
|
|
3
|
+
if (moduleIds.length === 0)
|
|
4
|
+
return { configMap: {}, envBlocks: [], added: [] };
|
|
5
|
+
const { resolved, added } = resolveWithDependencies(moduleIds);
|
|
6
|
+
const configMap = {};
|
|
7
|
+
for (const id of resolved)
|
|
8
|
+
configMap[id] = { enabled: true, addedAt: opts.addedAt, version: opts.version };
|
|
9
|
+
const envBlocks = resolved.map((id) => getObservabilityModule(id)?.envBlock({ projectDir: opts.projectDir }) ?? "");
|
|
10
|
+
return { configMap, envBlocks, added };
|
|
11
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type ObservabilityModuleId = "obs-stack" | "tracing" | "trace-store" | "metrics" | "logging" | "alerting" | "error-sink";
|
|
2
|
+
export declare const OBSERVABILITY_MODULE_IDS: readonly ObservabilityModuleId[];
|
|
3
|
+
export interface ObservabilityScaffoldOpts {
|
|
4
|
+
projectDir: string;
|
|
5
|
+
nonInteractive: boolean;
|
|
6
|
+
tier?: string;
|
|
7
|
+
localRepo?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ObservabilityModuleDescriptor {
|
|
10
|
+
id: ObservabilityModuleId;
|
|
11
|
+
label: string;
|
|
12
|
+
description: string;
|
|
13
|
+
dependencies: ObservabilityModuleId[];
|
|
14
|
+
envBlock: (opts: {
|
|
15
|
+
projectDir: string;
|
|
16
|
+
}) => string;
|
|
17
|
+
infraFiles: string[];
|
|
18
|
+
composeServices: string[];
|
|
19
|
+
packageDeps: Record<string, string>;
|
|
20
|
+
scaffold?: (opts: ObservabilityScaffoldOpts) => Promise<{
|
|
21
|
+
filesCreated: string[];
|
|
22
|
+
}>;
|
|
23
|
+
setup?: (opts: ObservabilityScaffoldOpts) => Promise<void>;
|
|
24
|
+
verify?: (projectDir: string) => Promise<{
|
|
25
|
+
ok: boolean;
|
|
26
|
+
message: string;
|
|
27
|
+
dashboardUrl?: string;
|
|
28
|
+
}>;
|
|
29
|
+
validate?: (projectDir: string) => Promise<void>;
|
|
30
|
+
cleanup?: (opts: ObservabilityScaffoldOpts) => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export declare function getObservabilityModule(id: string): ObservabilityModuleDescriptor | undefined;
|
|
33
|
+
export declare function allObservabilityModules(): ObservabilityModuleDescriptor[];
|
|
34
|
+
export declare function resolveWithDependencies(ids: string[]): {
|
|
35
|
+
resolved: ObservabilityModuleId[];
|
|
36
|
+
added: ObservabilityModuleId[];
|
|
37
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const OBSERVABILITY_MODULE_IDS = [
|
|
4
|
+
"obs-stack",
|
|
5
|
+
"tracing",
|
|
6
|
+
"trace-store",
|
|
7
|
+
"metrics",
|
|
8
|
+
"logging",
|
|
9
|
+
"alerting",
|
|
10
|
+
"error-sink",
|
|
11
|
+
];
|
|
12
|
+
const REGISTRY = {
|
|
13
|
+
"obs-stack": {
|
|
14
|
+
id: "obs-stack",
|
|
15
|
+
label: "Observability stack",
|
|
16
|
+
description: "Local Prometheus/Grafana/Loki/Tempo dev stack (tiered: none|lite|full)",
|
|
17
|
+
dependencies: [],
|
|
18
|
+
envBlock: () => "",
|
|
19
|
+
infraFiles: [],
|
|
20
|
+
composeServices: [],
|
|
21
|
+
packageDeps: {},
|
|
22
|
+
scaffold: async ({ projectDir, tier, localRepo }) => {
|
|
23
|
+
const { parseObsTier } = await import("../../services/obs-tiers.js");
|
|
24
|
+
const t = parseObsTier(tier ?? "lite");
|
|
25
|
+
if (t === "none")
|
|
26
|
+
return { filesCreated: [] };
|
|
27
|
+
const { resolveSdkSource } = await import("../runtime/shared.js");
|
|
28
|
+
const { setupObservabilityStack } = await import("../../services/obs-setup.js");
|
|
29
|
+
const source = await resolveSdkSource(projectDir, localRepo);
|
|
30
|
+
return { filesCreated: setupObservabilityStack(source, projectDir, t).copied };
|
|
31
|
+
},
|
|
32
|
+
verify: async (projectDir) => {
|
|
33
|
+
const compose = path.join(projectDir, "infra", "metrics", "docker-compose.yml");
|
|
34
|
+
if (!fs.existsSync(compose))
|
|
35
|
+
return { ok: true, message: "no dev stack copied (tier none)" };
|
|
36
|
+
const { parse } = await import("yaml");
|
|
37
|
+
const doc = parse(fs.readFileSync(compose, "utf8"));
|
|
38
|
+
const n = Object.keys(doc.services ?? {}).length;
|
|
39
|
+
return { ok: true, message: `dev stack present — ${n} service(s)`, dashboardUrl: "http://localhost:3000" };
|
|
40
|
+
},
|
|
41
|
+
cleanup: async ({ projectDir }) => {
|
|
42
|
+
fs.rmSync(path.join(projectDir, "infra", "metrics"), { recursive: true, force: true });
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
tracing: {
|
|
46
|
+
id: "tracing",
|
|
47
|
+
label: "Distributed tracing",
|
|
48
|
+
description: "OTLP spans + W3C propagation → Tempo/Jaeger (already wired in all triggers)",
|
|
49
|
+
dependencies: [],
|
|
50
|
+
envBlock: () => [
|
|
51
|
+
"# Tracing (tracing module). UNCOMMENT the endpoint to start exporting spans.",
|
|
52
|
+
"# Until then tracing is inert (the trigger no-ops without an endpoint).",
|
|
53
|
+
"# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318",
|
|
54
|
+
"# BLOK_TRACING_DISABLED=1 # force-disable even when an endpoint is set",
|
|
55
|
+
].join("\n"),
|
|
56
|
+
infraFiles: [],
|
|
57
|
+
composeServices: ["tempo"],
|
|
58
|
+
packageDeps: {},
|
|
59
|
+
verify: async (projectDir) => {
|
|
60
|
+
const envPath = path.join(projectDir, ".env.local");
|
|
61
|
+
const content = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
|
|
62
|
+
const active = content
|
|
63
|
+
.split("\n")
|
|
64
|
+
.map((l) => l.trim())
|
|
65
|
+
.find((l) => !l.startsWith("#") && /^OTEL_EXPORTER_OTLP_(TRACES_)?ENDPOINT=\S/.test(l));
|
|
66
|
+
if (active)
|
|
67
|
+
return { ok: true, message: `exporting spans to ${active.split("=").slice(1).join("=")}` };
|
|
68
|
+
return { ok: true, message: "added (inert) — uncomment OTEL_EXPORTER_OTLP_ENDPOINT to start exporting" };
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
"trace-store": {
|
|
72
|
+
id: "trace-store",
|
|
73
|
+
label: "Run trace store",
|
|
74
|
+
description: "Durable run history + Studio API (sqlite | postgres | memory)",
|
|
75
|
+
dependencies: [],
|
|
76
|
+
envBlock: () => [
|
|
77
|
+
"# Trace store (trace-store module). sqlite is durable + the default outside tests.",
|
|
78
|
+
"BLOK_TRACE_STORE=sqlite",
|
|
79
|
+
"BLOK_TRACE_SQLITE_PATH=.blok/trace.db",
|
|
80
|
+
].join("\n"),
|
|
81
|
+
infraFiles: [],
|
|
82
|
+
composeServices: [],
|
|
83
|
+
packageDeps: {},
|
|
84
|
+
},
|
|
85
|
+
metrics: {
|
|
86
|
+
id: "metrics",
|
|
87
|
+
label: "Prometheus metrics",
|
|
88
|
+
description: "~33 blok_* metrics + the /metrics exporter (ON by default)",
|
|
89
|
+
dependencies: [],
|
|
90
|
+
envBlock: () => [
|
|
91
|
+
"# Metrics (metrics module). The /metrics exporter is ON by default.",
|
|
92
|
+
"# BLOK_METRICS_DISABLED=1 # uncomment to turn the exporter OFF",
|
|
93
|
+
"# BLOK_METRICS_PORT=9464 # override the default exporter port",
|
|
94
|
+
].join("\n"),
|
|
95
|
+
infraFiles: [],
|
|
96
|
+
composeServices: [],
|
|
97
|
+
packageDeps: {},
|
|
98
|
+
},
|
|
99
|
+
logging: {
|
|
100
|
+
id: "logging",
|
|
101
|
+
label: "Structured logging → Loki",
|
|
102
|
+
description: "JSON logs (run_id/trace_id) shipped to Loki via Grafana Alloy",
|
|
103
|
+
dependencies: ["trace-store"],
|
|
104
|
+
envBlock: () => [
|
|
105
|
+
"# Logging (logging module). Structured JSON to stdout; ship to Loki via Alloy.",
|
|
106
|
+
"CONSOLE_LOG_ACTIVE=true",
|
|
107
|
+
"# BLOK_LOG_LEVEL=info",
|
|
108
|
+
].join("\n"),
|
|
109
|
+
infraFiles: [],
|
|
110
|
+
composeServices: ["loki", "alloy"],
|
|
111
|
+
packageDeps: {},
|
|
112
|
+
verify: async (projectDir) => {
|
|
113
|
+
const envPath = path.join(projectDir, ".env.local");
|
|
114
|
+
const env = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
|
|
115
|
+
const active = env
|
|
116
|
+
.split("\n")
|
|
117
|
+
.map((l) => l.trim())
|
|
118
|
+
.some((l) => !l.startsWith("#") && /^CONSOLE_LOG_ACTIVE\s*=\s*true$/i.test(l));
|
|
119
|
+
const shipper = fs.existsSync(path.join(projectDir, "infra", "metrics", "alloy-config.alloy"));
|
|
120
|
+
if (!active)
|
|
121
|
+
return { ok: true, message: "structured logging is OFF — set CONSOLE_LOG_ACTIVE=true" };
|
|
122
|
+
if (!shipper)
|
|
123
|
+
return { ok: true, message: "structured JSON logging on; add obs-stack=full to ship to Loki" };
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
message: "shipping JSON logs to Loki via Alloy",
|
|
127
|
+
dashboardUrl: "http://localhost:3000/explore",
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
alerting: {
|
|
132
|
+
id: "alerting",
|
|
133
|
+
label: "Alerting rules",
|
|
134
|
+
description: "Prometheus alert rules + Helm PrometheusRule",
|
|
135
|
+
dependencies: ["metrics"],
|
|
136
|
+
envBlock: () => [
|
|
137
|
+
"# Alerting (alerting module). Rules live in infra/metrics/rules + the Helm PrometheusRule.",
|
|
138
|
+
"BLOK_ALERTING_ENABLED=true",
|
|
139
|
+
].join("\n"),
|
|
140
|
+
infraFiles: [],
|
|
141
|
+
composeServices: ["alertmanager"],
|
|
142
|
+
packageDeps: {},
|
|
143
|
+
verify: async (projectDir) => {
|
|
144
|
+
const rules = path.join(projectDir, "infra", "metrics", "rules", "blok-alerts.yml");
|
|
145
|
+
if (fs.existsSync(rules))
|
|
146
|
+
return { ok: true, message: "alert rules present (validate: promtool check rules)" };
|
|
147
|
+
return { ok: true, message: "enabled; rules ship with obs-stack=full (infra/metrics/rules)" };
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
"error-sink": {
|
|
151
|
+
id: "error-sink",
|
|
152
|
+
label: "Error sink (Sentry)",
|
|
153
|
+
description: "Forward unhandled errors to a Sentry-compatible error sink (set SENTRY_DSN)",
|
|
154
|
+
dependencies: [],
|
|
155
|
+
envBlock: () => [
|
|
156
|
+
"# Error sink (error-sink module). Set a DSN to forward unhandled errors;",
|
|
157
|
+
"# unset = inert. Needs the @sentry/node peer dep installed.",
|
|
158
|
+
"# SENTRY_DSN=",
|
|
159
|
+
].join("\n"),
|
|
160
|
+
infraFiles: [],
|
|
161
|
+
composeServices: [],
|
|
162
|
+
packageDeps: {},
|
|
163
|
+
verify: async (projectDir) => {
|
|
164
|
+
const envPath = path.join(projectDir, ".env.local");
|
|
165
|
+
const env = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
|
|
166
|
+
const dsnSet = env
|
|
167
|
+
.split("\n")
|
|
168
|
+
.map((l) => l.trim())
|
|
169
|
+
.some((l) => !l.startsWith("#") && /^SENTRY_DSN=\S/.test(l));
|
|
170
|
+
const installed = fs.existsSync(path.join(projectDir, "node_modules", "@sentry", "node"));
|
|
171
|
+
if (!dsnSet)
|
|
172
|
+
return { ok: true, message: "added (inert) — set SENTRY_DSN to start forwarding errors" };
|
|
173
|
+
if (!installed)
|
|
174
|
+
return { ok: true, message: "SENTRY_DSN set but @sentry/node missing — run npm i @sentry/node" };
|
|
175
|
+
return { ok: true, message: "forwarding unhandled errors to Sentry" };
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
export function getObservabilityModule(id) {
|
|
180
|
+
return REGISTRY[id];
|
|
181
|
+
}
|
|
182
|
+
export function allObservabilityModules() {
|
|
183
|
+
return OBSERVABILITY_MODULE_IDS.map((id) => REGISTRY[id]);
|
|
184
|
+
}
|
|
185
|
+
export function resolveWithDependencies(ids) {
|
|
186
|
+
const out = new Set();
|
|
187
|
+
const visit = (id) => {
|
|
188
|
+
const mod = getObservabilityModule(id);
|
|
189
|
+
if (!mod)
|
|
190
|
+
throw new Error(`Unknown observability module "${id}". Known: ${OBSERVABILITY_MODULE_IDS.join(", ")}.`);
|
|
191
|
+
if (out.has(mod.id))
|
|
192
|
+
return;
|
|
193
|
+
for (const dep of mod.dependencies)
|
|
194
|
+
visit(dep);
|
|
195
|
+
out.add(mod.id);
|
|
196
|
+
};
|
|
197
|
+
for (const id of ids)
|
|
198
|
+
visit(id);
|
|
199
|
+
const resolved = OBSERVABILITY_MODULE_IDS.filter((id) => out.has(id));
|
|
200
|
+
const requested = new Set(ids);
|
|
201
|
+
const added = resolved.filter((id) => !requested.has(id));
|
|
202
|
+
return { resolved, added };
|
|
203
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|