@intentius/chant-lexicon-otel 0.100.0 → 0.102.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 (131) 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 +111 -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/import/generator.d.ts.map +1 -1
  26. package/dist/import/parser.d.ts.map +1 -1
  27. package/dist/index.d.ts +3 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/init-templates.d.ts +24 -0
  30. package/dist/init-templates.d.ts.map +1 -0
  31. package/dist/integrity.json +10 -5
  32. package/dist/lint/audit-catalog.d.ts +1 -1
  33. package/dist/lint/audit-catalog.d.ts.map +1 -1
  34. package/dist/lint/post-synth/index.d.ts.map +1 -1
  35. package/dist/lint/post-synth/otel-helpers.d.ts +32 -9
  36. package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
  37. package/dist/lint/post-synth/otel113.d.ts +8 -0
  38. package/dist/lint/post-synth/otel113.d.ts.map +1 -0
  39. package/dist/lint/post-synth/otel114.d.ts +8 -0
  40. package/dist/lint/post-synth/otel114.d.ts.map +1 -0
  41. package/dist/lint/post-synth/otel115.d.ts +8 -0
  42. package/dist/lint/post-synth/otel115.d.ts.map +1 -0
  43. package/dist/lint/post-synth/otel116.d.ts +8 -0
  44. package/dist/lint/post-synth/otel116.d.ts.map +1 -0
  45. package/dist/lint/post-synth/otel117.d.ts +8 -0
  46. package/dist/lint/post-synth/otel117.d.ts.map +1 -0
  47. package/dist/manifest.json +1 -1
  48. package/dist/meta.json +15 -0
  49. package/dist/model.d.ts.map +1 -1
  50. package/dist/okf/index.md +8 -0
  51. package/dist/okf/rules/OTEL113.md +11 -0
  52. package/dist/okf/rules/OTEL114.md +11 -0
  53. package/dist/okf/rules/OTEL115.md +11 -0
  54. package/dist/okf/rules/OTEL116.md +11 -0
  55. package/dist/okf/rules/OTEL117.md +11 -0
  56. package/dist/okf/types/DeltaToCumulativeProcessor.md +9 -0
  57. package/dist/okf/types/K8sLeaderElectorExtension.md +9 -0
  58. package/dist/okf/types/SignalToMetricsConnector.md +9 -0
  59. package/dist/platform.d.ts +6 -2
  60. package/dist/platform.d.ts.map +1 -1
  61. package/dist/plugin.d.ts.map +1 -1
  62. package/dist/rules/otel-helpers.ts +43 -11
  63. package/dist/rules/otel113.ts +17 -0
  64. package/dist/rules/otel114.ts +17 -0
  65. package/dist/rules/otel115.ts +17 -0
  66. package/dist/rules/otel116.ts +17 -0
  67. package/dist/rules/otel117.ts +17 -0
  68. package/dist/skill-defs.d.ts +1 -1
  69. package/dist/skill-defs.d.ts.map +1 -1
  70. package/dist/skills/chant-otel.md +11 -5
  71. package/dist/topology.d.ts +5 -0
  72. package/dist/topology.d.ts.map +1 -1
  73. package/dist/validate-config.d.ts +14 -2
  74. package/dist/validate-config.d.ts.map +1 -1
  75. package/dist/validate.d.ts.map +1 -1
  76. package/package.json +3 -3
  77. package/src/codegen/docs.ts +9 -4
  78. package/src/components/common.ts +17 -0
  79. package/src/components/connectors.ts +209 -1
  80. package/src/components/exporters.ts +2 -0
  81. package/src/components/extensions.test.ts +70 -0
  82. package/src/components/extensions.ts +60 -2
  83. package/src/components/processors.test.ts +54 -0
  84. package/src/components/processors.ts +41 -2
  85. package/src/components/receivers.ts +5 -0
  86. package/src/composites/catalog.test.ts +38 -0
  87. package/src/composites/catalog.ts +81 -0
  88. package/src/composites/composites.test.ts +138 -0
  89. package/src/composites/index.ts +6 -0
  90. package/src/composites/node-agent.ts +220 -0
  91. package/src/configmap.ts +67 -0
  92. package/src/connectors.test.ts +197 -1
  93. package/src/define.ts +9 -0
  94. package/src/genai.test.ts +301 -6
  95. package/src/genai.ts +323 -8
  96. package/src/generated/lexicon-otel.json +15 -0
  97. package/src/import/roundtrip.test.ts +21 -1
  98. package/src/import/testdata/fixtures.ts +37 -5
  99. package/src/import/testdata/k8s-leader-elector.yaml +35 -0
  100. package/src/import/testdata/signaltometrics.yaml +83 -0
  101. package/src/index.ts +16 -0
  102. package/src/init-templates.test.ts +72 -0
  103. package/src/init-templates.ts +148 -0
  104. package/src/lint/audit-catalog.ts +41 -1
  105. package/src/lint/post-synth/connector-graph.test.ts +207 -0
  106. package/src/lint/post-synth/index.ts +10 -0
  107. package/src/lint/post-synth/otel-helpers.ts +43 -11
  108. package/src/lint/post-synth/otel113.ts +17 -0
  109. package/src/lint/post-synth/otel114.ts +17 -0
  110. package/src/lint/post-synth/otel115.ts +17 -0
  111. package/src/lint/post-synth/otel116.ts +17 -0
  112. package/src/lint/post-synth/otel117.ts +17 -0
  113. package/src/lint/post-synth/post-synth.test.ts +236 -1
  114. package/src/lint/post-synth/testdata/otel113-fail.yaml +23 -0
  115. package/src/lint/post-synth/testdata/otel113-pass.yaml +21 -0
  116. package/src/lint/post-synth/testdata/otel114-fail.yaml +25 -0
  117. package/src/lint/post-synth/testdata/otel114-pass.yaml +23 -0
  118. package/src/lint/post-synth/testdata/otel115-fail.yaml +29 -0
  119. package/src/lint/post-synth/testdata/otel115-pass.yaml +29 -0
  120. package/src/platform.test.ts +26 -0
  121. package/src/platform.ts +17 -13
  122. package/src/plugin.test.ts +18 -0
  123. package/src/plugin.ts +22 -0
  124. package/src/skills/chant-otel.md +11 -5
  125. package/src/testdata/genai-configured.yaml +108 -0
  126. package/src/testdata/genai-default.yaml +92 -0
  127. package/src/topology.test.ts +12 -0
  128. package/src/topology.ts +16 -0
  129. package/src/typecheck.test.ts +112 -0
  130. package/src/validate-config.ts +426 -2
  131. package/src/validate.ts +17 -0
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Built-in connectors: spanmetrics, servicegraph, routing, forward, count, sum.
2
+ * Built-in connectors: spanmetrics, servicegraph, routing, forward, count, sum,
3
+ * signaltometrics.
3
4
  *
