@intentius/chant-lexicon-otel 0.106.4 → 0.108.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.
Files changed (86) hide show
  1. package/README.md +1 -1
  2. package/dist/collector.d.ts +6 -0
  3. package/dist/collector.d.ts.map +1 -1
  4. package/dist/composites/catalog.d.ts.map +1 -1
  5. package/dist/composites/genai-pipeline.d.ts +33 -0
  6. package/dist/composites/genai-pipeline.d.ts.map +1 -0
  7. package/dist/composites/index.d.ts +8 -0
  8. package/dist/composites/index.d.ts.map +1 -1
  9. package/dist/composites/otlp-collector.d.ts +43 -0
  10. package/dist/composites/otlp-collector.d.ts.map +1 -0
  11. package/dist/composites/red-metrics.d.ts +77 -0
  12. package/dist/composites/red-metrics.d.ts.map +1 -0
  13. package/dist/composites/tail-sampling-tier.d.ts +90 -0
  14. package/dist/composites/tail-sampling-tier.d.ts.map +1 -0
  15. package/dist/configmap.d.ts +16 -0
  16. package/dist/configmap.d.ts.map +1 -1
  17. package/dist/genai.d.ts +19 -0
  18. package/dist/genai.d.ts.map +1 -1
  19. package/dist/import/embedded.d.ts +8 -0
  20. package/dist/import/embedded.d.ts.map +1 -1
  21. package/dist/index.d.ts +8 -5
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/integrity.json +3 -3
  24. package/dist/lint/post-synth/otel-helpers.d.ts +9 -2
  25. package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
  26. package/dist/manifest.json +1 -1
  27. package/dist/metric-names.d.ts +68 -0
  28. package/dist/metric-names.d.ts.map +1 -1
  29. package/dist/op/activities/collector-audit.d.ts +95 -0
  30. package/dist/op/activities/collector-audit.d.ts.map +1 -0
  31. package/dist/op/activities/collector-health.d.ts +74 -0
  32. package/dist/op/activities/collector-health.d.ts.map +1 -0
  33. package/dist/op/activities/exec.d.ts +19 -0
  34. package/dist/op/activities/exec.d.ts.map +1 -0
  35. package/dist/op/activities/github.d.ts +55 -0
  36. package/dist/op/activities/github.d.ts.map +1 -0
  37. package/dist/op/activities/index.d.ts +20 -0
  38. package/dist/op/activities/index.d.ts.map +1 -0
  39. package/dist/op/activities/otelcol.d.ts +96 -0
  40. package/dist/op/activities/otelcol.d.ts.map +1 -0
  41. package/dist/op/activity-contracts.d.ts +96 -0
  42. package/dist/op/activity-contracts.d.ts.map +1 -0
  43. package/dist/op/audit-op.d.ts +35 -0
  44. package/dist/op/audit-op.d.ts.map +1 -0
  45. package/dist/op/builders.d.ts +43 -0
  46. package/dist/op/builders.d.ts.map +1 -0
  47. package/dist/platform.d.ts +14 -0
  48. package/dist/platform.d.ts.map +1 -1
  49. package/dist/rules/otel-helpers.ts +18 -5
  50. package/package.json +14 -3
  51. package/src/collector.ts +9 -0
  52. package/src/composites/catalog.test.ts +1 -1
  53. package/src/composites/catalog.ts +292 -0
  54. package/src/composites/genai-pipeline.test.ts +53 -0
  55. package/src/composites/genai-pipeline.ts +40 -0
  56. package/src/composites/index.ts +8 -0
  57. package/src/composites/otlp-collector.test.ts +43 -0
  58. package/src/composites/otlp-collector.ts +70 -0
  59. package/src/composites/red-metrics.test.ts +131 -0
  60. package/src/composites/red-metrics.ts +148 -0
  61. package/src/composites/tail-sampling-tier.test.ts +100 -0
  62. package/src/composites/tail-sampling-tier.ts +182 -0
  63. package/src/configmap-operator.test.ts +44 -0
  64. package/src/configmap.ts +35 -0
  65. package/src/detect.ts +13 -1
  66. package/src/genai.ts +72 -59
  67. package/src/import/embedded.test.ts +36 -0
  68. package/src/import/embedded.ts +28 -4
  69. package/src/index.ts +57 -2
  70. package/src/lint/post-synth/otel-helpers.ts +18 -5
  71. package/src/metric-names.ts +141 -0
  72. package/src/op/activities/collector-audit.test.ts +166 -0
  73. package/src/op/activities/collector-audit.ts +265 -0
  74. package/src/op/activities/collector-health.test.ts +115 -0
  75. package/src/op/activities/collector-health.ts +211 -0
  76. package/src/op/activities/exec.ts +33 -0
  77. package/src/op/activities/github.ts +127 -0
  78. package/src/op/activities/index.ts +19 -0
  79. package/src/op/activities/otelcol.test.ts +162 -0
  80. package/src/op/activities/otelcol.ts +233 -0
  81. package/src/op/activity-contracts.test.ts +103 -0
  82. package/src/op/activity-contracts.ts +92 -0
  83. package/src/op/audit-op.ts +53 -0
  84. package/src/op/builders.ts +52 -0
  85. package/src/op/testdata/otelcol-components-v0.130.0.yaml +82 -0
  86. package/src/platform.ts +45 -8
