@intentius/chant-lexicon-otel 0.95.0 → 0.96.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 (107) hide show
  1. package/README.md +30 -9
  2. package/dist/catalog.d.ts +2 -1
  3. package/dist/catalog.d.ts.map +1 -1
  4. package/dist/codegen/docs.d.ts.map +1 -1
  5. package/dist/collector.d.ts +4 -1
  6. package/dist/collector.d.ts.map +1 -1
  7. package/dist/components/connectors.d.ts +156 -0
  8. package/dist/components/connectors.d.ts.map +1 -0
  9. package/dist/components/filtering.d.ts +95 -0
  10. package/dist/components/filtering.d.ts.map +1 -0
  11. package/dist/components/index.d.ts +4 -0
  12. package/dist/components/index.d.ts.map +1 -1
  13. package/dist/components/k8s-receivers.d.ts +74 -0
  14. package/dist/components/k8s-receivers.d.ts.map +1 -0
  15. package/dist/components/sampling.d.ts +260 -0
  16. package/dist/components/sampling.d.ts.map +1 -0
  17. package/dist/define.d.ts +31 -4
  18. package/dist/define.d.ts.map +1 -1
  19. package/dist/genai.d.ts +165 -0
  20. package/dist/genai.d.ts.map +1 -0
  21. package/dist/index.d.ts +5 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/integrity.json +8 -7
  24. package/dist/lint/audit-catalog.d.ts +2 -1
  25. package/dist/lint/audit-catalog.d.ts.map +1 -1
  26. package/dist/lint/post-synth/index.d.ts.map +1 -1
  27. package/dist/lint/post-synth/otel-helpers.d.ts +1 -1
  28. package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
  29. package/dist/lint/post-synth/otel101.d.ts +1 -1
  30. package/dist/lint/post-synth/otel103.d.ts +1 -1
  31. package/dist/lint/post-synth/otel112.d.ts +8 -0
  32. package/dist/lint/post-synth/otel112.d.ts.map +1 -0
  33. package/dist/manifest.json +1 -1
  34. package/dist/meta.json +70 -0
  35. package/dist/metric-names.d.ts +121 -0
  36. package/dist/metric-names.d.ts.map +1 -0
  37. package/dist/model.d.ts +23 -4
  38. package/dist/model.d.ts.map +1 -1
  39. package/dist/okf/index.md +15 -0
  40. package/dist/okf/rules/OTEL112.md +11 -0
  41. package/dist/okf/types/CountConnector.md +9 -0
  42. package/dist/okf/types/FilterProcessor.md +9 -0
  43. package/dist/okf/types/ForwardConnector.md +9 -0
  44. package/dist/okf/types/K8sClusterReceiver.md +9 -0
  45. package/dist/okf/types/KubeletStatsReceiver.md +9 -0
  46. package/dist/okf/types/LoadBalancingExporter.md +9 -0
  47. package/dist/okf/types/ProbabilisticSamplerProcessor.md +9 -0
  48. package/dist/okf/types/RedactionProcessor.md +9 -0
  49. package/dist/okf/types/RoutingConnector.md +9 -0
  50. package/dist/okf/types/ServiceGraphConnector.md +9 -0
  51. package/dist/okf/types/SpanMetricsConnector.md +9 -0
  52. package/dist/okf/types/SumConnector.md +9 -0
  53. package/dist/okf/types/TailSamplingProcessor.md +9 -0
  54. package/dist/okf/types/TransformProcessor.md +9 -0
  55. package/dist/pipeline.d.ts +9 -3
  56. package/dist/pipeline.d.ts.map +1 -1
  57. package/dist/plugin.d.ts +1 -1
  58. package/dist/rules/otel-helpers.ts +1 -1
  59. package/dist/rules/otel101.ts +1 -1
  60. package/dist/rules/otel103.ts +1 -1
  61. package/dist/rules/otel112.ts +17 -0
  62. package/dist/semconv.d.ts +33 -0
  63. package/dist/semconv.d.ts.map +1 -0
  64. package/dist/serializer.d.ts +1 -1
  65. package/dist/skills/chant-otel.md +28 -5
  66. package/dist/topology.d.ts +34 -5
  67. package/dist/topology.d.ts.map +1 -1
  68. package/dist/validate-config.d.ts +6 -2
  69. package/dist/validate-config.d.ts.map +1 -1
  70. package/dist/validate.d.ts.map +1 -1
  71. package/package.json +3 -3
  72. package/src/catalog.ts +2 -1
  73. package/src/codegen/docs.ts +14 -8
  74. package/src/collector.ts +9 -1
  75. package/src/components/connectors.ts +276 -0
  76. package/src/components/filtering.test.ts +192 -0
  77. package/src/components/filtering.ts +192 -0
  78. package/src/components/index.ts +4 -0
  79. package/src/components/k8s-receivers.test.ts +123 -0
  80. package/src/components/k8s-receivers.ts +107 -0
  81. package/src/components/sampling.test.ts +464 -0
  82. package/src/components/sampling.ts +521 -0
  83. package/src/connectors.test.ts +388 -0
  84. package/src/define.ts +36 -4
  85. package/src/genai.test.ts +526 -0
  86. package/src/genai.ts +466 -0
  87. package/src/generated/lexicon-otel.json +70 -0
  88. package/src/index.ts +39 -0
  89. package/src/lint/audit-catalog.ts +11 -2
  90. package/src/lint/post-synth/index.ts +2 -0
  91. package/src/lint/post-synth/otel-helpers.ts +1 -1
  92. package/src/lint/post-synth/otel101.ts +1 -1
  93. package/src/lint/post-synth/otel103.ts +1 -1
  94. package/src/lint/post-synth/otel112.ts +17 -0
  95. package/src/metric-names.test.ts +80 -0
  96. package/src/metric-names.ts +176 -0
  97. package/src/model.ts +26 -4
  98. package/src/otelcol-validate.test.ts +143 -0
  99. package/src/pipeline.ts +9 -3
  100. package/src/plugin.test.ts +1 -0
  101. package/src/plugin.ts +1 -1
  102. package/src/semconv.ts +63 -0
  103. package/src/serializer.ts +1 -1
  104. package/src/skills/chant-otel.md +28 -5
  105. package/src/topology.ts +54 -8
  106. package/src/validate-config.ts +92 -4
  107. package/src/validate.ts +8 -0
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * OTEL101: A pipeline uses a receiver, processor or exporter that is not declared
3
3
  *
