@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.
- package/README.md +8 -6
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/components/common.d.ts +2 -0
- package/dist/components/common.d.ts.map +1 -1
- package/dist/components/connectors.d.ts +109 -1
- package/dist/components/connectors.d.ts.map +1 -1
- package/dist/components/exporters.d.ts.map +1 -1
- package/dist/components/extensions.d.ts +26 -2
- package/dist/components/extensions.d.ts.map +1 -1
- package/dist/components/processors.d.ts +15 -2
- package/dist/components/processors.d.ts.map +1 -1
- package/dist/components/receivers.d.ts.map +1 -1
- package/dist/composites/catalog.d.ts +3 -0
- package/dist/composites/catalog.d.ts.map +1 -0
- package/dist/composites/index.d.ts +6 -0
- package/dist/composites/index.d.ts.map +1 -0
- package/dist/composites/node-agent.d.ts +110 -0
- package/dist/composites/node-agent.d.ts.map +1 -0
- package/dist/configmap.d.ts +35 -0
- package/dist/configmap.d.ts.map +1 -0
- package/dist/define.d.ts +9 -0
- package/dist/define.d.ts.map +1 -1
- package/dist/genai.d.ts +145 -2
- package/dist/genai.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/init-templates.d.ts +24 -0
- package/dist/init-templates.d.ts.map +1 -0
- package/dist/integrity.json +10 -5
- package/dist/lint/audit-catalog.d.ts +1 -1
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/otel-helpers.d.ts +32 -9
- package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/otel113.d.ts +8 -0
- package/dist/lint/post-synth/otel113.d.ts.map +1 -0
- package/dist/lint/post-synth/otel114.d.ts +8 -0
- package/dist/lint/post-synth/otel114.d.ts.map +1 -0
- package/dist/lint/post-synth/otel115.d.ts +8 -0
- package/dist/lint/post-synth/otel115.d.ts.map +1 -0
- package/dist/lint/post-synth/otel116.d.ts +8 -0
- package/dist/lint/post-synth/otel116.d.ts.map +1 -0
- package/dist/lint/post-synth/otel117.d.ts +8 -0
- package/dist/lint/post-synth/otel117.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +15 -0
- package/dist/okf/index.md +8 -0
- package/dist/okf/rules/OTEL113.md +11 -0
- package/dist/okf/rules/OTEL114.md +11 -0
- package/dist/okf/rules/OTEL115.md +11 -0
- package/dist/okf/rules/OTEL116.md +11 -0
- package/dist/okf/rules/OTEL117.md +11 -0
- package/dist/okf/types/DeltaToCumulativeProcessor.md +9 -0
- package/dist/okf/types/K8sLeaderElectorExtension.md +9 -0
- package/dist/okf/types/SignalToMetricsConnector.md +9 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/otel-helpers.ts +43 -11
- package/dist/rules/otel113.ts +17 -0
- package/dist/rules/otel114.ts +17 -0
- package/dist/rules/otel115.ts +17 -0
- package/dist/rules/otel116.ts +17 -0
- package/dist/rules/otel117.ts +17 -0
- package/dist/skills/chant-otel.md +11 -5
- package/dist/topology.d.ts +5 -0
- package/dist/topology.d.ts.map +1 -1
- package/dist/validate-config.d.ts +6 -2
- package/dist/validate-config.d.ts.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen/docs.ts +9 -4
- package/src/components/common.ts +17 -0
- package/src/components/connectors.ts +209 -1
- package/src/components/exporters.ts +2 -0
- package/src/components/extensions.test.ts +70 -0
- package/src/components/extensions.ts +60 -2
- package/src/components/processors.test.ts +54 -0
- package/src/components/processors.ts +41 -2
- package/src/components/receivers.ts +5 -0
- package/src/composites/catalog.test.ts +38 -0
- package/src/composites/catalog.ts +81 -0
- package/src/composites/composites.test.ts +138 -0
- package/src/composites/index.ts +6 -0
- package/src/composites/node-agent.ts +219 -0
- package/src/configmap.ts +67 -0
- package/src/connectors.test.ts +197 -1
- package/src/define.ts +9 -0
- package/src/genai.test.ts +301 -6
- package/src/genai.ts +323 -8
- package/src/generated/lexicon-otel.json +15 -0
- package/src/import/roundtrip.test.ts +21 -1
- package/src/import/testdata/fixtures.ts +37 -5
- package/src/import/testdata/k8s-leader-elector.yaml +35 -0
- package/src/import/testdata/signaltometrics.yaml +83 -0
- package/src/index.ts +16 -0
- package/src/init-templates.test.ts +72 -0
- package/src/init-templates.ts +146 -0
- package/src/lint/audit-catalog.ts +41 -1
- package/src/lint/post-synth/connector-graph.test.ts +207 -0
- package/src/lint/post-synth/index.ts +10 -0
- package/src/lint/post-synth/otel-helpers.ts +43 -11
- package/src/lint/post-synth/otel113.ts +17 -0
- package/src/lint/post-synth/otel114.ts +17 -0
- package/src/lint/post-synth/otel115.ts +17 -0
- package/src/lint/post-synth/otel116.ts +17 -0
- package/src/lint/post-synth/otel117.ts +17 -0
- package/src/lint/post-synth/post-synth.test.ts +223 -1
- package/src/lint/post-synth/testdata/otel113-fail.yaml +23 -0
- package/src/lint/post-synth/testdata/otel113-pass.yaml +21 -0
- package/src/lint/post-synth/testdata/otel114-fail.yaml +25 -0
- package/src/lint/post-synth/testdata/otel114-pass.yaml +23 -0
- package/src/lint/post-synth/testdata/otel115-fail.yaml +29 -0
- package/src/lint/post-synth/testdata/otel115-pass.yaml +29 -0
- package/src/plugin.test.ts +18 -0
- package/src/plugin.ts +22 -0
- package/src/skills/chant-otel.md +11 -5
- package/src/testdata/genai-configured.yaml +108 -0
- package/src/testdata/genai-default.yaml +92 -0
- package/src/topology.test.ts +12 -0
- package/src/topology.ts +16 -0
- package/src/typecheck.test.ts +112 -0
- package/src/validate-config.ts +415 -2
- package/src/validate.ts +17 -0
package/src/validate-config.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
);
|