@intentius/chant-lexicon-otel 0.97.0 → 0.99.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/components/common.d.ts +7 -2
- package/dist/components/common.d.ts.map +1 -1
- package/dist/components/filtering.d.ts +58 -1
- package/dist/components/filtering.d.ts.map +1 -1
- package/dist/components/processors.d.ts +10 -0
- package/dist/components/processors.d.ts.map +1 -1
- package/dist/components/receivers.d.ts +87 -7
- package/dist/components/receivers.d.ts.map +1 -1
- package/dist/import/embedded.d.ts +12 -0
- package/dist/import/embedded.d.ts.map +1 -0
- 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/components/common.ts +7 -2
- package/src/components/filtering.test.ts +27 -0
- package/src/components/filtering.ts +65 -2
- package/src/components/processors.ts +4 -0
- package/src/components/receivers.ts +88 -4
- package/src/import/embedded.test.ts +56 -0
- package/src/import/embedded.ts +41 -0
- package/src/import/generated-types.e2e.test.ts +110 -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 +15 -0
- package/src/skills/chant-otel.md +11 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { CollectorConfig } from "../model";
|
|
3
|
+
import { builtinClassName, generateCollectorFiles, OtelCollectorGenerator, tsLiteral } from "./generator";
|
|
4
|
+
import { OtelCollectorParser } from "./parser";
|
|
5
|
+
|
|
6
|
+
const files = (config: CollectorConfig, meta = {}) =>
|
|
7
|
+
Object.fromEntries(generateCollectorFiles(config, meta).map((f) => [f.path, f.content]));
|
|
8
|
+
|
|
9
|
+
describe("builtinClassName", () => {
|
|
10
|
+
test("maps every built-in kind and type to its exported class", () => {
|
|
11
|
+
expect(builtinClassName("receiver", "otlp")).toBe("OtlpReceiver");
|
|
12
|
+
expect(builtinClassName("exporter", "otlp")).toBe("OtlpExporter");
|
|
13
|
+
expect(builtinClassName("processor", "tail_sampling")).toBe("TailSamplingProcessor");
|
|
14
|
+
expect(builtinClassName("connector", "spanmetrics")).toBe("SpanMetricsConnector");
|
|
15
|
+
expect(builtinClassName("connector", "sum")).toBe("SumConnector");
|
|
16
|
+
expect(builtinClassName("receiver", "k8s_cluster")).toBe("K8sClusterReceiver");
|
|
17
|
+
expect(builtinClassName("extension", "health_check")).toBe("HealthCheckExtension");
|
|
18
|
+
expect(builtinClassName("exporter", "datadog")).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("tsLiteral", () => {
|
|
23
|
+
test("writes plain keys bare, quotes the rest, and prefers single quotes for OTTL", () => {
|
|
24
|
+
expect(tsLiteral({ a: 1, "b.c": true, d: null }, 0)).toBe('{ a: 1, "b.c": true, d: null }');
|
|
25
|
+
expect(tsLiteral('attributes["x"] == "y"', 0)).toBe(`'attributes["x"] == "y"'`);
|
|
26
|
+
expect(tsLiteral(`it's "both"`, 0)).toBe(JSON.stringify(`it's "both"`));
|
|
27
|
+
expect(tsLiteral("${env:X}", 0)).toBe('"${env:X}"');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("breaks a value across lines past 100 columns", () => {
|
|
31
|
+
const out = tsLiteral({ list: Array.from({ length: 12 }, (_, i) => `item-${i}`) }, 0);
|
|
32
|
+
expect(out.split("\n").length).toBeGreaterThan(1);
|
|
33
|
+
expect(out.split("\n").every((l) => l.length <= 100)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("generateCollectorFiles", () => {
|
|
38
|
+
test("one module per section, pipelines importing what they reference", () => {
|
|
39
|
+
const out = files({
|
|
40
|
+
receivers: { otlp: { protocols: { grpc: null } } },
|
|
41
|
+
processors: { batch: null },
|
|
42
|
+
exporters: { "otlp/tempo": { endpoint: "tempo:4317" } },
|
|
43
|
+
service: { pipelines: { traces: { receivers: ["otlp"], processors: ["batch"], exporters: ["otlp/tempo"] } } },
|
|
44
|
+
});
|
|
45
|
+
expect(Object.keys(out)).toEqual(["receivers.ts", "processors.ts", "exporters.ts", "pipelines.ts"]);
|
|
46
|
+
expect(out["receivers.ts"]).toContain('const otlpProtocols: OtlpReceiverConfig["protocols"] = { grpc: null };');
|
|
47
|
+
expect(out["receivers.ts"]).toContain("const otlp = new OtlpReceiver({ protocols: otlpProtocols });");
|
|
48
|
+
expect(out["receivers.ts"]).toContain("export { otlp };");
|
|
49
|
+
expect(out["processors.ts"]).toContain("const batch = new BatchProcessor();");
|
|
50
|
+
expect(out["exporters.ts"]).toContain('const otlpTempo = new OtlpExporter({ name: "tempo", endpoint: "tempo:4317" });');
|
|
51
|
+
expect(out["pipelines.ts"]).toContain('import { otlpTempo } from "./exporters";');
|
|
52
|
+
expect(out["pipelines.ts"]).toContain(
|
|
53
|
+
[
|
|
54
|
+
"const traces = new Pipeline({",
|
|
55
|
+
' signal: "traces",',
|
|
56
|
+
" receivers: [otlp],",
|
|
57
|
+
" processors: [batch],",
|
|
58
|
+
" exporters: [otlpTempo],",
|
|
59
|
+
"});",
|
|
60
|
+
].join("\n"),
|
|
61
|
+
);
|
|
62
|
+
expect(out["pipelines.ts"]).toContain("export { traces };");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("an id shared across kinds gets a kind suffix; a connector is one constant on both sides", () => {
|
|
66
|
+
const out = files({
|
|
67
|
+
receivers: { otlp: {}, prometheus: { config: { scrape_configs: [] } } },
|
|
68
|
+
exporters: { otlp: { endpoint: "x:4317" }, prometheus: { endpoint: "0.0.0.0:8889" } },
|
|
69
|
+
connectors: { spanmetrics: {} },
|
|
70
|
+
service: {
|
|
71
|
+
pipelines: {
|
|
72
|
+
traces: { receivers: ["otlp"], exporters: ["otlp", "spanmetrics"] },
|
|
73
|
+
metrics: { receivers: ["prometheus", "spanmetrics"], exporters: ["prometheus"] },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
expect(out["receivers.ts"]).toContain("const otlpReceiver = new OtlpReceiver();");
|
|
78
|
+
expect(out["exporters.ts"]).toContain('const otlpExporter = new OtlpExporter({ endpoint: "x:4317" });');
|
|
79
|
+
expect(out["pipelines.ts"]).toContain(" receivers: [otlpReceiver],\n exporters: [otlpExporter, spanmetrics],");
|
|
80
|
+
expect(out["pipelines.ts"]).toContain(" receivers: [prometheusReceiver, spanmetrics],\n exporters: [prometheusExporter],");
|
|
81
|
+
expect(out["pipelines.ts"]).toContain('import { spanmetrics } from "./connectors";');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a component type chant does not ship is defined once with defineComponent and COLLECTOR_PIN", () => {
|
|
85
|
+
const out = files({
|
|
86
|
+
exporters: { "datadog/a": { api: { key: "${env:DD}" } }, "datadog/b": {}, otlp_http: {} },
|
|
87
|
+
service: { pipelines: { logs: { receivers: [], exporters: ["datadog/a", "datadog/b", "otlp_http"] } } },
|
|
88
|
+
});
|
|
89
|
+
const custom = out["custom-components.ts"];
|
|
90
|
+
expect(custom).toContain('import { COLLECTOR_PIN, defineComponent } from "@intentius/chant-lexicon-otel";');
|
|
91
|
+
expect(custom.match(/defineComponent<Record<string, unknown>>\(\)/g)).toHaveLength(2);
|
|
92
|
+
expect(custom).toContain('const DatadogExporter = defineComponent<Record<string, unknown>>()({\n kind: "exporter",\n type: "datadog",\n pin: COLLECTOR_PIN,\n});');
|
|
93
|
+
// `OtlpHttpExporter` is the built-in otlphttp exporter's class, so this one is numbered.
|
|
94
|
+
expect(custom).toContain("const OtlpHttpExporter2 = defineComponent");
|
|
95
|
+
expect(custom).toContain("export { DatadogExporter, OtlpHttpExporter2 };");
|
|
96
|
+
expect(out["exporters.ts"]).toContain('import { DatadogExporter, OtlpHttpExporter2 } from "./custom-components";');
|
|
97
|
+
expect(out["exporters.ts"]).toContain("const datadogAApi = { key: \"${env:DD}\" };");
|
|
98
|
+
expect(out["exporters.ts"]).toContain('const datadogA = new DatadogExporter({ name: "a", api: datadogAApi });');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a pin named by the header replaces COLLECTOR_PIN", () => {
|
|
102
|
+
const out = files(
|
|
103
|
+
{ exporters: { "acme/x": {} } },
|
|
104
|
+
{ pins: [{ kind: "exporter", id: "acme/x", pin: { source: "@acme/otel", version: "2.0.0", digest: "sha256:1" } }] },
|
|
105
|
+
);
|
|
106
|
+
expect(out["custom-components.ts"]).toContain('pin: { source: "@acme/otel", version: "2.0.0", digest: "sha256:1" },');
|
|
107
|
+
expect(out["custom-components.ts"]).not.toContain("COLLECTOR_PIN");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a Service only when extensions or telemetry differ from the default", () => {
|
|
111
|
+
const ext = { health_check: null, zpages: null };
|
|
112
|
+
expect(files({ extensions: ext, service: { extensions: ["health_check", "zpages"] } })["service.ts"]).toBeUndefined();
|
|
113
|
+
expect(files({ extensions: ext, service: { extensions: ["zpages", "health_check"] } })["service.ts"]).toContain(
|
|
114
|
+
"const service = new Service({ extensions: [zpages, healthCheck] });",
|
|
115
|
+
);
|
|
116
|
+
// Declared but not enabled: an empty list, so the rebuilt config enables none either.
|
|
117
|
+
expect(files({ extensions: ext })["service.ts"]).toContain("const service = new Service({ extensions: [] });");
|
|
118
|
+
const withTelemetry = files({ service: { telemetry: { logs: { level: "warn" } } } })["service.ts"];
|
|
119
|
+
expect(withTelemetry).toContain('const telemetry: ServiceTelemetry = { logs: { level: "warn" } };');
|
|
120
|
+
expect(withTelemetry).toContain("const service = new Service({ telemetry });");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("more than eight declarables of one section split into numbered modules", () => {
|
|
124
|
+
const processors = Object.fromEntries(Array.from({ length: 10 }, (_, i) => [`batch/b${i}`, null]));
|
|
125
|
+
const out = files({ processors });
|
|
126
|
+
expect(Object.keys(out)).toEqual(["processors-1.ts", "processors-2.ts"]);
|
|
127
|
+
expect(out["processors-1.ts"].match(/new BatchProcessor/g)).toHaveLength(8);
|
|
128
|
+
expect(out["processors-2.ts"].match(/new BatchProcessor/g)).toHaveLength(2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("an id nothing declares stays a string; a signal Pipeline does not type is suppressed with a reason", () => {
|
|
132
|
+
const out = files({ service: { pipelines: { "profiles/x": { receivers: ["otlp"], exporters: ["debug"] } } } });
|
|
133
|
+
expect(out["pipelines.ts"]).toContain(
|
|
134
|
+
[
|
|
135
|
+
"const profilesX = new Pipeline({",
|
|
136
|
+
' // @ts-expect-error "profiles" is a collector signal Pipeline does not type',
|
|
137
|
+
' signal: "profiles",',
|
|
138
|
+
' name: "x",',
|
|
139
|
+
' receivers: ["otlp"],',
|
|
140
|
+
' exporters: ["debug"],',
|
|
141
|
+
"});",
|
|
142
|
+
].join("\n"),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("names are valid identifiers even from awkward ids", () => {
|
|
147
|
+
const out = files({
|
|
148
|
+
exporters: { "otlp/2": { endpoint: "a:1" }, "otlp/my-backend.eu": { endpoint: "b:1" }, "debug/new": {} },
|
|
149
|
+
service: { pipelines: { "traces/default": { receivers: [], exporters: ["otlp/2"] } } },
|
|
150
|
+
});
|
|
151
|
+
expect(out["exporters.ts"]).toContain("const otlp2 = new OtlpExporter(");
|
|
152
|
+
expect(out["exporters.ts"]).toContain("const otlpMyBackendEu = new OtlpExporter(");
|
|
153
|
+
expect(out["exporters.ts"]).toContain("const debugNew = new DebugExporter(");
|
|
154
|
+
expect(out["pipelines.ts"]).toContain("const tracesDefault = new Pipeline(");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("OtelCollectorGenerator", () => {
|
|
159
|
+
test("generates from the parser's IR, and returns a file for an empty config", () => {
|
|
160
|
+
const gen = new OtelCollectorGenerator();
|
|
161
|
+
const ir = new OtelCollectorParser().parse("receivers:\n otlp: {}\n");
|
|
162
|
+
expect(gen.generate(ir).map((f) => f.path)).toEqual(["receivers.ts"]);
|
|
163
|
+
expect(gen.generate({ resources: [], parameters: [] })).toHaveLength(1);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -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
|
+
}
|