@intentius/chant-lexicon-otel 0.99.0 → 0.101.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 (122) hide show
  1. package/README.md +8 -6
  2. package/dist/codegen/docs.d.ts.map +1 -1
  3. package/dist/components/common.d.ts +2 -0
  4. package/dist/components/common.d.ts.map +1 -1
  5. package/dist/components/connectors.d.ts +109 -1
  6. package/dist/components/connectors.d.ts.map +1 -1
  7. package/dist/components/exporters.d.ts.map +1 -1
  8. package/dist/components/extensions.d.ts +26 -2
  9. package/dist/components/extensions.d.ts.map +1 -1
  10. package/dist/components/processors.d.ts +15 -2
  11. package/dist/components/processors.d.ts.map +1 -1
  12. package/dist/components/receivers.d.ts.map +1 -1
  13. package/dist/composites/catalog.d.ts +3 -0
  14. package/dist/composites/catalog.d.ts.map +1 -0
  15. package/dist/composites/index.d.ts +6 -0
  16. package/dist/composites/index.d.ts.map +1 -0
  17. package/dist/composites/node-agent.d.ts +110 -0
  18. package/dist/composites/node-agent.d.ts.map +1 -0
  19. package/dist/configmap.d.ts +35 -0
  20. package/dist/configmap.d.ts.map +1 -0
  21. package/dist/define.d.ts +9 -0
  22. package/dist/define.d.ts.map +1 -1
  23. package/dist/genai.d.ts +145 -2
  24. package/dist/genai.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/init-templates.d.ts +24 -0
  28. package/dist/init-templates.d.ts.map +1 -0
  29. package/dist/integrity.json +10 -5
  30. package/dist/lint/audit-catalog.d.ts +1 -1
  31. package/dist/lint/audit-catalog.d.ts.map +1 -1
  32. package/dist/lint/post-synth/index.d.ts.map +1 -1
  33. package/dist/lint/post-synth/otel-helpers.d.ts +32 -9
  34. package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
  35. package/dist/lint/post-synth/otel113.d.ts +8 -0
  36. package/dist/lint/post-synth/otel113.d.ts.map +1 -0
  37. package/dist/lint/post-synth/otel114.d.ts +8 -0
  38. package/dist/lint/post-synth/otel114.d.ts.map +1 -0
  39. package/dist/lint/post-synth/otel115.d.ts +8 -0
  40. package/dist/lint/post-synth/otel115.d.ts.map +1 -0
  41. package/dist/lint/post-synth/otel116.d.ts +8 -0
  42. package/dist/lint/post-synth/otel116.d.ts.map +1 -0
  43. package/dist/lint/post-synth/otel117.d.ts +8 -0
  44. package/dist/lint/post-synth/otel117.d.ts.map +1 -0
  45. package/dist/manifest.json +1 -1
  46. package/dist/meta.json +15 -0
  47. package/dist/okf/index.md +8 -0
  48. package/dist/okf/rules/OTEL113.md +11 -0
  49. package/dist/okf/rules/OTEL114.md +11 -0
  50. package/dist/okf/rules/OTEL115.md +11 -0
  51. package/dist/okf/rules/OTEL116.md +11 -0
  52. package/dist/okf/rules/OTEL117.md +11 -0
  53. package/dist/okf/types/DeltaToCumulativeProcessor.md +9 -0
  54. package/dist/okf/types/K8sLeaderElectorExtension.md +9 -0
  55. package/dist/okf/types/SignalToMetricsConnector.md +9 -0
  56. package/dist/plugin.d.ts.map +1 -1
  57. package/dist/rules/otel-helpers.ts +43 -11
  58. package/dist/rules/otel113.ts +17 -0
  59. package/dist/rules/otel114.ts +17 -0
  60. package/dist/rules/otel115.ts +17 -0
  61. package/dist/rules/otel116.ts +17 -0
  62. package/dist/rules/otel117.ts +17 -0
  63. package/dist/skills/chant-otel.md +11 -5
  64. package/dist/topology.d.ts +5 -0
  65. package/dist/topology.d.ts.map +1 -1
  66. package/dist/validate-config.d.ts +6 -2
  67. package/dist/validate-config.d.ts.map +1 -1
  68. package/dist/validate.d.ts.map +1 -1
  69. package/package.json +2 -2
  70. package/src/codegen/docs.ts +9 -4
  71. package/src/components/common.ts +17 -0
  72. package/src/components/connectors.ts +209 -1
  73. package/src/components/exporters.ts +2 -0
  74. package/src/components/extensions.test.ts +70 -0
  75. package/src/components/extensions.ts +60 -2
  76. package/src/components/processors.test.ts +54 -0
  77. package/src/components/processors.ts +41 -2
  78. package/src/components/receivers.ts +5 -0
  79. package/src/composites/catalog.test.ts +38 -0
  80. package/src/composites/catalog.ts +81 -0
  81. package/src/composites/composites.test.ts +138 -0
  82. package/src/composites/index.ts +6 -0
  83. package/src/composites/node-agent.ts +219 -0
  84. package/src/configmap.ts +67 -0
  85. package/src/connectors.test.ts +197 -1
  86. package/src/define.ts +9 -0
  87. package/src/genai.test.ts +301 -6
  88. package/src/genai.ts +323 -8
  89. package/src/generated/lexicon-otel.json +15 -0
  90. package/src/import/roundtrip.test.ts +21 -1
  91. package/src/import/testdata/fixtures.ts +37 -5
  92. package/src/import/testdata/k8s-leader-elector.yaml +35 -0
  93. package/src/import/testdata/signaltometrics.yaml +83 -0
  94. package/src/index.ts +16 -0
  95. package/src/init-templates.test.ts +72 -0
  96. package/src/init-templates.ts +146 -0
  97. package/src/lint/audit-catalog.ts +41 -1
  98. package/src/lint/post-synth/connector-graph.test.ts +207 -0
  99. package/src/lint/post-synth/index.ts +10 -0
  100. package/src/lint/post-synth/otel-helpers.ts +43 -11
  101. package/src/lint/post-synth/otel113.ts +17 -0
  102. package/src/lint/post-synth/otel114.ts +17 -0
  103. package/src/lint/post-synth/otel115.ts +17 -0
  104. package/src/lint/post-synth/otel116.ts +17 -0
  105. package/src/lint/post-synth/otel117.ts +17 -0
  106. package/src/lint/post-synth/post-synth.test.ts +223 -1
  107. package/src/lint/post-synth/testdata/otel113-fail.yaml +23 -0
  108. package/src/lint/post-synth/testdata/otel113-pass.yaml +21 -0
  109. package/src/lint/post-synth/testdata/otel114-fail.yaml +25 -0
  110. package/src/lint/post-synth/testdata/otel114-pass.yaml +23 -0
  111. package/src/lint/post-synth/testdata/otel115-fail.yaml +29 -0
  112. package/src/lint/post-synth/testdata/otel115-pass.yaml +29 -0
  113. package/src/plugin.test.ts +18 -0
  114. package/src/plugin.ts +22 -0
  115. package/src/skills/chant-otel.md +11 -5
  116. package/src/testdata/genai-configured.yaml +108 -0
  117. package/src/testdata/genai-default.yaml +92 -0
  118. package/src/topology.test.ts +12 -0
  119. package/src/topology.ts +16 -0
  120. package/src/typecheck.test.ts +112 -0
  121. package/src/validate-config.ts +415 -2
  122. package/src/validate.ts +17 -0