4
5
  * A connector is an exporter in one pipeline and a receiver in another. Put
5
6
  * the same entity in `exporters` of the pipeline that feeds it and in
@@ -274,3 +275,210 @@ export const SumConnector = defineBuiltin<SumConnectorConfig, "connector", "sum"
274
275
  return problems;
275
276
  },
276
277
  });
278
+
279
+ // ── signaltometrics ──────────────────────────────────────────────────
280
+ //
281
+ // Typed against connector/signaltometricsconnector at COLLECTOR_PIN
282
+ // (collector-contrib v0.130.0): config/config.go and README.md at that tag,
283
+ // https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/v0.130.0/connector/signaltometricsconnector
284
+ // The collector type is `signaltometrics`. Upstream marks every signal pair
285
+ // alpha. At v0.130.0 there is no span event section; spans, data points,
286
+ // logs and profiles each take a list of metrics.
287
+
288
+ /** The input sections of a `signaltometrics` connector, one per signal it reads. */
289
+ export const SIGNAL_TO_METRICS_SIGNALS = ["spans", "datapoints", "logs", "profiles"] as const;
290
+ export type SignalToMetricsSignal = (typeof SIGNAL_TO_METRICS_SIGNALS)[number];
291
+
292
+ /** The metric types a `signaltometrics` entry can produce. Each entry names exactly one. */
293
+ export const SIGNAL_TO_METRICS_TYPES = ["sum", "gauge", "histogram", "exponential_histogram"] as const;
294
+ export type SignalToMetricsType = (typeof SIGNAL_TO_METRICS_TYPES)[number];
295
+
296
+ /**
297
+ * The connector's default explicit histogram bounds, used when `histogram`
298
+ * sets no `buckets`. They are plain numbers in whatever unit `value` returns,
299
+ * not durations.
300
+ */
301
+ export const SIGNAL_TO_METRICS_DEFAULT_BUCKETS: readonly number[] = Object.freeze([
302
+ 2, 4, 6, 8, 10, 50, 100, 200, 400, 800, 1000, 1400, 2000, 5000, 10_000, 15_000,
303
+ ]);
304
+
305
+ /**
306
+ * An attribute of the produced metric.
307
+ *
308
+ * In `attributes`: without `default_value` or `optional`, an item lacking the
309
+ * attribute is not counted at all; with `default_value`, it is counted under
310
+ * that value; with `optional: true`, it is counted without the attribute.
311
+ * Set at most one of the two.
312
+ *
313
+ * In `include_resource_attributes`: an include list. `default_value` fills a
314
+ * missing resource attribute; `optional` changes nothing.
315
+ */
316
+ export interface SignalToMetricsAttribute {
317
+ key: string;
318
+ default_value?: string | number | boolean;
319
+ optional?: boolean;
320
+ }
321
+
322
+ /** A delta sum of `value` over the items in each batch. */
323
+ export interface SignalToMetricsSum {
324
+ /** OTTL value expression; its result type (int or double) is the sum's. A constant is a string: `"1"`. */
325
+ value: string;
326
+ }
327
+
328
+ /** The last `value` seen in each batch. */
329
+ export interface SignalToMetricsGauge {
330
+ /** OTTL value expression. With `ExtractGrokPatterns`, select one key: `ExtractGrokPatterns(...)["key"]`. */
331
+ value: string;
332
+ }
333
+
334
+ /** An explicit-bucket histogram of `value`. */
335
+ export interface SignalToMetricsHistogram {
336
+ /** Upper bounds, increasing, in the unit `value` returns. Default `SIGNAL_TO_METRICS_DEFAULT_BUCKETS`. */
337
+ buckets?: number[];
338
+ /** OTTL value expression for how many observations each item counts as. Default: one per item. */
339
+ count?: string;
340
+ /** OTTL value expression for the observed value, e.g. `Seconds(end_time - start_time)` or an attribute. */
341
+ value: string;
342
+ }
343
+
344
+ /** A base-2 exponential histogram of `value`. */
345
+ export interface SignalToMetricsExponentialHistogram {
346
+ /** Buckets per positive or negative range, 2 to 16384. Default 160. */
347
+ max_size?: number;
348
+ /** OTTL value expression for how many observations each item counts as. Default: one per item. */
349
+ count?: string;
350
+ /** OTTL value expression for the observed value. */
351
+ value: string;
352
+ }
353
+
354
+ /** What every `signaltometrics` entry has, whatever metric type it produces. */
355
+ export interface SignalToMetricsMetricBase {
356
+ /** The OTLP metric name, used as written. */
357
+ name: string;
358
+ description?: string;
359
+ /** The metric's unit, e.g. `s` or `{token}`. */
360
+ unit?: string;
361
+ /** Resource attributes the metric keeps. Unset or empty keeps them all. */
362
+ include_resource_attributes?: SignalToMetricsAttribute[];
363
+ /** Attributes of the item (span, data point, log record) the metric is split by. Unset: no attributes. */
364
+ attributes?: SignalToMetricsAttribute[];
365
+ /** OTTL conditions, ORed; an item counts when any matches. Unset counts every item. */
366
+ conditions?: string[];
367
+ }
368
+
369
+ type OneMetricType<K extends SignalToMetricsType, V> = { [P in K]: V } & { [P in Exclude<SignalToMetricsType, K>]?: never };
370
+
371
+ /** One metric a `signaltometrics` connector produces: a name and exactly one metric type. */
372
+ export type SignalToMetricsMetric = SignalToMetricsMetricBase &
373
+ (
374
+ | OneMetricType<"sum", SignalToMetricsSum>
375
+ | OneMetricType<"gauge", SignalToMetricsGauge>
376
+ | OneMetricType<"histogram", SignalToMetricsHistogram>
377
+ | OneMetricType<"exponential_histogram", SignalToMetricsExponentialHistogram>
378
+ );
379
+
380
+ /**
381
+ * The metrics to produce, per input signal. The connector emits delta
382
+ * temporality and aggregates only within each batch it receives, so an
383
+ * exporter that needs cumulative input (Prometheus remote write) wants a
384
+ * `deltatocumulative` processor in front of it.
385
+ */
386
+ export interface SignalToMetricsConnectorConfig {
387
+ spans?: SignalToMetricsMetric[];
388
+ datapoints?: SignalToMetricsMetric[];
389
+ logs?: SignalToMetricsMetric[];
390
+ profiles?: SignalToMetricsMetric[];
391
+ }
392
+
393
+ /** One entry of a `signaltometrics` config, with where it sits. */
394
+ export interface SignalToMetricsEntry {
395
+ signal: SignalToMetricsSignal;
396
+ index: number;
397
+ metric: SignalToMetricsMetric;
398
+ /** The metric types the entry names. A valid entry names exactly one. */
399
+ types: SignalToMetricsType[];
400
+ }
401
+
402
+ /** Every metric entry of a `signaltometrics` config, in section order. Reads untyped config too. */
403
+ export function signalToMetricsEntries(config: SignalToMetricsConnectorConfig): SignalToMetricsEntry[] {
404
+ const out: SignalToMetricsEntry[] = [];
405
+ for (const signal of SIGNAL_TO_METRICS_SIGNALS) {
406
+ const list = (config as Record<string, unknown>)?.[signal];
407
+ if (!Array.isArray(list)) continue;
408
+ list.forEach((metric, index) => {
409
+ const m = (metric ?? {}) as Record<string, unknown>;
410
+ const types = SIGNAL_TO_METRICS_TYPES.filter((t) => m[t] !== undefined && m[t] !== null);
411
+ out.push({ signal, index, metric: m as unknown as SignalToMetricsMetric, types });
412
+ });
413
+ }
414
+ return out;
415
+ }
416
+
417
+ const GROK_KEY_SELECTOR = /ExtractGrokPatterns\([^)]*\)\s*\[[^\]]+\]/;
418
+
419
+ /** The checks config/config.go makes at v0.130.0 that need no OTTL parser, plus increasing buckets. */
420
+ function validateSignalToMetrics(c: SignalToMetricsConnectorConfig): string[] {
421
+ const problems: string[] = [];
422
+ const entries = signalToMetricsEntries(c);
423
+ if (entries.length === 0) {
424
+ problems.push("no metric is configured under spans, datapoints, logs or profiles; the collector refuses the connector");
425
+ }
426
+ for (const { signal, index, metric, types } of entries) {
427
+ const m = metric as unknown as SignalToMetricsMetricBase & Partial<Record<SignalToMetricsType, Record<string, unknown>>>;
428
+ const at = m.name ? `${signal}[${index}] (${m.name})` : `${signal}[${index}]`;
429
+ if (!m.name) problems.push(`${at}: name is missing`);
430
+ if (types.length !== 1) {
431
+ problems.push(
432
+ `${at}: name exactly one metric type (sum, gauge, histogram or exponential_histogram); ${types.length === 0 ? "found none" : `found ${types.join(" and ")}`}`,
433
+ );
434
+ }
435
+ for (const t of types) {
436
+ const body = m[t];
437
+ if (typeof body?.value !== "string" || body.value === "") problems.push(`${at}: ${t}.value is missing`);
438
+ }
439
+ const buckets = m.histogram?.buckets;
440
+ if (Array.isArray(buckets) && buckets.some((b, i) => i > 0 && !((b as number) > (buckets[i - 1] as number)))) {
441
+ problems.push(`${at}: histogram.buckets must increase`);
442
+ }
443
+ const maxSize = m.exponential_histogram?.max_size;
444
+ if (typeof maxSize === "number" && maxSize !== 0 && (maxSize < 2 || maxSize > 16384)) {
445
+ problems.push(`${at}: exponential_histogram.max_size must be between 2 and 16384`);
446
+ }
447
+ const gauge = m.gauge?.value;
448
+ if (typeof gauge === "string" && gauge.includes("ExtractGrokPatterns") && !GROK_KEY_SELECTOR.test(gauge)) {
449
+ problems.push(`${at}: gauge.value with ExtractGrokPatterns needs one key selector, ExtractGrokPatterns(...)["key"]`);
450
+ }
451
+ const seen = new Set<string>();
452
+ (m.attributes ?? []).forEach((a, i) => {
453
+ if (!a?.key) {
454
+ problems.push(`${at}: attributes[${i}]: key is missing`);
455
+ return;
456
+ }
457
+ if (a.default_value !== undefined && a.optional) {
458
+ problems.push(`${at}: attributes "${a.key}": set default_value or optional, not both`);
459
+ }
460
+ if (seen.has(a.key)) problems.push(`${at}: attributes "${a.key}" is listed twice`);
461
+ seen.add(a.key);
462
+ });
463
+ }
464
+ return problems;
465
+ }
466
+
467
+ /**
468
+ * Builds metrics from spans, data points, logs or profiles, each with the
469
+ * name, type, unit, attributes and conditions you choose. A histogram, sum or
470
+ * gauge takes its value from an OTTL expression, such as an attribute or the
471
+ * span's duration.
472
+ */
473
+ export const SignalToMetricsConnector = defineBuiltin<SignalToMetricsConnectorConfig, "connector", "signaltometrics">({
474
+ kind: "connector",
475
+ type: "signaltometrics",
476
+ description: "Builds named sum, gauge and histogram metrics from spans, data points, logs or profiles, with values and conditions in OTTL",
477
+ connects: [
478
+ { from: "traces", to: "metrics" },
479
+ { from: "metrics", to: "metrics" },
480
+ { from: "logs", to: "metrics" },
481
+ { from: "profiles", to: "metrics" },
482
+ ],
483
+ validate: validateSignalToMetrics,
484
+ });
@@ -16,6 +16,7 @@ export const OtlpExporter = defineBuiltin<OtlpExporterConfig, "exporter", "otlp"
16
16
  description: "Sends traces, metrics and logs over OTLP gRPC",
