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,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 {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveObservabilitySelection } from "./apply.js";
|
|
3
|
+
import { allObservabilityModules, resolveWithDependencies } from "./descriptor.js";
|
|
4
|
+
describe("resolveWithDependencies", () => {
|
|
5
|
+
it("pulls in a transitive dependency (alerting → metrics)", () => {
|
|
6
|
+
const { resolved, added } = resolveWithDependencies(["alerting"]);
|
|
7
|
+
expect(resolved).toContain("metrics");
|
|
8
|
+
expect(resolved).toContain("alerting");
|
|
9
|
+
expect(added).toEqual(["metrics"]);
|
|
10
|
+
});
|
|
11
|
+
it("logging → trace-store", () => {
|
|
12
|
+
expect(resolveWithDependencies(["logging"]).resolved).toContain("trace-store");
|
|
13
|
+
});
|
|
14
|
+
it("no spurious deps for a leaf module, and no duplicates", () => {
|
|
15
|
+
const { resolved, added } = resolveWithDependencies(["tracing", "tracing"]);
|
|
16
|
+
expect(resolved).toEqual(["tracing"]);
|
|
17
|
+
expect(added).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
it("throws on an unknown module id", () => {
|
|
20
|
+
expect(() => resolveWithDependencies(["nope"])).toThrow(/Unknown observability module/);
|
|
21
|
+
});
|
|
22
|
+
it("every dependency id is itself a real module", () => {
|
|
23
|
+
const ids = new Set(allObservabilityModules().map((m) => m.id));
|
|
24
|
+
for (const m of allObservabilityModules())
|
|
25
|
+
for (const dep of m.dependencies)
|
|
26
|
+
expect(ids.has(dep)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe("resolveObservabilitySelection (create-time)", () => {
|
|
30
|
+
const opts = { addedAt: "2026-01-01T00:00:00.000Z", version: "0.6.0", projectDir: "/tmp/x" };
|
|
31
|
+
it("empty selection → empty everything", () => {
|
|
32
|
+
expect(resolveObservabilitySelection([], opts)).toEqual({ configMap: {}, envBlocks: [], added: [] });
|
|
33
|
+
});
|
|
34
|
+
it("builds a config map for the resolved set incl. deps", () => {
|
|
35
|
+
const { configMap, added } = resolveObservabilitySelection(["alerting"], opts);
|
|
36
|
+
expect(Object.keys(configMap).sort()).toEqual(["alerting", "metrics"]);
|
|
37
|
+
expect(configMap.metrics).toEqual({ enabled: true, addedAt: opts.addedAt, version: opts.version });
|
|
38
|
+
expect(added).toEqual(["metrics"]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { program } from "../../services/commander.js";
|
|
3
|
+
import { observabilityAdd } from "./add.js";
|
|
4
|
+
import { OBSERVABILITY_MODULE_IDS } from "./descriptor.js";
|
|
5
|
+
import { observabilityList } from "./list.js";
|
|
6
|
+
import { observabilityRemove } from "./remove.js";
|
|
7
|
+
import { observabilityStatus } from "./status.js";
|
|
8
|
+
const MODULE_LIST = OBSERVABILITY_MODULE_IDS.join(", ");
|
|
9
|
+
const observability = new Command("observability")
|
|
10
|
+
.alias("obs")
|
|
11
|
+
.description("Add, remove, list, or check observability modules (metrics, tracing, logging, …) in a project");
|
|
12
|
+
observability.action(() => {
|
|
13
|
+
observability.help();
|
|
14
|
+
});
|
|
15
|
+
observability
|
|
16
|
+
.command("add")
|
|
17
|
+
.description(`Enable an observability module (${MODULE_LIST})`)
|
|
18
|
+
.argument("[module]", "Module to add (omit for an interactive picker)")
|
|
19
|
+
.option("-d, --directory <path>", "Project directory (default: current directory)")
|
|
20
|
+
.option("--force", "Re-apply even if the module is already enabled")
|
|
21
|
+
.option("--tier <tier>", "obs-stack only: none | lite | full (default: lite)")
|
|
22
|
+
.option("--local <path>", "obs-stack only: copy infra from a local blok repo instead of fetching")
|
|
23
|
+
.option("-y, --yes", "Skip prompts (non-interactive; auto-enables dependencies)")
|
|
24
|
+
.action(async (moduleArg, options) => {
|
|
25
|
+
await observabilityAdd(moduleArg, options);
|
|
26
|
+
});
|
|
27
|
+
observability
|
|
28
|
+
.command("remove")
|
|
29
|
+
.alias("rm")
|
|
30
|
+
.description("Disable an observability module")
|
|
31
|
+
.argument("<module>", "Module to remove")
|
|
32
|
+
.option("-d, --directory <path>", "Project directory (default: current directory)")
|
|
33
|
+
.option("-y, --yes", "Skip confirmation (non-interactive)")
|
|
34
|
+
.action(async (moduleArg, options) => {
|
|
35
|
+
await observabilityRemove(moduleArg, options);
|
|
36
|
+
});
|
|
37
|
+
observability
|
|
38
|
+
.command("list")
|
|
39
|
+
.alias("ls")
|
|
40
|
+
.description("List enabled modules and which are available to add")
|
|
41
|
+
.option("-d, --directory <path>", "Project directory (default: current directory)")
|
|
42
|
+
.option("--json", "Output as JSON")
|
|
43
|
+
.action(async (options) => {
|
|
44
|
+
await observabilityList(options);
|
|
45
|
+
});
|
|
46
|
+
observability
|
|
47
|
+
.command("status")
|
|
48
|
+
.description("Report the health of each enabled observability module")
|
|
49
|
+
.option("-d, --directory <path>", "Project directory (default: current directory)")
|
|
50
|
+
.action(async (options) => {
|
|
51
|
+
await observabilityStatus(options);
|
|
52
|
+
});
|
|
53
|
+
program.addCommand(observability);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import color from "picocolors";
|
|
3
|
+
import { allObservabilityModules } from "./descriptor.js";
|
|
4
|
+
import { readConfigSafe, reportObservabilityError, resolveProjectRoot } from "./shared.js";
|
|
5
|
+
export async function observabilityList(options) {
|
|
6
|
+
try {
|
|
7
|
+
const root = resolveProjectRoot(options.directory);
|
|
8
|
+
const enabled = readConfigSafe(root).observability ?? {};
|
|
9
|
+
const modules = allObservabilityModules();
|
|
10
|
+
if (options.json) {
|
|
11
|
+
console.log(JSON.stringify(modules.map((m) => ({
|
|
12
|
+
id: m.id,
|
|
13
|
+
label: m.label,
|
|
14
|
+
enabled: Boolean(enabled[m.id]?.enabled),
|
|
15
|
+
addedAt: enabled[m.id]?.addedAt,
|
|
16
|
+
dependencies: m.dependencies,
|
|
17
|
+
})), null, 2));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
p.intro(color.inverse(" Observability modules "));
|
|
21
|
+
const on = modules.filter((m) => enabled[m.id]?.enabled);
|
|
22
|
+
const off = modules.filter((m) => !enabled[m.id]?.enabled);
|
|
23
|
+
if (on.length > 0) {
|
|
24
|
+
p.note(on
|
|
25
|
+
.map((m) => {
|
|
26
|
+
const added = enabled[m.id]?.addedAt;
|
|
27
|
+
const when = added ? color.dim(` added ${added.slice(0, 10)}`) : "";
|
|
28
|
+
return `${color.green("✓")} ${color.bold(m.label.padEnd(26))} ${color.dim(m.id.padEnd(13))}${when}`;
|
|
29
|
+
})
|
|
30
|
+
.join("\n"), `Enabled (${on.length})`);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
p.log.info(color.dim("No observability modules enabled yet."));
|
|
34
|
+
}
|
|
35
|
+
if (off.length > 0) {
|
|
36
|
+
p.note(off
|
|
37
|
+
.map((m) => `${color.bold(m.label.padEnd(26))} ${color.dim(m.description)}\n ${color.dim(`blokctl observability add ${m.id}`)}`)
|
|
38
|
+
.join("\n"), `Available to add (${off.length})`);
|
|
39
|
+
}
|
|
40
|
+
p.outro(color.dim("Add with `blokctl observability add <id>`, remove with `… remove <id>`."));
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
reportObservabilityError(err);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 logging = getObservabilityModule("logging");
|
|
7
|
+
describe("logging module (MO-LOGGING)", () => {
|
|
8
|
+
it("depends on trace-store and declares loki + alloy compose services", () => {
|
|
9
|
+
expect(logging.dependencies).toEqual(["trace-store"]);
|
|
10
|
+
expect(logging.composeServices).toEqual(["loki", "alloy"]);
|
|
11
|
+
});
|
|
12
|
+
it("envBlock turns structured logging on", () => {
|
|
13
|
+
expect(logging.envBlock({ projectDir: "/tmp/x" })).toContain("CONSOLE_LOG_ACTIVE=true");
|
|
14
|
+
});
|
|
15
|
+
describe("verify()", () => {
|
|
16
|
+
let tmp;
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "blok-logging-"));
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
it("reports OFF when CONSOLE_LOG_ACTIVE isn't true", async () => {
|
|
24
|
+
fs.writeFileSync(path.join(tmp, ".env.local"), "PORT=4000\n# CONSOLE_LOG_ACTIVE=true\n");
|
|
25
|
+
const r = await logging.verify?.(tmp);
|
|
26
|
+
expect(r?.message).toMatch(/OFF/);
|
|
27
|
+
});
|
|
28
|
+
it("reports on-but-no-shipper when logging is active without the alloy config", async () => {
|
|
29
|
+
fs.writeFileSync(path.join(tmp, ".env.local"), "CONSOLE_LOG_ACTIVE=true\n");
|
|
30
|
+
const r = await logging.verify?.(tmp);
|
|
31
|
+
expect(r?.message).toMatch(/add obs-stack=full/);
|
|
32
|
+
expect(r?.dashboardUrl).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
it("reports shipping when active AND the alloy config is present", async () => {
|
|
35
|
+
fs.writeFileSync(path.join(tmp, ".env.local"), "CONSOLE_LOG_ACTIVE=true\n");
|
|
36
|
+
fs.mkdirSync(path.join(tmp, "infra", "metrics"), { recursive: true });
|
|
37
|
+
fs.writeFileSync(path.join(tmp, "infra", "metrics", "alloy-config.alloy"), "// alloy\n");
|
|
38
|
+
const r = await logging.verify?.(tmp);
|
|
39
|
+
expect(r?.message).toMatch(/shipping JSON logs to Loki/);
|
|
40
|
+
expect(r?.dashboardUrl).toContain("/explore");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
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 obsStack = getObservabilityModule("obs-stack");
|
|
7
|
+
describe("obs-stack module retrofit (MO-STACK T4)", () => {
|
|
8
|
+
let tmp;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "blok-obsstack-"));
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
13
|
+
it("scaffold with tier=none is a no-op (writes nothing)", async () => {
|
|
14
|
+
const r = await obsStack.scaffold?.({ projectDir: tmp, nonInteractive: true, tier: "none" });
|
|
15
|
+
expect(r).toEqual({ filesCreated: [] });
|
|
16
|
+
expect(fs.existsSync(path.join(tmp, "infra"))).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
it("verify reports tier-none when no stack is present", async () => {
|
|
19
|
+
expect((await obsStack.verify?.(tmp))?.message).toMatch(/tier none/);
|
|
20
|
+
});
|
|
21
|
+
it("verify reports the service count when a stack is present", async () => {
|
|
22
|
+
fs.mkdirSync(path.join(tmp, "infra", "metrics"), { recursive: true });
|
|
23
|
+
fs.writeFileSync(path.join(tmp, "infra", "metrics", "docker-compose.yml"), "services:\n prometheus: {}\n grafana: {}\n");
|
|
24
|
+
const r = await obsStack.verify?.(tmp);
|
|
25
|
+
expect(r?.message).toMatch(/2 service/);
|
|
26
|
+
expect(r?.dashboardUrl).toContain("3000");
|
|
27
|
+
});
|
|
28
|
+
it("cleanup removes the copied infra/metrics (the remove contract)", async () => {
|
|
29
|
+
fs.mkdirSync(path.join(tmp, "infra", "metrics"), { recursive: true });
|
|
30
|
+
await obsStack.cleanup?.({ projectDir: tmp, nonInteractive: true });
|
|
31
|
+
expect(fs.existsSync(path.join(tmp, "infra", "metrics"))).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
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, withoutObservabilityModule } from "../../services/observability-mutations.js";
|
|
7
|
+
import { OBSERVABILITY_MODULE_IDS, getObservabilityModule } from "./descriptor.js";
|
|
8
|
+
import { ObservabilityCommandError, readConfigSafe, reportObservabilityError, resolveProjectRoot } from "./shared.js";
|
|
9
|
+
export async function observabilityRemove(moduleArg, options) {
|
|
10
|
+
try {
|
|
11
|
+
const id = moduleArg.trim().toLowerCase();
|
|
12
|
+
const mod = getObservabilityModule(id);
|
|
13
|
+
if (!mod) {
|
|
14
|
+
throw new ObservabilityCommandError(`Unknown observability module "${id}". Known: ${OBSERVABILITY_MODULE_IDS.join(", ")}.`);
|
|
15
|
+
}
|
|
16
|
+
const root = resolveProjectRoot(options.directory);
|
|
17
|
+
const config = readConfigSafe(root);
|
|
18
|
+
const enabled = config.observability ?? {};
|
|
19
|
+
const nonInteractive = isNonInteractive() || options.yes === true;
|
|
20
|
+
p.intro(color.inverse(` Remove ${mod.label} `));
|
|
21
|
+
if (!enabled[mod.id]) {
|
|
22
|
+
p.outro(color.dim(`${mod.label} isn't enabled in this project — nothing to remove.`));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const dependents = Object.keys(enabled).filter((eid) => getObservabilityModule(eid)?.dependencies.includes(mod.id));
|
|
26
|
+
if (dependents.length > 0) {
|
|
27
|
+
const labels = dependents.map((d) => getObservabilityModule(d)?.label ?? d).join(", ");
|
|
28
|
+
p.log.warn(`${color.yellow(labels)} ${dependents.length > 1 ? "depend" : "depends"} on ${color.bold(mod.label)} — remove ${dependents.length > 1 ? "them" : "it"} too, or expect reduced function.`);
|
|
29
|
+
}
|
|
30
|
+
if (!nonInteractive) {
|
|
31
|
+
const ok = await p.confirm({ message: `Remove the ${mod.label} module?`, initialValue: false });
|
|
32
|
+
if (p.isCancel(ok) || !ok) {
|
|
33
|
+
p.outro(color.dim("Left unchanged."));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (mod.cleanup)
|
|
38
|
+
await mod.cleanup({ projectDir: root, nonInteractive });
|
|
39
|
+
const nextConfig = withoutObservabilityModule(config, mod.id);
|
|
40
|
+
fs.mkdirSync(path.join(root, ".blok"), { recursive: true });
|
|
41
|
+
fs.writeFileSync(path.join(root, ".blok", "config.json"), `${JSON.stringify(nextConfig, null, 2)}\n`);
|
|
42
|
+
const remainingIds = Object.keys(nextConfig.observability ?? {});
|
|
43
|
+
const envBlocks = remainingIds.map((eid) => getObservabilityModule(eid)?.envBlock({ projectDir: root }) ?? "");
|
|
44
|
+
const envPath = path.join(root, ".env.local");
|
|
45
|
+
if (fs.existsSync(envPath)) {
|
|
46
|
+
fs.writeFileSync(envPath, rewriteObservabilityEnvBlock(fs.readFileSync(envPath, "utf8"), envBlocks));
|
|
47
|
+
}
|
|
48
|
+
p.note([
|
|
49
|
+
`${color.red("−")} .blok/config.json ${color.dim(`observability.${mod.id}`)}`,
|
|
50
|
+
`${color.red("−")} .env.local ${color.dim(`${mod.label} env block`)}`,
|
|
51
|
+
mod.infraFiles.length > 0 && !mod.cleanup
|
|
52
|
+
? `${color.yellow("•")} infra files left in place ${color.dim("(remove by hand if unused)")}`
|
|
53
|
+
: "",
|
|
54
|
+
]
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.join("\n"), `${mod.label} removed`);
|
|
57
|
+
p.outro(color.dim(`Re-add anytime: blokctl observability add ${mod.id}.`));
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
reportObservabilityError(err);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { readConfigSafe, resolveProjectRoot } from "../runtime/shared.js";
|
|
2
|
+
export { readConfigSafe, resolveProjectRoot };
|
|
3
|
+
export declare class ObservabilityCommandError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export declare function readFrameworkVersion(projectRoot: string): string | undefined;
|
|
6
|
+
export declare function reportObservabilityError(err: unknown): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import color from "picocolors";
|
|
3
|
+
import { RuntimeCommandError, readConfigSafe, readFrameworkTag, resolveProjectRoot } from "../runtime/shared.js";
|
|
4
|
+
export { readConfigSafe, resolveProjectRoot };
|
|
5
|
+
export class ObservabilityCommandError extends Error {
|
|
6
|
+
}
|
|
7
|
+
export function readFrameworkVersion(projectRoot) {
|
|
8
|
+
try {
|
|
9
|
+
return readFrameworkTag(projectRoot)?.replace(/^v/, "");
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function reportObservabilityError(err) {
|
|
16
|
+
if (err instanceof ObservabilityCommandError || err instanceof RuntimeCommandError) {
|
|
17
|
+
p.cancel(err.message);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
p.cancel(color.red(`Unexpected error: ${err?.message ?? String(err)}`));
|
|
21
|
+
}
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import color from "picocolors";
|
|
3
|
+
import { getObservabilityModule } from "./descriptor.js";
|
|
4
|
+
import { readConfigSafe, reportObservabilityError, resolveProjectRoot } from "./shared.js";
|
|
5
|
+
export async function observabilityStatus(options) {
|
|
6
|
+
try {
|
|
7
|
+
const root = resolveProjectRoot(options.directory);
|
|
8
|
+
const enabled = readConfigSafe(root).observability ?? {};
|
|
9
|
+
const enabledIds = Object.keys(enabled).filter((id) => enabled[id]?.enabled);
|
|
10
|
+
p.intro(color.inverse(" Observability status "));
|
|
11
|
+
if (enabledIds.length === 0) {
|
|
12
|
+
p.outro(color.dim("No observability modules enabled. Add one with `blokctl observability add <id>`."));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const rows = [];
|
|
16
|
+
for (const id of enabledIds) {
|
|
17
|
+
const mod = getObservabilityModule(id);
|
|
18
|
+
if (!mod)
|
|
19
|
+
continue;
|
|
20
|
+
if (mod.verify) {
|
|
21
|
+
const res = await mod.verify(root);
|
|
22
|
+
const mark = res.ok ? color.green("✓") : color.yellow("!");
|
|
23
|
+
const link = res.dashboardUrl ? color.dim(` ${res.dashboardUrl}`) : "";
|
|
24
|
+
rows.push(`${mark} ${color.bold(mod.label.padEnd(26))} ${res.message}${link}`);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
rows.push(`${color.dim("•")} ${color.bold(mod.label.padEnd(26))} ${color.dim("enabled (no health check yet)")}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
p.note(rows.join("\n"), `Enabled (${enabledIds.length})`);
|
|
31
|
+
p.outro(color.dim("Health probes land with each module epic."));
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
reportObservabilityError(err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|