@intentius/chant-lexicon-otel 0.102.0 → 0.104.1

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 (122) hide show
  1. package/README.md +1 -1
  2. package/dist/attribution.d.ts +69 -0
  3. package/dist/attribution.d.ts.map +1 -0
  4. package/dist/codegen/docs.d.ts.map +1 -1
  5. package/dist/components/processors.d.ts +1 -1
  6. package/dist/components/processors.d.ts.map +1 -1
  7. package/dist/components/sampling.d.ts +3 -0
  8. package/dist/components/sampling.d.ts.map +1 -1
  9. package/dist/config-hygiene.d.ts +33 -0
  10. package/dist/config-hygiene.d.ts.map +1 -0
  11. package/dist/define.d.ts +18 -2
  12. package/dist/define.d.ts.map +1 -1
  13. package/dist/import/generator.d.ts +5 -1
  14. package/dist/import/generator.d.ts.map +1 -1
  15. package/dist/import/parser.d.ts +1 -1
  16. package/dist/import/parser.d.ts.map +1 -1
  17. package/dist/index.d.ts +4 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/integrity.json +15 -5
  20. package/dist/lint/audit-catalog.d.ts +3 -2
  21. package/dist/lint/audit-catalog.d.ts.map +1 -1
  22. package/dist/lint/post-synth/index.d.ts.map +1 -1
  23. package/dist/lint/post-synth/otel-helpers.d.ts +2 -2
  24. package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
  25. package/dist/lint/post-synth/otel118.d.ts +8 -0
  26. package/dist/lint/post-synth/otel118.d.ts.map +1 -0
  27. package/dist/lint/post-synth/otel119.d.ts +8 -0
  28. package/dist/lint/post-synth/otel119.d.ts.map +1 -0
  29. package/dist/lint/post-synth/otel120.d.ts +8 -0
  30. package/dist/lint/post-synth/otel120.d.ts.map +1 -0
  31. package/dist/lint/post-synth/otel121.d.ts +8 -0
  32. package/dist/lint/post-synth/otel121.d.ts.map +1 -0
  33. package/dist/lint/post-synth/otel122.d.ts +8 -0
  34. package/dist/lint/post-synth/otel122.d.ts.map +1 -0
  35. package/dist/lint/post-synth/otel123.d.ts +8 -0
  36. package/dist/lint/post-synth/otel123.d.ts.map +1 -0
  37. package/dist/lint/post-synth/otel124.d.ts +8 -0
  38. package/dist/lint/post-synth/otel124.d.ts.map +1 -0
  39. package/dist/lint/post-synth/otel125.d.ts +8 -0
  40. package/dist/lint/post-synth/otel125.d.ts.map +1 -0
  41. package/dist/lint/post-synth/otel126.d.ts +8 -0
  42. package/dist/lint/post-synth/otel126.d.ts.map +1 -0
  43. package/dist/lint/post-synth/otel127.d.ts +8 -0
  44. package/dist/lint/post-synth/otel127.d.ts.map +1 -0
  45. package/dist/lint/rules/literal-credential.d.ts.map +1 -1
  46. package/dist/manifest.json +1 -1
  47. package/dist/metric-names.d.ts +29 -0
  48. package/dist/metric-names.d.ts.map +1 -1
  49. package/dist/model.d.ts +24 -0
  50. package/dist/model.d.ts.map +1 -1
  51. package/dist/okf/index.md +10 -0
  52. package/dist/okf/rules/OTEL118.md +11 -0
  53. package/dist/okf/rules/OTEL119.md +11 -0
  54. package/dist/okf/rules/OTEL120.md +11 -0
  55. package/dist/okf/rules/OTEL121.md +11 -0
  56. package/dist/okf/rules/OTEL122.md +11 -0
  57. package/dist/okf/rules/OTEL123.md +11 -0
  58. package/dist/okf/rules/OTEL124.md +11 -0
  59. package/dist/okf/rules/OTEL125.md +11 -0
  60. package/dist/okf/rules/OTEL126.md +11 -0
  61. package/dist/okf/rules/OTEL127.md +11 -0
  62. package/dist/platform.d.ts +11 -8
  63. package/dist/platform.d.ts.map +1 -1
  64. package/dist/rules/literal-credential.ts +2 -3
  65. package/dist/rules/otel-helpers.ts +15 -4
  66. package/dist/rules/otel118.ts +17 -0
  67. package/dist/rules/otel119.ts +17 -0
  68. package/dist/rules/otel120.ts +17 -0
  69. package/dist/rules/otel121.ts +17 -0
  70. package/dist/rules/otel122.ts +17 -0
  71. package/dist/rules/otel123.ts +17 -0
  72. package/dist/rules/otel124.ts +17 -0
  73. package/dist/rules/otel125.ts +17 -0
  74. package/dist/rules/otel126.ts +17 -0
  75. package/dist/rules/otel127.ts +17 -0
  76. package/dist/semconv.d.ts +2 -1
  77. package/dist/semconv.d.ts.map +1 -1
  78. package/dist/skills/chant-otel.md +6 -2
  79. package/dist/validate-config.d.ts +27 -9
  80. package/dist/validate-config.d.ts.map +1 -1
  81. package/package.json +2 -2
  82. package/src/attribution.ts +385 -0
  83. package/src/codegen/docs.ts +8 -1
  84. package/src/components/k8s-receivers.test.ts +5 -2
  85. package/src/components/processors.ts +1 -1
  86. package/src/components/sampling.ts +3 -0
  87. package/src/composites/composites.test.ts +16 -0
  88. package/src/config-hygiene.ts +470 -0
  89. package/src/connectors.test.ts +3 -0
  90. package/src/define.ts +25 -4
  91. package/src/import/generator.test.ts +42 -4
  92. package/src/import/generator.ts +7 -2
  93. package/src/import/parser.test.ts +11 -0
  94. package/src/import/parser.ts +16 -4
  95. package/src/import/roundtrip.test.ts +90 -14
  96. package/src/index.ts +7 -0
  97. package/src/lint/audit-catalog.ts +87 -2
  98. package/src/lint/post-synth/hygiene.test.ts +334 -0
  99. package/src/lint/post-synth/index.ts +20 -0
  100. package/src/lint/post-synth/otel-helpers.ts +15 -4
  101. package/src/lint/post-synth/otel118.test.ts +308 -0
  102. package/src/lint/post-synth/otel118.ts +17 -0
  103. package/src/lint/post-synth/otel119.ts +17 -0
  104. package/src/lint/post-synth/otel120.ts +17 -0
  105. package/src/lint/post-synth/otel121.ts +17 -0
  106. package/src/lint/post-synth/otel122.ts +17 -0
  107. package/src/lint/post-synth/otel123.ts +17 -0
  108. package/src/lint/post-synth/otel124.ts +17 -0
  109. package/src/lint/post-synth/otel125.ts +17 -0
  110. package/src/lint/post-synth/otel126.ts +17 -0
  111. package/src/lint/post-synth/otel127.ts +17 -0
  112. package/src/lint/post-synth/post-synth.test.ts +18 -0
  113. package/src/lint/rules/literal-credential.ts +2 -3
  114. package/src/metric-names.test.ts +24 -2
  115. package/src/metric-names.ts +66 -1
  116. package/src/model.ts +48 -0
  117. package/src/platform.test.ts +35 -0
  118. package/src/platform.ts +28 -19
  119. package/src/plugin.test.ts +10 -0
  120. package/src/semconv.ts +6 -2
  121. package/src/skills/chant-otel.md +6 -2
  122. package/src/validate-config.ts +81 -16
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Collector config checks for deprecated fields, exposure and delivery
3
+ * settings, and two fields the collector checks against a fixed list
4
+ * (OTEL119-OTEL127). Each is a plain function over a parsed `CollectorConfig`,
5
+ * so it runs on emitted YAML, on an imported file and on a config inside a
6
+ * ConfigMap alike. `validateCollectorConfig` calls `configHygieneIssues`.
7
+ *
8
+ * Every list and version here is read from collector or collector-contrib
9
+ * v0.130.0 (`COLLECTOR_PIN`); the lint rules page cites the file for each.
10
+ */
11
+
12
+ import { parseComponentId, type CollectorConfig } from "./model";
13
+ import { componentListeners, listenerPaths, type CollectorIssue } from "./validate-config";
14
+
15
+ /**
16
+ * Keys whose value is a credential. `*_file` keys name a path, which is fine.
17
+ * OTEL002 reads TypeScript source with this pattern and OTEL120 reads YAML
18
+ * with it, so the two agree on what a credential is.
19
+ */
20
+ export const SECRET_KEY = /(authorization|api[-_]?key|password|passwd|secret|token|key_pem|x-honeycomb-team)/i;
21
+
22
+ type Body = Record<string, unknown>;
23
+ type Section = "receivers" | "processors" | "exporters" | "extensions" | "connectors";
24
+ const SECTIONS: Array<[Section, string]> = [
25
+ ["receivers", "receiver"],
26
+ ["processors", "processor"],
27
+ ["exporters", "exporter"],
28
+ ["extensions", "extension"],
29
+ ["connectors", "connector"],
30
+ ];
31
+
32
+ function isObject(value: unknown): value is Body {
33
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34
+ }
35
+
36
+ function typeOf(id: string): string {
37
+ return parseComponentId(id)?.type ?? id;
38
+ }
39
+
40
+ /** The host an endpoint names: `http://h:4318/v1`, `dns:///h:4317`, `h:4317`, `[::1]:4317` or `h`. */
41
+ function endpointHost(endpoint: string): string {
42
+ let rest = endpoint.trim().replace(/^[a-z][a-z0-9+.-]*:\/\/\/?/i, "");
43
+ rest = rest.split("/")[0];
44
+ const bracketed = rest.match(/^\[([^\]]*)\]/);
45
+ if (bracketed) return bracketed[1];
46
+ const colon = rest.lastIndexOf(":");
47
+ return colon > 0 && /^\d+$/.test(rest.slice(colon + 1)) ? rest.slice(0, colon) : rest;
48
+ }
49
+
50
+ function isLoopback(host: string): boolean {
51
+ const h = host.replace(/^\[|\]$/g, "").toLowerCase();
52
+ return h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h);
53
+ }
54
+
55
+ /** The pipelines' started receivers, processors and exporters, by id. */
56
+ function startedIds(config: CollectorConfig, field: "receivers" | "processors" | "exporters"): Set<string> {
57
+ return new Set(Object.values(config.service?.pipelines ?? {}).flatMap((p) => (p?.[field] ?? []).map(String)));
58
+ }
59
+
60
+ /**
61
+ * Where a non-listener exporter sends to, when it names a host other than
62
+ * loopback. `${env:...}` hosts count as remote: only a literal loopback
63
+ * address is known to stay on the machine.
64
+ */
65
+ function remoteEndpoint(id: string, body: Body): string | undefined {
66
+ if (listenerPaths("exporter", typeOf(id), body).length > 0) return undefined; // prometheus serves, it doesn't send
67
+ const endpoint = ["endpoint", "traces_endpoint", "metrics_endpoint", "logs_endpoint"].map((k) => body[k]).find((v) => typeof v === "string" && v !== "");
68
+ if (typeof endpoint !== "string" || isLoopback(endpointHost(endpoint))) return undefined;
69
+ return endpoint;
70
+ }
71
+
72
+ // ── OTEL119: deprecated before the pin ───────────────────────────────
73
+
74
+ /** The paths under a tail_sampling policy that set `invert_match: true`. */
75
+ function invertMatchPaths(value: unknown, path: string[], out: string[]): string[] {
76
+ if (Array.isArray(value)) value.forEach((v, i) => invertMatchPaths(v, [...path.slice(0, -1), `${path[path.length - 1] ?? ""}[${i}]`], out));
77
+ else if (isObject(value)) {
78
+ for (const [key, v] of Object.entries(value)) {
79
+ if (key === "invert_match" && v === true) out.push([...path, key].join("."));
80
+ else invertMatchPaths(v, [...path, key], out);
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * OTEL119: a field deprecated at or before the pin. The collector still
88
+ * reads each of these at v0.130.0, and a later release drops them.
89
+ *
90
+ * - `invert_match: true` in a `tail_sampling` policy: inverted decisions are
91
+ * deprecated in contrib v0.126.0 (#39833) for a `drop` policy.
92
+ * - `service.telemetry.metrics.address`: deprecated in core v0.111.0
93
+ * (#11205) for `readers`.
94
+ * - `dimensions_cache_size` on `spanmetrics`: deprecated in contrib v0.125.0
95
+ * (#39646), and marked so in its config at v0.130.0 (#41101), for
96
+ * `aggregation_cardinality_limit`.
97
+ */
98
+ function deprecatedFieldIssues(config: CollectorConfig): CollectorIssue[] {
99
+ const issues: CollectorIssue[] = [];
100
+ for (const [id, raw] of Object.entries(config.processors ?? {})) {
101
+ if (typeOf(id) !== "tail_sampling" || !isObject(raw)) continue;
102
+ const policies = Array.isArray(raw.policies) ? raw.policies : [];
103
+ policies.forEach((policy, i) => {
104
+ const name = isObject(policy) && typeof policy.name === "string" ? `"${policy.name}"` : `policies[${i}]`;
105
+ for (const path of invertMatchPaths(policy, [], [])) {
106
+ issues.push({
107
+ code: "OTEL119",
108
+ severity: "warning",
109
+ component: id,
110
+ message: `processor "${id}" policy ${name} sets ${path}; inverted decisions are deprecated since collector-contrib v0.126.0. Use a drop policy to keep the traces out instead`,
111
+ });
112
+ }
113
+ });
114
+ }
115
+ const metrics = (config.service?.telemetry as Body | undefined)?.metrics;
116
+ if (isObject(metrics) && metrics.address !== undefined) {
117
+ issues.push({
118
+ code: "OTEL119",
119
+ severity: "warning",
120
+ message: `service.telemetry.metrics.address is deprecated since collector v0.111.0; serve the collector's own metrics with service.telemetry.metrics.readers (pull.exporter.prometheus host and port) instead`,
121
+ });
122
+ }
123
+ for (const [id, raw] of Object.entries(config.connectors ?? {})) {
124
+ if (typeOf(id) !== "spanmetrics" || !isObject(raw) || raw.dimensions_cache_size === undefined) continue;
125
+ issues.push({
126
+ code: "OTEL119",
127
+ severity: "warning",
128
+ component: id,
129
+ message: `connector "${id}" sets dimensions_cache_size, deprecated since collector-contrib v0.125.0; use aggregation_cardinality_limit to bound its series instead`,
130
+ });
131
+ }
132
+ return issues;
133
+ }
134
+
135
+ // ── OTEL120: literal credential in the config ────────────────────────
136
+
137
+ /** Every `key: value` under a component whose key names a credential and whose value is written out. */
138
+ function literalSecrets(value: unknown, path: string[], out: string[]): string[] {
139
+ if (Array.isArray(value)) value.forEach((v) => (isObject(v) || Array.isArray(v) ? literalSecrets(v, path, out) : undefined));
140
+ else if (isObject(value)) {
141
+ for (const [key, v] of Object.entries(value)) {
142
+ if (isObject(v) || Array.isArray(v)) literalSecrets(v, [...path, key], out);
143
+ else if (SECRET_KEY.test(key) && !/_file$/i.test(key) && (typeof v === "string" || typeof v === "number")) {
144
+ const text = String(v);
145
+ if (text !== "" && !text.includes("${")) out.push([...path, key].join("."));
146
+ }
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ /**
153
+ * OTEL120: OTEL002's check over the config itself, so an imported config or
154
+ * one inside a ConfigMap gets it too. Same key pattern, same exceptions: a
155
+ * value containing `${` is a reference the collector expands, and a key
156
+ * ending in `_file` is a path. List items don't inherit their parent's key,
157
+ * as in OTEL002, so a list of header names is not a credential.
158
+ */
159
+ function literalCredentialIssues(config: CollectorConfig): CollectorIssue[] {
160
+ const issues: CollectorIssue[] = [];
161
+ for (const [section, noun] of SECTIONS) {
162
+ for (const [id, body] of Object.entries(config[section] ?? {})) {
163
+ for (const path of literalSecrets(body, [], [])) {
164
+ issues.push({
165
+ code: "OTEL120",
166
+ severity: "error",
167
+ component: id,
168
+ message: `${noun} "${id}" has a literal credential at ${path}; write "\${env:NAME}" (or "\${file:/path}") and let the collector read it at start-up`,
169
+ });
170
+ }
171
+ }
172
+ }
173
+ return issues;
174
+ }
175
+
176
+ // ── OTEL121: credentials over plaintext ──────────────────────────────
177
+
178
+ /**
179
+ * OTEL121: a started exporter sends a credential (a header such as
180
+ * `authorization` or `api-key`, or a top-level key such as `api_key` or
181
+ * `token`) to a non-loopback endpoint without TLS. Plaintext means an
182
+ * `http://` endpoint, or `tls.insecure: true` on an endpoint that isn't
183
+ * `https://`: an HTTP exporter picks TLS from the scheme, a gRPC exporter
184
+ * from `tls.insecure`. A credential sent through an `auth` extension is not
185
+ * checked.
186
+ */
187
+ function plaintextCredentialIssues(config: CollectorConfig): CollectorIssue[] {
188
+ const issues: CollectorIssue[] = [];
189
+ const started = startedIds(config, "exporters");
190
+ for (const [id, raw] of Object.entries(config.exporters ?? {})) {
191
+ if (!started.has(id) || !isObject(raw)) continue;
192
+ const endpoint = remoteEndpoint(id, raw);
193
+ if (!endpoint) continue;
194
+ const insecure = isObject(raw.tls) && raw.tls.insecure === true;
195
+ const plaintext = /^http:\/\//i.test(endpoint) || (insecure && !/^https:\/\//i.test(endpoint));
196
+ if (!plaintext) continue;
197
+ const headers = isObject(raw.headers) ? Object.keys(raw.headers).filter((k) => SECRET_KEY.test(k)).map((k) => `headers.${k}`) : [];
198
+ const keys = Object.entries(raw)
199
+ .filter(([k, v]) => SECRET_KEY.test(k) && !/_file$/i.test(k) && (typeof v === "string" || typeof v === "number") && String(v) !== "")
200
+ .map(([k]) => k);
201
+ const sent = [...headers, ...keys];
202
+ if (sent.length === 0) continue;
203
+ const why = /^http:\/\//i.test(endpoint) ? "an http:// endpoint" : "tls.insecure: true";
204
+ issues.push({
205
+ code: "OTEL121",
206
+ severity: "warning",
207
+ component: id,
208
+ message: `exporter "${id}" sends ${sent.join(", ")} to ${endpoint} over ${why}, so the credential crosses the network in plaintext. Use https:// or drop tls.insecure`,
209
+ });
210
+ }
211
+ return issues;
212
+ }
213
+
214
+ // ── OTEL122: debug endpoints off loopback ────────────────────────────
215
+
216
+ /**
217
+ * The extensions OTEL122 reports off loopback. zpages serves live span
218
+ * samples and pipeline internals, pprof serves heap and goroutine profiles
219
+ * and can be made to run CPU profiles, and neither has authentication.
220
+ * `health_check` is left out on purpose: it serves only a status, and a
221
+ * kubelet probe reaches it on the pod IP, so `otlpCollector()`, `NodeAgent`
222
+ * and `genAiPipeline()` bind it on 0.0.0.0.
223
+ */
224
+ const DEBUG_EXTENSIONS = new Set(["zpages", "pprof"]);
225
+
226
+ /** OTEL122: a started zpages or pprof extension listens on a non-loopback address. */
227
+ function debugEndpointIssues(config: CollectorConfig): CollectorIssue[] {
228
+ const issues: CollectorIssue[] = [];
229
+ const enabled = new Set((config.service?.extensions ?? []).map(String));
230
+ for (const [id, body] of Object.entries(config.extensions ?? {})) {
231
+ if (!enabled.has(id) || !DEBUG_EXTENSIONS.has(typeOf(id))) continue;
232
+ for (const listener of componentListeners("extension", id, body)) {
233
+ if (isLoopback(listener.host)) continue;
234
+ const host = listener.host === "" ? "every interface" : listener.host;
235
+ issues.push({
236
+ code: "OTEL122",
237
+ severity: "warning",
238
+ component: id,
239
+ message: `extension "${id}" listens on ${host}:${listener.port}, so anyone who reaches that address can read the collector's internals without authentication. Bind it to localhost:${listener.port} and port-forward when you need it`,
240
+ });
241
+ }
242
+ }
243
+ return issues;
244
+ }
245
+
246
+ // ── OTEL123: detailed debug output beside a real exporter ────────────
247
+
248
+ /**
249
+ * OTEL123: a pipeline that sends to a backend also sends to a `debug`
250
+ * exporter at `verbosity: detailed`, which writes every record, with all
251
+ * its attributes and bodies, to the collector's own log. That copies what
252
+ * the backend gets, sensitive values included, to wherever the collector's
253
+ * logs go. A pipeline with only `debug` exporters is left alone: that is a
254
+ * test setup.
255
+ */
256
+ function detailedDebugIssues(config: CollectorConfig): CollectorIssue[] {
257
+ const issues: CollectorIssue[] = [];
258
+ const exporters = config.exporters ?? {};
259
+ for (const [pipelineId, pipeline] of Object.entries(config.service?.pipelines ?? {})) {
260
+ const ids = (pipeline?.exporters ?? []).map(String).filter((id) => id in exporters);
261
+ const others = ids.filter((id) => typeOf(id) !== "debug");
262
+ if (others.length === 0) continue;
263
+ for (const id of ids) {
264
+ const body = exporters[id];
265
+ if (typeOf(id) !== "debug" || !isObject(body) || body.verbosity !== "detailed") continue;
266
+ issues.push({
267
+ code: "OTEL123",
268
+ severity: "warning",
269
+ pipeline: pipelineId,
270
+ component: id,
271
+ message: `pipeline "${pipelineId}" exports to ${others.map((o) => `"${o}"`).join(", ")} and to "${id}" at verbosity: detailed, which writes every record in full to the collector's log. Use verbosity: basic, or keep detailed output to a test pipeline`,
272
+ });
273
+ }
274
+ }
275
+ return issues;
276
+ }
277
+
278
+ // ── OTEL124: queue or retry off for a remote exporter ────────────────
279
+
280
+ /**
281
+ * OTEL124: a started exporter sends to a remote endpoint with
282
+ * `sending_queue.enabled: false` or `retry_on_failure.enabled: false`.
283
+ * Without the queue a slow backend blocks the pipeline; without retries one
284
+ * failed request drops its data.
285
+ */
286
+ function deliveryIssues(config: CollectorConfig): CollectorIssue[] {
287
+ const issues: CollectorIssue[] = [];
288
+ const started = startedIds(config, "exporters");
289
+ for (const [id, raw] of Object.entries(config.exporters ?? {})) {
290
+ if (!started.has(id) || !isObject(raw)) continue;
291
+ const endpoint = remoteEndpoint(id, raw);
292
+ if (!endpoint) continue;
293
+ if (isObject(raw.sending_queue) && raw.sending_queue.enabled === false) {
294
+ issues.push({
295
+ code: "OTEL124",
296
+ severity: "warning",
297
+ component: id,
298
+ message: `exporter "${id}" sends to ${endpoint} with sending_queue.enabled: false, so a slow or unreachable backend blocks the pipeline and data is refused upstream. Leave the queue on`,
299
+ });
300
+ }
301
+ if (isObject(raw.retry_on_failure) && raw.retry_on_failure.enabled === false) {
302
+ issues.push({
303
+ code: "OTEL124",
304
+ severity: "warning",
305
+ component: id,
306
+ message: `exporter "${id}" sends to ${endpoint} with retry_on_failure.enabled: false, so one failed request drops its data. Leave retries on`,
307
+ });
308
+ }
309
+ }
310
+ return issues;
311
+ }
312
+
313
+ // ── OTEL125: no batching before a network exporter ───────────────────
314
+
315
+ /**
316
+ * The exporters OTEL125 knows send one request per incoming batch unless
317
+ * told otherwise: `otlp` and `otlphttp`, whose default `sending_queue` at
318
+ * v0.130.0 has no `batch` (`exporterhelper/internal/queue_sender.go`,
319
+ * `NewDefaultQueueConfig`). Other exporters may batch on their own, so they
320
+ * are not checked.
321
+ */
322
+ const UNBATCHED_EXPORTERS = new Set(["otlp", "otlphttp"]);
323
+
324
+ /**
325
+ * OTEL125: a pipeline sends to a remote `otlp` or `otlphttp` exporter, has
326
+ * no `batch` processor, and the exporter does not batch in its own
327
+ * `sending_queue.batch`. Each small request then goes out on its own.
328
+ */
329
+ function batchIssues(config: CollectorConfig): CollectorIssue[] {
330
+ const issues: CollectorIssue[] = [];
331
+ const exporters = config.exporters ?? {};
332
+ for (const [pipelineId, pipeline] of Object.entries(config.service?.pipelines ?? {})) {
333
+ if ((pipeline?.processors ?? []).some((id) => typeOf(String(id)) === "batch")) continue;
334
+ for (const id of (pipeline?.exporters ?? []).map(String)) {
335
+ const body = exporters[id];
336
+ if (!UNBATCHED_EXPORTERS.has(typeOf(id)) || !isObject(body)) continue;
337
+ const endpoint = remoteEndpoint(id, body);
338
+ if (!endpoint) continue;
339
+ const queue = body.sending_queue;
340
+ if (isObject(queue) && queue.batch !== undefined && queue.batch !== null) continue;
341
+ issues.push({
342
+ code: "OTEL125",
343
+ severity: "warning",
344
+ pipeline: pipelineId,
345
+ component: id,
346
+ message: `pipeline "${pipelineId}" sends to "${id}" (${endpoint}) with no batch processor, and the exporter doesn't batch on its own, so every small request goes out by itself. Add a batch processor to the pipeline, or set sending_queue.batch on the exporter`,
347
+ });
348
+ }
349
+ }
350
+ return issues;
351
+ }
352
+
353
+ // ── OTEL126, OTEL127: names checked against a fixed list ─────────────
354
+
355
+ /**
356
+ * The fields `k8sattributes` can extract at v0.130.0: the cases of
357
+ * `Config.Validate` (`processor/k8sattributesprocessor/config.go`), with
358
+ * the semconv v1.6.1 keys and `options.go` constants they name.
359
+ */
360
+ export const K8S_ATTRIBUTES_METADATA: readonly string[] = Object.freeze([
361
+ "k8s.namespace.name",
362
+ "k8s.pod.name",
363
+ "k8s.pod.uid",
364
+ "k8s.pod.hostname",
365
+ "k8s.pod.start_time",
366
+ "k8s.pod.ip",
367
+ "k8s.deployment.name",
368
+ "k8s.deployment.uid",
369
+ "k8s.replicaset.name",
370
+ "k8s.replicaset.uid",
371
+ "k8s.daemonset.name",
372
+ "k8s.daemonset.uid",
373
+ "k8s.statefulset.name",
374
+ "k8s.statefulset.uid",
375
+ "k8s.job.name",
376
+ "k8s.job.uid",
377
+ "k8s.cronjob.name",
378
+ "k8s.node.name",
379
+ "k8s.node.uid",
380
+ "k8s.container.name",
381
+ "container.id",
382
+ "container.image.name",
383
+ "container.image.tag",
384
+ "service.namespace",
385
+ "service.name",
386
+ "service.version",
387
+ "service.instance.id",
388
+ "container.image.repo_digests",
389
+ "k8s.cluster.uid",
390
+ ]);
391
+
392
+ /**
393
+ * The detectors `resourcedetection` knows at v0.130.0: the keys of the map
394
+ * in `NewFactory` (`processor/resourcedetectionprocessor/factory.go`), as
395
+ * each detector's `TypeStr` spells them.
396
+ */
397
+ export const RESOURCE_DETECTORS: readonly string[] = Object.freeze([
398
+ "aks",
399
+ "azure",
400
+ "consul",
401
+ "docker",
402
+ "ec2",
403
+ "ecs",
404
+ "eks",
405
+ "elastic_beanstalk",
406
+ "lambda",
407
+ "env",
408
+ "gcp",
409
+ "heroku",
410
+ "system",
411
+ "openshift",
412
+ "k8snode",
413
+ "kubeadm",
414
+ "dynatrace",
415
+ ]);
416
+
417
+ /** OTEL126: `k8sattributes` `extract.metadata` names a field the processor can't extract, so the collector refuses the config. */
418
+ function k8sAttributesIssues(config: CollectorConfig): CollectorIssue[] {
419
+ const known = new Set(K8S_ATTRIBUTES_METADATA);
420
+ const issues: CollectorIssue[] = [];
421
+ for (const [id, raw] of Object.entries(config.processors ?? {})) {
422
+ if (typeOf(id) !== "k8sattributes" || !isObject(raw) || !isObject(raw.extract)) continue;
423
+ for (const field of Array.isArray(raw.extract.metadata) ? raw.extract.metadata : []) {
424
+ if (typeof field !== "string" || known.has(field)) continue;
425
+ issues.push({
426
+ code: "OTEL126",
427
+ severity: "error",
428
+ component: id,
429
+ message: `processor "${id}" extracts "${field}" (extract.metadata), which k8sattributes does not support at the pinned release; the collector refuses to start`,
430
+ });
431
+ }
432
+ }
433
+ return issues;
434
+ }
435
+
436
+ /** OTEL127: a started `resourcedetection` lists a detector the processor doesn't have, so it fails to build and the collector exits. */
437
+ function detectorIssues(config: CollectorConfig): CollectorIssue[] {
438
+ const known = new Set(RESOURCE_DETECTORS);
439
+ const started = startedIds(config, "processors");
440
+ const issues: CollectorIssue[] = [];
441
+ for (const [id, raw] of Object.entries(config.processors ?? {})) {
442
+ if (typeOf(id) !== "resourcedetection" || !started.has(id) || !isObject(raw)) continue;
443
+ for (const detector of Array.isArray(raw.detectors) ? raw.detectors : []) {
444
+ if (typeof detector !== "string" || detector.includes("${") || known.has(detector.trim())) continue;
445
+ const hint = detector === "elasticbeanstalk" ? ' (the detector is "elastic_beanstalk")' : "";
446
+ issues.push({
447
+ code: "OTEL127",
448
+ severity: "error",
449
+ component: id,
450
+ message: `processor "${id}" lists detector "${detector}"${hint}, which resourcedetection does not have at the pinned release; the processor fails to build and the collector exits`,
451
+ });
452
+ }
453
+ }
454
+ return issues;
455
+ }
456
+
457
+ /** OTEL119-OTEL127 over one collector config. */
458
+ export function configHygieneIssues(config: CollectorConfig): CollectorIssue[] {
459
+ return [
460
+ ...deprecatedFieldIssues(config),
461
+ ...literalCredentialIssues(config),
462
+ ...plaintextCredentialIssues(config),
463
+ ...debugEndpointIssues(config),
464
+ ...detailedDebugIssues(config),
465
+ ...deliveryIssues(config),
466
+ ...batchIssues(config),
467
+ ...k8sAttributesIssues(config),
468
+ ...detectorIssues(config),
469
+ ];
470
+ }
@@ -235,6 +235,9 @@ service:
235
235
  expect(diags.every((d) => d.severity === "error" && d.checkId === "OTEL112")).toBe(true);
236
236
  // One-sided use is OTEL101's, not this rule's.
237
237
  expect(otel101.check(ctxFor(yaml))).toEqual([]);
238
+ // span_metrics, the collector's newer name, is checked the same.
239
+ const renamed = otel112.check(ctxFor(yaml.replace(/spanmetrics/g, "span_metrics")));
240
+ expect(renamed.map((d) => d.message)).toEqual(diags.map((d) => d.message.replace(/spanmetrics/g, "span_metrics")));
238
241
  });
239
242
 
240
243
  test("flags only the pipeline that pairs with nothing", () => {
package/src/define.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  * non-built-in component was checked against. The collector ignores it.
26
26
  * - OTEL109 fails a build whose custom component has no usable pin.
27
27
  *
28
- * Semantic-convention pins (`GENAI_SEMCONV_PIN`) record which version of an
28
+ * Semantic-convention pins (`SEMCONV_PIN`, `GENAI_SEMCONV_PIN`) record which version of an
29
29
  * attribute vocabulary a preset's keys follow. They are not tied to a
30
30
  * component type, so `collectorTopology()` reports them under `semconv` for
31
31
  * each component whose config uses that vocabulary, and the serializer writes
@@ -34,7 +34,7 @@
34
34
 
35
35
  import { createResource } from "@intentius/chant/runtime";
36
36
  import type { Declarable } from "@intentius/chant/declarable";
37
- import { componentId, type ComponentKind, type ConnectorSignalPair } from "./model";
37
+ import { canonicalComponentType, componentId, type ComponentKind, type ConnectorSignalPair } from "./model";
38
38
 
39
39
  /** Where a component's config schema comes from, and which version of it the type follows. */
40
40
  export interface SchemaPin {
@@ -55,6 +55,22 @@ export const COLLECTOR_PIN: SchemaPin = Object.freeze({
55
55
  version: "v0.130.0",
56
56
  });
57
57
 
58
+ /**
59
+ * The semantic-conventions release this package's other attribute keys
60
+ * follow, today the `k8s.*` keys `NodeAgent` writes. v1.27.0 is the newest
61
+ * release the Kubernetes components of contrib v0.130.0 import:
62
+ * `go.opentelemetry.io/otel/semconv/v1.27.0` in the kubeletstats, k8sobjects
63
+ * and k8sevents receivers. The k8sattributes processor still imports v1.6.1
64
+ * and v1.8.0, and v1.27.0 keeps the `k8s.*` keys it writes. Like
65
+ * `COLLECTOR_PIN`, it moves only when this package does, and
66
+ * `collectorTopology()` reports it for every component whose config names a
67
+ * `k8s.` attribute.
68
+ */
69
+ export const SEMCONV_PIN: SchemaPin = Object.freeze({
70
+ source: "github.com/open-telemetry/semantic-conventions",
71
+ version: "v1.27.0",
72
+ });
73
+
58
74
  /**
59
75
  * The OpenTelemetry GenAI semantic conventions the GenAI preset
60
76
  * (`genAiPipeline()`) follows: which attributes carry prompt and completion
@@ -177,9 +193,14 @@ export function definitionFor(entityType: string): ComponentDefinition | undefin
177
193
  return registry().get(entityType);
178
194
  }
179
195
 
180
- /** The definition for a collector `kind` + `type`, e.g. the built-in `exporter` `otlp`. */
196
+ /**
197
+ * The definition for a collector `kind` + `type`, e.g. the built-in
198
+ * `exporter` `otlp`. A renamed built-in's new name (`otlp_grpc`) finds the
199
+ * built-in unless a component is registered under the new name itself.
200
+ */
181
201
  export function definitionOf(kind: ComponentKind, type: string): ComponentDefinition | undefined {
182
- return registry().get(componentEntityType(kind, type));
202
+ const reg = registry();
203
+ return reg.get(componentEntityType(kind, type)) ?? reg.get(componentEntityType(kind, canonicalComponentType(kind, type)));
183
204
  }
184
205
 
185
206
  /** Every registered definition, built-ins first in registration order. */
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import type { CollectorConfig } from "../model";
2
+ import { COMPONENT_TYPE_ALIASES, type CollectorConfig } from "../model";
3
3
  import { builtinClassName, generateCollectorFiles, OtelCollectorGenerator, tsLiteral } from "./generator";
4
4
  import { OtelCollectorParser } from "./parser";
5
5
 
@@ -17,6 +17,44 @@ describe("builtinClassName", () => {
17
17
  expect(builtinClassName("extension", "health_check")).toBe("HealthCheckExtension");
18
18
  expect(builtinClassName("exporter", "datadog")).toBeUndefined();
19
19
  });
20
+
21
+ test("maps a renamed built-in's newer name to the class of the old one", () => {
22
+ for (const a of COMPONENT_TYPE_ALIASES) {
23
+ expect(builtinClassName(a.kind, a.type), a.type).toBe(builtinClassName(a.kind, a.builtin));
24
+ expect(builtinClassName(a.kind, a.type), a.type).toBeDefined();
25
+ }
26
+ expect(builtinClassName("exporter", "otlp_grpc")).toBe("OtlpExporter");
27
+ // The rename is per kind: the otlp receiver kept its name.
28
+ expect(builtinClassName("receiver", "otlp_grpc")).toBeUndefined();
29
+ });
30
+ });
31
+
32
+ describe("a config using the collector's newer names", () => {
33
+ test("imports to the built-ins and warns that the old name is written", () => {
34
+ const yaml = [
35
+ "receivers: { otlp: { protocols: { grpc: null } } }",
36
+ "exporters: { otlp_grpc/tempo: { endpoint: 'tempo:4317' }, prometheus: { endpoint: '0.0.0.0:8889' } }",
37
+ "connectors: { span_metrics: {} }",
38
+ "service:",
39
+ " pipelines:",
40
+ " traces: { receivers: [otlp], exporters: [otlp_grpc/tempo, span_metrics] }",
41
+ " metrics: { receivers: [span_metrics], exporters: [prometheus] }",
42
+ ].join("\n");
43
+ const ir = new OtelCollectorParser().parse(yaml);
44
+ expect(ir.warnings).toEqual([
45
+ 'exporters.otlp_grpc/tempo uses "otlp_grpc", the collector\'s newer name for "otlp"; it imports as the built-in, which writes "otlp"',
46
+ 'connectors.span_metrics uses "span_metrics", the collector\'s newer name for "spanmetrics"; it imports as the built-in, which writes "spanmetrics"',
47
+ ]);
48
+ const out = Object.fromEntries(new OtelCollectorGenerator().generate(ir).map((f) => [f.path, f.content]));
49
+ expect(out["exporters.ts"]).toContain('new OtlpExporter({ name: "tempo", endpoint: "tempo:4317" })');
50
+ expect(out["connectors.ts"]).toContain("new SpanMetricsConnector()");
51
+ expect(Object.keys(out)).not.toContain("custom-components.ts");
52
+ });
53
+
54
+ test("warns when an old and a new name collapse to one id", () => {
55
+ const ir = new OtelCollectorParser().parse("connectors: { spanmetrics: {}, span_metrics: {} }\n");
56
+ expect(ir.warnings).toContain('connectors.span_metrics and connectors.spanmetrics become the same id "spanmetrics"; rename one of them');
57
+ });
20
58
  });
21
59
 
22
60
  describe("tsLiteral", () => {
@@ -83,14 +121,14 @@ describe("generateCollectorFiles", () => {
83
121
 
84
122
  test("a component type chant does not ship is defined once with defineComponent and COLLECTOR_PIN", () => {
85
123
  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"] } } },
124
+ exporters: { "datadog/a": { api: { key: "${env:DD}" } }, "datadog/b": {}, otlp__http: {} },
125
+ service: { pipelines: { logs: { receivers: [], exporters: ["datadog/a", "datadog/b", "otlp__http"] } } },
88
126
  });
89
127
  const custom = out["custom-components.ts"];
90
128
  expect(custom).toContain('import { COLLECTOR_PIN, defineComponent } from "@intentius/chant-lexicon-otel";');
91
129
  expect(custom.match(/defineComponent<Record<string, unknown>>\(\)/g)).toHaveLength(2);
92
130
  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.
131
+ // `OtlpHttpExporter` is the built-in otlphttp exporter's class, so `otlp__http` gets a numbered one.
94
132
  expect(custom).toContain("const OtlpHttpExporter2 = defineComponent");
95
133
  expect(custom).toContain("export { DatadogExporter, OtlpHttpExporter2 };");
96
134
  expect(out["exporters.ts"]).toContain('import { DatadogExporter, OtlpHttpExporter2 } from "./custom-components";');
@@ -25,6 +25,7 @@ import { COLLECTOR_PIN, type ComponentClass, type SchemaPin } from "../define";
25
25
  import {
26
26
  COMPONENT_KINDS,
27
27
  SECTION_OF,
28
+ canonicalComponentType,
28
29
  SIGNALS,
29
30
  parseComponentId,
30
31
  pipelineSignal,
@@ -58,9 +59,13 @@ const BUILTIN_CLASSES: ReadonlyMap<string, string> = (() => {
58
59
  return map;
59
60
  })();
60
61
 
61
- /** The class name of the built-in for `kind` + `type`, if chant ships one. */
62
+ /**
63
+ * The class name of the built-in for `kind` + `type`, if chant ships one.
64
+ * A renamed built-in's new name (`span_metrics`) gives the class for the old
65
+ * one, which emits the old name.
66
+ */
62
67
  export function builtinClassName(kind: ComponentKind, type: string): string | undefined {
63
- return BUILTIN_CLASSES.get(`${kind}:${type}`);
68
+ return BUILTIN_CLASSES.get(`${kind}:${type}`) ?? BUILTIN_CLASSES.get(`${kind}:${canonicalComponentType(kind, type)}`);
64
69
  }
65
70
 
66
71
  const RESERVED = new Set(
@@ -110,6 +110,17 @@ exporters:
110
110
  expect(pins).toEqual([]);
111
111
  });
112
112
 
113
+ test("a k8s semconv line at another version is read and warned about", () => {
114
+ const { semconv, warnings } = parseCollectorYaml(
115
+ ["# chant: semconv k8s github.com/open-telemetry/semantic-conventions@v1.21.0 (k8sattributes)", "receivers: {}"].join("\n"),
116
+ );
117
+ expect(semconv).toEqual([{ namespace: "k8s", pin: { source: "github.com/open-telemetry/semantic-conventions", version: "v1.21.0" } }]);
118
+ expect(warnings).toEqual([
119
+ "the config was built against k8s semantic conventions github.com/open-telemetry/semantic-conventions@v1.21.0; " +
120
+ "this lexicon follows github.com/open-telemetry/semantic-conventions@v1.27.0, which the rebuilt config will name",
121
+ ]);
122
+ });
123
+
113
124
  test("names what it cannot carry", () => {
114
125
  const { config, warnings } = parseCollectorYaml(
115
126
  [