17
17
  validate: (c) => (c.endpoint ? [] : ["endpoint is empty"]),
18
18
  endpoints: (c) => (c.endpoint ? [c.endpoint] : []),
19
+ protocols: () => ["grpc"],
19
20
  });
20
21
 
21
22
  // ── otlphttp ─────────────────────────────────────────────────────────
@@ -39,6 +40,7 @@ export const OtlpHttpExporter = defineBuiltin<OtlpHttpExporterConfig, "exporter"
39
40
  : ["set endpoint, or at least one of traces_endpoint, metrics_endpoint and logs_endpoint"],
40
41
  endpoints: (c) =>
41
42
  [c.endpoint, c.traces_endpoint, c.metrics_endpoint, c.logs_endpoint].filter((e): e is string => typeof e === "string" && e !== ""),
43
+ protocols: (c) => [c.encoding === "json" ? "http/json" : "http/protobuf"],
42
44
  });
43
45
 
44
46
  // ── debug ────────────────────────────────────────────────────────────
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { load } from "js-yaml";
3
+ import type { Declarable } from "@intentius/chant/declarable";
4
+ import { collectorYaml } from "../collector";
5
+ import { validateCollectorEntities } from "../validate-config";
6
+ import { COLLECTOR_PIN } from "../define";
7
+ import { Pipeline, Service } from "../pipeline";
8
+ import { DebugExporter } from "./exporters";
9
+ import { K8sClusterReceiver } from "./k8s-receivers";
10
+ import { K8sLeaderElectorExtension, type K8sLeaderElectorExtensionConfig } from "./extensions";
11
+
12
+ function problems(component: Declarable): string[] {
13
+ return validateCollectorEntities([component])
14
+ .filter((i) => i.code === "OTEL107")
15
+ .map((i) => i.message.replace(/^\w+ "[^"]+": /, ""));
16
+ }
17
+
18
+ const lease = { lease_name: "otel-k8s-cluster", lease_namespace: "observability" };
19
+
20
+ describe("k8s_leader_elector extension", () => {
21
+ test("is a built-in pinned to the collector release", () => {
22
+ expect(K8sLeaderElectorExtension.definition.kind).toBe("extension");
23
+ expect(K8sLeaderElectorExtension.definition.type).toBe("k8s_leader_elector");
24
+ expect(K8sLeaderElectorExtension.definition.builtin).toBe(true);
25
+ expect(K8sLeaderElectorExtension.definition.pin).toBe(COLLECTOR_PIN);
26
+ });
27
+
28
+ test("a k8s_cluster receiver names it by componentId", () => {
29
+ const elector = new K8sLeaderElectorExtension({ name: "cluster", auth_type: "serviceAccount", ...lease, lease_duration: "20s" });
30
+ const cluster = new K8sClusterReceiver({ k8s_leader_elector: elector.componentId });
31
+ const debug = new DebugExporter({});
32
+ const yaml = collectorYaml([
33
+ cluster,
34
+ elector,
35
+ debug,
36
+ new Pipeline({ signal: "metrics", receivers: [cluster], exporters: [debug] }),
37
+ new Service({ extensions: [elector] }),
38
+ ]);
39
+ expect(load(yaml)).toMatchObject({
40
+ receivers: { k8s_cluster: { k8s_leader_elector: "k8s_leader_elector/cluster" } },
41
+ extensions: {
42
+ "k8s_leader_elector/cluster": { auth_type: "serviceAccount", lease_name: "otel-k8s-cluster", lease_namespace: "observability", lease_duration: "20s" },
43
+ },
44
+ service: { extensions: ["k8s_leader_elector/cluster"] },
45
+ });
46
+ expect(problems(elector)).toEqual([]);
47
+ });
48
+
49
+ test("needs lease_name and lease_namespace", () => {
50
+ expect(problems(new K8sLeaderElectorExtension({ lease_name: "", lease_namespace: "obs" }))).toEqual([
51
+ "lease_name and lease_namespace must be set",
52
+ ]);
53
+ expect(problems(new K8sLeaderElectorExtension({} as K8sLeaderElectorExtensionConfig))).toEqual([
54
+ "lease_name and lease_namespace must be set",
55
+ ]);
56
+ });
57
+
58
+ test("checks the durations the way client-go's leader elector does", () => {
59
+ expect(problems(new K8sLeaderElectorExtension({ ...lease, lease_duration: "10s" }))).toEqual([
60
+ "lease_duration must be greater than renew_deadline",
61
+ ]);
62
+ expect(problems(new K8sLeaderElectorExtension({ ...lease, renew_deadline: "2400ms" }))).toEqual([
63
+ "renew_deadline must be greater than 1.2 times retry_period",
64
+ ]);
65
+ expect(problems(new K8sLeaderElectorExtension({ ...lease, retry_period: "0s" }))).toEqual(["retry_period must be positive"]);
66
+ expect(problems(new K8sLeaderElectorExtension({ ...lease, lease_duration: "1m30s", renew_deadline: "1m", retry_period: "5s" }))).toEqual([]);
67
+ // A value this can't read (an env reference) is left to the collector.
68
+ expect(problems(new K8sLeaderElectorExtension({ ...lease, lease_duration: "${env:LEASE}" }))).toEqual([]);
69
+ });
70
+ });
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Built-in extensions: health_check, pprof, zpages.
2
+ * Built-in extensions: health_check, pprof, zpages, k8s_leader_elector.
3
3
  */
4
4
 
5
5
  import { defineBuiltin } from "../define";
6
- import type { TLSServerSettings } from "./common";
6
+ import { goDurationMs, type Duration, type TLSServerSettings } from "./common";
7
+ import type { K8sAuthType } from "./k8s-receivers";
7
8
 
8
9
  export interface HealthCheckExtensionConfig {
9
10
  /** Default `localhost:13133`. */
@@ -49,3 +50,60 @@ export const ZPagesExtension = defineBuiltin<ZPagesExtensionConfig, "extension",
49
50
  description: "Serves zPages for live debugging of receivers and exporters",
50
51
  endpoints: (c) => [c.endpoint ?? "localhost:55679"],
51
52
  });
