@intentius/chant-lexicon-otel 0.95.1 → 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
@@ -0,0 +1,192 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { load } from "js-yaml";
3
+ import type { Declarable } from "@intentius/chant/declarable";
4
+ import { otelSerializer } from "../serializer";
5
+ import { collectorYaml } from "../collector";
6
+ import { validateCollectorEntities } from "../validate-config";
7
+ import { COLLECTOR_PIN } from "../define";
8
+ import { DebugExporter } from "./exporters";
9
+ import { OtlpReceiver } from "./receivers";
10
+ import { FilterProcessor, RedactionProcessor, TransformProcessor } from "./filtering";
11
+ import { Pipeline } from "../pipeline";
12
+
13
+ function entities(record: Record<string, unknown>): Map<string, Declarable> {
14
+ return new Map(Object.entries(record) as Array<[string, Declarable]>);
15
+ }
16
+
17
+ function primary(out: ReturnType<typeof otelSerializer.serialize>): string {
18
+ return typeof out === "string" ? out : out.primary;
19
+ }
20
+
21
+ function problems(component: Declarable): string[] {
22
+ return validateCollectorEntities([component])
23
+ .filter((i) => i.code === "OTEL107")
24
+ .map((i) => i.message.replace(/^\w+ "[^"]+": /, ""));
25
+ }
26
+
27
+ describe("filter, transform and redaction are built-ins pinned to the collector release", () => {
28
+ test.each([
29
+ [FilterProcessor, "filter"],
30
+ [TransformProcessor, "transform"],
31
+ [RedactionProcessor, "redaction"],
32
+ ] as const)("%o", (Cls, type) => {
33
+ expect(Cls.definition.kind).toBe("processor");
34
+ expect(Cls.definition.type).toBe(type);
35
+ expect(Cls.definition.builtin).toBe(true);
36
+ expect(Cls.definition.pin).toBe(COLLECTOR_PIN);
37
+ });
38
+ });
39
+
40
+ describe("filter processor", () => {
41
+ test("serializes OTTL conditions per signal and context as strings", () => {
42
+ const filter = new FilterProcessor({
43
+ error_mode: "ignore",
44
+ traces: { span: ['attributes["http.route"] == "/healthz"'], spanevent: ['name == "debug"'] },
45
+ metrics: { datapoint: ['metric.name == "rpc.duration" and value_double < 0'] },
46
+ logs: { log_record: ["severity_number < SEVERITY_NUMBER_WARN"] },
47
+ });
48
+ expect(primary(otelSerializer.serialize(entities({ filter })))).toBe(`processors:
49
+ filter:
50
+ error_mode: ignore
51
+ traces:
52
+ span: ["attributes[\\"http.route\\"] == \\"/healthz\\""]
53
+ spanevent: [name == "debug"]
54
+ metrics:
55
+ datapoint: [metric.name == "rpc.duration" and value_double < 0]
56
+ logs:
57
+ log_record: [severity_number < SEVERITY_NUMBER_WARN]
58
+ `);
59
+ const parsed = load(primary(otelSerializer.serialize(entities({ filter })))) as any;
60
+ expect(parsed.processors.filter.traces.spanevent).toEqual(['name == "debug"']);
61
+ expect(parsed.processors.filter.metrics.datapoint).toEqual(['metric.name == "rpc.duration" and value_double < 0']);
62
+ });
63
+
64
+ test("a named instance renders under filter/name in a pipeline, conditions intact", () => {
65
+ const otlp = new OtlpReceiver({ protocols: { grpc: {} } });
66
+ const drop = new FilterProcessor({ name: "health", traces: { span: ['attributes["http.route"] == "/healthz"'] } });
67
+ const debug = new DebugExporter({});
68
+ const parsed = load(
69
+ collectorYaml([otlp, drop, debug, new Pipeline({ signal: "traces", receivers: [otlp], processors: [drop], exporters: [debug] })]),
70
+ ) as any;
71
+ expect(parsed.processors["filter/health"]).toEqual({ traces: { span: ['attributes["http.route"] == "/healthz"'] } });
72
+ expect(parsed.service.pipelines.traces.processors).toEqual(["filter/health"]);
73
+ });
74
+
75
+ test("a filter with no condition drops nothing, and says so", () => {
76
+ expect(problems(new FilterProcessor({}))).toEqual([
77
+ "no condition is set for any signal, so the processor drops nothing",
78
+ ]);
79
+ expect(problems(new FilterProcessor({ logs: { log_record: [" "] } }))).toContain(
80
+ "logs.log_record[0] is an empty condition",
81
+ );
82
+ expect(problems(new FilterProcessor({ logs: { log_record: ["true"] } }))).toEqual([]);
83
+ });
84
+ });
85
+
86
+ describe("transform processor", () => {
87
+ test("serializes statement groups per context, and bare statements", () => {
88
+ const transform = new TransformProcessor({
89
+ error_mode: "ignore",
90
+ trace_statements: [
91
+ {
92
+ context: "span",
93
+ conditions: ["kind == SPAN_KIND_SERVER"],
94
+ statements: ['set(attributes["tier"], "edge")'],
95
+ error_mode: "propagate",
96
+ },
97
+ 'delete_key(span.attributes, "http.request.header.cookie")',
98
+ ],
99
+ metric_statements: [{ context: "datapoint", statements: ['delete_key(attributes, "pod_ip")'] }],
100
+ log_statements: [{ statements: ['set(log.severity_text, "WARN") where log.severity_number == 13'] }],
101
+ });
102
+ const parsed = load(primary(otelSerializer.serialize(entities({ transform })))) as any;
103
+ expect(parsed.processors.transform).toEqual({
104
+ error_mode: "ignore",
105
+ trace_statements: [
106
+ {
107
+ context: "span",
108
+ conditions: ["kind == SPAN_KIND_SERVER"],
109
+ statements: ['set(attributes["tier"], "edge")'],
110
+ error_mode: "propagate",
111
+ },
112
+ 'delete_key(span.attributes, "http.request.header.cookie")',
113
+ ],
114
+ metric_statements: [{ context: "datapoint", statements: ['delete_key(attributes, "pod_ip")'] }],
115
+ log_statements: [{ statements: ['set(log.severity_text, "WARN") where log.severity_number == 13'] }],
116
+ });
117
+ });
118
+
119
+ test("the rendered text keeps each statement as one quoted string", () => {
120
+ const transform = new TransformProcessor({
121
+ log_statements: [{ context: "log", statements: ['set(attributes["a"], "b")'] }],
122
+ });
123
+ expect(collectorYaml([transform])).toBe(`processors:
124
+ transform:
125
+ log_statements:
126
+ - context: log
127
+ statements: ["set(attributes[\\"a\\"], \\"b\\")"]
128
+ `);
129
+ });
130
+
131
+ test("a context from another signal, an empty group and no statements are reported", () => {
132
+ const wrong = new TransformProcessor({
133
+ // @ts-expect-error: "log" is not a trace context; the check catches it for untyped callers too
134
+ trace_statements: [{ context: "log", statements: ["set(attributes[\"a\"], 1)"] }, { context: "span", statements: [] }],
135
+ });
136
+ expect(problems(wrong)).toEqual([
137
+ 'trace_statements[0] has context "log"; trace_statements runs in resource, scope, span, spanevent',
138
+ "trace_statements[1] has no statements",
139
+ ]);
140
+ expect(problems(new TransformProcessor({}))).toEqual([
141
+ "no statement is set for any signal, so the processor changes nothing",
142
+ ]);
143
+ });
144
+ });
145
+
146
+ describe("redaction processor", () => {
147
+ test("serializes allow and block lists and the hash function", () => {
148
+ const redaction = new RedactionProcessor({
149
+ allow_all_keys: false,
150
+ allowed_keys: ["http.method", "http.route"],
151
+ ignored_keys: ["service.name"],
152
+ blocked_key_patterns: [".*token.*"],
153
+ blocked_values: ["4[0-9]{12}(?:[0-9]{3})?"],
154
+ allowed_values: [".+@example\\.com"],
155
+ hash_function: "sha3",
156
+ summary: "debug",
157
+ });
158
+ expect(collectorYaml([redaction])).toBe(`processors:
159
+ redaction:
160
+ allow_all_keys: false
161
+ allowed_keys: [http.method, http.route]
162
+ ignored_keys: [service.name]
163
+ blocked_key_patterns: [.*token.*]
164
+ blocked_values: ["4[0-9]{12}(?:[0-9]{3})?"]
165
+ allowed_values: [.+@example\\.com]
166
+ hash_function: sha3
167
+ summary: debug
168
+ `);
169
+ const parsed = load(collectorYaml([redaction])) as any;
170
+ expect(parsed.processors.redaction.blocked_values).toEqual(["4[0-9]{12}(?:[0-9]{3})?"]);
171
+ expect(parsed.processors.redaction.allowed_values).toEqual([".+@example\\.com"]);
172
+ expect(problems(redaction)).toEqual([]);
173
+ });
174
+
175
+ test("with neither allow_all_keys nor allowed_keys every attribute would be deleted", () => {
176
+ expect(problems(new RedactionProcessor({ blocked_values: ["secret"] }))).toEqual([
177
+ "neither allow_all_keys nor allowed_keys is set, so every attribute is deleted; set allow_all_keys: true to keep keys, or allowed_keys: [] if deleting all is intended",
178
+ ]);
179
+ expect(problems(new RedactionProcessor({ allowed_keys: [] }))).toEqual([]);
180
+ expect(problems(new RedactionProcessor({ allow_all_keys: true, blocked_values: ["secret"] }))).toEqual([]);
181
+ });
182
+
183
+ test("contradictions and no-ops are reported", () => {
184
+ expect(problems(new RedactionProcessor({ allow_all_keys: true, allowed_keys: ["a"], hash_function: "md5" }))).toEqual([
185
+ "allowed_keys has no effect while allow_all_keys is true",
186
+ "hash_function is set but nothing is blocked, so nothing is hashed",
187
+ ]);
188
+ expect(problems(new RedactionProcessor({ allow_all_keys: true, blocked_values: [""] }))).toEqual([
189
+ "blocked_values[0] is an empty pattern",
190
+ ]);
191
+ });
192
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Built-in processors that drop, rewrite or redact telemetry: filter,
3
+ * transform, redaction.
4
+ *
5
+ * `filter` and `transform` take OTTL. chant types the structure around it
6
+ * (which signal, which context, which list) and keeps each OTTL condition or
7
+ * statement as a string; the collector parses them when it loads the config.
8
+ */
9
+
10
+ import { defineBuiltin } from "../define";
11
+
12
+ /** What an OTTL processor does when a condition or statement errors at run time. */
13
+ export type OttlErrorMode = "ignore" | "silent" | "propagate";
14
+
15
+ // ── filter ───────────────────────────────────────────────────────────
16
+
17
+ /** OTTL conditions per context. Telemetry matching any condition is dropped. */
18
+ export interface FilterProcessorConfig {
19
+ /** Default `propagate`: an erroring condition fails the batch. `ignore` logs and moves on. */
20
+ error_mode?: OttlErrorMode;
21
+ traces?: {
22
+ /** Drops the span, e.g. `attributes["http.route"] == "/healthz"`. */
23
+ span?: string[];
24
+ /** Drops the span event. */
25
+ spanevent?: string[];
26
+ };
27
+ metrics?: {
28
+ /** Drops the whole metric, e.g. `name == "http.server.duration" and type == METRIC_DATA_TYPE_HISTOGRAM`. */
29
+ metric?: string[];
30
+ /** Drops the data point. */
31
+ datapoint?: string[];
32
+ };
33
+ logs?: {
34
+ /** Drops the log record, e.g. `severity_number < SEVERITY_NUMBER_WARN`. */
35
+ log_record?: string[];
36
+ };
37
+ }
38
+
39
+ /** Drops spans, span events, metrics, data points and log records that match OTTL conditions. */
40
+ export const FilterProcessor = defineBuiltin<FilterProcessorConfig, "processor", "filter">({
41
+ kind: "processor",
42
+ type: "filter",
43
+ description: "Drops spans, metrics and logs that match OTTL conditions",
44
+ validate: (c) => {
45
+ const lists: Array<[string, string[] | undefined]> = [
46
+ ["traces.span", c.traces?.span],
47
+ ["traces.spanevent", c.traces?.spanevent],
48
+ ["metrics.metric", c.metrics?.metric],
49
+ ["metrics.datapoint", c.metrics?.datapoint],
50
+ ["logs.log_record", c.logs?.log_record],
51
+ ];
52
+ const problems: string[] = [];
53
+ for (const [path, list] of lists) {
54
+ (list ?? []).forEach((cond, i) => {
55
+ if (typeof cond !== "string" || cond.trim() === "") problems.push(`${path}[${i}] is an empty condition`);
56
+ });
57
+ }
58
+ if (lists.every(([, list]) => !list || list.length === 0)) {
59
+ problems.push("no condition is set for any signal, so the processor drops nothing");
60
+ }
61
+ return problems;
62
+ },
63
+ });
64
+
65
+ // ── transform ────────────────────────────────────────────────────────
66
+
67
+ /** OTTL contexts a trace statement group can run in. */
68
+ export type TraceStatementContext = "resource" | "scope" | "span" | "spanevent";
69
+ /** OTTL contexts a metric statement group can run in. */
70
+ export type MetricStatementContext = "resource" | "scope" | "metric" | "datapoint";
71
+ /** OTTL contexts a log statement group can run in. */
72
+ export type LogStatementContext = "resource" | "scope" | "log";
73
+
74
+ /**
75
+ * A group of OTTL statements run in one context. `conditions` gate every
76
+ * statement in the group; each statement may also carry its own `where`.
77
+ */
78
+ export interface TransformStatementGroup<Ctx extends string = string> {
79
+ /** The OTTL context. Leave it out and the collector infers it from the paths the statements use. */
80
+ context?: Ctx;
81
+ conditions?: string[];
82
+ statements: string[];
83
+ /** Overrides the processor's `error_mode` for this group. */
84
+ error_mode?: OttlErrorMode;
85
+ }
86
+
87
+ /** Each entry is a group, or a bare statement whose context the collector infers. */
88
+ export type TransformStatements<Ctx extends string> = Array<string | TransformStatementGroup<Ctx>>;
89
+
90
+ export interface TransformProcessorConfig {
91
+ /** Default `propagate`. */
92
+ error_mode?: OttlErrorMode;
93
+ trace_statements?: TransformStatements<TraceStatementContext>;
94
+ metric_statements?: TransformStatements<MetricStatementContext>;
95
+ log_statements?: TransformStatements<LogStatementContext>;
96
+ }
97
+
98
+ const TRANSFORM_CONTEXTS: Record<"trace_statements" | "metric_statements" | "log_statements", readonly string[]> = {
99
+ trace_statements: ["resource", "scope", "span", "spanevent"],
100
+ metric_statements: ["resource", "scope", "metric", "datapoint"],
101
+ log_statements: ["resource", "scope", "log"],
102
+ };
103
+
104
+ /** Rewrites telemetry with OTTL statements, grouped per signal and context. */
105
+ export const TransformProcessor = defineBuiltin<TransformProcessorConfig, "processor", "transform">({
106
+ kind: "processor",
107
+ type: "transform",
108
+ description: "Rewrites spans, metrics and logs with OTTL statements, grouped by context",
109
+ validate: (c) => {
110
+ const problems: string[] = [];
111
+ let total = 0;
112
+ for (const field of Object.keys(TRANSFORM_CONTEXTS) as Array<keyof typeof TRANSFORM_CONTEXTS>) {
113
+ const allowed = TRANSFORM_CONTEXTS[field];
114
+ (c[field] ?? []).forEach((entry, i) => {
115
+ if (typeof entry === "string") {
116
+ if (entry.trim() === "") problems.push(`${field}[${i}] is an empty statement`);
117
+ else total++;
118
+ return;
119
+ }
120
+ if (entry.context !== undefined && !allowed.includes(entry.context)) {
121
+ problems.push(`${field}[${i}] has context "${entry.context}"; ${field} runs in ${allowed.join(", ")}`);
122
+ }
123
+ if (!entry.statements || entry.statements.length === 0) {
124
+ problems.push(`${field}[${i}] has no statements`);
125
+ }
126
+ total += entry.statements?.length ?? 0;
127
+ });
128
+ }
129
+ if (total === 0) problems.push("no statement is set for any signal, so the processor changes nothing");
130
+ return problems;
131
+ },
132
+ });
133
+
134
+ // ── redaction ────────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Attribute keys and values to keep, mask or hash. Applies to span, span
138
+ * event, log record and metric data point attributes (not resource
139
+ * attributes).
140
+ *
141
+ * Keys: with `allow_all_keys` false, any attribute whose key is not in
142
+ * `allowed_keys` or `ignored_keys` is deleted. Values: a value matching a
143
+ * `blocked_values` pattern, or any value of a key matching a
144
+ * `blocked_key_patterns` pattern, is masked with `****`, or replaced by its
145
+ * hash when `hash_function` is set; `allowed_values` exempts a value from
146
+ * masking. Patterns are Go (RE2) regular expressions.
147
+ */
148
+ export interface RedactionProcessorConfig {
149
+ /** Keep every key, so only values are masked. Default false. */
150
+ allow_all_keys?: boolean;
151
+ /** The keys that survive when `allow_all_keys` is false. */
152
+ allowed_keys?: string[];
153
+ /** Keys never touched: not deleted, not masked. */
154
+ ignored_keys?: string[];
155
+ /** Regexes on keys; every value of a matching key is masked. */
156
+ blocked_key_patterns?: string[];
157
+ /** Regexes on values; the matching part is masked. */
158
+ blocked_values?: string[];
159
+ /** Regexes on values that are never masked, even when they match a blocked pattern. */
160
+ allowed_values?: string[];
161
+ /** Hash blocked values with this function instead of masking them. */
162
+ hash_function?: "md5" | "sha1" | "sha3";
163
+ /** How much the processor records about what it redacted, as span attributes. Default `info`. */
164
+ summary?: "debug" | "info" | "silent";
165
+ }
166
+
167
+ /** Deletes attributes whose keys aren't allowed, and masks or hashes values that match blocked patterns. */
168
+ export const RedactionProcessor = defineBuiltin<RedactionProcessorConfig, "processor", "redaction">({
169
+ kind: "processor",
170
+ type: "redaction",
171
+ description: "Deletes attributes not on an allow list and masks or hashes values that match block patterns",
172
+ validate: (c) => {
173
+ const problems: string[] = [];
174
+ if (c.allow_all_keys !== true && c.allowed_keys === undefined) {
175
+ problems.push(
176
+ "neither allow_all_keys nor allowed_keys is set, so every attribute is deleted; set allow_all_keys: true to keep keys, or allowed_keys: [] if deleting all is intended",
177
+ );
178
+ }
179
+ if (c.allow_all_keys === true && c.allowed_keys !== undefined && c.allowed_keys.length > 0) {
180
+ problems.push("allowed_keys has no effect while allow_all_keys is true");
181
+ }
182
+ for (const field of ["blocked_key_patterns", "blocked_values", "allowed_values"] as const) {
183
+ (c[field] ?? []).forEach((p, i) => {
184
+ if (typeof p !== "string" || p === "") problems.push(`${field}[${i}] is an empty pattern`);
185
+ });
186
+ }
187
+ if (c.hash_function !== undefined && !c.blocked_values?.length && !c.blocked_key_patterns?.length) {
188
+ problems.push("hash_function is set but nothing is blocked, so nothing is hashed");
189
+ }
190
+ return problems;
191
+ },
192
+ });
@@ -1,5 +1,9 @@
1
1
  export * from "./common";
2
2
  export * from "./receivers";
3
+ export * from "./k8s-receivers";
3
4
  export * from "./processors";
5
+ export * from "./filtering";
4
6
  export * from "./exporters";
7
+ export * from "./connectors";
5
8
  export * from "./extensions";
9
+ export * from "./sampling";
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { load } from "js-yaml";
3
+ import type { Declarable } from "@intentius/chant/declarable";
4
+ import { otelSerializer } from "../serializer";
5
+ import { collectorYaml } from "../collector";
6
+ import { collectorTopology } from "../topology";
7
+ import { validateCollectorEntities } from "../validate-config";
8
+ import { COLLECTOR_PIN } from "../define";
9
+ import { DebugExporter } from "./exporters";
10
+ import { K8sClusterReceiver, KubeletStatsReceiver } from "./k8s-receivers";
11
+ import { Pipeline } from "../pipeline";
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
+ function primary(out: ReturnType<typeof otelSerializer.serialize>): string {
20
+ return typeof out === "string" ? out : out.primary;
21
+ }
22
+
23
+ describe("k8s_cluster and kubeletstats are built-ins pinned to the collector release", () => {
24
+ test.each([
25
+ [K8sClusterReceiver, "k8s_cluster"],
26
+ [KubeletStatsReceiver, "kubeletstats"],
27
+ ] as const)("%o", (Cls, type) => {
28
+ expect(Cls.definition.kind).toBe("receiver");
29
+ expect(Cls.definition.type).toBe(type);
30
+ expect(Cls.definition.builtin).toBe(true);
31
+ expect(Cls.definition.pin).toBe(COLLECTOR_PIN);
32
+ });
33
+ });
34
+
35
+ describe("k8s_cluster receiver", () => {
36
+ test("serializes", () => {
37
+ const cluster = new K8sClusterReceiver({
38
+ auth_type: "serviceAccount",
39
+ collection_interval: "30s",
40
+ node_conditions_to_report: ["Ready", "MemoryPressure"],
41
+ allocatable_types_to_report: ["cpu", "memory"],
42
+ k8s_leader_elector: "k8s_leader_elector",
43
+ metrics: { "k8s.pod.status_reason": { enabled: true } },
44
+ });
45
+ const out = primary(otelSerializer.serialize(new Map([["cluster", cluster as unknown as Declarable]])));
46
+ expect(out).toBe(`receivers:
47
+ k8s_cluster:
48
+ auth_type: serviceAccount
49
+ collection_interval: 30s
50
+ node_conditions_to_report: [Ready, MemoryPressure]
51
+ allocatable_types_to_report: [cpu, memory]
52
+ k8s_leader_elector: k8s_leader_elector
53
+ metrics:
54
+ k8s.pod.status_reason:
55
+ enabled: true
56
+ `);
57
+ expect(problems(cluster)).toEqual([]);
58
+ });
59
+
60
+ test("renders in a metrics pipeline and reports no endpoint", () => {
61
+ const cluster = new K8sClusterReceiver({});
62
+ const debug = new DebugExporter({});
63
+ const yaml = collectorYaml([cluster, debug, new Pipeline({ signal: "metrics", receivers: [cluster], exporters: [debug] })]);
64
+ const parsed = load(yaml) as any;
65
+ expect(parsed.receivers).toEqual({ k8s_cluster: {} });
66
+ expect(parsed.service.pipelines.metrics.receivers).toEqual(["k8s_cluster"]);
67
+ const topo = collectorTopology(parsed);
68
+ expect(topo.components.find((c) => c.id === "k8s_cluster")?.endpoints ?? []).toEqual([]);
69
+ });
70
+
71
+ test("an unknown distribution is reported", () => {
72
+ // @ts-expect-error: not a distribution
73
+ expect(problems(new K8sClusterReceiver({ distribution: "eks" }))).toEqual([
74
+ 'distribution "eks" is not kubernetes or openshift',
75
+ ]);
76
+ });
77
+ });
78
+
79
+ describe("kubeletstats receiver", () => {
80
+ test("serializes the usual per-node agent shape, and reports the kubelet as its endpoint", () => {
81
+ const kubelet = new KubeletStatsReceiver({
82
+ auth_type: "serviceAccount",
83
+ endpoint: "https://${env:K8S_NODE_NAME}:10250",
84
+ insecure_skip_verify: true,
85
+ collection_interval: "20s",
86
+ metric_groups: ["node", "pod", "container"],
87
+ extra_metadata_labels: ["container.id"],
88
+ node: "${env:K8S_NODE_NAME}",
89
+ });
90
+ const yaml = collectorYaml([kubelet]);
91
+ expect(yaml).toBe(`receivers:
92
+ kubeletstats:
93
+ auth_type: serviceAccount
94
+ endpoint: https://\${env:K8S_NODE_NAME}:10250
95
+ insecure_skip_verify: true
96
+ collection_interval: 20s
97
+ metric_groups: [node, pod, container]
98
+ extra_metadata_labels: [container.id]
99
+ node: \${env:K8S_NODE_NAME}
100
+ `);
101
+ const topo = collectorTopology(load(yaml) as any);
102
+ expect(topo.components.find((c) => c.id === "kubeletstats")?.endpoints).toEqual([
103
+ "https://${env:K8S_NODE_NAME}:10250",
104
+ ]);
105
+ expect(problems(kubelet)).toEqual([]);
106
+ });
107
+
108
+ test("tls auth, the default, needs a client certificate", () => {
109
+ expect(problems(new KubeletStatsReceiver({}))).toEqual([
110
+ "auth_type defaults to tls, which needs cert_file and key_file; in a pod set auth_type: serviceAccount",
111
+ ]);
112
+ expect(problems(new KubeletStatsReceiver({ auth_type: "tls", cert_file: "/c" }))).toEqual([
113
+ "auth_type tls needs cert_file and key_file; in a pod set auth_type: serviceAccount",
114
+ ]);
115
+ expect(problems(new KubeletStatsReceiver({ auth_type: "tls", cert_file: "/c", key_file: "/k" }))).toEqual([]);
116
+ });
117
+
118
+ test("an empty metric_groups collects nothing", () => {
119
+ expect(problems(new KubeletStatsReceiver({ auth_type: "none", metric_groups: [] }))).toEqual([
120
+ "metric_groups is empty, so no kubelet metric is collected",
121
+ ]);
122
+ });
123
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Built-in Kubernetes receivers: k8s_cluster (cluster-level object metrics,
3
+ * one instance per cluster) and kubeletstats (node, pod and container
4
+ * resource metrics from each node's kubelet, one instance per node).
5
+ */
6
+
7
+ import { defineBuiltin } from "../define";
8
+ import type { Duration } from "./common";
9
+
10
+ /** How a receiver authenticates to the Kubernetes API or the kubelet. */
11
+ export type K8sAuthType = "none" | "serviceAccount" | "kubeConfig" | "tls";
12
+
13
+ /** Turns one generated metric or resource attribute on or off. */
14
+ export interface MetricToggle {
15
+ enabled: boolean;
16
+ }
17
+
18
+ // ── k8s_cluster ──────────────────────────────────────────────────────
19
+
20
+ export type K8sAllocatableType = "cpu" | "memory" | "ephemeral-storage" | "storage" | "pods";
21
+
22
+ export type K8sNodeCondition = "Ready" | "MemoryPressure" | "DiskPressure" | "PIDPressure" | "NetworkUnavailable";
23
+
24
+ export interface K8sClusterReceiverConfig {
25
+ /** Default `serviceAccount`. */
26
+ auth_type?: K8sAuthType;
27
+ /** Default `10s`. */
28
+ collection_interval?: Duration;
29
+ /** Default `[Ready]`. */
30
+ node_conditions_to_report?: Array<K8sNodeCondition | (string & {})>;
31
+ allocatable_types_to_report?: K8sAllocatableType[];
32
+ /** Default `kubernetes`. `openshift` adds cluster quota metrics. */
33
+ distribution?: "kubernetes" | "openshift";
34
+ /** Exporter ids that receive entity metadata updates. */
35
+ metadata_exporters?: string[];
36
+ metadata_collection_interval?: Duration;
37
+ /** The id of a `k8s_leader_elector` extension, so only the leader among replicas collects. */
38
+ k8s_leader_elector?: string;
39
+ /** Watch one namespace instead of the whole cluster. */
40
+ namespace?: string;
41
+ metrics?: Record<string, MetricToggle>;
42
+ resource_attributes?: Record<string, MetricToggle>;
43
+ }
44
+
45
+ /**
46
+ * Cluster-level metrics and entity events from the Kubernetes API. Run one
47
+ * instance per cluster (a single-replica Deployment, or behind
48
+ * `k8s_leader_elector`); on every node it reports each object once per node.
49
+ */
50
+ export const K8sClusterReceiver = defineBuiltin<K8sClusterReceiverConfig, "receiver", "k8s_cluster">({
51
+ kind: "receiver",
52
+ type: "k8s_cluster",
53
+ description: "Cluster-level metrics from the Kubernetes API; run one instance per cluster",
54
+ validate: (c) =>
55
+ c.distribution !== undefined && c.distribution !== "kubernetes" && c.distribution !== "openshift"
56
+ ? [`distribution "${String(c.distribution)}" is not kubernetes or openshift`]
57
+ : [],
58
+ });
59
+
60
+ // ── kubeletstats ─────────────────────────────────────────────────────
61
+
62
+ export type KubeletMetricGroup = "container" | "pod" | "node" | "volume";
63
+
64
+ export interface KubeletStatsReceiverConfig {
65
+ /** Default `10s`. */
66
+ collection_interval?: Duration;
67
+ initial_delay?: Duration;
68
+ /** Default `tls`, which needs `cert_file` and `key_file`. In a pod, use `serviceAccount`. */
69
+ auth_type?: K8sAuthType;
70
+ /** The kubelet, e.g. `https://${env:K8S_NODE_NAME}:10250`. */
71
+ endpoint?: string;
72
+ insecure_skip_verify?: boolean;
73
+ ca_file?: string;
74
+ cert_file?: string;
75
+ key_file?: string;
76
+ /** Default `[container, pod, node]`. */
77
+ metric_groups?: KubeletMetricGroup[];
78
+ /** Extra labels on container and volume metrics. */
79
+ extra_metadata_labels?: Array<"container.id" | "k8s.volume.type">;
80
+ /** Kubernetes API access, for persistent volume claim metadata. */
81
+ k8s_api_config?: { auth_type: K8sAuthType; context?: string };
82
+ /** The node name, for the limit and request utilization metrics; usually `${env:K8S_NODE_NAME}`. */
83
+ node?: string;
84
+ collect_all_network_interfaces?: { pod?: boolean; node?: boolean };
85
+ metrics?: Record<string, MetricToggle>;
86
+ resource_attributes?: Record<string, MetricToggle>;
87
+ }
88
+
89
+ /** Node, pod, container and volume resource metrics from the local kubelet. Run one instance per node. */
90
+ export const KubeletStatsReceiver = defineBuiltin<KubeletStatsReceiverConfig, "receiver", "kubeletstats">({
91
+ kind: "receiver",
92
+ type: "kubeletstats",
93
+ description: "Node, pod and container resource metrics from the kubelet; run one instance per node",
94
+ validate: (c) => {
95
+ const problems: string[] = [];
96
+ if ((c.auth_type ?? "tls") === "tls" && (!c.cert_file || !c.key_file)) {
97
+ problems.push(
98
+ `auth_type ${c.auth_type ? "tls" : "defaults to tls, which"} needs cert_file and key_file; in a pod set auth_type: serviceAccount`,
99
+ );
100
+ }
101
+ if (c.metric_groups !== undefined && c.metric_groups.length === 0) {
102
+ problems.push("metric_groups is empty, so no kubelet metric is collected");
103
+ }
104
+ return problems;
105
+ },
106
+ endpoints: (c) => (c.endpoint ? [c.endpoint] : []),
107
+ });