@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.
Files changed (37) hide show
  1. package/README.md +5 -0
  2. package/dist/codegen/docs.d.ts.map +1 -1
  3. package/dist/import/generator.d.ts +39 -0
  4. package/dist/import/generator.d.ts.map +1 -0
  5. package/dist/import/parser.d.ts +61 -0
  6. package/dist/import/parser.d.ts.map +1 -0
  7. package/dist/integrity.json +4 -4
  8. package/dist/lint/rules/literal-credential.d.ts.map +1 -1
  9. package/dist/manifest.json +1 -1
  10. package/dist/plugin.d.ts.map +1 -1
  11. package/dist/rules/literal-credential.ts +46 -17
  12. package/dist/skills/chant-otel.md +11 -0
  13. package/package.json +2 -2
  14. package/src/codegen/docs.ts +2 -1
  15. package/src/import/generated-types.e2e.test.ts +75 -0
  16. package/src/import/generator.test.ts +165 -0
  17. package/src/import/generator.ts +509 -0
  18. package/src/import/parser.test.ts +174 -0
  19. package/src/import/parser.ts +236 -0
  20. package/src/import/roundtrip.test.ts +365 -0
  21. package/src/import/testdata/agent-observability-agent.yaml +54 -0
  22. package/src/import/testdata/agent-observability-gateway.yaml +128 -0
  23. package/src/import/testdata/fixtures.ts +274 -0
  24. package/src/import/testdata/gateway.yaml +153 -0
  25. package/src/import/testdata/upstream/couchbase.yaml +150 -0
  26. package/src/import/testdata/upstream/fault-tolerant-logs.yaml +27 -0
  27. package/src/import/testdata/upstream/kubernetes-filelog.yaml +86 -0
  28. package/src/import/testdata/upstream/loadbalancing-agent.yaml +38 -0
  29. package/src/import/testdata/upstream/loadbalancing-backend.yaml +27 -0
  30. package/src/import/testdata/upstream/logline-filter-in.yaml +20 -0
  31. package/src/import/testdata/upstream/logline-filter-out.yaml +20 -0
  32. package/src/import/testdata/upstream/secure-tracing.yaml +26 -0
  33. package/src/import/testdata/upstream/servicegraph-nop.yaml +28 -0
  34. package/src/lint/rules/literal-credential.ts +46 -17
  35. package/src/lint/rules/rules.test.ts +16 -0
  36. package/src/plugin.ts +10 -0
  37. package/src/skills/chant-otel.md +11 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Collector YAML -> `TemplateIR`, for `chant import`.