53
+
54
+ // ── k8s_leader_elector ───────────────────────────────────────────────
55
+ //
56
+ // Typed against extension/k8sleaderelector at COLLECTOR_PIN
57
+ // (collector-contrib v0.130.0): config.go, factory.go, leader_elector.go and README.md at that tag,
58
+ // https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/v0.130.0/extension/k8sleaderelector
59
+ // The config squashes internal/k8sconfig's APIConfig (`auth_type`, `context`).
60
+ // Upstream marks it alpha. It ships in the otelcol-contrib and otelcol-k8s
61
+ // distributions at v0.130.0.
62
+
63
+ export interface K8sLeaderElectorExtensionConfig {
64
+ /** How to reach the Kubernetes API. Default `serviceAccount`. */
65
+ auth_type?: K8sAuthType;
66
+ /** With `auth_type: kubeConfig`, the kubeconfig context to use instead of the current one. */
67
+ context?: string;
68
+ /** The Lease object the replicas compete for. Required. */
69
+ lease_name: string;
70
+ /** The namespace of the Lease. Required. */
71
+ lease_namespace: string;
72
+ /** How long a lease is held without renewal. Default `15s`. */
73
+ lease_duration?: Duration;
74
+ /** How long the leader keeps retrying a renewal before giving up the lease; less than `lease_duration`. Default `10s`. */
75
+ renew_deadline?: Duration;
76
+ /** How long to wait between attempts to acquire or renew. Default `2s`. */
77
+ retry_period?: Duration;
78
+ }
79
+
80
+ /**
81
+ * Elects one leader among collector replicas through a Kubernetes Lease, so a
82
+ * receiver that names it (`k8s_cluster`'s `k8s_leader_elector`) collects only
83
+ * in the replica holding the lease. The collector's ServiceAccount needs
84
+ * access to `leases` in `coordination.k8s.io` in `lease_namespace`.
85
+ */
86
+ export const K8sLeaderElectorExtension = defineBuiltin<K8sLeaderElectorExtensionConfig, "extension", "k8s_leader_elector">({
87
+ kind: "extension",
88
+ type: "k8s_leader_elector",
89
+ description: "Elects one leader among collector replicas through a Kubernetes Lease",
90
+ validate: (c) => {
91
+ const problems: string[] = [];
92
+ // config.go Validate.
93
+ if (!c.lease_name || !c.lease_namespace) problems.push("lease_name and lease_namespace must be set");
94
+ // client-go's leaderelection.NewLeaderElector, which leader_elector.go calls when the extension starts.
95
+ const lease = goDurationMs(c.lease_duration, 15_000);
96
+ const renew = goDurationMs(c.renew_deadline, 10_000);
97
+ const retry = goDurationMs(c.retry_period, 2_000);
98
+ for (const [key, ms] of [["lease_duration", lease], ["renew_deadline", renew], ["retry_period", retry]] as const) {
99
+ if (ms !== undefined && ms <= 0) problems.push(`${key} must be positive`);
100
+ }
101
+ if (lease !== undefined && renew !== undefined && lease > 0 && renew > 0 && lease <= renew) {
102
+ problems.push("lease_duration must be greater than renew_deadline");
103
+ }
104
+ if (renew !== undefined && retry !== undefined && renew > 0 && retry > 0 && renew <= 1.2 * retry) {
105
+ problems.push("renew_deadline must be greater than 1.2 times retry_period");
106
+ }
107
+ return problems;
108
+ },
109
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { load } from "js-yaml";
3
+ import type { Declarable } from "@intentius/chant/declarable";
4
+ import { collectorYaml } from "../collector";
5
+ import { validateCollectorEntities } from "../validate-config";
6
+ import { COLLECTOR_PIN } from "../define";
7
+ import { Pipeline } from "../pipeline";
8
+ import { DebugExporter } from "./exporters";
9
+ import { SumConnector } from "./connectors";
10
+ import { OtlpReceiver } from "./receivers";
11
+ import { DeltaToCumulativeProcessor, type DeltaToCumulativeProcessorConfig } from "./processors";
12
+
13
+ function problems(component: Declarable): string[] {
14
+ return validateCollectorEntities([component])
15
+ .filter((i) => i.code === "OTEL107")
16
+ .map((i) => i.message.replace(/^\w+ "[^"]+": /, ""));
17
+ }
18
+
19
+ describe("deltatocumulative processor", () => {
20
+ test("is a built-in pinned to the collector release", () => {
21
+ expect(DeltaToCumulativeProcessor.definition.kind).toBe("processor");
22
+ expect(DeltaToCumulativeProcessor.definition.type).toBe("deltatocumulative");
23
+ expect(DeltaToCumulativeProcessor.definition.builtin).toBe(true);
24
+ expect(DeltaToCumulativeProcessor.definition.pin).toBe(COLLECTOR_PIN);
25
+ });
26
+
27
+ test("sits in a metrics pipeline after a delta-emitting connector", () => {
28
+ const otlp = new OtlpReceiver({ protocols: { grpc: {} } });
29
+ const sum = new SumConnector({ spans: { "tokens": { source_attribute: "gen_ai.usage.input_tokens" } } });
30
+ const d2c = new DeltaToCumulativeProcessor({ max_stale: "10m", max_streams: 1000 });
31
+ const debug = new DebugExporter({});
32
+ const config = load(
33
+ collectorYaml([
34
+ otlp,
35
+ new Pipeline({ signal: "traces", receivers: [otlp], exporters: [sum] }),
36
+ new Pipeline({ signal: "metrics", receivers: [sum], processors: [d2c], exporters: [debug] }),
37
+ ]),
38
+ ) as any;
39
+ expect(config.processors.deltatocumulative).toEqual({ max_stale: "10m", max_streams: 1000 });
40
+ expect(config.service.pipelines.metrics.processors).toEqual(["deltatocumulative"]);
41
+ });
42
+
43
+ test.each<[string, DeltaToCumulativeProcessorConfig, string[]]>([
44
+ ["defaults", {}, []],
45
+ ["set", { max_stale: "1h", max_streams: 0 }, []],
46
+ ["zero max_stale", { max_stale: "0s" }, ['max_stale must be a positive duration (got "0s")']],
47
+ ["bare zero max_stale", { max_stale: "0" }, ['max_stale must be a positive duration (got "0")']],
48
+ ["negative max_stale", { max_stale: "-5m" }, ['max_stale must be a positive duration (got "-5m")']],
49
+ ["negative max_streams", { max_streams: -1 }, ["max_streams must be a whole number, 0 or more (got -1)"]],
50
+ ["fractional max_streams", { max_streams: 1.5 }, ["max_streams must be a whole number, 0 or more (got 1.5)"]],
51
+ ])("checks what config.go Validate checks: %s", (_label, config, expected) => {
52
+ expect(problems(new DeltaToCumulativeProcessor(config))).toEqual(expected);
53
+ });
54
+ });
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Built-in processors: batch, memory_limiter, resource, attributes,
3
- * k8sattributes, resourcedetection.
3
+ * k8sattributes, resourcedetection, deltatocumulative.
4
4
  */
5
5
 
6
6
  import { defineBuiltin } from "../define";
7
- import type { AttributeAction, Duration } from "./common";
7
+ import { goDurationMs, type AttributeAction, type Duration } from "./common";
8
8
 
9
9
  // ── batch ────────────────────────────────────────────────────────────
10
10
 
@@ -210,3 +210,42 @@ export const ResourceDetectionProcessor = defineBuiltin<
210
210
  description: "Detects cloud, container and host resource attributes",
211
211
  validate: (c) => (c.detectors?.length ? [] : ["detectors is empty, so nothing is detected"]),
212
212
  });
213
+
214
+ // ── deltatocumulative ────────────────────────────────────────────────
215
+ //
216
+ // Typed against processor/deltatocumulativeprocessor at COLLECTOR_PIN
217
+ // (collector-contrib v0.130.0): config.go, factory.go and README.md at that tag,
218
+ // https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/v0.130.0/processor/deltatocumulativeprocessor
219
+ // Upstream marks it alpha for metrics. It ships in the otelcol-contrib and
220
+ // otelcol-k8s distributions at v0.130.0, and keeps every stream's running
221
+ // total in memory, so one collector instance must see all of a stream's
222
+ // deltas for its totals to be right.
223
+
224
+ export interface DeltaToCumulativeProcessorConfig {
225
+ /** How long a stream that receives no samples is kept before it is dropped. Positive. Default `5m`. */
226
+ max_stale?: Duration;
227
+ /** The most streams tracked at once; samples of new streams past it are dropped. Not negative. Default unlimited. */
228
+ max_streams?: number;
229
+ }
230
+
231
+ /**
232
+ * Turns delta metrics into cumulative ones by keeping a running total per
233
+ * stream. Exporters that read only cumulative data, such as
234
+ * `prometheusremotewrite`, need it after connectors that emit deltas (`sum`,
235
+ * `count`, `signaltometrics`). Cumulative metrics pass through unchanged.
236
+ */
237
+ export const DeltaToCumulativeProcessor = defineBuiltin<DeltaToCumulativeProcessorConfig, "processor", "deltatocumulative">({
238
+ kind: "processor",
239
+ type: "deltatocumulative",
240
+ description: "Converts delta metrics to cumulative by keeping a running total per stream",
241
+ validate: (c) => {
242
+ // config.go Validate.
243
+ const problems: string[] = [];
244
+ const stale = goDurationMs(c.max_stale, 5 * 60_000);
245
+ if (stale !== undefined && stale <= 0) problems.push(`max_stale must be a positive duration (got ${JSON.stringify(c.max_stale)})`);
246
+ if (c.max_streams !== undefined && (!Number.isInteger(c.max_streams) || c.max_streams < 0)) {
247
+ problems.push(`max_streams must be a whole number, 0 or more (got ${c.max_streams})`);
248
+ }
249
+ return problems;
250
+ },
251
+ });
@@ -35,6 +35,11 @@ export const OtlpReceiver = defineBuiltin<OtlpReceiverConfig, "receiver", "otlp"
35
35
  if (c.protocols && "http" in c.protocols) out.push(c.protocols.http?.endpoint ?? "localhost:4318");
36
36
  return out;
37
37
  },