@@ -13,6 +13,9 @@ import { definitionFor, definitionOf, isOTelComponent, isUsablePin, runValidator
13
13
  import { componentConfig } from "./collector";
14
14
  import { isComponentId, parseComponentId, pipelineSignal, SIGNALS, type CollectorConfig, type ConnectorSignalPair } from "./model";
15
15
  import { isPipelineEntity } from "./pipeline";
16
+ import { signalToMetricsEntries, type SignalToMetricsConnectorConfig } from "./components/connectors";
17
+ import { genAiCardinalityRisk } from "./genai";
18
+ import { collectorTopology, type TopologyEdge } from "./topology";
16
19
  // OTEL112 reads the built-in connectors' signal pairs from the registry.
17
20
  import "./components/connectors";
18
21
 
@@ -26,7 +29,12 @@ export type CollectorIssueCode =
26
29
  | "OTEL107"
27
30
  | "OTEL108"
28
31
  | "OTEL109"
29
- | "OTEL112";
32
+ | "OTEL112"
33
+ | "OTEL113"
34
+ | "OTEL114"
35
+ | "OTEL115"
36
+ | "OTEL116"
37
+ | "OTEL117";
30
38
 
31
39
  export interface CollectorIssue {
32
40
  code: CollectorIssueCode;
@@ -56,7 +64,11 @@ function describePairs(pairs: ReadonlyArray<ConnectorSignalPair>): string {
56
64
 
57
65
  /**
58
66
  * Check a collector config's references and pipeline shape (OTEL101-OTEL106),
59
- * and each connector's signals against its definition (OTEL112).
67
+ * each connector's signals against its definition (OTEL112), cycles through
68
+ * connectors (OTEL113), connector ids shared with a receiver or exporter
69
+ * (OTEL114), `routing` connector targets (OTEL115), the attributes
70
+ * connectors split metrics by (OTEL116), and started components that listen
71
+ * on the same address (OTEL117).
60
72
  */
61
73
  export function validateCollectorConfig(config: CollectorConfig): CollectorIssue[] {
62
74
  const issues: CollectorIssue[] = [];
@@ -211,6 +223,407 @@ export function validateCollectorConfig(config: CollectorConfig): CollectorIssue
211
223
  }
212
224
  }
213
225
 
226
+ issues.push(
227
+ ...connectorCycleIssues(config),
228
+ ...connectorIdIssues(config),
229
+ ...routingTargetIssues(config),
230
+ ...metricAttributeIssues(config),
231
+ ...listenerIssues(config),
232
+ );
233
+
234
+ return issues;
235
+ }
236
+
237
+ /**
238
+ * OTEL113: the pipelines and the connectors between them must form a graph
239
+ * with no cycle, or the collector refuses to start. Reads the edges
240
+ * `collectorTopology()` returns, so an edge counts only for a signal pair the
241
+ * connector supports, as in the collector's own graph. Reports each strongly
242
+ * connected group of pipelines once, with one cycle through it.
243
+ */
244
+ function connectorCycleIssues(config: CollectorConfig): CollectorIssue[] {
245
+ const { pipelines, edges } = collectorTopology(config);
246
+ const out = new Map<string, TopologyEdge[]>();
247
+ for (const e of edges) out.set(e.from, [...(out.get(e.from) ?? []), e]);
248
+
249
+ // Tarjan's strongly connected components, in pipeline order.
250
+ const order = pipelines.map((p) => p.id);
251
+ const index = new Map<string, number>();
252
+ const low = new Map<string, number>();
253
+ const stack: string[] = [];
254
+ const onStack = new Set<string>();
255
+ const groups: string[][] = [];
256
+ const visit = (v: string) => {
257
+ index.set(v, index.size);
258
+ low.set(v, index.get(v)!);
259
+ stack.push(v);
260
+ onStack.add(v);
261
+ for (const { to } of out.get(v) ?? []) {
262
+ if (!index.has(to)) {
263
+ visit(to);
264
+ low.set(v, Math.min(low.get(v)!, low.get(to)!));
265
+ } else if (onStack.has(to)) {
266
+ low.set(v, Math.min(low.get(v)!, index.get(to)!));
267
+ }
268
+ }
269
+ if (low.get(v) === index.get(v)) {
270
+ const group: string[] = [];
271
+ let w: string;
272
+ do {
273
+ w = stack.pop()!;
274
+ onStack.delete(w);
275
+ group.push(w);
276
+ } while (w !== v);
277
+ groups.push(group);
278
+ }
279
+ };
280
+ for (const id of order) if (!index.has(id)) visit(id);
281
+
282
+ const issues: CollectorIssue[] = [];
283
+ for (const group of groups) {
284
+ const members = new Set(group);
285
+ const start = order.find((id) => members.has(id))!;
286
+ const path = shortestCycle(start, members, out);
287
+ if (!path) continue; // a single pipeline with no edge to itself
288
+ const hops = path.map((e) => `${e.from} -> ${e.connector}`).join(" -> ");
289
+ issues.push({
290
+ code: "OTEL113",
291
+ severity: "error",
292
+ pipeline: start,
293
+ message: `pipelines form a cycle through connectors: ${hops} -> ${start}; the collector refuses to start`,
294
+ });
295
+ }
296
+ return issues;
297
+ }
298
+
299
+ /** The shortest path of edges from `start` back to itself, staying inside `members`. */
300
+ function shortestCycle(start: string, members: Set<string>, out: Map<string, TopologyEdge[]>): TopologyEdge[] | undefined {
301
+ const via = new Map<string, TopologyEdge>();
302
+ const queue = [start];
303
+ while (queue.length > 0) {
304
+ const v = queue.shift()!;
305
+ for (const e of out.get(v) ?? []) {
306
+ if (!members.has(e.to)) continue;
307
+ if (e.to === start) {
308
+ const path = [e];
309
+ for (let at = v; at !== start; at = via.get(at)!.from) path.unshift(via.get(at)!);
310
+ return path;
311
+ }
312
+ if (!via.has(e.to)) {
313
+ via.set(e.to, e);
314
+ queue.push(e.to);
315
+ }
316
+ }
317
+ }
318
+ return undefined;
319
+ }
320
+
321
+ /**
322
+ * OTEL114: a pipeline names a connector the same way it names a receiver or
323
+ * an exporter, so the collector refuses a connector id that is also declared
324
+ * under receivers or exporters, used or not.
325
+ */
326
+ function connectorIdIssues(config: CollectorConfig): CollectorIssue[] {
327
+ const receivers = ids(config.receivers);
328
+ const exporters = ids(config.exporters);
329
+ const issues: CollectorIssue[] = [];
330
+ for (const id of ids(config.connectors)) {
331
+ const clashes = [receivers.has(id) ? "receiver" : "", exporters.has(id) ? "exporter" : ""].filter(Boolean);
332
+ if (clashes.length === 0) continue;
333
+ const type = parseComponentId(id)?.type ?? id;
334
+ issues.push({
335
+ code: "OTEL114",
336
+ severity: "error",
337
+ component: id,
338
+ message: `connector "${id}" has the same id as a declared ${clashes.join(" and ")}, so a pipeline listing "${id}" is ambiguous and the collector refuses to start; rename one of them (e.g. "${type}/connector")`,
339
+ });
340
+ }
341
+ return issues;
342
+ }
343
+
344
+ /**
345
+ * OTEL115: each pipeline a `routing` connector routes to, in `table[].pipelines`
346
+ * or `default_pipelines`, must list that connector in its receivers. The
347
+ * connector can only hand data to the pipelines it feeds, and the collector
348
+ * refuses to start when a route names any other.
349
+ */
350
+ function routingTargetIssues(config: CollectorConfig): CollectorIssue[] {
351
+ const pipelines = config.service?.pipelines ?? {};
352
+ const issues: CollectorIssue[] = [];
353
+ for (const [id, raw] of Object.entries(config.connectors ?? {})) {
354
+ if (parseComponentId(id)?.type !== "routing") continue;
355
+ const cfg = (raw ?? {}) as { table?: Array<{ pipelines?: unknown }>; default_pipelines?: unknown };
356
+ const targets: Array<[string, string]> = [];
357
+ (Array.isArray(cfg.table) ? cfg.table : []).forEach((item, i) => {
358
+ for (const t of Array.isArray(item?.pipelines) ? item.pipelines : []) targets.push([String(t), `table[${i}].pipelines`]);
359
+ });
360
+ for (const t of Array.isArray(cfg.default_pipelines) ? cfg.default_pipelines : []) targets.push([String(t), "default_pipelines"]);
361
+
362
+ const reported = new Set<string>();
363
+ for (const [target, where] of targets) {
364
+ if (reported.has(target)) continue;
365
+ const pipeline = Object.prototype.hasOwnProperty.call(pipelines, target) ? pipelines[target] : undefined;
366
+ if (pipeline && (pipeline.receivers ?? []).map(String).includes(id)) continue;
367
+ reported.add(target);
368
+ issues.push({
369
+ code: "OTEL115",
370
+ severity: "error",
371
+ pipeline: target,
372
+ component: id,
373
+ message: pipeline
374
+ ? `routing connector "${id}" routes to pipeline "${target}" (${where}), which does not list "${id}" in its receivers; the collector refuses to start`
375
+ : `routing connector "${id}" routes to pipeline "${target}" (${where}), which is not declared under service.pipelines; the collector refuses to start`,
376
+ });
377
+ }
378
+ }
379
+ return issues;
380
+ }
381
+
382
+ /** Where a connector lists the attribute keys it splits metrics by. */
383
+ interface MetricAttributeUse {
384
+ key: string;
385
+ /** The config field, e.g. `dimensions` or `spans.genai.tokens.attributes`. */
386
+ field: string;
387
+ }
388
+
389
+ function list(value: unknown): unknown[] {
390
+ return Array.isArray(value) ? value : [];
391
+ }
392
+
393
+ function keysOf(items: unknown, prop: "name" | "key", field: string): MetricAttributeUse[] {
394
+ return list(items).flatMap((item) => {
395
+ const key = typeof item === "string" ? item : (item as Record<string, unknown> | null)?.[prop];
396
+ return typeof key === "string" ? [{ key, field }] : [];
397
+ });
398
+ }
399
+
400
+ /**
401
+ * The attribute keys a built-in metrics connector puts on the metrics it
402
+ * makes, by type. An empty or unset `include_resource_attributes` keeps
403
+ * every resource attribute, whichever they are, so only listed keys can be
404
+ * named.
405
+ */
406
+ function metricAttributeUses(type: string, config: Record<string, unknown>): MetricAttributeUse[] {
407
+ switch (type) {
408
+ case "spanmetrics": {
409
+ const histogram = config.histogram as Record<string, unknown> | undefined;
410
+ const events = config.events as Record<string, unknown> | undefined;
411
+ return [
412
+ ...keysOf(config.dimensions, "name", "dimensions"),
413
+ ...keysOf(config.calls_dimensions, "name", "calls_dimensions"),
414
+ ...keysOf(histogram?.dimensions, "name", "histogram.dimensions"),
415
+ ...keysOf(events?.dimensions, "name", "events.dimensions"),
416
+ ];
417
+ }
418
+ case "servicegraph":
419
+ return keysOf(config.dimensions, "name", "dimensions");
420
+ case "count":
421
+ case "sum":
422
+ return Object.entries(config).flatMap(([section, metrics]) =>
423
+ typeof metrics === "object" && metrics !== null && !Array.isArray(metrics)
424
+ ? Object.entries(metrics as Record<string, unknown>).flatMap(([name, info]) =>
425
+ keysOf((info as Record<string, unknown> | null)?.attributes, "key", `${section}.${name}.attributes`),
426
+ )
427
+ : [],
428
+ );
429
+ case "signaltometrics":
430
+ return signalToMetricsEntries(config as SignalToMetricsConnectorConfig).flatMap(({ signal, index, metric }) => {
431
+ const at = metric.name ? `${signal}[${index}] (${metric.name})` : `${signal}[${index}]`;
432
+ return [
433
+ ...keysOf(metric.attributes, "key", `${at}.attributes`),
434
+ ...keysOf(metric.include_resource_attributes, "key", `${at}.include_resource_attributes`),
435
+ ];
436
+ });
437
+ default:
438
+ return [];
439
+ }
440
+ }
441
+
442
+ /**
443
+ * OTEL116: a connector splits metrics by a GenAI attribute that takes a new
444
+ * value per response, tool call, conversation, session or user, or by a
445
+ * content attribute. Each value starts new time series, so the metric's
446
+ * series grow with traffic. The keys are `GENAI_HIGH_CARDINALITY_ATTRIBUTES`
447
+ * and the content keys in `genai.ts`.
448
+ */
449
+ function metricAttributeIssues(config: CollectorConfig): CollectorIssue[] {
450
+ const issues: CollectorIssue[] = [];
451
+ for (const [id, raw] of Object.entries(config.connectors ?? {})) {
452
+ const type = parseComponentId(id)?.type ?? id;
453
+ const body = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
454
+ for (const { key, field } of metricAttributeUses(type, body)) {
455
+ const risk = genAiCardinalityRisk(key);
456
+ if (!risk) continue;
457
+ const why =
458
+ risk === "content"
459
+ ? "it carries message content, which is unbounded and sensitive"
460
+ : "it takes a new value per request, conversation or user";
461
+ issues.push({
462
+ code: "OTEL116",
463
+ severity: "warning",
464
+ component: id,
465
+ message: `connector "${id}" splits metrics by "${key}" (${field}); ${why}, so every value starts new time series. Drop it from the metric's attributes, or keep it on spans and logs`,
466
+ });
467
+ }
468
+ }
469
+ return issues;
470
+ }
471
+
472
+ /** One address a started component listens on. */
473
+ interface Listener {
474
+ /** Who listens: `receiver "otlp" (protocols.grpc.endpoint)`. */
475
+ owner: string;
476
+ component?: string;
477
+ host: string;
478
+ port: number;
479
+ transport: "tcp" | "udp";
480
+ }
481
+
482
+ /**
483
+ * The listeners OTEL117 knows, by component type: the config key path to an
484
+ * address, its default at `COLLECTOR_PIN` (collector and contrib v0.130.0,
485
+ * each component's `factory.go`), and the transport. `present` means the
486
+ * default applies only when the key's parent is in the config: an otlp or
487
+ * jaeger protocol that isn't listed doesn't start. A type not listed here is
488
+ * not checked, since its `endpoint` may be a server it connects to (the
489
+ * kubeletstats receiver's is the kubelet).
490
+ */
491
+ interface ListenerSpec {
492
+ path: string[];
493
+ default?: string;
494
+ transport?: "udp";
495
+ present?: boolean;
496
+ }
497
+
498
+ const LISTENERS: Record<"receiver" | "exporter" | "extension", Record<string, ListenerSpec[]>> = {
499
+ receiver: {
500
+ otlp: [
501
+ { path: ["protocols", "grpc", "endpoint"], default: "localhost:4317", present: true },
502
+ { path: ["protocols", "http", "endpoint"], default: "localhost:4318", present: true },
503
+ ],
504
+ zipkin: [{ path: ["endpoint"], default: "localhost:9411" }],
505
+ jaeger: [
506
+ { path: ["protocols", "grpc", "endpoint"], default: "localhost:14250", present: true },
507
+ { path: ["protocols", "thrift_http", "endpoint"], default: "localhost:14268", present: true },
508
+ { path: ["protocols", "thrift_binary", "endpoint"], default: "localhost:6832", present: true, transport: "udp" },
509
+ { path: ["protocols", "thrift_compact", "endpoint"], default: "localhost:6831", present: true, transport: "udp" },
510
+ ],
511
+ },
512
+ exporter: {
513
+ prometheus: [{ path: ["endpoint"] }],
514
+ },
515
+ extension: {
516
+ health_check: [{ path: ["endpoint"], default: "localhost:13133" }],
517
+ zpages: [{ path: ["endpoint"], default: "localhost:55679" }],
518
+ pprof: [{ path: ["endpoint"], default: "localhost:1777" }],
519
+ },
520
+ };
521
+
522
+ /** The collector's own metrics endpoint when `service.telemetry.metrics` names no reader. */
523
+ const DEFAULT_TELEMETRY_METRICS = { host: "localhost", port: 8888 };
524
+
525
+ const WILDCARD_HOSTS = new Set(["", "0.0.0.0", "::", "[::]"]);
526
+
527
+ function splitAddress(address: unknown): { host: string; port: number } | undefined {
528
+ if (typeof address !== "string") return undefined;
529
+ const m = address.match(/^(.*):(\d+)$/);
530
+ if (!m) return undefined;
531
+ return { host: m[1], port: Number(m[2]) };
532
+ }
533
+
534
+ function at(value: unknown, path: string[]): { found: boolean; value: unknown } {
535
+ let cur: unknown = value;
536
+ for (const key of path) {
537
+ if (typeof cur !== "object" || cur === null || !(key in (cur as Record<string, unknown>))) return { found: false, value: undefined };
538
+ cur = (cur as Record<string, unknown>)[key];
539
+ }
540
+ return { found: true, value: cur };
541
+ }
542
+
543
+ function componentListeners(kind: "receiver" | "exporter" | "extension", id: string, body: unknown): Listener[] {
544
+ const type = parseComponentId(id)?.type ?? id;
545
+ const out: Listener[] = [];
546
+ for (const spec of LISTENERS[kind][type] ?? []) {
547
+ if (spec.present && !at(body, spec.path.slice(0, -1)).found) continue;
548
+ const { found, value } = at(body, spec.path);
549
+ const address = splitAddress(found && value !== undefined && value !== null ? value : spec.default);
550
+ if (!address) continue;
551
+ out.push({ owner: `${kind} "${id}" (${spec.path.join(".")})`, component: id, ...address, transport: spec.transport ?? "tcp" });
552
+ }
553
+ return out;
554
+ }
555
+
556
+ /** The collector's own metrics endpoints, from `service.telemetry.metrics`. */
557
+ function telemetryListeners(config: CollectorConfig): Listener[] {
558
+ const metrics = (config.service?.telemetry as Record<string, unknown> | undefined)?.metrics as Record<string, unknown> | undefined;
559
+ if (metrics?.level === "none") return [];
560
+ const readers = Array.isArray(metrics?.readers) ? (metrics.readers as unknown[]) : undefined;
561
+ if (!readers) return [{ owner: "the collector's own metrics (service.telemetry.metrics, by default)", ...DEFAULT_TELEMETRY_METRICS, transport: "tcp" }];
562
+ const out: Listener[] = [];
563
+ readers.forEach((reader, i) => {
564
+ const prom = at(reader, ["pull", "exporter", "prometheus"]);
565
+ if (!prom.found || typeof prom.value !== "object" || prom.value === null) return;
566
+ const { host, port } = prom.value as { host?: unknown; port?: unknown };
567
+ const portNumber = typeof port === "number" ? port : typeof port === "string" && /^\d+$/.test(port) ? Number(port) : undefined;
568
+ if (portNumber === undefined) return;
569
+ out.push({
570
+ owner: `the collector's own metrics (service.telemetry.metrics.readers[${i}])`,
571
+ host: typeof host === "string" ? host : "localhost",
572
+ port: portNumber,
573
+ transport: "tcp",
574
+ });
575
+ });
576
+ return out;
577
+ }
578
+
579
+ function sameAddress(a: Listener, b: Listener): boolean {
580
+ if (a.port !== b.port || a.transport !== b.transport) return false;
581
+ return a.host === b.host || WILDCARD_HOSTS.has(a.host) || WILDCARD_HOSTS.has(b.host);
582
+ }
583
+
584
+ /**
585
+ * OTEL117: two things the collector starts listen on the same address, so
586
+ * the second bind fails with "address already in use" and the collector
587
+ * exits. `otelcol validate` builds the config without binding, so it does
588
+ * not catch this. Started means: a receiver or exporter some pipeline lists,
589
+ * an extension in `service.extensions`, and the collector's own metrics
590
+ * endpoint. A wildcard host (`0.0.0.0`, `::`, empty) overlaps every host on
591
+ * its port; an `${env:...}` host is compared as written.
592
+ */
593
+ function listenerIssues(config: CollectorConfig): CollectorIssue[] {
594
+ const pipelines = Object.values(config.service?.pipelines ?? {});
595
+ const started = (refs: Array<string[] | undefined>) => new Set(refs.flatMap((r) => (r ?? []).map(String)));
596
+ const receivers = started(pipelines.map((p) => p?.receivers));
597
+ const exporters = started(pipelines.map((p) => p?.exporters));
598
+ const extensions = new Set((config.service?.extensions ?? []).map(String));
599
+
600
+ const listeners: Listener[] = [];
601
+ const sections: Array<["receiver" | "exporter" | "extension", Record<string, unknown> | undefined, Set<string>]> = [
602
+ ["receiver", config.receivers, receivers],
603
+ ["exporter", config.exporters, exporters],
604
+ ["extension", config.extensions, extensions],
605
+ ];
606
+ for (const [kind, section, startedIds] of sections) {
607
+ for (const [id, body] of Object.entries(section ?? {})) {
608
+ if (startedIds.has(id)) listeners.push(...componentListeners(kind, id, body));
609
+ }
610
+ }
611
+ listeners.push(...telemetryListeners(config));
612
+
613
+ const issues: CollectorIssue[] = [];
614
+ for (let i = 0; i < listeners.length; i++) {
615
+ for (let j = 0; j < i; j++) {
616
+ const [first, second] = [listeners[j], listeners[i]];
617
+ if (!sameAddress(first, second)) continue;
618
+ const udp = first.transport === "udp" ? " (UDP)" : "";
619
+ issues.push({
620
+ code: "OTEL117",
621
+ severity: "error",
622
+ ...(second.component ? { component: second.component } : first.component ? { component: first.component } : {}),
623
+ message: `${first.owner} listens on ${first.host}:${first.port}${udp} and ${second.owner} on ${second.host}:${second.port}${udp}; the second bind fails with "address already in use" and the collector exits. Move one of them to another port`,
624
+ });
625
+ }
626
+ }
214
627
  return issues;
215
628
  }
216
629
 
package/src/validate.ts CHANGED
@@ -31,15 +31,24 @@ export const REQUIRED_NAMES = [
31
31
  "RedactionProcessor",
32
32
  "TailSamplingProcessor",
33
33
  "ProbabilisticSamplerProcessor",
34
+ "DeltaToCumulativeProcessor",
34
35
  "OtlpExporter",
35
36
  "OtlpHttpExporter",
36
37
  "DebugExporter",
37
38
  "PrometheusExporter",
38
39
  "GoogleCloudExporter",
39
40
  "LoadBalancingExporter",
41
+ "SpanMetricsConnector",
42
+ "ServiceGraphConnector",
43
+ "RoutingConnector",
44
+ "ForwardConnector",
45
+ "CountConnector",
46
+ "SumConnector",
47
+ "SignalToMetricsConnector",
40
48
  "HealthCheckExtension",
41
49
  "PprofExtension",
42
50
  "ZPagesExtension",
51
+ "K8sLeaderElectorExtension",
43
52
  "Pipeline",
44
53
  "Service",
45
54
  ];
@@ -55,6 +64,14 @@ export async function validate(): Promise<ValidateResult> {
55
64
  : { name: "required-names", ok: false, error: `Missing required names: ${missing.join(", ")}` },
56
65
  );
57
66
 
67
+ // The list names every entity the registry has, so a new built-in is held to it from the start.
68
+ const unlisted = Object.keys(registry).filter((n) => !REQUIRED_NAMES.includes(n));
69
+ checks.push(
70
+ unlisted.length === 0
71
+ ? { name: "required-names-complete", ok: true }
72
+ : { name: "required-names-complete", ok: false, error: `In the registry but not in REQUIRED_NAMES: ${unlisted.join(", ")}` },
73
+ );
74
+
58
75
  const classes = (Object.entries(components) as Array<[string, unknown]>).filter(
59
76
  (e): e is [string, ComponentClass] => typeof e[1] === "function" && "definition" in (e[1] as object),
60
77
  );