4
- * The collector refuses to start on this. Typed entity references cannot go wrong this way, so it mostly catches id strings (`"otlp/backend"`) and hand-edited or parsed configs. A connector id is accepted in receivers and exporters.
4
+ * The collector refuses to start on this. Typed entity references cannot go wrong this way, so it mostly catches id strings (`"otlp/backend"`) and hand-edited or parsed configs. A connector id is accepted in receivers and exporters, but a connector counts as used only when one pipeline lists it as an exporter and another lists it as a receiver; listed on one side only, it fails this check.
5
5
  */
6
6
 
7
7
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * OTEL103: A declared component is never used
3
3
  *
4
- * A receiver, processor or exporter no pipeline lists, or an extension missing from service.extensions, is ignored by the collector. Usually it means a pipeline was meant to list it. A warning, since the config still runs.
4
+ * A receiver, processor, exporter or connector no pipeline lists, or an extension missing from service.extensions, is ignored by the collector. Usually it means a pipeline was meant to list it. A warning, since the config still runs.
5
5
  */
6
6
 
7
7
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * OTEL112: A connector joins pipelines whose signals it does not convert
3
+ *
4
+ * Each connector supports fixed signal pairs: `spanmetrics` and `servicegraph` read traces and write metrics, `count` writes metrics from any signal, `routing` and `forward` keep the signal. Every pipeline that feeds a connector must pair with a pipeline it feeds through one of those pairs, and the other way round, or the collector refuses to start. A traces pipeline that receives from `spanmetrics` fails this check. Custom connectors are checked when their `defineComponent` call lists `connects`.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { configDiagnostics } from "./otel-helpers";
9
+
10
+ export const otel112: PostSynthCheck = {
11
+ id: "OTEL112",
12
+ description: "A connector joins pipelines whose signals it does not convert",
13
+
14
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ return configDiagnostics(ctx, "OTEL112");
16
+ },
17
+ };
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { PrometheusExporter, SpanMetricsConnector } from "./components";
3
+ import { genAiComponents, genAiMetrics } from "./genai";
4
+ import { prometheusLabel, prometheusMetricName, spanMetricsNames } from "./metric-names";
5
+
6
+ describe("spanMetricsNames", () => {
7
+ test("defaults: the traces.span.metrics namespace and a millisecond histogram", () => {
8
+ const n = spanMetricsNames(new SpanMetricsConnector({}));
9
+ expect(n.namespace).toBe("traces.span.metrics");
10
+ expect(n.calls).toEqual({
11
+ name: "traces.span.metrics.calls",
12
+ prometheus: "traces_span_metrics_calls_total",
13
+ type: "sum",
14
+ dimensions: ["service.name", "span.name", "span.kind", "status.code"],
15
+ });
16
+ expect(n.duration?.prometheus).toBe("traces_span_metrics_duration_milliseconds");
17
+ expect(n.duration?.unit).toBe("ms");
18
+ expect(n.events).toBeUndefined();
19
+ expect(n.labels).toEqual({ service: "service_name", spanName: "span_name", spanKind: "span_kind", statusCode: "status_code" });
20
+ expect(n.errorStatus).toBe("STATUS_CODE_ERROR");
21
+ });
22
+
23
+ test("namespace, unit and dimensions come from the declaration", () => {
24
+ const connector = new SpanMetricsConnector({
25
+ namespace: "shop.spans",
26
+ dimensions: [{ name: "http.route" }],
27
+ calls_dimensions: [{ name: "peer.service" }],
28
+ exclude_dimensions: ["span.kind"],
29
+ histogram: { unit: "s", dimensions: [{ name: "http.method" }] },
30
+ events: { enabled: true, dimensions: [{ name: "exception.type" }] },
31
+ });
32
+ const n = spanMetricsNames(connector);
33
+ expect(n.calls.prometheus).toBe("shop_spans_calls_total");
34
+ expect(n.calls.dimensions).toEqual(["service.name", "span.name", "status.code", "http.route", "peer.service"]);
35
+ expect(n.duration?.prometheus).toBe("shop_spans_duration_seconds");
36
+ expect(n.duration?.dimensions).toEqual(["service.name", "span.name", "status.code", "http.route", "http.method"]);
37
+ expect(n.events?.prometheus).toBe("shop_spans_events_total");
38
+ expect(n.labels.spanKind).toBeUndefined();
39
+ });
40
+
41
+ test("an empty namespace means no prefix; a disabled histogram means no duration", () => {
42
+ const n = spanMetricsNames({ namespace: "", histogram: { disable: true } });
43
+ expect(n.calls.prometheus).toBe("calls_total");
44
+ expect(n.duration).toBeUndefined();
45
+ });
46
+
47
+ test("the exporter's namespace and suffix setting", () => {
48
+ const connector = new SpanMetricsConnector({ namespace: "spans" });
49
+ const exporter = new PrometheusExporter({ endpoint: "0.0.0.0:8889", namespace: "otel" });
50
+ expect(spanMetricsNames(connector, exporter).calls.prometheus).toBe("otel_spans_calls_total");
51
+ expect(spanMetricsNames(connector, { add_metric_suffixes: false }).duration?.prometheus).toBe("spans_duration");
52
+ });
53
+
54
+ test("only a spanmetrics connector", () => {
55
+ expect(() => spanMetricsNames(new PrometheusExporter({ endpoint: "0.0.0.0:8889" }) as never)).toThrow(/spanmetrics/);
56
+ });
57
+
58
+ test("agrees with genAiMetrics for the preset's own connector", () => {
59
+ for (const namespace of [undefined, "agents"]) {
60
+ const parts = genAiComponents({ namespace });
61
+ const n = spanMetricsNames(parts.spanMetrics);
62
+ const g = genAiMetrics({ namespace });
63
+ expect(n.calls.prometheus).toBe(g.calls.prometheus);
64
+ expect(n.duration?.prometheus).toBe(g.duration.prometheus);
65
+ expect(n.calls.dimensions).toEqual(g.calls.dimensions);
66
+ }
67
+ });
68
+ });
69
+
70
+ describe("prometheus naming", () => {
71
+ test("labels", () => {
72
+ expect(prometheusLabel("gen_ai.request.model")).toBe("gen_ai_request_model");
73
+ expect(prometheusLabel("0day")).toBe("key_0day");
74
+ });
75
+ test("suffixes are not doubled, and brace units add nothing", () => {
76
+ expect(prometheusMetricName("requests_total", "sum")).toBe("requests_total");
77
+ expect(prometheusMetricName("latency.seconds", "histogram", "s")).toBe("latency_seconds");
78
+ expect(prometheusMetricName("calls", "sum", "{call}")).toBe("calls_total");
79
+ });
80
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The metric names a collector emits, as the `prometheus` exporter serves
3
+ * them: `spanMetricsNames()` for a `spanmetrics` connector, and the naming
4
+ * rules `genAiMetrics()` uses too.
5
+ *
6
+ * A dashboard or an SLO reads names from here instead of repeating them, so
7
+ * renaming the connector's namespace (or the exporter's) moves every query
8
+ * built from it.
9
+ *
10
+ * Naming follows the collector's Prometheus translation at `COLLECTOR_PIN`
11
+ * with the exporter's defaults: `.` and any other character Prometheus does
12
+ * not allow become `_`; counters take `_total`; a unit becomes a suffix
13
+ * (`ms` is `_milliseconds`, `s` is `_seconds`); the exporter's `namespace`
14
+ * is a prefix joined with `_`; `add_metric_suffixes: false` drops the
15
+ * suffixes. Attribute names become label names the same way
16
+ * (`service.name` is `service_name`).
17
+ *
18
+ * This module holds types and string functions only, so a lexicon that
19
+ * builds queries from it does not load the collector components.
20
+ */
21
+
22
+ /** A metric a collector component emits, as the collector names it and as Prometheus exposes it. */
23
+ export interface CollectorMetric {
24
+ /** The OTLP metric name. */
25
+ name: string;
26
+ /** The name the `prometheus` exporter serves; histograms add `_bucket`, `_sum` and `_count`. */
27
+ prometheus: string;
28
+ type: "sum" | "histogram";
29
+ unit?: string;
30
+ /** Attribute names on its data points, beyond the resource. Prometheus labels replace `.` with `_`. */
31
+ dimensions: string[];
32
+ }
33
+
34
+ /** The four dimensions spanmetrics puts on every metric unless `exclude_dimensions` names them. */
35
+ export const SPANMETRICS_DEFAULT_DIMENSIONS: readonly string[] = Object.freeze(["service.name", "span.name", "span.kind", "status.code"]);
36
+
37
+ /** The spanmetrics namespace when the connector sets none. */
38
+ export const SPANMETRICS_DEFAULT_NAMESPACE = "traces.span.metrics";
39
+
40
+ /** The `status.code` value of a span that ended in error: the value an error-ratio query selects. */
41
+ export const SPAN_STATUS_ERROR = "STATUS_CODE_ERROR";
42
+
43
+ /** The Prometheus unit words for the units collector components use. */
44
+ const UNIT_WORDS: Record<string, string> = {
45
+ ms: "milliseconds",
46
+ s: "seconds",
47
+ us: "microseconds",
48
+ ns: "nanoseconds",
49
+ By: "bytes",
50
+ "1": "ratio",
51
+ };
52
+
53
+ /** How the `prometheus` exporter is configured, as far as naming goes. */
54
+ export interface PrometheusNaming {
55
+ /** The exporter's `namespace`, put in front of every name and joined with `_`. */
56
+ namespace?: string;
57
+ /** The exporter's `add_metric_suffixes` (default true): unit and `_total` suffixes. */
58
+ add_metric_suffixes?: boolean;
59
+ }
60
+
61
+ /** An attribute name as a Prometheus label: `service.name` is `service_name`. */
62
+ export function prometheusLabel(attribute: string): string {
63
+ const label = attribute.replace(/[^A-Za-z0-9_]/g, "_");
64
+ return /^[0-9]/.test(label) ? `key_${label}` : label;
65
+ }
66
+
67
+ /**
68
+ * An OTLP metric name as the `prometheus` exporter serves it.
69
+ *
70
+ * @param type `sum` (a monotonic counter, `_total`) or `histogram`.
71
+ * @param unit The metric's unit, e.g. `ms`. Units in braces (`{call}`) add nothing.
72
+ */
73
+ export function prometheusMetricName(name: string, type: "sum" | "histogram", unit?: string, naming: PrometheusNaming = {}): string {
74
+ let out = name.replace(/[^A-Za-z0-9_:]/g, "_");
75
+ if (naming.namespace) out = `${naming.namespace.replace(/[^A-Za-z0-9_:]/g, "_")}_${out}`;
76
+ if (naming.add_metric_suffixes === false) return out;
77
+ const word = unit && !unit.startsWith("{") ? (UNIT_WORDS[unit] ?? unit.replace(/[^A-Za-z0-9_:]/g, "_")) : undefined;
78
+ if (word && !out.endsWith(`_${word}`)) out = `${out}_${word}`;
79
+ if (type === "sum" && !out.endsWith("_total")) out = `${out}_total`;
80
+ return out;
81
+ }
82
+
83
+ /** The parts of a spanmetrics config the metric names depend on. */
84
+ export interface SpanMetricsNamingConfig {
85
+ namespace?: string;
86
+ dimensions?: Array<{ name: string }>;
87
+ calls_dimensions?: Array<{ name: string }>;
88
+ exclude_dimensions?: string[];
89
+ histogram?: { disable?: boolean; unit?: "ms" | "s"; dimensions?: Array<{ name: string }> };
90
+ events?: { enabled?: boolean; dimensions?: Array<{ name: string }> };
91
+ }
92
+
93
+ /** The metrics one `spanmetrics` connector emits, and the labels a query selects them by. */
94
+ export interface SpanMetricsNames {
95
+ /** The connector's namespace (empty: no prefix). */
96
+ namespace: string;
97
+ /** Span count; `status.code` = `STATUS_CODE_ERROR` selects the errors. */
98
+ calls: CollectorMetric;
99
+ /** Span duration histogram. Absent when `histogram.disable` is set. */
100
+ duration?: CollectorMetric;
101
+ /** Span event count. Present only when `events.enabled` is set. */
102
+ events?: CollectorMetric;
103
+ /**
104
+ * The default dimensions as Prometheus labels. One is absent when
105
+ * `exclude_dimensions` leaves it out, so a query can't select by it.
106
+ */
107
+ labels: { service?: string; spanName?: string; spanKind?: string; statusCode?: string };
108
+ /** The `status_code` value of an error span. */
109
+ errorStatus: string;
110
+ }
111
+
112
+ type Declared = { componentType?: unknown; props?: unknown };
113
+
114
+ function configOf(connector: SpanMetricsNamingConfig | Declared): SpanMetricsNamingConfig {
115
+ const d = connector as Declared;
116
+ if (typeof d.componentType === "string") {
117
+ if (d.componentType !== "spanmetrics") throw new Error(`spanMetricsNames: expected a spanmetrics connector, got ${d.componentType}`);
118
+ return (d.props ?? {}) as SpanMetricsNamingConfig;
119
+ }
120
+ return connector as SpanMetricsNamingConfig;
121
+ }
122
+
123
+ /**
124
+ * The Prometheus names of the metrics a `spanmetrics` connector emits, read
125
+ * from its declaration: its namespace, dimensions and histogram unit, and
126
+ * optionally the `prometheus` exporter's namespace and suffix setting.
127
+ *
128
+ * @example
129
+ * ```ts
130
+ * const spans = new SpanMetricsConnector({ namespace: "shop", histogram: { unit: "s" } });
131
+ * spanMetricsNames(spans).calls.prometheus; // "shop_calls_total"
132
+ * spanMetricsNames(spans).duration!.prometheus; // "shop_duration_seconds"
133
+ * ```
134
+ */
135
+ export function spanMetricsNames(
136
+ connector: SpanMetricsNamingConfig | Declared,
137
+ exporter?: PrometheusNaming | { props?: PrometheusNaming },
138
+ ): SpanMetricsNames {
139
+ const c = configOf(connector);
140
+ const naming: PrometheusNaming =
141
+ exporter && "props" in exporter && typeof exporter.props === "object" ? (exporter.props as PrometheusNaming) : ((exporter ?? {}) as PrometheusNaming);
142
+ const namespace = c.namespace ?? SPANMETRICS_DEFAULT_NAMESPACE;
143
+ const metricName = (n: string) => (namespace === "" ? n : `${namespace}.${n}`);
144
+ const excluded = new Set(c.exclude_dimensions ?? []);
145
+ const defaults = SPANMETRICS_DEFAULT_DIMENSIONS.filter((d) => !excluded.has(d));
146
+ const names = (extra?: Array<{ name: string }>) => (extra ?? []).map((d) => d.name);
147
+ const common = [...defaults, ...names(c.dimensions)];
148
+
149
+ const metric = (n: string, type: "sum" | "histogram", dims: string[], unit?: string): CollectorMetric => ({
150
+ name: metricName(n),
151
+ prometheus: prometheusMetricName(metricName(n), type, unit, naming),
152
+ type,
153
+ ...(unit ? { unit } : {}),
154
+ dimensions: dims,
155
+ });
156
+
157
+ const label = (attr: string) => (defaults.includes(attr) ? prometheusLabel(attr) : undefined);
158
+ const labels: SpanMetricsNames["labels"] = {};
159
+ const service = label("service.name");
160
+ const spanName = label("span.name");
161
+ const spanKind = label("span.kind");
162
+ const statusCode = label("status.code");
163
+ if (service) labels.service = service;
164
+ if (spanName) labels.spanName = spanName;
165
+ if (spanKind) labels.spanKind = spanKind;
166
+ if (statusCode) labels.statusCode = statusCode;
167
+
168
+ return {
169
+ namespace,
170
+ calls: metric("calls", "sum", [...common, ...names(c.calls_dimensions)]),
171
+ ...(c.histogram?.disable ? {} : { duration: metric("duration", "histogram", [...common, ...names(c.histogram?.dimensions)], c.histogram?.unit ?? "ms") }),
172
+ ...(c.events?.enabled ? { events: metric("events", "sum", [...common, ...names(c.events.dimensions)]) } : {}),
173
+ labels,
174
+ errorStatus: SPAN_STATUS_ERROR,
175
+ };
176
+ }
package/src/model.ts CHANGED
@@ -12,22 +12,44 @@
12
12
  export const SIGNALS = ["traces", "metrics", "logs"] as const;
13
13
  export type Signal = (typeof SIGNALS)[number];
14
14
 
15
- /** The component kinds this lexicon types. Connectors are not modeled yet. */
16
- export const COMPONENT_KINDS = ["receiver", "processor", "exporter", "extension"] as const;
15
+ /**
16
+ * The component kinds this lexicon types. A connector is an exporter in one
17
+ * pipeline and a receiver in another, which is how one pipeline feeds another
18
+ * (traces into span metrics, say).
19
+ */
20
+ export const COMPONENT_KINDS = ["receiver", "processor", "exporter", "connector", "extension"] as const;
17
21
  export type ComponentKind = (typeof COMPONENT_KINDS)[number];
18
22
 
23
+ /** A top-level config section that holds components. */
24
+ export type ComponentSection = "receivers" | "processors" | "exporters" | "connectors" | "extensions";
25
+
19
26
  /** The top-level config section each component kind lives under. */
20
- export const SECTION_OF: Record<ComponentKind, "receivers" | "processors" | "exporters" | "extensions"> = {
27
+ export const SECTION_OF: Record<ComponentKind, ComponentSection> = {
21
28
  receiver: "receivers",
22
29
  processor: "processors",
23
30
  exporter: "exporters",
31
+ connector: "connectors",
24
32
  extension: "extensions",
25
33
  };
26
34
 
35
+ /**
36
+ * One signal pair a connector supports: it is an exporter in a `from`
37
+ * pipeline and a receiver in a `to` pipeline. `spanmetrics` has one,
38
+ * traces to metrics; `forward` has one per signal. `profiles` is the
39
+ * collector's experimental fourth signal, which `count` reads.
40
+ */
41
+ export interface ConnectorSignalPair {
42
+ from: Signal | "profiles";
43
+ to: Signal | "profiles";
44
+ }
45
+
27
46
  /** A component id, `type` or `type/name`, exactly as the collector spells it. */
28
47
  export type ComponentId = string;
29
48
 
30
- /** One pipeline under `service.pipelines`. */
49
+ /**
50
+ * One pipeline under `service.pipelines`. `receivers` and `exporters` may name
51
+ * connectors as well as receivers and exporters.
52
+ */
31
53
  export interface PipelineConfig {
32
54
  receivers?: ComponentId[];
33
55
  processors?: ComponentId[];
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The filter, transform and redaction processors and the k8s_cluster and
3
+ * kubeletstats receivers, rendered into one config and checked by the real
4
+ * collector.
5
+ *
6
+ * The `otelcol validate` tests run when `otelcol-contrib` is on PATH (or
7
+ * `OTELCOL_BIN` names a contrib build) and skip otherwise; CI does not install
8
+ * it. The structural tests run everywhere.
9
+ */
10
+
11
+ import { describe, expect, test } from "vitest";
12
+ import { spawnSync } from "child_process";
13
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
14
+ import { tmpdir } from "os";
15
+ import { join } from "path";
16
+ import { load } from "js-yaml";
17
+ import type { Declarable } from "@intentius/chant/declarable";
18
+ import { collectorYaml } from "./collector";
19
+ import { validateCollectorConfig, validateCollectorEntities } from "./validate-config";
20
+ import type { CollectorConfig } from "./model";
21
+ import { Pipeline } from "./pipeline";
22
+ import {
23
+ DebugExporter,
24
+ FilterProcessor,
25
+ K8sClusterReceiver,
26
+ KubeletStatsReceiver,
27
+ OtlpReceiver,
28
+ RedactionProcessor,
29
+ TransformProcessor,
30
+ } from "./components";
31
+
32
+ function findOtelcol(): string | undefined {
33
+ const candidates = [process.env.OTELCOL_BIN, "otelcol-contrib"].filter((c): c is string => !!c);
34
+ for (const bin of candidates) {
35
+ const r = spawnSync(bin, ["--version"], { encoding: "utf-8" });
36
+ if (r.status === 0) return bin;
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ const OTELCOL = findOtelcol();
42
+
43
+ function otelcolValidate(yaml: string): { ok: boolean; output: string } {
44
+ const dir = mkdtempSync(join(tmpdir(), "chant-otelcol-"));
45
+ try {
46
+ const file = join(dir, "config.yaml");
47
+ writeFileSync(file, yaml);
48
+ // kubeletstats and k8s_cluster are built during validate; auth_type none keeps them off the Kubernetes API.
49
+ const r = spawnSync(OTELCOL!, ["validate", `--config=${file}`], { encoding: "utf-8", timeout: 60_000 });
50
+ return { ok: r.status === 0, output: `${r.stdout ?? ""}${r.stderr ?? ""}` };
51
+ } finally {
52
+ rmSync(dir, { recursive: true, force: true });
53
+ }
54
+ }
55
+
56
+ function fixture(): Declarable[] {
57
+ const otlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });
58
+ const cluster = new K8sClusterReceiver({
59
+ auth_type: "none",
60
+ collection_interval: "30s",
61
+ node_conditions_to_report: ["Ready", "MemoryPressure"],
62
+ allocatable_types_to_report: ["cpu", "memory", "pods"],
63
+ });
64
+ const kubelet = new KubeletStatsReceiver({
65
+ auth_type: "none",
66
+ endpoint: "http://localhost:10255",
67
+ metric_groups: ["node", "pod", "container"],
68
+ extra_metadata_labels: ["container.id"],
69
+ });
70
+ const dropHealth = new FilterProcessor({
71
+ name: "health",
72
+ error_mode: "ignore",
73
+ traces: { span: ['attributes["http.route"] == "/healthz"'] },
74
+ metrics: { datapoint: ['metric.name == "k8s.pod.phase" and value_int == 0'] },
75
+ logs: { log_record: ["severity_number < SEVERITY_NUMBER_INFO"] },
76
+ });
77
+ const tidy = new TransformProcessor({
78
+ error_mode: "ignore",
79
+ trace_statements: [
80
+ { context: "span", conditions: ["kind == SPAN_KIND_SERVER"], statements: ['set(attributes["tier"], "edge")'] },
81
+ 'delete_key(span.attributes, "http.request.header.cookie")',
82
+ ],
83
+ metric_statements: [{ context: "datapoint", statements: ['delete_key(attributes, "pod_ip")'] }],
84
+ log_statements: [{ context: "log", statements: ['set(severity_text, "WARN") where severity_number == 13'] }],
85
+ });
86
+ const scrub = new RedactionProcessor({
87
+ allow_all_keys: true,
88
+ blocked_key_patterns: ["^gen_ai\\.(prompt|completion)"],
89
+ blocked_values: ["4[0-9]{12}(?:[0-9]{3})?"],
90
+ allowed_values: [".+@example\\.com"],
91
+ hash_function: "sha3",
92
+ summary: "silent",
93
+ });
94
+ const debug = new DebugExporter({});
95
+ return [
96
+ otlp,
97
+ cluster,
98
+ kubelet,
99
+ dropHealth,
100
+ tidy,
101
+ scrub,
102
+ debug,
103
+ new Pipeline({ signal: "traces", receivers: [otlp], processors: [dropHealth, tidy, scrub], exporters: [debug] }),
104
+ new Pipeline({ signal: "metrics", receivers: [cluster, kubelet], processors: [dropHealth, tidy], exporters: [debug] }),
105
+ new Pipeline({ signal: "logs", receivers: [otlp], processors: [dropHealth, tidy, scrub], exporters: [debug] }),
106
+ ];
107
+ }
108
+
109
+ describe("rendered config with filter, transform, redaction, k8s_cluster and kubeletstats", () => {
110
+ test("passes the lexicon's own checks", () => {
111
+ const entities = fixture();
112
+ expect(validateCollectorEntities(entities)).toEqual([]);
113
+ const config = load(collectorYaml(entities)) as CollectorConfig;
114
+ expect(validateCollectorConfig(config)).toEqual([]);
115
+ expect(Object.keys(config.receivers ?? {})).toEqual(["otlp", "k8s_cluster", "kubeletstats"]);
116
+ expect(Object.keys(config.processors ?? {})).toEqual(["filter/health", "transform", "redaction"]);
117
+ expect(config.service?.pipelines?.metrics?.receivers).toEqual(["k8s_cluster", "kubeletstats"]);
118
+ });
119
+
120
+ test("OTTL and regexes reach the YAML unchanged", () => {
121
+ const config = load(collectorYaml(fixture())) as any;
122
+ expect(config.processors["filter/health"].traces.span).toEqual(['attributes["http.route"] == "/healthz"']);
123
+ expect(config.processors.transform.trace_statements[1]).toBe('delete_key(span.attributes, "http.request.header.cookie")');
124
+ expect(config.processors.redaction.blocked_key_patterns).toEqual(["^gen_ai\\.(prompt|completion)"]);
125
+ });
126
+
127
+ test.skipIf(!OTELCOL)("otelcol validate accepts it", () => {
128
+ const { ok, output } = otelcolValidate(collectorYaml(fixture()));
129
+ expect(output).toBe("");
130
+ expect(ok).toBe(true);
131
+ });
132
+
133
+ test.skipIf(!OTELCOL)("otelcol validate rejects OTTL written for the wrong context", () => {
134
+ const otlp = new OtlpReceiver({ protocols: { grpc: {} } });
135
+ const bad = new FilterProcessor({ traces: { span: ['metric.name == "x"'] } });
136
+ const debug = new DebugExporter({});
137
+ const { ok, output } = otelcolValidate(
138
+ collectorYaml([otlp, bad, debug, new Pipeline({ signal: "traces", receivers: [otlp], processors: [bad], exporters: [debug] })]),
139
+ );
140
+ expect(ok).toBe(false);
141
+ expect(output).toContain("unable to parse OTTL condition");
142
+ });
143
+ });
package/src/pipeline.ts CHANGED
@@ -5,6 +5,10 @@
5
5
  * keeps the reference checked by TypeScript, or by id string (`otlp/backend`)
6
6
  * for a component declared somewhere chant can't see. OTEL101 catches a
7
7
  * string that names nothing declared.
8
+ *
9
+ * A connector goes in `exporters` of the pipeline that feeds it and in
10
+ * `receivers` of the pipeline it feeds. The same entity on both sides is what
11
+ * joins the two pipelines.
8
12
  */
9
13
 
10
14
  import { createResource } from "@intentius/chant/runtime";
@@ -12,7 +16,7 @@ import type { Declarable } from "@intentius/chant/declarable";
12
16
  import type { OTelComponent } from "./define";
13
17
  import { componentId, type ComponentKind, type Signal } from "./model";
14
18
 
15
- /** A reference to a component of kind `K`: the declared entity, or its id. */
19
+ /** A reference to a component of kind `K` (or of any kind in a union): the declared entity, or its id. */
16
20
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
21
  export type ComponentRef<K extends ComponentKind> = OTelComponent<K, string, any> | string;
18
22
 
@@ -20,9 +24,11 @@ export interface PipelineProps {
20
24
  signal: Signal;
21
25
  /** The instance name. The pipeline id becomes `signal/name`. */
22
26
  name?: string;
23
- receivers: ComponentRef<"receiver">[];
27
+ /** Receivers, and connectors this pipeline takes data from. */
28
+ receivers: ComponentRef<"receiver" | "connector">[];
24
29
  processors?: ComponentRef<"processor">[];
25
- exporters: ComponentRef<"exporter">[];
30
+ /** Exporters, and connectors this pipeline hands data to. */
31
+ exporters: ComponentRef<"exporter" | "connector">[];
26
32
  }
27
33
 
28
34
  export interface PipelineEntity extends Declarable {
@@ -28,6 +28,7 @@ describe("otel plugin", () => {
28
28
  "OTEL107",
29
29
  "OTEL108",
30
30
  "OTEL109",
31
+ "OTEL112",
31
32
  ]);
32
33
  for (const id of ids) expect(id.startsWith("OTEL")).toBe(true);
33
34
  });
package/src/plugin.ts CHANGED
@@ -26,7 +26,7 @@ const catalogResource: McpResourceContribution = {
26
26
  /**
27
27
  * OpenTelemetry Collector lexicon plugin.
28
28
  *
29
- * Typed receivers, processors, exporters and extensions, pipelines and the
29
+ * Typed receivers, processors, exporters, connectors and extensions, pipelines and the
30
30
  * service block, serialized to one collector config file. A component chant
31
31
  * doesn't ship comes in through `defineComponent`, and is serialized and
32
32
  * checked the same way as the built-ins.
package/src/semconv.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Semantic-convention vocabularies a collector config can depend on, and how
3
+ * to tell that it does.
4
+ *
5
+ * A collector config has no metadata channel, so a config cannot say which
6
+ * semconv version its attribute keys follow. What it can show is that it uses
7
+ * a vocabulary: a `spanmetrics` dimension named `gen_ai.request.model`, an
8
+ * OTTL statement deleting `gen_ai.input.messages`. `semconvUsage()` finds
9
+ * those and pairs each vocabulary with the pin this package's keys follow, the
10
+ * same way `collectorTopology()` pairs a component type with the pin of the
11
+ * definition this process has. Reading a parsed YAML file and reading the
12
+ * declaration give the same answer.
13
+ */
14
+
15
+ import { GENAI_SEMCONV_PIN, type SchemaPin } from "./define";
16
+ import { SECTION_OF, type CollectorConfig, type ComponentKind } from "./model";
17
+
18
+ /** One attribute vocabulary and the version of its conventions this package follows. */
19
+ export interface SemconvVocabulary {
20
+ /** The attribute namespace, e.g. `gen_ai`. */
21
+ namespace: string;
22
+ pin: SchemaPin;
23
+ /** True when a string in a component's config refers to this namespace. */
24
+ matches: (text: string) => boolean;
25
+ }
26
+
27
+ // `gen_ai.` as a key, in OTTL (`attributes["gen_ai.x"]`) or in an RE2 pattern (`gen_ai\.`).
28
+ const GEN_AI_REF = /(^|[^A-Za-z0-9_])gen_ai(\\\\|\\)?\./;
29
+
30
+ export const SEMCONV_VOCABULARIES: ReadonlyArray<SemconvVocabulary> = Object.freeze([
31
+ { namespace: "gen_ai", pin: GENAI_SEMCONV_PIN, matches: (text: string) => GEN_AI_REF.test(text) },
32
+ ]);
33
+
34
+ /** A vocabulary a config uses, with the components that use it. */
35
+ export interface SemconvUsage extends SchemaPin {
36
+ namespace: string;
37
+ /** Component ids whose config names an attribute of this namespace, in config order. */
38
+ components: string[];
39
+ }
40
+
41
+ function mentions(value: unknown, test: (text: string) => boolean): boolean {
42
+ if (typeof value === "string") return test(value);
43
+ if (Array.isArray(value)) return value.some((v) => mentions(v, test));
44
+ if (value && typeof value === "object") {
45
+ return Object.entries(value as Record<string, unknown>).some(([k, v]) => test(k) || mentions(v, test));
46
+ }
47
+ return false;
48
+ }
49
+
50
+ /** The semconv vocabularies a collector config uses, each with its pin and the components that use it. */
51
+ export function semconvUsage(config: CollectorConfig): SemconvUsage[] {
52
+ const out: SemconvUsage[] = [];
53
+ for (const vocab of SEMCONV_VOCABULARIES) {
54
+ const components: string[] = [];
55
+ for (const kind of Object.keys(SECTION_OF) as ComponentKind[]) {
56
+ for (const [id, cfg] of Object.entries(config[SECTION_OF[kind]] ?? {})) {
57
+ if (mentions(cfg, vocab.matches)) components.push(id);
58
+ }
59
+ }
60
+ if (components.length > 0) out.push({ namespace: vocab.namespace, ...vocab.pin, components });
61
+ }
62
+ return out;
63
+ }
package/src/serializer.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Emits one collector config file from every otel entity in the build: the
5
5
  * YAML `otelcol --config` reads as it is. Section order is fixed (receivers,
6
- * processors, exporters, extensions, service); components and pipelines keep
6
+ * processors, exporters, connectors, extensions, service); components and pipelines keep
7
7
  * the order they are declared in, and each component's config keeps the key
8
8
  * order it was written in, since collector docs and diffs read that way.
9
9
  *