3
+ *
4
+ * A collector config is one document whose parts name each other: pipelines
5
+ * list component ids, a connector sits in two pipelines, `service.extensions`
6
+ * lists extension ids. So the IR carries the whole config as one resource of
7
+ * type `OTel::Collector`, and the generator lays out the modules itself, with
8
+ * those names turned into imports of declared entities. (Core splits an IR
9
+ * with more than three resources into one generate() call per category and
10
+ * keeps only the first file of each, which would lose the imports between a
11
+ * pipeline and the components it references.)
12
+ *
13
+ * The config is carried as parsed, with nothing dropped from a component's
14
+ * config. What the lexicon cannot express is named in `warnings`.
15
+ *
16
+ * The `# chant:` lines the serializer writes above a config are read back:
17
+ * a custom component's schema pin becomes the pin of the `defineComponent`
18
+ * the generator writes for it. A config without them imports against
19
+ * `COLLECTOR_PIN`.
20
+ */
21
+
22
+ import * as jsYaml from "js-yaml";
23
+ import type { TemplateIR, TemplateParser } from "@intentius/chant/import/parser";
24
+ import { GENAI_SEMCONV_PIN, 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
+
35
+ /** The IR resource type for a whole collector config. */
36
+ export const COLLECTOR_RESOURCE_TYPE = "OTel::Collector";
37
+
38
+ /** One `# chant: <kind> <id> schema <source>@<version>` header line, read back. */
39
+ export interface HeaderPin {
40
+ kind: ComponentKind;
41
+ id: string;
42
+ pin: SchemaPin;
43
+ }
44
+
45
+ /** One `# chant: semconv <namespace> <source>@<version>` header line, read back. */
46
+ export interface HeaderSemconv {
47
+ namespace: string;
48
+ pin: SchemaPin;
49
+ }
50
+
51
+ /** What the parser read from a collector YAML file. */
52
+ export interface ParsedCollector {
53
+ config: CollectorConfig;
54
+ pins: HeaderPin[];
55
+ semconv: HeaderSemconv[];
56
+ warnings: string[];
57
+ }
58
+
59
+ /** `properties` of the `OTel::Collector` resource. */
60
+ export interface CollectorResourceProperties {
61
+ config: CollectorConfig;
62
+ }
63
+
64
+ /** `metadata` of the `OTel::Collector` resource. */
65
+ export interface CollectorResourceMetadata {
66
+ pins: HeaderPin[];
67
+ semconv: HeaderSemconv[];
68
+ }
69
+
70
+ // YAML 1.2 core types plus `<<` merge keys. The default schema would also
71
+ // turn an unquoted date into a Date, which the collector never does.
72
+ // js-yaml exports its built-in types at runtime; @types/js-yaml does not declare them.
73
+ const MERGE_TYPE = (jsYaml as unknown as { types: { merge: jsYaml.Type } }).types.merge;
74
+ const COLLECTOR_YAML_SCHEMA = jsYaml.CORE_SCHEMA.extend({ implicit: [MERGE_TYPE] });
75
+
76
+ const SECTIONS = new Set<string>(["receivers", "processors", "exporters", "connectors", "extensions", "service"]);
77
+ const SERVICE_KEYS = new Set(["extensions", "telemetry", "pipelines"]);
78
+ const PIPELINE_KEYS = new Set(["receivers", "processors", "exporters"]);
79
+
80
+ const PIN_LINE = /^#\s*chant:\s*(receiver|processor|exporter|connector|extension)\s+(\S+)\s+schema\s+(\S+)@([^@\s]+)(?:\s+(\S+))?\s*$/;
81
+ const SEMCONV_LINE = /^#\s*chant:\s*semconv\s+(\S+)\s+(\S+)@([^@\s]+)(?:\s+([^\s(]+))?(?:\s+\(.*\))?\s*$/;
82
+
83
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
84
+ return typeof v === "object" && v !== null && !Array.isArray(v);
85
+ }
86
+
87
+ /** The `# chant:` lines of the comment block at the top of the file. */
88
+ function readHeader(content: string, warnings: string[]): { pins: HeaderPin[]; semconv: HeaderSemconv[] } {
89
+ const pins: HeaderPin[] = [];
90
+ const semconv: HeaderSemconv[] = [];
91
+ for (const raw of content.split(/\r?\n/)) {
92
+ const line = raw.trim();
93
+ if (line === "") continue;
94
+ if (!line.startsWith("#")) break;
95
+ if (!/^#\s*chant:/.test(line)) continue;
96
+ const pin = PIN_LINE.exec(line);
97
+ if (pin) {
98
+ const [, kind, id, source, version, digest] = pin;
99
+ if (source === "(unpinned)" || version === "(unpinned)") continue;
100
+ pins.push({ kind: kind as ComponentKind, id, pin: digest ? { source, version, digest } : { source, version } });
101
+ continue;
102
+ }
103
+ const sc = SEMCONV_LINE.exec(line);
104
+ if (sc) {
105
+ const [, namespace, source, version, digest] = sc;
106
+ semconv.push({ namespace, pin: digest ? { source, version, digest } : { source, version } });
107
+ if (namespace === "gen_ai" && (source !== GENAI_SEMCONV_PIN.source || version !== GENAI_SEMCONV_PIN.version)) {
108
+ warnings.push(
109
+ `the config was built against ${namespace} semantic conventions ${source}@${version}; ` +
110
+ `this lexicon follows ${GENAI_SEMCONV_PIN.source}@${GENAI_SEMCONV_PIN.version}, which the rebuilt config will name`,
111
+ );
112
+ }
113
+ continue;
114
+ }
115
+ warnings.push(`header line "${line}" is not a schema pin chant recognises; it is not carried`);
116
+ }
117
+ return { pins, semconv };
118
+ }
119
+
120
+ /** Parse collector YAML into the plain config model, the header pins and what cannot be carried. */
121
+ export function parseCollectorYaml(content: string): ParsedCollector {
122
+ const warnings: string[] = [];
123
+ const { pins, semconv } = readHeader(content, warnings);
124
+ const doc = content.trim() === "" ? {} : jsYaml.load(content, { schema: COLLECTOR_YAML_SCHEMA });
125
+ if (doc === null || doc === undefined) return { config: {}, pins, semconv, warnings };
126
+ if (!isPlainObject(doc)) {
127
+ throw new Error("an OpenTelemetry Collector config is a YAML mapping; this document is not one");
128
+ }
129
+
130
+ const config: CollectorConfig = {};
131
+ for (const [key, value] of Object.entries(doc)) {
132
+ if (!SECTIONS.has(key)) {
133
+ warnings.push(`top-level section "${key}" is not part of a collector config; it is not carried`);
134
+ continue;
135
+ }
136
+ if (value === null || value === undefined) continue;
137
+ if (!isPlainObject(value)) {
138
+ warnings.push(`"${key}" is not a mapping; it is not carried`);
139
+ continue;
140
+ }
141
+ if (key === "service") continue;
142
+ const section: Record<string, Record<string, unknown> | null> = {};
143
+ for (const [id, cfg] of Object.entries(value)) {
144
+ if (!parseComponentId(id)) warnings.push(`${key}: "${id}" is not a type[/name] component id`);
145
+ if (cfg !== null && cfg !== undefined && !isPlainObject(cfg)) {
146
+ warnings.push(`${key}.${id} is not a mapping; it is carried as an empty config`);
147
+ section[id] = null;
148
+ continue;
149
+ }
150
+ if (isPlainObject(cfg) && "name" in cfg) {
151
+ warnings.push(
152
+ `${key}.${id} has a config key "name", which chant uses for the instance name; the key is not carried`,
153
+ );
154
+ }
155
+ section[id] = (cfg as Record<string, unknown> | null | undefined) ?? null;
156
+ }
157
+ (config as Record<string, unknown>)[key] = section;
158
+ }
159
+
160
+ const service = (doc as Record<string, unknown>).service;
161
+ if (isPlainObject(service)) {
162
+ config.service = {};
163
+ for (const [key, value] of Object.entries(service)) {
164
+ if (!SERVICE_KEYS.has(key)) {
165
+ warnings.push(`service.${key} is not carried; chant's Service declares extensions and telemetry`);
166
+ continue;
167
+ }
168
+ if (value === null || value === undefined) continue;
169
+ if (key === "extensions") {
170
+ if (!Array.isArray(value)) warnings.push("service.extensions is not a list; it is not carried");
171
+ else config.service.extensions = value.map(String);
172
+ } else if (key === "telemetry") {
173
+ if (!isPlainObject(value)) warnings.push("service.telemetry is not a mapping; it is not carried");
174
+ else config.service.telemetry = value;
175
+ } else if (key === "pipelines") {
176
+ if (!isPlainObject(value)) {
177
+ warnings.push("service.pipelines is not a mapping; it is not carried");
178
+ continue;
179
+ }
180
+ config.service.pipelines = {};
181
+ for (const [pid, p] of Object.entries(value)) {
182
+ const signal = pipelineSignal(pid);
183
+ if (!(SIGNALS as readonly string[]).includes(signal)) {
184
+ warnings.push(
185
+ `pipeline "${pid}" carries signal "${signal}", which Pipeline does not type; it is imported with a type suppression`,
186
+ );
187
+ }
188
+ const entry: Record<string, string[]> = {};
189
+ if (isPlainObject(p)) {
190
+ for (const [k, list] of Object.entries(p)) {
191
+ if (!PIPELINE_KEYS.has(k)) {
192
+ warnings.push(`service.pipelines.${pid}.${k} is not carried; a Pipeline has receivers, processors and exporters`);
193
+ continue;
194
+ }
195
+ if (list === null || list === undefined) continue;
196
+ if (!Array.isArray(list)) {
197
+ warnings.push(`service.pipelines.${pid}.${k} is not a list; it is not carried`);
198
+ continue;
199
+ }
200
+ entry[k] = list.map(String);
201
+ }
202
+ }
203
+ config.service.pipelines[pid] = entry;
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ return { config, pins, semconv, warnings };
210
+ }
211
+
212
+ /** Which kind a section's components are. */
213
+ export const KIND_OF_SECTION: Record<string, ComponentKind> = Object.fromEntries(
214
+ COMPONENT_KINDS.map((k) => [SECTION_OF[k], k]),
215
+ );
216
+
217
+ /** The OpenTelemetry Collector config parser `chant import` runs. */
218
+ export class OtelCollectorParser implements TemplateParser {
219
+ parse(content: string): TemplateIR {
220
+ const parsed = parseCollectorYaml(content);
221
+ const properties: CollectorResourceProperties = { config: parsed.config };
222
+ const metadata: CollectorResourceMetadata = { pins: parsed.pins, semconv: parsed.semconv };
223
+ return {
224
+ resources: [
225
+ {
226
+ logicalId: "collector",
227
+ type: COLLECTOR_RESOURCE_TYPE,
228
+ properties: properties as unknown as Record<string, unknown>,
229
+ metadata: metadata as unknown as Record<string, unknown>,
230
+ },
231
+ ],
232
+ parameters: [],
233
+ warnings: parsed.warnings,
234
+ };
235
+ }
236
+ }
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Round trips through `chant import`.
3
+ *
4
+ * YAML -> TypeScript -> `chant build` -> YAML must give back the same config
5
+ * (key order and quoting aside) for every fixture: the otel examples' built
6
+ * output, a gateway with tail_sampling, loadbalancing and spanmetrics,
7
+ * `genAiPipeline()` output, the two collector configs of
8
+ * examples/agent-observability, and collector-contrib example configs at the
9
+ * pinned release. The generated source must lint clean.
10
+ *
11
+ * The other direction too: a config using every built-in's typed fields
12
+ * survives TypeScript -> YAML -> TypeScript -> YAML.
13
+ *
14
+ * Where `otelcol-contrib` is on PATH (or `OTELCOL_BIN` names a contrib
15
+ * build) the re-emitted configs are also checked by `otelcol validate`.
16
+ * generated-types.e2e.test.ts type-checks the generated source.
17
+ */
18
+
19
+ import { describe, expect, test } from "vitest";
20
+ import { spawnSync } from "child_process";
21
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
22
+ import { tmpdir } from "os";
23
+ import { join } from "path";
24
+ import { load } from "js-yaml";
25
+ import { build } from "@intentius/chant/build";
26
+ import { lintCommand } from "@intentius/chant/cli/commands/lint";
27
+ import { importCommand, importFromContent } from "@intentius/chant/cli/commands/import";
28
+ import { otelSerializer } from "../serializer";
29
+ import { collectorYaml } from "../collector";
30
+ import { registeredDefinitions } from "../define";
31
+ import { genAiPipeline } from "../genai";
32
+ import { SECTION_OF, type CollectorConfig, type ComponentKind } from "../model";
33
+ import * as c from "../components";
34
+ import { OtelCollectorParser } from "./parser";
35
+ import { OtelCollectorGenerator } from "./generator";
36
+ import { everyBuiltin, exampleOutputs, pkgDir, primary, read, UPSTREAM_BUILTIN_ONLY } from "./testdata/fixtures";
37
+
38
+ // ── helpers ─────────────────────────────────────────────────────────
39
+
40
+ /** A parsed config with the differences the collector does not see taken out. */
41
+ function normalize(yaml: string): CollectorConfig {
42
+ const doc = (load(yaml) ?? {}) as CollectorConfig;
43
+ for (const section of Object.values(SECTION_OF)) {
44
+ const s = doc[section];
45
+ if (!s) continue;
46
+ for (const id of Object.keys(s)) if (s[id] === null) s[id] = {};
47
+ if (Object.keys(s).length === 0) delete doc[section];
48
+ }
49
+ const service = doc.service;
50
+ if (service) {
51
+ if (service.extensions?.length === 0) delete service.extensions;
52
+ for (const p of Object.values(service.pipelines ?? {})) {
53
+ p.receivers ??= [];
54
+ p.exporters ??= [];
55
+ if (!p.processors || p.processors.length === 0) delete p.processors;
56
+ }
57
+ if (Object.keys(service).length === 0) delete doc.service;
58
+ }
59
+ return doc;
60
+ }
61
+
62
+ /**
63
+ * The `# chant:` header lines, with the component list of a semconv line
64
+ * sorted: the rebuilt config lists components in the order the build
65
+ * discovers them, which within a module is export-name order.
66
+ */
67
+ function headerLines(yaml: string): string[] {
68
+ return yaml
69
+ .split("\n")
70
+ .filter((l) => /^#\s*chant:/.test(l))
71
+ .map((l) => l.replace(/\(([^)]*)\)$/, (_, list: string) => `(${list.split(", ").sort().join(", ")})`));
72
+ }
73
+
74
+ interface Imported {
75
+ source: string;
76
+ warnings: string[];
77
+ yaml: string;
78
+ buildErrors: unknown[];
79
+ lint: { errorCount: number; warningCount: number; output: string };
80
+ }
81
+
82
+ /** A chant project dir inside the package, so the lexicon resolves as it does for a user. */
83
+ function projectDir(): string {
84
+ const dir = mkdtempSync(join(pkgDir, ".roundtrip-tmp-"));
85
+ mkdirSync(join(dir, "src"));
86
+ writeFileSync(join(dir, "chant.config.ts"), 'export default { lexicons: ["otel"] };\n');
87
+ writeFileSync(join(dir, "package.json"), '{ "name": "otel-import-roundtrip", "private": true, "type": "module" }\n');
88
+ return dir;
89
+ }
90
+
91
+ /** YAML -> IR -> TypeScript -> `chant build` -> YAML, and `chant lint` over the source. */
92
+ async function importAndBuild(yaml: string): Promise<Imported> {
93
+ const ir = new OtelCollectorParser().parse(yaml);
94
+ const files = new OtelCollectorGenerator().generate(ir);
95
+ const dir = projectDir();
96
+ try {
97
+ const srcDir = join(dir, "src");
98
+ for (const file of files) writeFileSync(join(srcDir, file.path), file.content);
99
+ const result = await build(srcDir, [otelSerializer]);
100
+ const lint = await lintCommand({ path: srcDir, format: "stylish" });
101
+ return {
102
+ source: files.map((f) => `// ${f.path}\n${f.content}`).join("\n"),
103
+ warnings: ir.warnings ?? [],
104
+ yaml: primary(result.outputs.get("otel")),
105
+ buildErrors: result.errors,
106
+ lint: { errorCount: lint.errorCount, warningCount: lint.warningCount, output: lint.output },
107
+ };
108
+ } finally {
109
+ rmSync(dir, { recursive: true, force: true });
110
+ }
111
+ }
112
+
113
+ /** Every import is expected to rebuild equal and lint clean; a fixture may name lint rules it expects to fire. */
114
+ async function expectRoundTrip(yaml: string, opts: { lintRules?: string[] } = {}): Promise<Imported> {
115
+ const out = await importAndBuild(yaml);
116
+ expect(out.buildErrors).toEqual([]);
117
+ expect(normalize(out.yaml)).toEqual(normalize(yaml));
118
+ if (opts.lintRules) {
119
+ for (const rule of opts.lintRules) expect(out.lint.output).toContain(rule);
120
+ } else {
121
+ if (out.lint.errorCount + out.lint.warningCount > 0) console.log(out.lint.output);
122
+ expect(out.lint.errorCount).toBe(0);
123
+ expect(out.lint.warningCount).toBe(0);
124
+ }
125
+ return out;
126
+ }
127
+
128
+ function findOtelcol(): string | undefined {
129
+ const candidates = [process.env.OTELCOL_BIN, "otelcol-contrib"].filter((b): b is string => !!b);
130
+ for (const bin of candidates) {
131
+ const r = spawnSync(bin, ["--version"], { encoding: "utf-8" });
132
+ if (r.status === 0) return bin;
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ const OTELCOL = findOtelcol();
138
+
139
+ function otelcolValidate(yaml: string): { ok: boolean; output: string } {
140
+ const dir = mkdtempSync(join(tmpdir(), "chant-otelcol-import-"));
141
+ try {
142
+ const file = join(dir, "config.yaml");
143
+ writeFileSync(file, yaml);
144
+ const r = spawnSync(OTELCOL!, ["validate", `--config=${file}`], {
145
+ encoding: "utf-8",
146
+ timeout: 60_000,
147
+ env: { ...process.env, TEMPO_TOKEN: "t", DEPLOY_ENV: "test", SPLUNK_HEC_TOKEN: "t", K8S_NODE_NAME: "node-1" },
148
+ });
149
+ return { ok: r.status === 0, output: `${r.stdout ?? ""}${r.stderr ?? ""}` };
150
+ } finally {
151
+ rmSync(dir, { recursive: true, force: true });
152
+ }
153
+ }
154
+
155
+ describe("YAML -> TypeScript -> YAML", () => {
156
+ test("the otel examples' built output", async () => {
157
+ const outputs = await exampleOutputs();
158
+ expect(outputs.map(([n]) => n)).toEqual(["custom-component", "genai-agent", "getting-started", "k8s-node-agent"]);
159
+ for (const [name, yaml] of outputs) {
160
+ const out = await expectRoundTrip(yaml);
161
+ // The `# chant:` header comes back too: the custom component's pin, the semconv line.
162
+ expect(headerLines(out.yaml), name).toEqual(headerLines(yaml));
163
+ }
164
+ });
165
+
166
+ test("a gateway with tail_sampling, loadbalancing and spanmetrics", async () => {
167
+ const yaml = read("gateway.yaml");
168
+ const out = await expectRoundTrip(yaml);
169
+ expect(out.source).toContain("new TailSamplingProcessor(");
170
+ expect(out.source).toContain("new LoadBalancingExporter(");
171
+ expect(out.source).toContain("const spanmetrics = new SpanMetricsConnector(");
172
+ // The connector is one entity, on both sides of the join.
173
+ expect(out.source).toContain("exporters: [otlpTempo, spanmetrics]");
174
+ expect(out.source).toContain("receivers: [spanmetrics]");
175
+ // ${env:VAR} stays a reference.
176
+ expect(out.source).toContain('authorization: "Bearer ${env:TEMPO_TOKEN}"');
177
+ expect(out.source).toContain('default: "${env:DEPLOY_ENV}"');
178
+ // pprof is declared but not enabled, so the Service lists what is.
179
+ expect(out.source).toContain("extensions: [zpages, healthCheck]");
180
+ expect(out.source).toContain("const telemetry: ServiceTelemetry = {");
181
+ // Nested config is lifted into named consts typed by the component's config type (COR001).
182
+ expect(out.source).toContain('const tailSamplingPolicies: TailSamplingProcessorConfig["policies"] = [');
183
+ expect(out.warnings).toEqual([]);
184
+ });
185
+
186
+ test("genAiPipeline() output", async () => {
187
+ const tempo = new c.OtlpExporter({ name: "tempo", endpoint: "tempo:4317", tls: { insecure: true } });
188
+ const sampler = new c.ProbabilisticSamplerProcessor({ sampling_percentage: 25 });
189
+ for (const entities of [
190
+ genAiPipeline(),
191
+ genAiPipeline({ traceExporters: [tempo], sampling: [sampler], keepContent: true, logs: false }),
192
+ genAiPipeline({ maskValues: ["\\b[0-9]{13,16}\\b"], hashFunction: "sha3", healthCheck: false }),
193
+ ]) {
194
+ const yaml = collectorYaml(entities);
195
+ const out = await expectRoundTrip(yaml);
196
+ expect(headerLines(out.yaml)).toEqual(headerLines(yaml));
197
+ }
198
+ });
199
+
200
+ test("the collector configs of examples/agent-observability", async () => {
201
+ for (const file of ["agent-observability-agent.yaml", "agent-observability-gateway.yaml"]) {
202
+ await expectRoundTrip(read(file));
203
+ }
204
+ });
205
+
206
+ for (const file of UPSTREAM_BUILTIN_ONLY) {
207
+ test(`collector-contrib v0.130.0: ${file}`, async () => {
208
+ const out = await expectRoundTrip(read("upstream", file));
209
+ expect(out.source).not.toContain("defineComponent");
210
+ });
211
+ }
212
+
213
+ test.skipIf(!OTELCOL)("otelcol validate accepts every re-emitted config", async () => {
214
+ // Not the agent-observability agent: its k8s resolver needs a cluster to build (that example's own tests swap it for dns).
215
+ const yamls: Array<[string, string]> = [
216
+ ["gateway.yaml", read("gateway.yaml")],
217
+ ["agent-observability-gateway.yaml", read("agent-observability-gateway.yaml")],
218
+ ["genAiPipeline()", collectorYaml(genAiPipeline())],
219
+ ...UPSTREAM_BUILTIN_ONLY.map((f): [string, string] => [f, read("upstream", f)]),
220
+ // hostmetrics' root_path is accepted on linux only, which is where the node agent runs.
221
+ ...(await exampleOutputs()).filter(([n]) => n !== "k8s-node-agent" || process.platform === "linux"),
222
+ ];
223
+ for (const [name, yaml] of yamls) {
224
+ const { yaml: rebuilt } = await importAndBuild(yaml);
225
+ const { ok, output } = otelcolValidate(rebuilt);
226
+ expect(ok, `${name}: ${output}`).toBe(true);
227
+ }
228
+ });
229
+
230
+ });
231
+
232
+ describe("components chant does not ship", () => {
233
+ test("go through defineComponent with their config as data, and survive the round trip", async () => {
234
+ const out = await expectRoundTrip(read("upstream", "fault-tolerant-logs.yaml"));
235
+ expect(out.source).toContain("const FileStorageExtension = defineComponent<Record<string, unknown>>()({");
236
+ expect(out.source).toContain("pin: COLLECTOR_PIN,");
237
+ expect(out.source).toMatch(/\/\/ extension "file_storage" is not a component chant ships/);
238
+ expect(out.source).toContain('const fileStorageFilelogreceiver = new FileStorageExtension({');
239
+ expect(out.source).toContain('import { FileStorageExtension } from "./custom-components";');
240
+ // The typed built-ins keep referring to the storage extension by id, as data.
241
+ expect(out.source).toContain('storage: "file_storage/filelogreceiver"');
242
+ });
243
+
244
+ test("an unknown receiver and exporter in the loadbalancing agent example", async () => {
245
+ const out = await expectRoundTrip(read("upstream", "loadbalancing-agent.yaml"));
246
+ expect(out.source).toContain('type: "fluentforward"');
247
+ expect(out.source).toContain("new LoadBalancingExporter(");
248
+ });
249
+
250
+ test("nop receivers and exporters around a servicegraph connector", async () => {
251
+ const out = await expectRoundTrip(read("upstream", "servicegraph-nop.yaml"));
252
+ expect(out.source).toContain("const NopReceiver = defineComponent");
253
+ expect(out.source).toContain("const NopExporter = defineComponent");
254
+ expect(out.source).toContain("const servicegraph = new ServiceGraphConnector(");
255
+ });
256
+
257
+ test("a literal credential is imported as found and reported by OTEL002", async () => {
258
+ const out = await expectRoundTrip(read("upstream", "couchbase.yaml"), { lintRules: ["OTEL002"] });
259
+ expect(out.source).toContain('password: "otelpassword"');
260
+ expect(out.source).toContain("const MetricstransformProcessor = defineComponent");
261
+ });
262
+
263
+ test("a pin from the `# chant:` header is carried back into defineComponent", async () => {
264
+ const yaml = [
265
+ "# chant: exporter datadog/eu schema github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter@v0.129.0 sha256:abc",
266
+ "receivers:",
267
+ " otlp:",
268
+ " protocols:",
269
+ " grpc: {}",
270
+ "exporters:",
271
+ " datadog/eu:",
272
+ " api:",
273
+ " key: ${env:DD_API_KEY}",
274
+ " site: datadoghq.eu",
275
+ "service:",
276
+ " pipelines:",
277
+ " traces:",
278
+ " receivers: [otlp]",
279
+ " exporters: [datadog/eu]",
280
+ "",
281
+ ].join("\n");
282
+ const out = await expectRoundTrip(yaml);
283
+ expect(out.source).toContain('version: "v0.129.0"');
284
+ expect(out.source).toContain('digest: "sha256:abc"');
285
+ expect(out.source).not.toContain("COLLECTOR_PIN,");
286
+ expect(headerLines(out.yaml)).toEqual(headerLines(yaml));
287
+ });
288
+ });
289
+
290
+ describe("TypeScript -> YAML -> TypeScript -> YAML", () => {
291
+ test("every built-in, each using its typed fields", async () => {
292
+ const entities = everyBuiltin();
293
+ const first = collectorYaml(entities);
294
+
295
+ // The fixture covers every built-in this package registers.
296
+ const config = load(first) as CollectorConfig;
297
+ const used = new Set<string>();
298
+ for (const [kind, section] of Object.entries(SECTION_OF) as Array<[ComponentKind, keyof CollectorConfig]>) {
299
+ for (const id of Object.keys((config[section] as Record<string, unknown>) ?? {})) used.add(`${kind}:${id.split("/")[0]}`);
300
+ }
301
+ const builtins = registeredDefinitions().filter((d) => d.builtin).map((d) => `${d.kind}:${d.type}`);
302
+ expect([...used].sort()).toEqual([...new Set(builtins)].sort());
303
+
304
+ const out = await expectRoundTrip(first);
305
+ expect(out.source).not.toContain("defineComponent");
306
+ // Importing is a fixed point from there: the rebuilt YAML (components now in
307
+ // export-name order within each section) gives the same TypeScript and YAML, text for text.
308
+ const again = await importAndBuild(out.yaml);
309
+ const third = await importAndBuild(again.yaml);
310
+ expect(third.source).toBe(again.source);
311
+ expect(third.yaml).toBe(again.yaml);
312
+ });
313
+ });
314
+
315
+ // ── through core's import command ───────────────────────────────────
316
+
317
+ test("importFromContent writes one module per section through the otel plugin", async () => {
318
+ const dir = mkdtempSync(join(pkgDir, ".roundtrip-tmp-"));
319
+ try {
320
+ const output = join(dir, "src");
321
+ const result = await importFromContent({ content: read("gateway.yaml"), lexicon: "otel", output });
322
+ expect(result.error).toBeUndefined();
323
+ expect(result.success).toBe(true);
324
+ expect(result.lexicon).toBe("otel");
325
+ expect(result.generatedFiles).toEqual([
326
+ "receivers.ts",
327
+ "processors.ts",
328
+ "exporters.ts",
329
+ "connectors.ts",
330
+ "extensions.ts",
331
+ "pipelines.ts",
332
+ "service.ts",
333
+ ]);
334
+ const built = await build(output, [otelSerializer]);
335
+ expect(built.errors).toEqual([]);
336
+ expect(normalize(primary(built.outputs.get("otel")))).toEqual(normalize(read("gateway.yaml")));
337
+ } finally {
338
+ rmSync(dir, { recursive: true, force: true });
339
+ }
340
+ });
341
+
342
+ describe("chant import collector.yaml", () => {
343
+ for (const lexicon of [undefined, "otel"]) {
344
+ test(lexicon ? "with --lexicon otel" : "detected as a collector config", async () => {
345
+ const dir = projectDir();
346
+ try {
347
+ const templatePath = join(dir, "collector.yaml");
348
+ writeFileSync(templatePath, read("gateway.yaml"));
349
+ const output = join(dir, "src");
350
+ const result = await importCommand({ templatePath, output, force: true, lexicon });
351
+ expect(result.error).toBeUndefined();
352
+ expect(result.success).toBe(true);
353
+ expect(result.lexicon).toBe("otel");
354
+ expect(result.generatedFiles).toContain("pipelines.ts");
355
+ const built = await build(output, [otelSerializer]);
356
+ expect(built.errors).toEqual([]);
357
+ expect(normalize(primary(built.outputs.get("otel")))).toEqual(normalize(read("gateway.yaml")));
358
+ const lint = await lintCommand({ path: output, format: "stylish" });
359
+ expect(lint.errorCount + lint.warningCount, lint.output).toBe(0);
360
+ } finally {
361
+ rmSync(dir, { recursive: true, force: true });
362
+ }
363
+ });
364
+ }
365
+ });
@@ -0,0 +1,54 @@
1
+ # The otel-agent-config ConfigMap's config.yaml from examples/agent-observability,
2
+ # as that example builds it. Regenerate from examples/agent-observability/test/built.ts (agentConfigYaml).
3
+
4
+ receivers:
5
+ otlp:
6
+ protocols:
7
+ grpc:
8
+ endpoint: 0.0.0.0:4317
9
+ http:
10
+ endpoint: 0.0.0.0:4318
11
+
12
+ processors:
13
+ memory_limiter:
14
+ check_interval: 1s
15
+ limit_percentage: 80
16
+ spike_limit_percentage: 20
17
+ batch:
18
+ timeout: 1s
19
+
20
+ exporters:
21
+ loadbalancing/gateway:
22
+ routing_key: traceID
23
+ protocol:
24
+ otlp:
25
+ tls:
26
+ insecure: true
27
+ resolver:
28
+ k8s:
29
+ service: otel-gateway-headless.observability
30
+ ports: [4317]
31
+ otlp/gateway:
32
+ endpoint: otel-gateway.observability.svc:4317
33
+ tls:
34
+ insecure: true
35
+
36
+ extensions:
37
+ health_check:
38
+ endpoint: 0.0.0.0:13133
39
+
40
+ service:
41
+ extensions: [health_check]
42
+ pipelines:
43
+ traces:
44
+ receivers: [otlp]
45
+ processors: [memory_limiter, batch]
46
+ exporters: [loadbalancing/gateway]
47
+ metrics:
48
+ receivers: [otlp]
49
+ processors: [memory_limiter, batch]
50
+ exporters: [otlp/gateway]
51
+ logs:
52
+ receivers: [otlp]
53
+ processors: [memory_limiter, batch]
54
+ exporters: [otlp/gateway]