@@ -0,0 +1,115 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { createServer, type Server } from "node:http";
3
+ import type { AddressInfo } from "node:net";
4
+ import { collectorHealthObserve, declaredEndpoints, splitEndpoint } from "./collector-health";
5
+
6
+ function fakeFetch(answers: Record<string, number | "refused">): { f: typeof fetch; urls: string[] } {
7
+ const urls: string[] = [];
8
+ const f = (async (input: string | URL | Request) => {
9
+ const url = String(input);
10
+ urls.push(url);
11
+ const a = answers[url];
12
+ if (a === undefined || a === "refused") throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } });
13
+ return new Response("", { status: a });
14
+ }) as typeof fetch;
15
+ return { f, urls };
16
+ }
17
+
18
+ const withHealth = {
19
+ extensions: { health_check: { endpoint: "0.0.0.0:13133" }, zpages: { endpoint: "localhost:55679" } },
20
+ service: {
21
+ extensions: ["health_check", "zpages"],
22
+ telemetry: { metrics: { readers: [{ pull: { exporter: { prometheus: { host: "0.0.0.0", port: 8888 } } } }] } },
23
+ pipelines: { traces: { receivers: ["otlp"], exporters: ["debug"] } },
24
+ },
25
+ };
26
+
27
+ describe("the endpoints a collector config declares", () => {
28
+ test("health_check, zpages and the pull reader's metrics, with 0.0.0.0 read as localhost", () => {
29
+ expect(declaredEndpoints(withHealth)).toEqual([
30
+ { kind: "healthCheck", url: "http://localhost:13133/" },
31
+ { kind: "zpages", url: "http://localhost:55679/debug/servicez" },
32
+ { kind: "telemetry", url: "http://localhost:8888/metrics" },
33
+ ]);
34
+ });
35
+
36
+ test("health_check at its v0.130.0 default (localhost:13133, /) when it sets nothing, and its own path", () => {
37
+ expect(declaredEndpoints({ extensions: { health_check: {} }, service: { extensions: ["health_check"] } })).toEqual([
38
+ { kind: "healthCheck", url: "http://localhost:13133/" },
39
+ ]);
40
+ expect(
41
+ declaredEndpoints({ extensions: { "health_check/ready": { endpoint: "${env:POD_IP}:8080", path: "/ready" } }, service: { extensions: ["health_check/ready"] } }),
42
+ ).toEqual([{ kind: "healthCheck", url: "http://localhost:8080/ready" }]);
43
+ });
44
+
45
+ test("an extension declared but not enabled in service.extensions is not an endpoint", () => {
46
+ expect(declaredEndpoints({ extensions: { health_check: {} }, service: {} })).toEqual([]);
47
+ });
48
+
49
+ test("the older telemetry address, host and URL overrides", () => {
50
+ const cfg = { extensions: { health_check: {} }, service: { extensions: ["health_check"], telemetry: { metrics: { address: ":8888" } } } };
51
+ expect(declaredEndpoints(cfg, "otel.example")).toEqual([
52
+ { kind: "healthCheck", url: "http://otel.example:13133/" },
53
+ { kind: "telemetry", url: "http://otel.example:8888/metrics" },
54
+ ]);
55
+ expect(declaredEndpoints(cfg, undefined, { healthCheck: "http://127.0.0.1:31333/" })[0]).toEqual({ kind: "healthCheck", url: "http://127.0.0.1:31333/" });
56
+ });
57
+
58
+ test("splitEndpoint reads host:port, :port and URLs", () => {
59
+ expect(splitEndpoint("0.0.0.0:13133")).toEqual({ host: "0.0.0.0", port: "13133" });
60
+ expect(splitEndpoint(":8888")).toEqual({ host: "", port: "8888" });
61
+ expect(splitEndpoint("http://otel:13133/x")).toEqual({ host: "otel", port: "13133" });
62
+ expect(splitEndpoint("nope")).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe("collectorHealthObserve", () => {
67
+ test("one resource per collector: in-sync when every declared endpoint answers 200", async () => {
68
+ const { f } = fakeFetch({ "http://localhost:13133/": 200, "http://localhost:55679/debug/servicez": 200, "http://localhost:8888/metrics": 200 });
69
+ const r = await collectorHealthObserve({ collectors: [{ name: "gateway", configObject: withHealth }], _fetch: f, probeIntervalMs: 0 });
70
+ expect(r.resources).toEqual([{ name: "gateway", status: "in-sync", detail: "health_check ok, zpages ok, telemetry ok" }]);
71
+ });
72
+
73
+ test("drifted when an endpoint answers otherwise or not at all, naming each", async () => {
74
+ const { f, urls } = fakeFetch({ "http://localhost:13133/": 503, "http://localhost:55679/debug/servicez": 200 });
75
+ const r = await collectorHealthObserve({ collectors: [{ name: "gateway", configObject: withHealth }], _fetch: f, probes: 2, probeIntervalMs: 0 });
76
+ expect(r.resources[0].status).toBe("drifted");
77
+ expect(r.resources[0].detail).toBe(
78
+ "health_check http://localhost:13133/ answered 503; telemetry http://localhost:8888/metrics did not answer (ECONNREFUSED)",
79
+ );
80
+ // A failing endpoint is probed `probes` times before it counts.
81
+ expect(urls.filter((u) => u === "http://localhost:13133/")).toHaveLength(2);
82
+ });
83
+
84
+ test("no health_check in service.extensions is unknown, never drifted, and nothing is probed", async () => {
85
+ const { f, urls } = fakeFetch({});
86
+ const r = await collectorHealthObserve({
87
+ collectors: [{ name: "agent", configObject: { extensions: { zpages: {} }, service: { extensions: ["zpages"] } } }],
88
+ _fetch: f,
89
+ });
90
+ expect(r.resources).toEqual([{ name: "agent", status: "unknown", detail: "no health_check in service.extensions" }]);
91
+ expect(urls).toEqual([]);
92
+ });
93
+
94
+ test("an unreadable config is unknown", async () => {
95
+ const r = await collectorHealthObserve({ collectors: [{ name: "x", config: "/nonexistent/collector.yaml" }] });
96
+ expect(r.resources[0]).toMatchObject({ name: "x", status: "unknown" });
97
+ expect(r.resources[0].detail).toMatch(/config unreadable/);
98
+ });
99
+
100
+ test("against a real HTTP server standing in for health_check", async () => {
101
+ const server: Server = createServer((req, res) => {
102
+ res.statusCode = req.url === "/" ? 200 : 404;
103
+ res.end('{"status":"Server available"}');
104
+ });
105
+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
106
+ const port = (server.address() as AddressInfo).port;
107
+ try {
108
+ const config = { extensions: { health_check: { endpoint: `0.0.0.0:${port}` } }, service: { extensions: ["health_check"] } };
109
+ const up = await collectorHealthObserve({ collectors: [{ name: "c", configObject: config, host: "127.0.0.1" }] });
110
+ expect(up.resources[0].status).toBe("in-sync");
111
+ } finally {
112
+ await new Promise<void>((r) => server.close(() => r()));
113
+ }
114
+ });
115
+ });
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Running collectors as resources a `ConvergeOp` observes (#3369).
3
+ *
4
+ * `collectorHealthObserve` returns one resource per collector, from the
5
+ * endpoints its config declares:
6
+ *
7
+ * - the `health_check` extension (`service.extensions`), at its `endpoint`
8
+ * and `path`. At v0.130.0 it answers on `localhost:13133` at `/` by
9
+ * default, 200 while the collector runs, and reports no per-component
10
+ * status, so a 200 is all it says.
11
+ * - the `zpages` extension, when the service enables it: `/debug/servicez`
12
+ * on its endpoint (default `localhost:55679`).
13
+ * - the collector's own metrics, when `service.telemetry.metrics` declares
14
+ * where they are served: a `pull` reader's prometheus exporter
15
+ * (`host`/`port`), or the older `address`. `/metrics` on it.
16
+ *
17
+ * A collector whose service enables no `health_check` is `unknown`, never
18
+ * `drifted`: nothing it declares says whether it is healthy. One that does
19
+ * is `in-sync` when every declared endpoint answers 200, and `drifted`
20
+ * otherwise, with each endpoint that did not in the detail.
21
+ *
22
+ * The config's hosts are where the collector listens, which is often
23
+ * `0.0.0.0` or a pod address. `host` replaces the host of every endpoint (a
24
+ * port-forward, a container's published ports), and `endpoints` replaces
25
+ * whole URLs.
26
+ */
27
+
28
+ import { readFileSync } from "node:fs";
29
+ import { resolve } from "node:path";
30
+ import { load } from "js-yaml";
31
+ import { parseComponentId } from "../../model";
32
+
33
+ export type CollectorEndpointKind = "healthCheck" | "zpages" | "telemetry";
34
+
35
+ export interface ObservedCollector {
36
+ /** The resource name. */
37
+ name: string;
38
+ /** The collector config file, relative to the working directory, read on each tick. */
39
+ config?: string;
40
+ /** The config itself, in place of `config`. */
41
+ configObject?: Record<string, unknown>;
42
+ /** Where the collector is reached, replacing every endpoint's host. Default: the config's host, with 0.0.0.0 and empty read as localhost. */
43
+ host?: string;
44
+ /** Whole URLs, replacing the ones read from the config. */
45
+ endpoints?: Partial<Record<CollectorEndpointKind, string>>;
46
+ }
47
+
48
+ export interface CollectorHealthObserveArgs {
49
+ collectors: ObservedCollector[];
50
+ /** Per-request timeout, ms. Default 5000. */
51
+ timeoutMs?: number;
52
+ /** Probes of a failing endpoint before it counts as down. Default 2. */
53
+ probes?: number;
54
+ /** Time between probes, ms. Default 1000. */
55
+ probeIntervalMs?: number;
56
+ /** Replaces fetch. For tests. */
57
+ _fetch?: typeof fetch;
58
+ }
59
+
60
+ export interface CollectorHealthObserveResult {
61
+ resources: { name: string; status: "in-sync" | "drifted" | "unknown"; detail: string }[];
62
+ }
63
+
64
+ export interface DeclaredEndpoint {
65
+ kind: CollectorEndpointKind;
66
+ url: string;
67
+ }
68
+
69
+ const DEFAULTS = {
70
+ healthCheck: { endpoint: "localhost:13133", path: "/" },
71
+ zpages: { endpoint: "localhost:55679", path: "/debug/servicez" },
72
+ } as const;
73
+
74
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
75
+
76
+ /** `host:port`, `:port` or a URL, as host and port. A `${env:...}` host reads as unknown (empty). */
77
+ export function splitEndpoint(endpoint: string): { host: string; port: string } | undefined {
78
+ let s = endpoint.trim().replace(/^[a-z]+:\/\//i, "");
79
+ s = s.replace(/\/.*$/, "");
80
+ const m = /^(.*):(\d+)$/.exec(s);
81
+ if (!m) return undefined;
82
+ const host = m[1].includes("${") ? "" : m[1];
83
+ return { host, port: m[2] };
84
+ }
85
+
86
+ function urlOf(endpoint: string, path: string, host: string | undefined): string | undefined {
87
+ const parts = splitEndpoint(endpoint);
88
+ if (!parts) return undefined;
89
+ let h = host ?? parts.host;
90
+ if (h === "" || h === "0.0.0.0" || h === "::" || h === "[::]") h = "localhost";
91
+ return `http://${h}:${parts.port}${path.startsWith("/") ? path : `/${path}`}`;
92
+ }
93
+
94
+ /** The first enabled extension of `type`, and its config. */
95
+ function enabledExtension(config: Record<string, unknown>, type: string): Record<string, unknown> | undefined {
96
+ const service = isRecord(config.service) ? config.service : {};
97
+ const enabled = Array.isArray(service.extensions) ? service.extensions : [];
98
+ const extensions = isRecord(config.extensions) ? config.extensions : {};
99
+ for (const id of enabled) {
100
+ if (typeof id !== "string" || parseComponentId(id)?.type !== type) continue;
101
+ const body = extensions[id];
102
+ return isRecord(body) ? body : {};
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ /** Where `service.telemetry.metrics` serves the collector's own metrics, as `host:port`, when the config says. */
108
+ function telemetryEndpoint(config: Record<string, unknown>): string | undefined {
109
+ const service = isRecord(config.service) ? config.service : {};
110
+ const telemetry = isRecord(service.telemetry) ? service.telemetry : {};
111
+ const metrics = isRecord(telemetry.metrics) ? telemetry.metrics : undefined;
112
+ if (!metrics || metrics.level === "none") return undefined;
113
+ if (typeof metrics.address === "string") return metrics.address;
114
+ const readers = Array.isArray(metrics.readers) ? metrics.readers : [];
115
+ for (const r of readers) {
116
+ const prom = isRecord(r) && isRecord(r.pull) && isRecord(r.pull.exporter) ? r.pull.exporter.prometheus : undefined;
117
+ if (isRecord(prom) && (typeof prom.port === "number" || typeof prom.port === "string")) {
118
+ const host = typeof prom.host === "string" ? prom.host : "localhost";
119
+ return `${host}:${prom.port}`;
120
+ }
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ /**
126
+ * The endpoints a collector config declares, as URLs. `healthCheck` is
127
+ * present only when the service enables a `health_check` extension.
128
+ */
129
+ export function declaredEndpoints(config: Record<string, unknown>, host?: string, overrides: Partial<Record<CollectorEndpointKind, string>> = {}): DeclaredEndpoint[] {
130
+ const out: DeclaredEndpoint[] = [];
131
+ const push = (kind: CollectorEndpointKind, url: string | undefined) => {
132
+ const u = overrides[kind] ?? url;
133
+ if (u) out.push({ kind, url: u });
134
+ };
135
+ const hc = enabledExtension(config, "health_check");
136
+ if (hc) {
137
+ const endpoint = typeof hc.endpoint === "string" ? hc.endpoint : DEFAULTS.healthCheck.endpoint;
138
+ const path = typeof hc.path === "string" ? hc.path : DEFAULTS.healthCheck.path;
139
+ push("healthCheck", urlOf(endpoint, path, host));
140
+ }
141
+ const zp = enabledExtension(config, "zpages");
142
+ if (zp) {
143
+ const endpoint = typeof zp.endpoint === "string" ? zp.endpoint : DEFAULTS.zpages.endpoint;
144
+ push("zpages", urlOf(endpoint, DEFAULTS.zpages.path, host));
145
+ }
146
+ const tel = telemetryEndpoint(config);
147
+ if (tel) push("telemetry", urlOf(tel, "/metrics", host));
148
+ return out;
149
+ }
150
+
151
+ const LABEL: Record<CollectorEndpointKind, string> = { healthCheck: "health_check", zpages: "zpages", telemetry: "telemetry" };
152
+
153
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
154
+
155
+ async function probe(f: typeof fetch, url: string, timeoutMs: number): Promise<string | undefined> {
156
+ try {
157
+ const res = await f(url, { signal: AbortSignal.timeout(timeoutMs) });
158
+ await res.body?.cancel().catch(() => undefined);
159
+ return res.status === 200 ? undefined : `answered ${res.status}`;
160
+ } catch (err) {
161
+ const cause = (err as { cause?: { code?: string } }).cause?.code;
162
+ return `did not answer (${cause ?? (err as Error).message})`;
163
+ }
164
+ }
165
+
166
+ function collectorConfig(c: ObservedCollector): Record<string, unknown> {
167
+ if (c.configObject) return c.configObject;
168
+ if (!c.config) throw new Error(`collector "${c.name}" names no config`);
169
+ const value = load(readFileSync(resolve(process.cwd(), c.config), "utf8"));
170
+ if (!isRecord(value)) throw new Error(`collector "${c.name}": ${c.config} does not hold a YAML mapping`);
171
+ return value;
172
+ }
173
+
174
+ /** Observe each collector through the endpoints its config declares: one resource per collector. */
175
+ export async function collectorHealthObserve(args: CollectorHealthObserveArgs): Promise<CollectorHealthObserveResult> {
176
+ const f = args._fetch ?? fetch;
177
+ const timeoutMs = args.timeoutMs ?? 5000;
178
+ const probes = Math.max(1, args.probes ?? 2);
179
+ const interval = args.probeIntervalMs ?? 1000;
180
+ const resources: CollectorHealthObserveResult["resources"] = [];
181
+ for (const c of args.collectors) {
182
+ let config: Record<string, unknown>;
183
+ try {
184
+ config = collectorConfig(c);
185
+ } catch (err) {
186
+ resources.push({ name: c.name, status: "unknown", detail: `config unreadable: ${(err as Error).message}` });
187
+ continue;
188
+ }
189
+ const endpoints = declaredEndpoints(config, c.host, c.endpoints);
190
+ if (!endpoints.some((e) => e.kind === "healthCheck")) {
191
+ resources.push({ name: c.name, status: "unknown", detail: "no health_check in service.extensions" });
192
+ continue;
193
+ }
194
+ const failures: string[] = [];
195
+ for (const e of endpoints) {
196
+ let why: string | undefined;
197
+ for (let i = 0; i < probes; i++) {
198
+ why = await probe(f, e.url, timeoutMs);
199
+ if (!why) break;
200
+ if (i + 1 < probes) await sleep(interval);
201
+ }
202
+ if (why) failures.push(`${LABEL[e.kind]} ${e.url} ${why}`);
203
+ }
204
+ resources.push(
205
+ failures.length === 0
206
+ ? { name: c.name, status: "in-sync", detail: endpoints.map((e) => `${LABEL[e.kind]} ok`).join(", ") }
207
+ : { name: c.name, status: "drifted", detail: failures.join("; ") },
208
+ );
209
+ }
210
+ return { resources };
211
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * How the otel activities run a binary: a child process whose exit code,
3
+ * stdout and stderr are returned rather than thrown, so the caller decides
4
+ * what a non-zero exit means. Tests pass their own runner.
5
+ */
6
+
7
+ import { execFile } from "node:child_process";
8
+
9
+ export interface ExecResult {
10
+ /** The exit code, or null when the binary could not be started. */
11
+ code: number | null;
12
+ stdout: string;
13
+ stderr: string;
14
+ /** Why the binary could not be started (ENOENT and the like). */
15
+ error?: string;
16
+ }
17
+
18
+ export type ExecRunner = (bin: string, args: string[], opts?: { cwd?: string; timeoutMs?: number }) => Promise<ExecResult>;
19
+
20
+ export const defaultExec: ExecRunner = (bin, args, opts = {}) =>
21
+ new Promise((resolve) => {
22
+ execFile(
23
+ bin,
24
+ args,
25
+ { cwd: opts.cwd, timeout: opts.timeoutMs ?? 120_000, maxBuffer: 32 * 1024 * 1024, encoding: "utf8" },
26
+ (err, stdout, stderr) => {
27
+ if (!err) return resolve({ code: 0, stdout, stderr });
28
+ const e = err as NodeJS.ErrnoException & { code?: unknown };
29
+ if (typeof e.code === "number") return resolve({ code: e.code, stdout, stderr });
30
+ resolve({ code: null, stdout: stdout ?? "", stderr: stderr ?? "", error: e.message });
31
+ },
32
+ );
33
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * What the audit activities read from GitHub and write back to it: release
3
+ * lists over the REST API, and a sticky issue or a proposal pull request
4
+ * through `git` and `gh`. The prometheus lexicon's rule audit uses the issue
5
+ * half too.
6
+ */
7
+
8
+ import { execFile } from "node:child_process";
9
+
10
+ /** Runs `git` or `gh`, returning stdout. Rejects on a non-zero exit. */
11
+ export type CommandRunner = (bin: "git" | "gh", args: string[], cwd: string) => Promise<string>;
12
+
13
+ export const defaultRunner: CommandRunner = (bin, args, cwd) =>
14
+ new Promise((resolve, reject) => {
15
+ execFile(bin, args, { cwd, maxBuffer: 16 * 1024 * 1024, encoding: "utf8" }, (err, stdout, stderr) => {
16
+ if (err) reject(new Error(`${bin} ${args.join(" ")} failed: ${(stderr || err.message).trim()}`));
17
+ else resolve(stdout);
18
+ });
19
+ });
20
+
21
+ export interface Release {
22
+ tag: string;
23
+ publishedAt?: string;
24
+ }
25
+
26
+ /** `v1.2.3` as numbers, or undefined when the tag is not a plain semver. */
27
+ export function semverOf(tag: string): [number, number, number] | undefined {
28
+ const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
29
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
30
+ }
31
+
32
+ export function compareSemver(a: string, b: string): number {
33
+ const x = semverOf(a);
34
+ const y = semverOf(b);
35
+ if (!x || !y) return a.localeCompare(b);
36
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
37
+ return 0;
38
+ }
39
+
40
+ /**
41
+ * The published, non-prerelease releases of `repo` (`owner/name`) with a
42
+ * plain semver tag, newest first. Reads one page of 100, which covers
43
+ * months of collector releases.
44
+ */
45
+ export async function fetchReleases(repo: string, f: typeof fetch = fetch, token = process.env.GITHUB_TOKEN): Promise<Release[]> {
46
+ const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
47
+ if (token) headers.Authorization = `Bearer ${token}`;
48
+ const res = await f(`https://api.github.com/repos/${repo}/releases?per_page=100`, { headers });
49
+ if (!res.ok) throw new Error(`GitHub releases of ${repo}: HTTP ${res.status}`);
50
+ const body = (await res.json()) as Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean; published_at?: string }>;
51
+ return body
52
+ .filter((r) => !r.draft && !r.prerelease && typeof r.tag_name === "string" && semverOf(r.tag_name))
53
+ .map((r) => ({ tag: r.tag_name!, ...(r.published_at ? { publishedAt: r.published_at } : {}) }))
54
+ .sort((a, b) => compareSemver(b.tag, a.tag));
55
+ }
56
+
57
+ /**
58
+ * Open an issue titled `title`, or edit the body of the open one that
59
+ * already has that title, so a scheduled audit keeps one issue current
60
+ * instead of opening one per run. Returns its URL.
61
+ */
62
+ export async function stickyIssue(run: CommandRunner, cwd: string, title: string, body: string, labels: string[] = []): Promise<string> {
63
+ const listed = await run("gh", ["issue", "list", "--state", "open", "--search", `"${title}" in:title`, "--json", "number,title,url"], cwd);
64
+ const open = (JSON.parse(listed || "[]") as Array<{ number: number; title: string; url: string }>).find((i) => i.title === title);
65
+ if (open) {
66
+ await run("gh", ["issue", "edit", String(open.number), "--body", body], cwd);
67
+ return open.url;
68
+ }
69
+ const args = ["issue", "create", "--title", title, "--body", body];
70
+ for (const l of labels) args.push("--label", l);
71
+ return (await run("gh", args, cwd)).trim();
72
+ }
73
+
74
+ export interface ProposalFile {
75
+ /** Path relative to the repository root. */
76
+ path: string;
77
+ content: string;
78
+ }
79
+
80
+ export interface ProposeOptions {
81
+ /** The repository root. */
82
+ root: string;
83
+ branch: string;
84
+ title: string;
85
+ body: string;
86
+ files: ProposalFile[];
87
+ commitMessage: string;
88
+ remote?: string;
89
+ /** The branch the pull request targets. Default: the remote's default branch. */
90
+ base?: string;
91
+ /** A directory for the worktree the commit is made in. */
92
+ worktreeDir: string;
93
+ }
94
+
95
+ /**
96
+ * Commit `files` on `branch`, from the remote's default branch, in a
97
+ * worktree of its own (the checked-out branch and working tree are never
98
+ * touched), push it, and open a pull request, or edit the open one for that
99
+ * branch. Returns the pull request's URL.
100
+ */
101
+ export async function proposePullRequest(run: CommandRunner, o: ProposeOptions): Promise<string> {
102
+ const remote = o.remote ?? "origin";
103
+ await run("git", ["fetch", remote], o.root);
104
+ let base = o.base;
105
+ if (!base) {
106
+ const head = (await run("git", ["symbolic-ref", "--short", `refs/remotes/${remote}/HEAD`], o.root).catch(() => `${remote}/main`)).trim();
107
+ base = head.startsWith(`${remote}/`) ? head.slice(remote.length + 1) : head;
108
+ }
109
+ await run("git", ["worktree", "add", "--force", "-B", o.branch, o.worktreeDir, `${remote}/${base}`], o.root);
110
+ try {
111
+ const { writeFileSync } = await import("node:fs");
112
+ const { join } = await import("node:path");
113
+ for (const f of o.files) writeFileSync(join(o.worktreeDir, f.path), f.content);
114
+ await run("git", ["add", ...o.files.map((f) => f.path)], o.worktreeDir);
115
+ await run("git", ["commit", "-m", o.commitMessage], o.worktreeDir);
116
+ await run("git", ["push", "--force", remote, `HEAD:refs/heads/${o.branch}`], o.worktreeDir);
117
+ } finally {
118
+ await run("git", ["worktree", "remove", "--force", o.worktreeDir], o.root).catch(() => undefined);
119
+ }
120
+ const listed = await run("gh", ["pr", "list", "--head", o.branch, "--state", "open", "--json", "number,url"], o.root);
121
+ const open = (JSON.parse(listed || "[]") as Array<{ number: number; url: string }>)[0];
122
+ if (open) {
123
+ await run("gh", ["pr", "edit", String(open.number), "--title", o.title, "--body", o.body], o.root);
124
+ return open.url;
125
+ }
126
+ return (await run("gh", ["pr", "create", "--head", o.branch, "--base", base, "--title", o.title, "--body", o.body], o.root)).trim();
127
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * otel Op activities (#3369), resolved by the core activity registry when a
3
+ * project's `chant.config.ts` lists the `otel` lexicon. Every function this
4
+ * module exports is an activity, bound by its export name, so the helpers
5
+ * behind them stay in their own files.
6
+ *
7
+ * - `otelcolValidate`, `otelcolComponents`: the collector binary's own
8
+ * checks over a config file.
9
+ * - `collectorHealthObserve`: running collectors as the resources of a
10
+ * `ConvergeOp({ observe })`.
11
+ * - `collectorAudit`: the lexicon's pins against upstream releases, the
12
+ * step of `CollectorAuditOp`.
13
+ */
14
+ export { otelcolValidate, otelcolComponents } from "./otelcol";
15
+ export type { OtelcolValidateArgs, OtelcolValidateResult, OtelcolComponentsArgs, OtelcolComponentsResult, MissingComponent } from "./otelcol";
16
+ export { collectorHealthObserve } from "./collector-health";
17
+ export type { CollectorHealthObserveArgs, CollectorHealthObserveResult, ObservedCollector, CollectorEndpointKind } from "./collector-health";
18
+ export { collectorAudit } from "./collector-audit";
19
+ export type { CollectorAuditArgs, CollectorAuditResult, CollectorAuditFinding, CollectorAuditFindingKind, CollectorAuditMode } from "./collector-audit";
@@ -0,0 +1,162 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { COLLECTOR_PIN } from "../../define";
6
+ import type { ExecRunner } from "./exec";
7
+ import {
8
+ configComponents,
9
+ missingComponents,
10
+ otelcolBin,
11
+ otelcolComponents,
12
+ otelcolValidate,
13
+ parseComponentsOutput,
14
+ parseOtelcolVersion,
15
+ } from "./otelcol";
16
+
17
+ const FIXTURE = readFileSync(join(import.meta.dirname, "..", "testdata", "otelcol-components-v0.130.0.yaml"), "utf8");
18
+
19
+ const CONFIG = `receivers:
20
+ otlp:
21
+ protocols:
22
+ grpc:
23
+ endpoint: 0.0.0.0:4317
24
+ processors:
25
+ batch: {}
26
+ exporters:
27
+ debug: {}
28
+ otlp/tempo:
29
+ endpoint: tempo:4317
30
+ connectors:
31
+ spanmetrics: {}
32
+ extensions:
33
+ health_check: {}
34
+ service:
35
+ extensions: [health_check]
36
+ pipelines:
37
+ traces:
38
+ receivers: [otlp]
39
+ processors: [batch]
40
+ exporters: [debug, otlp/tempo, spanmetrics]
41
+ metrics:
42
+ receivers: [spanmetrics]
43
+ exporters: [debug]
44
+ `;
45
+
46
+ function configFile(text = CONFIG): string {
47
+ const dir = mkdtempSync(join(tmpdir(), "chant-otelcol-op-"));
48
+ const file = join(dir, "collector.yaml");
49
+ writeFileSync(file, text);
50
+ return file;
51
+ }
52
+
53
+ /** A runner that answers `--version` with `version`, and records the calls. */
54
+ function fakeExec(version: string, rest: Partial<Record<string, { code: number; stdout?: string; stderr?: string }>> = {}) {
55
+ const calls: string[][] = [];
56
+ const exec: ExecRunner = async (bin, args) => {
57
+ calls.push([bin, ...args]);
58
+ if (args[0] === "--version") return { code: 0, stdout: `otelcol-contrib version ${version}\n`, stderr: "" };
59
+ const r = rest[args[0]] ?? { code: 0 };
60
+ return { code: r.code, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
61
+ };
62
+ return { exec, calls };
63
+ }
64
+
65
+ const PIN = COLLECTOR_PIN.version.replace(/^v/, "");
66
+
67
+ describe("otelcolValidate", () => {
68
+ test("parses the version line the binary prints", () => {
69
+ expect(parseOtelcolVersion("otelcol-contrib version 0.130.0")).toBe("v0.130.0");
70
+ expect(parseOtelcolVersion("otelcol version v0.131.1-dev\n")).toBe("v0.131.1-dev");
71
+ expect(parseOtelcolVersion("no version here")).toBeUndefined();
72
+ });
73
+
74
+ test("the binary is bin, then $OTELCOL_BIN, then otelcol-contrib", () => {
75
+ expect(otelcolBin("/opt/otelcol", { OTELCOL_BIN: "x" })).toBe("/opt/otelcol");
76
+ expect(otelcolBin(undefined, { OTELCOL_BIN: "x" })).toBe("x");
77
+ expect(otelcolBin(undefined, {})).toBe("otelcol-contrib");
78
+ });
79
+
80
+ test("at the pinned version, runs validate over the config and passes", async () => {
81
+ const file = configFile();
82
+ const { exec, calls } = fakeExec(PIN, { validate: { code: 0, stdout: "" } });
83
+ const r = await otelcolValidate({ config: file, bin: "otelcol-contrib", _exec: exec });
84
+ expect(r).toMatchObject({ ok: true, version: COLLECTOR_PIN.version, bin: "otelcol-contrib" });
85
+ expect(calls[1]).toEqual(["otelcol-contrib", "validate", `--config=${file}`]);
86
+ });
87
+
88
+ test("refuses a binary at another version, before validating", async () => {
89
+ const { exec, calls } = fakeExec("0.140.0");
90
+ await expect(otelcolValidate({ config: configFile(), _exec: exec })).rejects.toThrow(/is v0\.140\.0.*COLLECTOR_PIN/);
91
+ expect(calls).toHaveLength(1);
92
+ });
93
+
94
+ test("with a version given, checks the binary against that one instead", async () => {
95
+ const ok = fakeExec("0.140.0", { validate: { code: 0 } });
96
+ await expect(otelcolValidate({ config: configFile(), version: "v0.140.0", _exec: ok.exec })).resolves.toMatchObject({ version: "v0.140.0" });
97
+ const off = fakeExec(PIN);
98
+ await expect(otelcolValidate({ config: configFile(), version: "0.140.0", _exec: off.exec })).rejects.toThrow(/the step asks for v0\.140\.0/);
99
+ });
100
+
101
+ test("a config the binary rejects fails the step with its output", async () => {
102
+ const { exec } = fakeExec(PIN, { validate: { code: 1, stderr: "Error: invalid configuration: receivers::otlp: unknown field" } });
103
+ await expect(otelcolValidate({ config: configFile(), _exec: exec })).rejects.toThrow(/rejected .*unknown field/s);
104
+ });
105
+
106
+ test("a binary that will not start says so", async () => {
107
+ const exec: ExecRunner = async () => ({ code: null, stdout: "", stderr: "", error: "spawn otelcol-contrib ENOENT" });
108
+ await expect(otelcolValidate({ config: configFile(), bin: "otelcol-contrib", _exec: exec })).rejects.toThrow(/could not run otelcol-contrib: spawn otelcol-contrib ENOENT/);
109
+ });
110
+ });
111
+
112
+ describe("otelcolComponents", () => {
113
+ test("parses the v0.130.0 components output (pins the unstable format)", () => {
114
+ const list = parseComponentsOutput(FIXTURE);
115
+ expect(list.version).toBe("v0.130.0");
116
+ expect([...list.components.receiver].sort()).toEqual(["k8s_cluster", "otlp", "prometheus"]);
117
+ expect([...list.components.processor].sort()).toEqual(["batch", "memory_limiter", "tail_sampling"]);
118
+ expect([...list.components.exporter].sort()).toEqual(["debug", "otlp", "prometheus"]);
119
+ expect([...list.components.connector].sort()).toEqual(["forward", "spanmetrics"]);
120
+ expect([...list.components.extension].sort()).toEqual(["health_check", "zpages"]);
121
+ });
122
+
123
+ test("reads the older forms too: plain name lists and mappings", () => {
124
+ const list = parseComponentsOutput("receivers: [otlp, jaeger]\nexporters:\n debug: {}\n otlp: {}\n");
125
+ expect([...list.components.receiver]).toEqual(["otlp", "jaeger"]);
126
+ expect([...list.components.exporter]).toEqual(["debug", "otlp"]);
127
+ expect(list.version).toBeUndefined();
128
+ });
129
+
130
+ test("output with no component sections is an error, not an empty list", () => {
131
+ expect(() => parseComponentsOutput("buildinfo:\n version: 0.130.0\n")).toThrow(/listed no receivers/);
132
+ expect(() => parseComponentsOutput("::: not yaml")).toThrow();
133
+ });
134
+
135
+ test("the config's components by kind, and the ones the binary lacks", () => {
136
+ const used = configComponents({ receivers: { otlp: {} }, exporters: { "otlp/tempo": {}, "loki/x": {} }, connectors: { spanmetrics: {} } });
137
+ expect(used.exporter).toEqual(["otlp/tempo", "loki/x"]);
138
+ const missing = missingComponents(used, parseComponentsOutput(FIXTURE));
139
+ expect(missing).toEqual([{ kind: "exporter", id: "loki/x", type: "loki" }]);
140
+ });
141
+
142
+ test("a renamed built-in counts under either name", () => {
143
+ const list = parseComponentsOutput("exporters:\n - name: otlp_grpc\nreceivers:\n - name: otlp\n");
144
+ expect(missingComponents(configComponents({ exporters: { otlp: {} } }), list)).toEqual([]);
145
+ const old = parseComponentsOutput("exporters:\n - name: otlp\nreceivers:\n - name: otlp\n");
146
+ expect(missingComponents(configComponents({ exporters: { otlp_grpc: {} } }), old)).toEqual([]);
147
+ });
148
+
149
+ test("passes a config the binary was built for", async () => {
150
+ const { exec } = fakeExec(PIN, { components: { code: 0, stdout: FIXTURE } });
151
+ const r = await otelcolComponents({ config: configFile(), _exec: exec });
152
+ expect(r.missing).toEqual([]);
153
+ expect(r.used.connector).toEqual(["spanmetrics"]);
154
+ expect(r.version).toBe("v0.130.0");
155
+ });
156
+
157
+ test("refuses a config with a component the binary was not built with", async () => {
158
+ const { exec } = fakeExec(PIN, { components: { code: 0, stdout: FIXTURE } });
159
+ const config = configFile(CONFIG.replace(" debug: {}", " debug: {}\n datadog: {}"));
160
+ await expect(otelcolComponents({ config, _exec: exec })).rejects.toThrow(/1 component\(s\).*exporter datadog/s);
161
+ });
162
+ });