38
+ // The HTTP receiver takes protobuf and JSON bodies on the same port.
39
+ protocols: (c) => [
40
+ ...(c.protocols && "grpc" in c.protocols ? ["grpc"] : []),
41
+ ...(c.protocols && "http" in c.protocols ? ["http/protobuf", "http/json"] : []),
42
+ ],
38
43
  });
39
44
 
40
45
  // ── prometheus ───────────────────────────────────────────────────────
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The composite catalog matches what this lexicon exports (#2662).
3
+ *
4
+ * `catalog.ts` is generated by `npm run generate:composite-catalogs` and is
5
+ * what `chant serve mcp` lists.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import { isCompositeDefinition } from "@intentius/chant";
10
+ import * as composites from "./index";
11
+ import { compositeCatalog } from "./catalog";
12
+ import { otelPlugin } from "../plugin";
13
+
14
+ const exported = Object.entries(composites)
15
+ .filter(([, value]) => isCompositeDefinition(value))
16
+ .map(([name]) => name)
17
+ .sort();
18
+ const catalogued = compositeCatalog.map((entry) => entry.name).sort();
19
+
20
+ describe("the otel composite catalog", () => {
21
+ test("every exported composite has an entry, and every entry names an exported composite", () => {
22
+ expect(exported).toEqual(["NodeAgent"]);
23
+ expect(catalogued, "regenerate with `npm run generate:composite-catalogs -- otel`").toEqual(exported);
24
+ });
25
+
26
+ test("every entry says what it is and names the components it bundles", () => {
27
+ for (const entry of compositeCatalog) {
28
+ expect(entry.lexicon, entry.name).toBe("otel");
29
+ expect(entry.description.length, entry.name).toBeGreaterThan(0);
30
+ expect(entry.bundles, entry.name).toContain("Pipeline");
31
+ expect(entry.bundles, entry.name).toContain("OtlpReceiver");
32
+ }
33
+ });
34
+
35
+ test("the plugin contributes it", () => {
36
+ expect(otelPlugin.composites?.()).toBe(compositeCatalog);
37
+ });
38
+ });
@@ -0,0 +1,81 @@
1
+ // Generated by scripts/generate-composite-catalogs.ts (#2662) from this
2
+ // lexicon's composite exports. Do not edit by hand: run
3
+ // `npm run generate:composite-catalogs`. catalog.test.ts holds it to the exports.
4
+ import type { CompositeEntry } from "@intentius/chant/lexicon";
5
+
6
+ export const compositeCatalog: CompositeEntry[] = [
7
+ {
8
+ "name": "NodeAgent",
9
+ "lexicon": "otel",
10
+ "description": "The collector config of a per-node Kubernetes agent.",
11
+ "bundles": [
12
+ "BatchProcessor",
13
+ "FileLogReceiver",
14
+ "HealthCheckExtension",
15
+ "HostMetricsReceiver",
16
+ "K8sAttributesProcessor",
17
+ "KubeletStatsReceiver",
18
+ "MemoryLimiterProcessor",
19
+ "OtlpReceiver",
20
+ "Pipeline",
21
+ "ResourceDetectionProcessor",
22
+ "ResourceProcessor"
23
+ ],
24
+ "params": [
25
+ {
26
+ "name": "exporters",
27
+ "type": "Exporter[]",
28
+ "required": true,
29
+ "description": "Where traces and logs go: usually the gateway (the k8s lexicon's `gatewayExporter()`), or an OTLP backend."
30
+ },
31
+ {
32
+ "name": "metricExporters",
33
+ "type": "Exporter[]",
34
+ "required": false,
35
+ "description": "Where metrics go, e.g. a `prometheus` exporter for a scrape on each node."
36
+ },
37
+ {
38
+ "name": "clusterName",
39
+ "type": "string",
40
+ "required": false,
41
+ "description": "Sets `k8s.cluster.name` on everything the agent sends."
42
+ },
43
+ {
44
+ "name": "nodeNameEnv",
45
+ "type": "string",
46
+ "required": false,
47
+ "description": "The environment variable holding this node's name, set from `spec.nodeName` (default `K8S_NODE_NAME`)."
48
+ },
49
+ {
50
+ "name": "hostMetrics",
51
+ "type": "boolean | { interval?: Duration; rootPath?: string }",
52
+ "required": false,
53
+ "description": "Node CPU, memory, load, filesystem and network from the host root at `/hostfs` (default: on, every 30s)."
54
+ },
55
+ {
56
+ "name": "containerLogs",
57
+ "type": "boolean | { selfContainer?: string }",
58
+ "required": false,
59
+ "description": "Container logs from `/var/log/pods` (default: on)."
60
+ },
61
+ {
62
+ "name": "kubeletStats",
63
+ "type": "boolean | { interval?: Duration }",
64
+ "required": false,
65
+ "description": "Pod and container resource metrics from this node's kubelet (default: off)."
66
+ },
67
+ {
68
+ "name": "memoryLimitMib",
69
+ "type": "number",
70
+ "required": false,
71
+ "description": "`memory_limiter`'s hard limit in MiB (default 400; the spike limit is a quarter of it)."
72
+ },
73
+ {
74
+ "name": "healthCheck",
75
+ "type": "boolean",
76
+ "required": false,
77
+ "description": "Serve `health_check` on 0.0.0.0:13133 (default: on)."
78
+ }
79
+ ]
80
+ }
81
+ ];