@intentius/chant-lexicon-otel 0.97.0 → 0.98.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/README.md +5 -0
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/import/generator.d.ts +39 -0
- package/dist/import/generator.d.ts.map +1 -0
- package/dist/import/parser.d.ts +61 -0
- package/dist/import/parser.d.ts.map +1 -0
- package/dist/integrity.json +4 -4
- package/dist/lint/rules/literal-credential.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/literal-credential.ts +46 -17
- package/dist/skills/chant-otel.md +11 -0
- package/package.json +2 -2
- package/src/codegen/docs.ts +2 -1
- package/src/import/generated-types.e2e.test.ts +75 -0
- package/src/import/generator.test.ts +165 -0
- package/src/import/generator.ts +509 -0
- package/src/import/parser.test.ts +174 -0
- package/src/import/parser.ts +236 -0
- package/src/import/roundtrip.test.ts +365 -0
- package/src/import/testdata/agent-observability-agent.yaml +54 -0
- package/src/import/testdata/agent-observability-gateway.yaml +128 -0
- package/src/import/testdata/fixtures.ts +274 -0
- package/src/import/testdata/gateway.yaml +153 -0
- package/src/import/testdata/upstream/couchbase.yaml +150 -0
- package/src/import/testdata/upstream/fault-tolerant-logs.yaml +27 -0
- package/src/import/testdata/upstream/kubernetes-filelog.yaml +86 -0
- package/src/import/testdata/upstream/loadbalancing-agent.yaml +38 -0
- package/src/import/testdata/upstream/loadbalancing-backend.yaml +27 -0
- package/src/import/testdata/upstream/logline-filter-in.yaml +20 -0
- package/src/import/testdata/upstream/logline-filter-out.yaml +20 -0
- package/src/import/testdata/upstream/secure-tracing.yaml +26 -0
- package/src/import/testdata/upstream/servicegraph-nop.yaml +28 -0
- package/src/lint/rules/literal-credential.ts +46 -17
- package/src/lint/rules/rules.test.ts +16 -0
- package/src/plugin.ts +10 -0
- package/src/skills/chant-otel.md +11 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TemplateIR` -> TypeScript, for `chant import`.
|
|
3
|
+
*
|
|
4
|
+
* Writes the collector the way the lexicon's examples are written: a module
|
|
5
|
+
* per section with one constant per component, typed by its built-in class
|
|
6
|
+
* (`const otlpTempo = new OtlpExporter({ name: "tempo", ... })`), nested
|
|
7
|
+
* settings lifted into consts typed by the component's config type (COR001),
|
|
8
|
+
* at most eight declarables per module (COR009), and an `export { ... }` list
|
|
9
|
+
* at the end of each. `pipelines.ts` declares a `Pipeline` per pipeline that
|
|
10
|
+
* references those constants, and `service.ts` a `Service` when
|
|
11
|
+
* `service.extensions` or `service.telemetry` needs one. A connector is one
|
|
12
|
+
* constant, listed in the exporters of the pipeline that feeds it and the
|
|
13
|
+
* receivers of the pipeline it feeds.
|
|
14
|
+
*
|
|
15
|
+
* A component type chant does not ship gets a `defineComponent` in
|
|
16
|
+
* `custom-components.ts`, with its config carried as data and a comment
|
|
17
|
+
* saying so. Its pin is the one the config's `# chant:` header named, or
|
|
18
|
+
* `COLLECTOR_PIN`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { GeneratedFile, TypeScriptGenerator } from "@intentius/chant/import/generator";
|
|
22
|
+
import type { ResourceIR, TemplateIR } from "@intentius/chant/import/parser";
|
|
23
|
+
import * as components from "../components";
|
|
24
|
+
import { COLLECTOR_PIN, type ComponentClass, type SchemaPin } from "../define";
|
|
25
|
+
import {
|
|
26
|
+
COMPONENT_KINDS,
|
|
27
|
+
SECTION_OF,
|
|
28
|
+
SIGNALS,
|
|
29
|
+
parseComponentId,
|
|
30
|
+
pipelineSignal,
|
|
31
|
+
type CollectorConfig,
|
|
32
|
+
type ComponentKind,
|
|
33
|
+
} from "../model";
|
|
34
|
+
import {
|
|
35
|
+
COLLECTOR_RESOURCE_TYPE,
|
|
36
|
+
type CollectorResourceMetadata,
|
|
37
|
+
type CollectorResourceProperties,
|
|
38
|
+
type HeaderPin,
|
|
39
|
+
} from "./parser";
|
|
40
|
+
|
|
41
|
+
const PACKAGE = "@intentius/chant-lexicon-otel";
|
|
42
|
+
|
|
43
|
+
const KIND_SEGMENT: Record<ComponentKind, string> = {
|
|
44
|
+
receiver: "Receiver",
|
|
45
|
+
processor: "Processor",
|
|
46
|
+
exporter: "Exporter",
|
|
47
|
+
connector: "Connector",
|
|
48
|
+
extension: "Extension",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** `kind:type` -> the exported class name of every built-in. */
|
|
52
|
+
const BUILTIN_CLASSES: ReadonlyMap<string, string> = (() => {
|
|
53
|
+
const map = new Map<string, string>();
|
|
54
|
+
for (const [exportName, value] of Object.entries(components)) {
|
|
55
|
+
const def = (value as Partial<ComponentClass> | undefined)?.definition;
|
|
56
|
+
if (typeof value === "function" && def?.builtin) map.set(`${def.kind}:${def.type}`, exportName);
|
|
57
|
+
}
|
|
58
|
+
return map;
|
|
59
|
+
})();
|
|
60
|
+
|
|
61
|
+
/** The class name of the built-in for `kind` + `type`, if chant ships one. */
|
|
62
|
+
export function builtinClassName(kind: ComponentKind, type: string): string | undefined {
|
|
63
|
+
return BUILTIN_CLASSES.get(`${kind}:${type}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const RESERVED = new Set(
|
|
67
|
+
(
|
|
68
|
+
"break case catch class const continue debugger default delete do else enum export extends false finally for " +
|
|
69
|
+
"function if import in instanceof new null return super switch this throw true try typeof var void while with " +
|
|
70
|
+
"yield let static implements interface package private protected public await arguments eval undefined"
|
|
71
|
+
).split(" "),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
75
|
+
|
|
76
|
+
function words(text: string): string[] {
|
|
77
|
+
return text.split(/[^A-Za-z0-9]+/).filter((w) => w !== "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function camel(text: string): string {
|
|
81
|
+
const parts = words(text);
|
|
82
|
+
if (parts.length === 0) return "";
|
|
83
|
+
return parts
|
|
84
|
+
.map((w, i) => (i === 0 ? w.charAt(0).toLowerCase() + w.slice(1) : w.charAt(0).toUpperCase() + w.slice(1)))
|
|
85
|
+
.join("");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pascal(text: string): string {
|
|
89
|
+
return words(text)
|
|
90
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
91
|
+
.join("");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── TypeScript literals ──────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
const MAX_LINE = 100;
|
|
97
|
+
|
|
98
|
+
function propertyKey(k: string): string {
|
|
99
|
+
return IDENT.test(k) ? k : JSON.stringify(k);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A string literal: double-quoted, or single-quoted when that saves escaping `"` (OTTL reads better). */
|
|
103
|
+
function stringLiteral(s: string): string {
|
|
104
|
+
const double = JSON.stringify(s);
|
|
105
|
+
if (!s.includes('"') || s.includes("'")) return double;
|
|
106
|
+
return `'${double.slice(1, -1).replace(/\\"/g, '"')}'`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function scalarLiteral(v: unknown): string {
|
|
110
|
+
if (v === null) return "null";
|
|
111
|
+
if (typeof v === "string") return stringLiteral(v);
|
|
112
|
+
if (typeof v === "number") {
|
|
113
|
+
if (Number.isNaN(v)) return "NaN";
|
|
114
|
+
if (v === Infinity) return "Infinity";
|
|
115
|
+
if (v === -Infinity) return "-Infinity";
|
|
116
|
+
return String(v);
|
|
117
|
+
}
|
|
118
|
+
if (typeof v === "boolean") return v ? "true" : "false";
|
|
119
|
+
return JSON.stringify(String(v));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One-line rendering of a value. */
|
|
123
|
+
function inline(v: unknown): string {
|
|
124
|
+
if (Array.isArray(v)) return `[${v.map(inline).join(", ")}]`;
|
|
125
|
+
if (typeof v === "object" && v !== null) {
|
|
126
|
+
const items = Object.entries(v)
|
|
127
|
+
.filter(([, x]) => x !== undefined)
|
|
128
|
+
.map(([k, x]) => `${propertyKey(k)}: ${inline(x)}`);
|
|
129
|
+
return items.length === 0 ? "{}" : `{ ${items.join(", ")} }`;
|
|
130
|
+
}
|
|
131
|
+
return scalarLiteral(v);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A value as a TypeScript expression, starting at column `col`, indented by `indent` spaces. */
|
|
135
|
+
export function tsLiteral(v: unknown, indent: number, col = indent): string {
|
|
136
|
+
const one = inline(v);
|
|
137
|
+
if (col + one.length + 2 <= MAX_LINE) return one;
|
|
138
|
+
const pad = " ".repeat(indent + 2);
|
|
139
|
+
const close = " ".repeat(indent);
|
|
140
|
+
if (Array.isArray(v)) {
|
|
141
|
+
const items = v.map((x) => `${pad}${tsLiteral(x, indent + 2)},`);
|
|
142
|
+
return `[\n${items.join("\n")}\n${close}]`;
|
|
143
|
+
}
|
|
144
|
+
if (typeof v === "object" && v !== null) {
|
|
145
|
+
const lines: string[] = [];
|
|
146
|
+
for (const [k, x] of Object.entries(v)) {
|
|
147
|
+
if (x === undefined) continue;
|
|
148
|
+
const key = `${pad}${propertyKey(k)}: `;
|
|
149
|
+
lines.push(`${key}${tsLiteral(x, indent + 2, key.length)},`);
|
|
150
|
+
}
|
|
151
|
+
return `{\n${lines.join("\n")}\n${close}}`;
|
|
152
|
+
}
|
|
153
|
+
return scalarLiteral(v);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** An object literal whose entries are already-rendered expressions, starting at column `col`. */
|
|
157
|
+
function objectOf(entries: Array<[string, string]>, indent: number, col: number): string {
|
|
158
|
+
if (entries.length === 0) return "{}";
|
|
159
|
+
const prop = ([k, v]: [string, string]) => (k === v && IDENT.test(k) ? k : `${propertyKey(k)}: ${v}`);
|
|
160
|
+
const one = `{ ${entries.map(prop).join(", ")} }`;
|
|
161
|
+
if (!one.includes("\n") && col + one.length + 2 <= MAX_LINE) return one;
|
|
162
|
+
const pad = " ".repeat(indent + 2);
|
|
163
|
+
return `{\n${entries.map((e) => `${pad}${prop(e)},`).join("\n")}\n${" ".repeat(indent)}}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── naming ───────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
interface ComponentDecl {
|
|
169
|
+
kind: ComponentKind;
|
|
170
|
+
id: string;
|
|
171
|
+
type: string;
|
|
172
|
+
name?: string;
|
|
173
|
+
config: Record<string, unknown>;
|
|
174
|
+
className: string;
|
|
175
|
+
builtin: boolean;
|
|
176
|
+
varName: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface PipelineDecl {
|
|
180
|
+
id: string;
|
|
181
|
+
signal: string;
|
|
182
|
+
name?: string;
|
|
183
|
+
lists: { receivers?: string[]; processors?: string[]; exporters?: string[] };
|
|
184
|
+
varName: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function assignNames(comps: ComponentDecl[], pipes: PipelineDecl[], taken: Set<string>): void {
|
|
188
|
+
const base = (s: string, fallback: string) => {
|
|
189
|
+
const c = camel(s);
|
|
190
|
+
return c === "" || /^[0-9]/.test(c) ? camel(`${fallback} ${s}`) : c;
|
|
191
|
+
};
|
|
192
|
+
const candidates = new Map<string, number>();
|
|
193
|
+
const compBase = comps.map((c) => base(c.id, c.kind));
|
|
194
|
+
const pipeBase = pipes.map((p) => base(p.id, "pipeline"));
|
|
195
|
+
for (const b of [...compBase, ...pipeBase]) candidates.set(b, (candidates.get(b) ?? 0) + 1);
|
|
196
|
+
const unique = (name: string) => {
|
|
197
|
+
let out = name;
|
|
198
|
+
for (let n = 2; taken.has(out) || RESERVED.has(out); n++) out = `${name}${n}`;
|
|
199
|
+
taken.add(out);
|
|
200
|
+
return out;
|
|
201
|
+
};
|
|
202
|
+
const clash = (b: string) => (candidates.get(b) ?? 0) > 1 || RESERVED.has(b) || taken.has(b);
|
|
203
|
+
comps.forEach((c, i) => {
|
|
204
|
+
const b = compBase[i];
|
|
205
|
+
c.varName = unique(clash(b) ? `${b}${KIND_SEGMENT[c.kind]}` : b);
|
|
206
|
+
});
|
|
207
|
+
pipes.forEach((p, i) => {
|
|
208
|
+
const b = pipeBase[i];
|
|
209
|
+
p.varName = unique(clash(b) ? `${b}Pipeline` : b);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── files ────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/** COR009's default: at most this many declarables per file. */
|
|
216
|
+
const DECLARABLES_PER_FILE = 8;
|
|
217
|
+
|
|
218
|
+
/** One generated module: what it imports, declares and exports. */
|
|
219
|
+
class Module {
|
|
220
|
+
readonly values = new Set<string>();
|
|
221
|
+
readonly types = new Set<string>();
|
|
222
|
+
readonly local = new Map<string, Set<string>>();
|
|
223
|
+
readonly body: string[] = [];
|
|
224
|
+
readonly exports: string[] = [];
|
|
225
|
+
constructor(
|
|
226
|
+
readonly path: string,
|
|
227
|
+
readonly summary: string,
|
|
228
|
+
) {}
|
|
229
|
+
|
|
230
|
+
use(name: string, from?: Module): void {
|
|
231
|
+
if (!from) this.values.add(name);
|
|
232
|
+
else if (from !== this) {
|
|
233
|
+
const spec = `./${from.path.replace(/\.ts$/, "")}`;
|
|
234
|
+
const set = this.local.get(spec) ?? new Set<string>();
|
|
235
|
+
set.add(name);
|
|
236
|
+
this.local.set(spec, set);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
block(lines: string[]): void {
|
|
241
|
+
if (this.body.length > 0) this.body.push("");
|
|
242
|
+
this.body.push(...lines);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
render(): string {
|
|
246
|
+
const byName = (a: string, b: string) => a.localeCompare(b, "en", { sensitivity: "base" });
|
|
247
|
+
const names = [...[...this.values].map((v) => v), ...[...this.types].map((t) => `type ${t}`)].sort((a, b) =>
|
|
248
|
+
byName(a.replace(/^type /, ""), b.replace(/^type /, "")),
|
|
249
|
+
);
|
|
250
|
+
const lines = [`/** ${this.summary} */`];
|
|
251
|
+
if (names.length > 0) {
|
|
252
|
+
const one = `import { ${names.join(", ")} } from "${PACKAGE}";`;
|
|
253
|
+
lines.push(one.length <= MAX_LINE ? one : `import {\n${names.map((n) => ` ${n},`).join("\n")}\n} from "${PACKAGE}";`);
|
|
254
|
+
}
|
|
255
|
+
for (const [spec, set] of [...this.local].sort(([a], [b]) => byName(a, b))) {
|
|
256
|
+
lines.push(`import { ${[...set].sort(byName).join(", ")} } from "${spec}";`);
|
|
257
|
+
}
|
|
258
|
+
lines.push("", ...this.body);
|
|
259
|
+
if (this.exports.length > 0) {
|
|
260
|
+
const one = `export { ${this.exports.join(", ")} };`;
|
|
261
|
+
lines.push("", one.length <= MAX_LINE ? one : `export {\n${this.exports.map((e) => ` ${e},`).join("\n")}\n};`);
|
|
262
|
+
}
|
|
263
|
+
return `${lines.join("\n")}\n`;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Split `items` into files of at most `DECLARABLES_PER_FILE`, named `base.ts` or `base-1.ts`, `base-2.ts`, … */
|
|
268
|
+
function chunk<T>(items: T[], base: string, summary: string): Array<[Module, T[]]> {
|
|
269
|
+
if (items.length === 0) return [];
|
|
270
|
+
if (items.length <= DECLARABLES_PER_FILE) return [[new Module(`${base}.ts`, summary), items]];
|
|
271
|
+
const out: Array<[Module, T[]]> = [];
|
|
272
|
+
for (let i = 0; i < items.length; i += DECLARABLES_PER_FILE) {
|
|
273
|
+
out.push([new Module(`${base}-${out.length + 1}.ts`, summary), items.slice(i, i + DECLARABLES_PER_FILE)]);
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── generation ───────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
function isCollectorResource(r: ResourceIR): boolean {
|
|
281
|
+
return r.type === COLLECTOR_RESOURCE_TYPE;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function samePin(a: SchemaPin, b: SchemaPin): boolean {
|
|
285
|
+
return a.source === b.source && a.version === b.version && (a.digest ?? "") === (b.digest ?? "");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** True for a value COR001 wants in a named const rather than inline in a constructor. */
|
|
289
|
+
function needsHoist(v: unknown): boolean {
|
|
290
|
+
if (Array.isArray(v)) return v.some((x) => typeof x === "object" && x !== null);
|
|
291
|
+
return typeof v === "object" && v !== null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const SECTION_SUMMARY: Record<ComponentKind, string> = {
|
|
295
|
+
receiver: "Receivers",
|
|
296
|
+
processor: "Processors",
|
|
297
|
+
exporter: "Exporters",
|
|
298
|
+
connector: "Connectors: each one is an exporter in the pipeline that feeds it and a receiver in the pipeline it feeds",
|
|
299
|
+
extension: "Extensions",
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Generate the TypeScript declaring one collector config: one module per
|
|
304
|
+
* section (split at eight declarables, as COR009 asks), `pipelines.ts`,
|
|
305
|
+
* `service.ts` when the config needs a Service, and `custom-components.ts`
|
|
306
|
+
* for component types chant does not ship.
|
|
307
|
+
*/
|
|
308
|
+
export function generateCollectorFiles(config: CollectorConfig, meta: Partial<CollectorResourceMetadata> = {}): GeneratedFile[] {
|
|
309
|
+
const pins: HeaderPin[] = meta.pins ?? [];
|
|
310
|
+
const comps: ComponentDecl[] = [];
|
|
311
|
+
const customClasses = new Map<string, { className: string; kind: ComponentKind; type: string; pin: SchemaPin }>();
|
|
312
|
+
const taken = new Set<string>(["Pipeline", "Service", "defineComponent", "COLLECTOR_PIN", "ServiceTelemetry", "telemetry"]);
|
|
313
|
+
for (const cls of BUILTIN_CLASSES.values()) taken.add(cls).add(`${cls}Config`);
|
|
314
|
+
|
|
315
|
+
for (const kind of COMPONENT_KINDS) {
|
|
316
|
+
const section = config[SECTION_OF[kind]];
|
|
317
|
+
for (const [id, raw] of Object.entries(section ?? {})) {
|
|
318
|
+
const parsed = parseComponentId(id);
|
|
319
|
+
const slash = id.indexOf("/");
|
|
320
|
+
const type = parsed?.type ?? (slash === -1 ? id : id.slice(0, slash));
|
|
321
|
+
const name = parsed ? parsed.name : slash === -1 ? undefined : id.slice(slash + 1);
|
|
322
|
+
const { name: _dropped, ...cfg } = (raw ?? {}) as Record<string, unknown>;
|
|
323
|
+
const builtin = builtinClassName(kind, type);
|
|
324
|
+
let className = builtin;
|
|
325
|
+
if (!className) {
|
|
326
|
+
const key = `${kind}:${type}`;
|
|
327
|
+
let custom = customClasses.get(key);
|
|
328
|
+
if (!custom) {
|
|
329
|
+
const base = `${/^[0-9]/.test(type) ? "Custom" : ""}${pascal(type) || "Custom"}${KIND_SEGMENT[kind]}`;
|
|
330
|
+
let cn = base;
|
|
331
|
+
for (let n = 2; taken.has(cn); n++) cn = `${base}${n}`;
|
|
332
|
+
taken.add(cn);
|
|
333
|
+
const headerPin = pins.find((p) => p.kind === kind && parseComponentId(p.id)?.type === type)?.pin;
|
|
334
|
+
custom = { className: cn, kind, type, pin: headerPin ?? COLLECTOR_PIN };
|
|
335
|
+
customClasses.set(key, custom);
|
|
336
|
+
}
|
|
337
|
+
className = custom.className;
|
|
338
|
+
}
|
|
339
|
+
comps.push({ kind, id, type, name, config: cfg, className, builtin: !!builtin, varName: "" });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const pipes: PipelineDecl[] = Object.entries(config.service?.pipelines ?? {}).map(([id, lists]) => {
|
|
344
|
+
const slash = id.indexOf("/");
|
|
345
|
+
return {
|
|
346
|
+
id,
|
|
347
|
+
signal: pipelineSignal(id),
|
|
348
|
+
name: slash === -1 ? undefined : id.slice(slash + 1),
|
|
349
|
+
lists: lists ?? {},
|
|
350
|
+
varName: "",
|
|
351
|
+
};
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
assignNames(comps, pipes, taken);
|
|
355
|
+
const unique = (name: string) => {
|
|
356
|
+
let out = name;
|
|
357
|
+
for (let n = 2; taken.has(out) || RESERVED.has(out); n++) out = `${name}${n}`;
|
|
358
|
+
taken.add(out);
|
|
359
|
+
return out;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const modules: Module[] = [];
|
|
363
|
+
const home = new Map<string, Module>(); // variable or class name -> module declaring it
|
|
364
|
+
|
|
365
|
+
// Custom component types, exported for every module that declares an instance.
|
|
366
|
+
if (customClasses.size > 0) {
|
|
367
|
+
const mod = new Module("custom-components.ts", "Component types chant does not ship, declared with defineComponent");
|
|
368
|
+
modules.push(mod);
|
|
369
|
+
mod.use("defineComponent");
|
|
370
|
+
for (const custom of customClasses.values()) {
|
|
371
|
+
const pinIsCollector = samePin(custom.pin, COLLECTOR_PIN);
|
|
372
|
+
if (pinIsCollector) mod.use("COLLECTOR_PIN");
|
|
373
|
+
mod.block([
|
|
374
|
+
`// ${custom.kind} "${custom.type}" is not a component chant ships, so its config is carried as data`,
|
|
375
|
+
"// and is not type-checked. Replace Record<string, unknown> with its config type to check it.",
|
|
376
|
+
pinIsCollector
|
|
377
|
+
? "// The imported config named no schema pin for it, so it is pinned to COLLECTOR_PIN."
|
|
378
|
+
: "// The pin is the one the imported config's `# chant:` header named.",
|
|
379
|
+
`const ${custom.className} = defineComponent<Record<string, unknown>>()({`,
|
|
380
|
+
` kind: ${JSON.stringify(custom.kind)},`,
|
|
381
|
+
` type: ${JSON.stringify(custom.type)},`,
|
|
382
|
+
` pin: ${pinIsCollector ? "COLLECTOR_PIN" : tsLiteral({ ...custom.pin }, 2, 7)},`,
|
|
383
|
+
"});",
|
|
384
|
+
]);
|
|
385
|
+
mod.exports.push(custom.className);
|
|
386
|
+
home.set(custom.className, mod);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Components, one module per section.
|
|
391
|
+
for (const kind of COMPONENT_KINDS) {
|
|
392
|
+
const ofKind = comps.filter((c) => c.kind === kind);
|
|
393
|
+
for (const [mod, items] of chunk(ofKind, SECTION_OF[kind], SECTION_SUMMARY[kind])) {
|
|
394
|
+
modules.push(mod);
|
|
395
|
+
for (const c of items) {
|
|
396
|
+
const lines: string[] = [];
|
|
397
|
+
if (c.builtin) mod.use(c.className);
|
|
398
|
+
else mod.use(c.className, home.get(c.className));
|
|
399
|
+
const entries: Array<[string, string]> = [];
|
|
400
|
+
if (c.name !== undefined) entries.push(["name", JSON.stringify(c.name)]);
|
|
401
|
+
for (const [key, value] of Object.entries(c.config)) {
|
|
402
|
+
if (value === undefined) continue;
|
|
403
|
+
if (!needsHoist(value)) {
|
|
404
|
+
entries.push([key, tsLiteral(value, 2, 4 + key.length)]);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const constName = unique(`${c.varName}${pascal(key) || "Value"}`);
|
|
408
|
+
let annotation = "";
|
|
409
|
+
if (c.builtin) {
|
|
410
|
+
mod.types.add(`${c.className}Config`);
|
|
411
|
+
annotation = `: ${c.className}Config[${JSON.stringify(key)}]`;
|
|
412
|
+
}
|
|
413
|
+
const head = `const ${constName}${annotation} = `;
|
|
414
|
+
lines.push(`${head}${tsLiteral(value, 0, head.length)};`);
|
|
415
|
+
entries.push([key, constName]);
|
|
416
|
+
}
|
|
417
|
+
const head = `const ${c.varName} = new ${c.className}(`;
|
|
418
|
+
lines.push(`${head}${entries.length === 0 ? "" : objectOf(entries, 0, head.length)});`);
|
|
419
|
+
mod.block(lines);
|
|
420
|
+
mod.exports.push(c.varName);
|
|
421
|
+
home.set(c.varName, mod);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const byKindId = new Map<string, ComponentDecl>();
|
|
427
|
+
for (const c of comps) byKindId.set(`${c.kind}:${c.id}`, c);
|
|
428
|
+
const ref = (mod: Module, id: string, kinds: ComponentKind[]): string => {
|
|
429
|
+
for (const k of kinds) {
|
|
430
|
+
const c = byKindId.get(`${k}:${id}`);
|
|
431
|
+
if (c) {
|
|
432
|
+
mod.use(c.varName, home.get(c.varName));
|
|
433
|
+
return c.varName;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return JSON.stringify(id);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// Pipelines.
|
|
440
|
+
for (const [mod, items] of chunk(pipes, "pipelines", "Pipelines")) {
|
|
441
|
+
modules.push(mod);
|
|
442
|
+
mod.use("Pipeline");
|
|
443
|
+
for (const p of items) {
|
|
444
|
+
const entries: Array<[string, string]> = [["signal", JSON.stringify(p.signal)]];
|
|
445
|
+
if (p.name !== undefined) entries.push(["name", JSON.stringify(p.name)]);
|
|
446
|
+
const list = (ids: string[] | undefined, kinds: ComponentKind[]) =>
|
|
447
|
+
`[${(ids ?? []).map((id) => ref(mod, id, kinds)).join(", ")}]`;
|
|
448
|
+
entries.push(["receivers", list(p.lists.receivers, ["receiver", "connector"])]);
|
|
449
|
+
if (p.lists.processors && p.lists.processors.length > 0) {
|
|
450
|
+
entries.push(["processors", list(p.lists.processors, ["processor"])]);
|
|
451
|
+
}
|
|
452
|
+
entries.push(["exporters", list(p.lists.exporters, ["exporter", "connector"])]);
|
|
453
|
+
const head = `const ${p.varName} = new Pipeline(`;
|
|
454
|
+
if ((SIGNALS as readonly string[]).includes(p.signal)) {
|
|
455
|
+
mod.block([`${head}${objectOf(entries, 0, head.length)});`]);
|
|
456
|
+
} else {
|
|
457
|
+
mod.block([
|
|
458
|
+
`${head}{`,
|
|
459
|
+
` // @ts-expect-error "${p.signal}" is a collector signal Pipeline does not type`,
|
|
460
|
+
...entries.map(([k, v]) => ` ${k}: ${v},`),
|
|
461
|
+
"});",
|
|
462
|
+
]);
|
|
463
|
+
}
|
|
464
|
+
mod.exports.push(p.varName);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Service: only when the config says something the default does not.
|
|
469
|
+
const declaredExtensions = comps.filter((c) => c.kind === "extension").map((c) => c.id);
|
|
470
|
+
const enabled = config.service?.extensions;
|
|
471
|
+
const telemetry = config.service?.telemetry;
|
|
472
|
+
const extensionsDiffer =
|
|
473
|
+
enabled === undefined
|
|
474
|
+
? declaredExtensions.length > 0
|
|
475
|
+
: enabled.length !== declaredExtensions.length || enabled.some((id, i) => declaredExtensions[i] !== id);
|
|
476
|
+
if (extensionsDiffer || telemetry !== undefined) {
|
|
477
|
+
const mod = new Module("service.ts", "service.extensions and service.telemetry");
|
|
478
|
+
modules.push(mod);
|
|
479
|
+
mod.use("Service");
|
|
480
|
+
const lines: string[] = [];
|
|
481
|
+
const entries: Array<[string, string]> = [];
|
|
482
|
+
if (extensionsDiffer) entries.push(["extensions", `[${(enabled ?? []).map((id) => ref(mod, id, ["extension"])).join(", ")}]`]);
|
|
483
|
+
if (telemetry !== undefined) {
|
|
484
|
+
mod.types.add("ServiceTelemetry");
|
|
485
|
+
const head = "const telemetry: ServiceTelemetry = ";
|
|
486
|
+
lines.push(`${head}${tsLiteral(telemetry, 0, head.length)};`);
|
|
487
|
+
entries.push(["telemetry", "telemetry"]);
|
|
488
|
+
}
|
|
489
|
+
const serviceName = unique("service");
|
|
490
|
+
const head = `const ${serviceName} = new Service(`;
|
|
491
|
+
lines.push(`${head}${objectOf(entries, 0, head.length)});`);
|
|
492
|
+
mod.block(lines);
|
|
493
|
+
mod.exports.push(serviceName);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return modules.map((m) => ({ path: m.path, content: m.render() }));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The OpenTelemetry Collector TypeScript generator `chant import` runs. */
|
|
500
|
+
export class OtelCollectorGenerator implements TypeScriptGenerator {
|
|
501
|
+
generate(ir: TemplateIR): GeneratedFile[] {
|
|
502
|
+
const resource = ir.resources.find(isCollectorResource);
|
|
503
|
+
const props = resource?.properties as unknown as CollectorResourceProperties | undefined;
|
|
504
|
+
const meta = resource?.metadata as unknown as CollectorResourceMetadata | undefined;
|
|
505
|
+
const files = generateCollectorFiles(props?.config ?? {}, meta ?? {});
|
|
506
|
+
// Core writes the first file of a generate() call as a whole module for some layouts; never hand it nothing.
|
|
507
|
+
return files.length > 0 ? files : [{ path: "pipelines.ts", content: "// The imported config declares nothing.\n" }];
|
|
508
|
+
}
|
|
509
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { COLLECTOR_RESOURCE_TYPE, OtelCollectorParser, parseCollectorYaml } from "./parser";
|
|
3
|
+
|
|
4
|
+
const parser = new OtelCollectorParser();
|
|
5
|
+
|
|
6
|
+
describe("parseCollectorYaml", () => {
|
|
7
|
+
test("reads every section as the collector's own layout", () => {
|
|
8
|
+
const { config, warnings } = parseCollectorYaml(`
|
|
9
|
+
receivers:
|
|
10
|
+
otlp:
|
|
11
|
+
protocols:
|
|
12
|
+
grpc:
|
|
13
|
+
processors:
|
|
14
|
+
batch:
|
|
15
|
+
exporters:
|
|
16
|
+
otlp/tempo:
|
|
17
|
+
endpoint: tempo:4317
|
|
18
|
+
connectors:
|
|
19
|
+
forward/x: {}
|
|
20
|
+
extensions:
|
|
21
|
+
health_check:
|
|
22
|
+
service:
|
|
23
|
+
extensions: [health_check]
|
|
24
|
+
telemetry:
|
|
25
|
+
logs: { level: debug }
|
|
26
|
+
pipelines:
|
|
27
|
+
traces:
|
|
28
|
+
receivers: [otlp]
|
|
29
|
+
processors: [batch]
|
|
30
|
+
exporters: [otlp/tempo, forward/x]
|
|
31
|
+
`);
|
|
32
|
+
expect(warnings).toEqual([]);
|
|
33
|
+
expect(config.receivers).toEqual({ otlp: { protocols: { grpc: null } } });
|
|
34
|
+
expect(config.processors).toEqual({ batch: null });
|
|
35
|
+
expect(config.exporters).toEqual({ "otlp/tempo": { endpoint: "tempo:4317" } });
|
|
36
|
+
expect(config.connectors).toEqual({ "forward/x": {} });
|
|
37
|
+
expect(config.extensions).toEqual({ health_check: null });
|
|
38
|
+
expect(config.service).toEqual({
|
|
39
|
+
extensions: ["health_check"],
|
|
40
|
+
telemetry: { logs: { level: "debug" } },
|
|
41
|
+
pipelines: { traces: { receivers: ["otlp"], processors: ["batch"], exporters: ["otlp/tempo", "forward/x"] } },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("keeps ${env:VAR} references and quoted scalars as strings", () => {
|
|
46
|
+
const { config } = parseCollectorYaml(`
|
|
47
|
+
exporters:
|
|
48
|
+
otlphttp:
|
|
49
|
+
endpoint: \${env:OTLP_ENDPOINT}
|
|
50
|
+
headers:
|
|
51
|
+
api-key: "\${env:API_KEY}"
|
|
52
|
+
compression: "none"
|
|
53
|
+
prometheus:
|
|
54
|
+
endpoint: "0.0.0.0:8889"
|
|
55
|
+
const_labels:
|
|
56
|
+
port: "8080"
|
|
57
|
+
started: 2024-01-01
|
|
58
|
+
`);
|
|
59
|
+
expect(config.exporters?.otlphttp).toEqual({
|
|
60
|
+
endpoint: "${env:OTLP_ENDPOINT}",
|
|
61
|
+
headers: { "api-key": "${env:API_KEY}" },
|
|
62
|
+
compression: "none",
|
|
63
|
+
});
|
|
64
|
+
// No YAML 1.1 dates: the collector reads this as a string, and so does the importer.
|
|
65
|
+
expect(config.exporters?.prometheus).toEqual({ endpoint: "0.0.0.0:8889", const_labels: { port: "8080", started: "2024-01-01" } });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("resolves anchors and merge keys", () => {
|
|
69
|
+
const { config } = parseCollectorYaml(`
|
|
70
|
+
exporters:
|
|
71
|
+
otlp/a: &base
|
|
72
|
+
endpoint: a:4317
|
|
73
|
+
tls: { insecure: true }
|
|
74
|
+
otlp/b:
|
|
75
|
+
<<: *base
|
|
76
|
+
endpoint: b:4317
|
|
77
|
+
`);
|
|
78
|
+
expect(config.exporters?.["otlp/b"]).toEqual({ endpoint: "b:4317", tls: { insecure: true } });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("reads the # chant: header back", () => {
|
|
82
|
+
const { pins, semconv, warnings } = parseCollectorYaml(
|
|
83
|
+
[
|
|
84
|
+
"# chant: exporter splunk_hec/security schema github.com/open-telemetry/opentelemetry-collector-contrib/exporter/splunkhecexporter@v0.130.0",
|
|
85
|
+
"# chant: processor acme schema @acme/otel-components@1.2.3 sha256:deadbeef",
|
|
86
|
+
"# chant: semconv gen_ai github.com/open-telemetry/semantic-conventions@v1.41.1 (transform, redaction)",
|
|
87
|
+
"receivers: {}",
|
|
88
|
+
].join("\n"),
|
|
89
|
+
);
|
|
90
|
+
expect(pins).toEqual([
|
|
91
|
+
{
|
|
92
|
+
kind: "exporter",
|
|
93
|
+
id: "splunk_hec/security",
|
|
94
|
+
pin: { source: "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/splunkhecexporter", version: "v0.130.0" },
|
|
95
|
+
},
|
|
96
|
+
{ kind: "processor", id: "acme", pin: { source: "@acme/otel-components", version: "1.2.3", digest: "sha256:deadbeef" } },
|
|
97
|
+
]);
|
|
98
|
+
expect(semconv).toEqual([{ namespace: "gen_ai", pin: { source: "github.com/open-telemetry/semantic-conventions", version: "v1.41.1" } }]);
|
|
99
|
+
expect(warnings).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("only the leading comment block is a header, and an unpinned line pins nothing", () => {
|
|
103
|
+
const { pins } = parseCollectorYaml(
|
|
104
|
+
[
|
|
105
|
+
"# chant: exporter x schema (unpinned)@(unpinned)",
|
|
106
|
+
"receivers: {}",
|
|
107
|
+
"# chant: exporter y schema src@v1",
|
|
108
|
+
].join("\n"),
|
|
109
|
+
);
|
|
110
|
+
expect(pins).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("names what it cannot carry", () => {
|
|
114
|
+
const { config, warnings } = parseCollectorYaml(
|
|
115
|
+
[
|
|
116
|
+
"# chant: semconv gen_ai github.com/open-telemetry/semantic-conventions@v1.30.0 (x)",
|
|
117
|
+
"# chant: something else",
|
|
118
|
+
"receivers:",
|
|
119
|
+
" thing:",
|
|
120
|
+
" name: dropped",
|
|
121
|
+
" other: kept",
|
|
122
|
+
" bad id: {}",
|
|
123
|
+
"exporters:",
|
|
124
|
+
" debug: 3",
|
|
125
|
+
"connectors: []",
|
|
126
|
+
"extras: {}",
|
|
127
|
+
"service:",
|
|
128
|
+
" pipelines:",
|
|
129
|
+
" profiles:",
|
|
130
|
+
" receivers: [thing]",
|
|
131
|
+
" exporters: [debug]",
|
|
132
|
+
" extra: [x]",
|
|
133
|
+
" unknown: true",
|
|
134
|
+
].join("\n"),
|
|
135
|
+
);
|
|
136
|
+
expect(warnings).toEqual([
|
|
137
|
+
"the config was built against gen_ai semantic conventions github.com/open-telemetry/semantic-conventions@v1.30.0; this lexicon follows github.com/open-telemetry/semantic-conventions@v1.41.1, which the rebuilt config will name",
|
|
138
|
+
'header line "# chant: something else" is not a schema pin chant recognises; it is not carried',
|
|
139
|
+
'receivers.thing has a config key "name", which chant uses for the instance name; the key is not carried',
|
|
140
|
+
'receivers: "bad id" is not a type[/name] component id',
|
|
141
|
+
"exporters.debug is not a mapping; it is carried as an empty config",
|
|
142
|
+
'"connectors" is not a mapping; it is not carried',
|
|
143
|
+
'top-level section "extras" is not part of a collector config; it is not carried',
|
|
144
|
+
'pipeline "profiles" carries signal "profiles", which Pipeline does not type; it is imported with a type suppression',
|
|
145
|
+
"service.pipelines.profiles.extra is not carried; a Pipeline has receivers, processors and exporters",
|
|
146
|
+
"service.unknown is not carried; chant's Service declares extensions and telemetry",
|
|
147
|
+
]);
|
|
148
|
+
expect(config.receivers?.thing).toEqual({ name: "dropped", other: "kept" });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("an empty file is an empty config; a non-mapping is an error", () => {
|
|
152
|
+
expect(parseCollectorYaml("").config).toEqual({});
|
|
153
|
+
expect(parseCollectorYaml("# just a comment\n").config).toEqual({});
|
|
154
|
+
expect(() => parseCollectorYaml("- a\n- b\n")).toThrow(/is a YAML mapping/);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("OtelCollectorParser", () => {
|
|
159
|
+
test("carries the whole config as one OTel::Collector resource, with the header in metadata", () => {
|
|
160
|
+
const ir = parser.parse(
|
|
161
|
+
"# chant: exporter x/y schema src@v1\nexporters:\n x/y: {}\nservice:\n pipelines:\n logs:\n receivers: []\n exporters: [x/y]\n",
|
|
162
|
+
);
|
|
163
|
+
expect(ir.resources).toHaveLength(1);
|
|
164
|
+
const [r] = ir.resources;
|
|
165
|
+
expect(r.type).toBe(COLLECTOR_RESOURCE_TYPE);
|
|
166
|
+
expect(r.logicalId).toBe("collector");
|
|
167
|
+
expect(r.properties).toEqual({
|
|
168
|
+
config: { exporters: { "x/y": {} }, service: { pipelines: { logs: { receivers: [], exporters: ["x/y"] } } } },
|
|
169
|
+
});
|
|
170
|
+
expect(r.metadata).toEqual({ pins: [{ kind: "exporter", id: "x/y", pin: { source: "src", version: "v1" } }], semconv: [] });
|
|
171
|
+
expect(ir.parameters).toEqual([]);
|
|
172
|
+
expect(ir.warnings).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
});
|