@intentius/chant-lexicon-otel 0.102.0 → 0.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +1 -1
  2. package/dist/attribution.d.ts +69 -0
  3. package/dist/attribution.d.ts.map +1 -0
  4. package/dist/codegen/docs.d.ts.map +1 -1
  5. package/dist/components/processors.d.ts +1 -1
  6. package/dist/components/processors.d.ts.map +1 -1
  7. package/dist/components/sampling.d.ts +3 -0
  8. package/dist/components/sampling.d.ts.map +1 -1
  9. package/dist/config-hygiene.d.ts +33 -0
  10. package/dist/config-hygiene.d.ts.map +1 -0
  11. package/dist/define.d.ts +18 -2
  12. package/dist/define.d.ts.map +1 -1
  13. package/dist/import/generator.d.ts +5 -1
  14. package/dist/import/generator.d.ts.map +1 -1
  15. package/dist/import/parser.d.ts +1 -1
  16. package/dist/import/parser.d.ts.map +1 -1
  17. package/dist/index.d.ts +4 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/integrity.json +15 -5
  20. package/dist/lint/audit-catalog.d.ts +3 -2
  21. package/dist/lint/audit-catalog.d.ts.map +1 -1
  22. package/dist/lint/post-synth/index.d.ts.map +1 -1
  23. package/dist/lint/post-synth/otel-helpers.d.ts +2 -2
  24. package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
  25. package/dist/lint/post-synth/otel118.d.ts +8 -0
  26. package/dist/lint/post-synth/otel118.d.ts.map +1 -0
  27. package/dist/lint/post-synth/otel119.d.ts +8 -0
  28. package/dist/lint/post-synth/otel119.d.ts.map +1 -0
  29. package/dist/lint/post-synth/otel120.d.ts +8 -0
  30. package/dist/lint/post-synth/otel120.d.ts.map +1 -0
  31. package/dist/lint/post-synth/otel121.d.ts +8 -0
  32. package/dist/lint/post-synth/otel121.d.ts.map +1 -0
  33. package/dist/lint/post-synth/otel122.d.ts +8 -0
  34. package/dist/lint/post-synth/otel122.d.ts.map +1 -0
  35. package/dist/lint/post-synth/otel123.d.ts +8 -0
  36. package/dist/lint/post-synth/otel123.d.ts.map +1 -0
  37. package/dist/lint/post-synth/otel124.d.ts +8 -0
  38. package/dist/lint/post-synth/otel124.d.ts.map +1 -0
  39. package/dist/lint/post-synth/otel125.d.ts +8 -0
  40. package/dist/lint/post-synth/otel125.d.ts.map +1 -0
  41. package/dist/lint/post-synth/otel126.d.ts +8 -0
  42. package/dist/lint/post-synth/otel126.d.ts.map +1 -0
  43. package/dist/lint/post-synth/otel127.d.ts +8 -0
  44. package/dist/lint/post-synth/otel127.d.ts.map +1 -0
  45. package/dist/lint/rules/literal-credential.d.ts.map +1 -1
  46. package/dist/manifest.json +1 -1
  47. package/dist/metric-names.d.ts +29 -0
  48. package/dist/metric-names.d.ts.map +1 -1
  49. package/dist/model.d.ts +24 -0
  50. package/dist/model.d.ts.map +1 -1
  51. package/dist/okf/index.md +10 -0
  52. package/dist/okf/rules/OTEL118.md +11 -0
  53. package/dist/okf/rules/OTEL119.md +11 -0
  54. package/dist/okf/rules/OTEL120.md +11 -0
  55. package/dist/okf/rules/OTEL121.md +11 -0
  56. package/dist/okf/rules/OTEL122.md +11 -0
  57. package/dist/okf/rules/OTEL123.md +11 -0
  58. package/dist/okf/rules/OTEL124.md +11 -0
  59. package/dist/okf/rules/OTEL125.md +11 -0
  60. package/dist/okf/rules/OTEL126.md +11 -0
  61. package/dist/okf/rules/OTEL127.md +11 -0
  62. package/dist/platform.d.ts +11 -8
  63. package/dist/platform.d.ts.map +1 -1
  64. package/dist/rules/literal-credential.ts +2 -3
  65. package/dist/rules/otel-helpers.ts +15 -4
  66. package/dist/rules/otel118.ts +17 -0
  67. package/dist/rules/otel119.ts +17 -0
  68. package/dist/rules/otel120.ts +17 -0
  69. package/dist/rules/otel121.ts +17 -0
  70. package/dist/rules/otel122.ts +17 -0
  71. package/dist/rules/otel123.ts +17 -0
  72. package/dist/rules/otel124.ts +17 -0
  73. package/dist/rules/otel125.ts +17 -0
  74. package/dist/rules/otel126.ts +17 -0
  75. package/dist/rules/otel127.ts +17 -0
  76. package/dist/semconv.d.ts +2 -1
  77. package/dist/semconv.d.ts.map +1 -1
  78. package/dist/skills/chant-otel.md +6 -2
  79. package/dist/validate-config.d.ts +27 -9
  80. package/dist/validate-config.d.ts.map +1 -1
  81. package/package.json +2 -2
  82. package/src/attribution.ts +385 -0
  83. package/src/codegen/docs.ts +8 -1
  84. package/src/components/k8s-receivers.test.ts +5 -2
  85. package/src/components/processors.ts +1 -1
  86. package/src/components/sampling.ts +3 -0
  87. package/src/composites/composites.test.ts +16 -0
  88. package/src/config-hygiene.ts +470 -0
  89. package/src/connectors.test.ts +3 -0
  90. package/src/define.ts +25 -4
  91. package/src/import/generator.test.ts +42 -4
  92. package/src/import/generator.ts +7 -2
  93. package/src/import/parser.test.ts +11 -0
  94. package/src/import/parser.ts +16 -4
  95. package/src/import/roundtrip.test.ts +90 -14
  96. package/src/index.ts +7 -0
  97. package/src/lint/audit-catalog.ts +87 -2
  98. package/src/lint/post-synth/hygiene.test.ts +334 -0
  99. package/src/lint/post-synth/index.ts +20 -0
  100. package/src/lint/post-synth/otel-helpers.ts +15 -4
  101. package/src/lint/post-synth/otel118.test.ts +308 -0
  102. package/src/lint/post-synth/otel118.ts +17 -0
  103. package/src/lint/post-synth/otel119.ts +17 -0
  104. package/src/lint/post-synth/otel120.ts +17 -0
  105. package/src/lint/post-synth/otel121.ts +17 -0
  106. package/src/lint/post-synth/otel122.ts +17 -0
  107. package/src/lint/post-synth/otel123.ts +17 -0
  108. package/src/lint/post-synth/otel124.ts +17 -0
  109. package/src/lint/post-synth/otel125.ts +17 -0
  110. package/src/lint/post-synth/otel126.ts +17 -0
  111. package/src/lint/post-synth/otel127.ts +17 -0
  112. package/src/lint/post-synth/post-synth.test.ts +18 -0
  113. package/src/lint/rules/literal-credential.ts +2 -3
  114. package/src/metric-names.test.ts +24 -2
  115. package/src/metric-names.ts +66 -1
  116. package/src/model.ts +48 -0
  117. package/src/platform.test.ts +35 -0
  118. package/src/platform.ts +28 -19
  119. package/src/plugin.test.ts +10 -0
  120. package/src/semconv.ts +6 -2
  121. package/src/skills/chant-otel.md +6 -2
  122. package/src/validate-config.ts +81 -16
@@ -21,11 +21,13 @@
21
21
 
22
22
  import * as jsYaml from "js-yaml";
23
23
  import type { TemplateIR, TemplateParser } from "@intentius/chant/import/parser";
24
- import { GENAI_SEMCONV_PIN, type SchemaPin } from "../define";
24
+ import type { SchemaPin } from "../define";
25
+ import { SEMCONV_VOCABULARIES } from "../semconv";
25
26
  import {
26
27
  COMPONENT_KINDS,
27
28
  SECTION_OF,
28
29
  SIGNALS,
30
+ canonicalComponentType,
29
31
  parseComponentId,
30
32
  pipelineSignal,
31
33
  type CollectorConfig,
@@ -104,10 +106,11 @@ function readHeader(content: string, warnings: string[]): { pins: HeaderPin[]; s
104
106
  if (sc) {
105
107
  const [, namespace, source, version, digest] = sc;
106
108
  semconv.push({ namespace, pin: digest ? { source, version, digest } : { source, version } });
107
- if (namespace === "gen_ai" && (source !== GENAI_SEMCONV_PIN.source || version !== GENAI_SEMCONV_PIN.version)) {
109
+ const ours = SEMCONV_VOCABULARIES.find((v) => v.namespace === namespace)?.pin;
110
+ if (ours && (source !== ours.source || version !== ours.version)) {
108
111
  warnings.push(
109
112
  `the config was built against ${namespace} semantic conventions ${source}@${version}; ` +
110
- `this lexicon follows ${GENAI_SEMCONV_PIN.source}@${GENAI_SEMCONV_PIN.version}, which the rebuilt config will name`,
113
+ `this lexicon follows ${ours.source}@${ours.version}, which the rebuilt config will name`,
111
114
  );
112
115
  }
113
116
  continue;
@@ -141,7 +144,16 @@ export function parseCollectorYaml(content: string): ParsedCollector {
141
144
  if (key === "service") continue;
142
145
  const section: Record<string, Record<string, unknown> | null> = {};
143
146
  for (const [id, cfg] of Object.entries(value)) {
144
- if (!parseComponentId(id)) warnings.push(`${key}: "${id}" is not a type[/name] component id`);
147
+ const parsed = parseComponentId(id);
148
+ if (!parsed) warnings.push(`${key}: "${id}" is not a type[/name] component id`);
149
+ const builtin = parsed && canonicalComponentType(KIND_OF_SECTION[key], parsed.type);
150
+ if (parsed && builtin && builtin !== parsed.type) {
151
+ warnings.push(
152
+ `${key}.${id} uses "${parsed.type}", the collector's newer name for "${builtin}"; it imports as the built-in, which writes "${builtin}"`,
153
+ );
154
+ const renamed = parsed.name === undefined ? builtin : `${builtin}/${parsed.name}`;
155
+ if (renamed in value) warnings.push(`${key}.${id} and ${key}.${renamed} become the same id "${renamed}"; rename one of them`);
156
+ }
145
157
  if (cfg !== null && cfg !== undefined && !isPlainObject(cfg)) {
146
158
  warnings.push(`${key}.${id} is not a mapping; it is carried as an empty config`);
147
159
  section[id] = null;
@@ -21,7 +21,7 @@ import { spawnSync } from "child_process";
21
21
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
22
22
  import { tmpdir } from "os";
23
23
  import { join } from "path";
24
- import { load } from "js-yaml";
24
+ import { dump, load } from "js-yaml";
25
25
  import { build } from "@intentius/chant/build";
26
26
  import { lintCommand } from "@intentius/chant/cli/commands/lint";
27
27
  import { importCommand, importFromContent } from "@intentius/chant/cli/commands/import";
@@ -29,7 +29,7 @@ import { otelSerializer } from "../serializer";
29
29
  import { collectorYaml } from "../collector";
30
30
  import { registeredDefinitions } from "../define";
31
31
  import { genAiPipeline } from "../genai";
32
- import { SECTION_OF, type CollectorConfig, type ComponentKind } from "../model";
32
+ import { COMPONENT_TYPE_ALIASES, SECTION_OF, type CollectorConfig, type ComponentKind } from "../model";
33
33
  import * as c from "../components";
34
34
  import { OtelCollectorParser } from "./parser";
35
35
  import { OtelCollectorGenerator } from "./generator";
@@ -230,23 +230,99 @@ describe("YAML -> TypeScript -> YAML", () => {
230
230
  }
231
231
 
232
232
  test.skipIf(!OTELCOL)("otelcol validate accepts every re-emitted config", async () => {
233
- // Not the agent-observability agent: its k8s resolver needs a cluster to build (that example's own tests swap it for dns).
234
- const yamls: Array<[string, string]> = [
233
+ // file_storage and hostmetrics need their directories to exist, so fault-tolerant-logs gets temp ones in place of /var/lib/otelcol.
234
+ const storage = mkdtempSync(join(tmpdir(), "chant-otelcol-storage-"));
235
+ try {
236
+ for (const d of ["receiver", "output"]) mkdirSync(join(storage, d));
237
+ // Not the agent-observability agent: its k8s resolver needs a cluster to build (that example's own tests swap it for dns).
238
+ const yamls: Array<[string, string]> = [
239
+ ["gateway.yaml", read("gateway.yaml")],
240
+ ["signaltometrics.yaml", read("signaltometrics.yaml")],
241
+ ["agent-observability-gateway.yaml", read("agent-observability-gateway.yaml")],
242
+ ["genAiPipeline()", collectorYaml(genAiPipeline())],
243
+ ...[...UPSTREAM_BUILTIN_ONLY, "loadbalancing-agent.yaml", "servicegraph-nop.yaml"].map((f): [string, string] => [f, read("upstream", f)]),
244
+ ["fault-tolerant-logs.yaml", read("upstream", "fault-tolerant-logs.yaml").replaceAll("/var/lib/otelcol/file_storage", storage)],
245
+ // hostmetrics' root_path is accepted on linux only, which is where the node agent runs.
246
+ // It must exist, so the node agent's /hostfs becomes the temp dir.
247
+ ...(await exampleOutputs())
248
+ .filter(([n]) => n !== "k8s-node-agent" || process.platform === "linux")
249
+ .map(([n, y]): [string, string] => [n, y.replaceAll("/hostfs", storage)]),
250
+ ];
251
+ for (const [name, yaml] of yamls) {
252
+ const { yaml: rebuilt } = await importAndBuild(yaml);
253
+ const { ok, output } = otelcolValidate(rebuilt);
254
+ expect(ok, `${name}: ${output}`).toBe(true);
255
+ }
256
+ } finally {
257
+ rmSync(storage, { recursive: true, force: true });
258
+ }
259
+ });
260
+
261
+ // couchbase fails validate upstream at the pinned release; a pin bump that fixes it shows here.
262
+ test.skipIf(!OTELCOL)("otelcol validate rejects couchbase.yaml as it does upstream", async () => {
263
+ const { yaml: rebuilt } = await importAndBuild(read("upstream", "couchbase.yaml"));
264
+ const { ok, output } = otelcolValidate(rebuilt);
265
+ expect(ok).toBe(false);
266
+ expect(output).toContain('undefined function "convert_gauge_to_sum"');
267
+ });
268
+
269
+ });
270
+
271
+ /** `yaml` with every renamed built-in written under the collector's newer name (`spanmetrics` as `span_metrics`). */
272
+ function withNewNames(yaml: string): string {
273
+ const doc = (load(yaml) ?? {}) as CollectorConfig;
274
+ const renamed = new Map<string, string>();
275
+ for (const kind of Object.keys(SECTION_OF) as ComponentKind[]) {
276
+ const section = doc[SECTION_OF[kind]];
277
+ if (!section) continue;
278
+ const out: Record<string, unknown> = {};
279
+ for (const [id, cfg] of Object.entries(section)) {
280
+ const slash = id.indexOf("/");
281
+ const type = slash === -1 ? id : id.slice(0, slash);
282
+ const alias = COMPONENT_TYPE_ALIASES.find((a) => a.kind === kind && a.builtin === type);
283
+ const next = alias ? `${alias.type}${slash === -1 ? "" : id.slice(slash)}` : id;
284
+ if (alias) renamed.set(`${kind}:${id}`, next);
285
+ out[next] = cfg;
286
+ }
287
+ (doc as Record<string, unknown>)[SECTION_OF[kind]] = out;
288
+ }
289
+ // A receiver and an exporter may share an id (`otlp`), so each list renames only its own kinds.
290
+ const kindsOf = { receivers: ["receiver", "connector"], processors: ["processor"], exporters: ["exporter", "connector"] } as const;
291
+ for (const p of Object.values(doc.service?.pipelines ?? {})) {
292
+ for (const list of ["receivers", "processors", "exporters"] as const) {
293
+ p[list] = p[list]?.map((id) => kindsOf[list].map((k) => renamed.get(`${k}:${id}`)).find(Boolean) ?? id);
294
+ }
295
+ }
296
+ return dump(doc, { lineWidth: -1 });
297
+ }
298
+
299
+ describe("the collector's newer names for renamed built-ins", () => {
300
+ test("import to the same classes as the old names, and build back to the old names", async () => {
301
+ const fixtures: Array<[string, string]> = [
302
+ ["every built-in", collectorYaml(everyBuiltin())],
235
303
  ["gateway.yaml", read("gateway.yaml")],
236
304
  ["signaltometrics.yaml", read("signaltometrics.yaml")],
237
- ["agent-observability-gateway.yaml", read("agent-observability-gateway.yaml")],
238
- ["genAiPipeline()", collectorYaml(genAiPipeline())],
239
- ...UPSTREAM_BUILTIN_ONLY.map((f): [string, string] => [f, read("upstream", f)]),
240
- // hostmetrics' root_path is accepted on linux only, which is where the node agent runs.
241
- ...(await exampleOutputs()).filter(([n]) => n !== "k8s-node-agent" || process.platform === "linux"),
242
305
  ];
243
- for (const [name, yaml] of yamls) {
244
- const { yaml: rebuilt } = await importAndBuild(yaml);
245
- const { ok, output } = otelcolValidate(rebuilt);
246
- expect(ok, `${name}: ${output}`).toBe(true);
306
+ const used = new Set<string>();
307
+ for (const [name, yaml] of fixtures) {
308
+ const renamedYaml = withNewNames(yaml);
309
+ if (renamedYaml === dump(load(yaml), { lineWidth: -1 })) continue;
310
+ const before = await importAndBuild(yaml);
311
+ const after = await importAndBuild(renamedYaml);
312
+ expect(after.buildErrors, name).toEqual([]);
313
+ const classes = (src: string) => [...src.matchAll(/new ([A-Z]\w+)\(/g)].map((m) => m[1]).sort();
314
+ expect(classes(after.source), name).toEqual(classes(before.source));
315
+ expect(after.source, name).not.toContain("defineComponent<");
316
+ // Emitted names stay the ones the pinned collector knows.
317
+ expect(normalize(after.yaml), name).toEqual(normalize(yaml));
318
+ for (const w of after.warnings) {
319
+ const m = /uses "(\w+)", the collector's newer name for "(\w+)"/.exec(w);
320
+ if (m) used.add(m[1]);
321
+ }
247
322
  }
323
+ // The every-built-in fixture uses all twelve renamed types.
324
+ expect([...used].sort()).toEqual(COMPONENT_TYPE_ALIASES.map((a) => a.type).sort());
248
325
  });
249
-
250
326
  });
251
327
 
252
328
  describe("components chant does not ship", () => {
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  componentEntityType,
33
33
  isOTelComponent,
34
34
  COLLECTOR_PIN,
35
+ SEMCONV_PIN,
35
36
  GENAI_SEMCONV_PIN,
36
37
  type SchemaPin,
37
38
  type SafeParseSchema,
@@ -54,6 +55,7 @@ export {
54
55
  type CollectorIssue,
55
56
  type CollectorIssueCode,
56
57
  } from "./validate-config";
58
+ export { K8S_ATTRIBUTES_METADATA, RESOURCE_DETECTORS } from "./config-hygiene";
57
59
  export {
58
60
  collectorTopology,
59
61
  collectorTopologyOf,
@@ -65,6 +67,7 @@ export {
65
67
  type SemconvUsage,
66
68
  } from "./topology";
67
69
  export { semconvUsage, SEMCONV_VOCABULARIES, type SemconvVocabulary } from "./semconv";
70
+ export { attributionIssues, isProtectedResourceAttribute, PROTECTED_RESOURCE_ATTRIBUTES } from "./attribution";
68
71
 
69
72
  // Composites
70
73
  export { NodeAgent, nodeAgentPropsProblem, type NodeAgentProps, type NodeAgentMembers, type NodeAgentInstance } from "./composites";
@@ -121,6 +124,10 @@ export {
121
124
  SPANMETRICS_DEFAULT_DIMENSIONS,
122
125
  SPANMETRICS_DEFAULT_NAMESPACE,
123
126
  SPAN_STATUS_ERROR,
127
+ SERVICEGRAPH_NAMESPACE,
128
+ serviceGraphNames,
129
+ type ServiceGraphNames,
130
+ type ServiceGraphNamingConfig,
124
131
  type CollectorMetric,
125
132
  type SpanMetricsNames,
126
133
  type SpanMetricsNamingConfig,
@@ -2,8 +2,9 @@
2
2
  * The otel lexicon's chant audit catalog, contributed via
3
3
  * `otelPlugin.auditCatalog()` (#687, #1346).
4
4
  *
5
- * OTEL101-OTEL106 and OTEL112-OTEL117 read the emitted collector YAML, so they are
6
- * `yamlBased`.
5
+ * OTEL101-OTEL106, OTEL112-OTEL117 and OTEL119-OTEL127 read the emitted collector YAML, so they are
6
+ * `yamlBased`. OTEL118 reads it too, but only in a build that stamps
7
+ * telemetry attribution, so it is not.
7
8
  * OTEL107-OTEL109 read the declared entities (a component's definition, its
8
9
  * schema pin), which a standalone YAML file does not carry, so they are
9
10
  * constructed with `yamlBased: false`. The two source-level lint rules are
@@ -148,4 +149,88 @@ export const otelAuditCatalog: Record<string, RuleMeta> = {
148
149
  "Give one of them another port, or a specific host that doesn't overlap the other's. The collector's own metrics listen on localhost:8888 unless service.telemetry.metrics sets a reader or level none.",
149
150
  { category: "correctness" },
150
151
  ),
152
+ // Runs only in a build that stamps telemetry attribution, which a standalone YAML file never is.
153
+ OTEL118: {
154
+ ...auditRule(
155
+ "OTEL118",
156
+ "report-only",
157
+ "guidance",
158
+ "Pipeline processor can remove or replace a telemetry attribution key",
159
+ "Leave service.name, service.version, deployment.environment.name, vcs.ref.head.revision and chant.* keys alone: use insert rather than upsert or update, keep them in keep_keys and in redaction's allowed_keys, and set override: false on resourcedetection.",
160
+ { category: "correctness" },
161
+ ),
162
+ yamlBased: false,
163
+ },
164
+ OTEL119: auditRule(
165
+ "OTEL119",
166
+ "report-only",
167
+ "guidance",
168
+ "Collector config sets a field deprecated before the pinned release",
169
+ "Replace invert_match with a drop policy, service.telemetry.metrics.address with readers, and spanmetrics dimensions_cache_size with aggregation_cardinality_limit.",
170
+ { category: "best-practice" },
171
+ ),
172
+ OTEL120: auditRule(
173
+ "OTEL120",
174
+ "merge-worthy",
175
+ "guidance",
176
+ "Collector credential written as a literal in the config",
177
+ 'Replace the value with `${env:NAME}` or `${file:/path}` so the collector reads it at start-up.',
178
+ { category: "security" },
179
+ ),
180
+ OTEL121: auditRule(
181
+ "OTEL121",
182
+ "merge-worthy",
183
+ "guidance",
184
+ "Exporter sends a credential over plaintext",
185
+ "Use an https:// endpoint, or drop tls.insecure, for an exporter that sends an authorization header or API key.",
186
+ { category: "security" },
187
+ ),
188
+ OTEL122: auditRule(
189
+ "OTEL122",
190
+ "merge-worthy",
191
+ "guidance",
192
+ "zpages or pprof listens on a non-loopback address",
193
+ "Bind zpages and pprof to localhost and reach them with a port-forward.",
194
+ { category: "security" },
195
+ ),
196
+ OTEL123: auditRule(
197
+ "OTEL123",
198
+ "report-only",
199
+ "guidance",
200
+ "debug exporter at verbosity detailed beside a real exporter",
201
+ "Set the debug exporter to verbosity basic, or keep detailed output to a pipeline that exports nowhere else.",
202
+ { category: "security" },
203
+ ),
204
+ OTEL124: auditRule(
205
+ "OTEL124",
206
+ "report-only",
207
+ "guidance",
208
+ "Remote exporter has its sending queue or retries turned off",
209
+ "Remove sending_queue.enabled: false and retry_on_failure.enabled: false from exporters that send over the network.",
210
+ { category: "best-practice" },
211
+ ),
212
+ OTEL125: auditRule(
213
+ "OTEL125",
214
+ "report-only",
215
+ "guidance",
216
+ "Pipeline sends to a remote otlp or otlphttp exporter without batching",
217
+ "Add a batch processor to the pipeline, or set sending_queue.batch on the exporter.",
218
+ { category: "best-practice" },
219
+ ),
220
+ OTEL126: auditRule(
221
+ "OTEL126",
222
+ "merge-worthy",
223
+ "guidance",
224
+ "k8sattributes extracts an unsupported metadata field",
225
+ "Use a field from the k8sattributes list at the pinned release, or extract it as a label or annotation.",
226
+ { category: "correctness" },
227
+ ),
228
+ OTEL127: auditRule(
229
+ "OTEL127",
230
+ "merge-worthy",
231
+ "guidance",
232
+ "resourcedetection lists an unknown detector",
233
+ "Name detectors as the processor registers them (elastic_beanstalk, not elasticbeanstalk), or remove the entry.",
234
+ { category: "correctness" },
235
+ ),
151
236
  };
@@ -0,0 +1,334 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { makePostSynthCtx } from "@intentius/chant-test-utils";
3
+ import { dump, load } from "js-yaml";
4
+ import type { Declarable } from "@intentius/chant/declarable";
5
+ import { otel119 } from "./otel119";
6
+ import { otel120 } from "./otel120";
7
+ import { otel121 } from "./otel121";
8
+ import { otel122 } from "./otel122";
9
+ import { otel123 } from "./otel123";
10
+ import { otel124 } from "./otel124";
11
+ import { otel125 } from "./otel125";
12
+ import { otel126 } from "./otel126";
13
+ import { otel127 } from "./otel127";
14
+ import { collectorConfigDiagnostics } from "./otel-helpers";
15
+ import { collectorYaml } from "../../collector";
16
+ import { otlpCollector } from "../../platform";
17
+ import { genAiPipeline } from "../../genai";
18
+ import { NodeAgent } from "../../composites";
19
+ import { OtlpExporter, OtlpHttpExporter, PrometheusExporter } from "../../components";
20
+ import { validateCollectorConfig } from "../../validate-config";
21
+ import type { CollectorConfig } from "../../model";
22
+
23
+ const run = (check: { check: typeof otel119.check }, config: object) => check.check(makePostSynthCtx("otel", dump(config, { lineWidth: -1 })));
24
+ const HYGIENE = ["OTEL119", "OTEL120", "OTEL121", "OTEL122", "OTEL123", "OTEL124", "OTEL125", "OTEL126", "OTEL127"];
25
+
26
+ /** A started otlp receiver and the given exporters, all in one traces pipeline with batch. */
27
+ function withExporters(exporters: Record<string, object>, extra: { processors?: string[] } = {}) {
28
+ return {
29
+ receivers: { otlp: { protocols: { grpc: {} } } },
30
+ processors: { batch: {} },
31
+ exporters,
32
+ service: { pipelines: { traces: { receivers: ["otlp"], processors: extra.processors ?? ["batch"], exporters: Object.keys(exporters) } } },
33
+ };
34
+ }
35
+
36
+ describe("OTEL119 deprecated fields", () => {
37
+ const tailSampling = (policies: object[]) => ({
38
+ receivers: { otlp: { protocols: { grpc: {} } } },
39
+ processors: { tail_sampling: { policies } },
40
+ exporters: { debug: {} },
41
+ service: { pipelines: { traces: { receivers: ["otlp"], processors: ["tail_sampling"], exporters: ["debug"] } } },
42
+ });
43
+
44
+ test("reports invert_match: true in a policy and in a sub-policy", () => {
45
+ const diags = run(
46
+ otel119,
47
+ tailSampling([
48
+ { name: "not-health", type: "string_attribute", string_attribute: { key: "http.route", values: ["/health"], invert_match: true } },
49
+ { name: "and", type: "and", and: { and_sub_policy: [{ name: "n", type: "numeric_attribute", numeric_attribute: { key: "k", min_value: 1, invert_match: true } }] } },
50
+ ]),
51
+ );
52
+ expect(diags.map((d) => d.message)).toEqual([
53
+ expect.stringContaining('policy "not-health" sets string_attribute.invert_match; inverted decisions are deprecated since collector-contrib v0.126.0'),
54
+ expect.stringContaining('policy "and" sets and.and_sub_policy[0].numeric_attribute.invert_match'),
55
+ ]);
56
+ expect(diags.every((d) => d.checkId === "OTEL119" && d.severity === "warning" && d.entity === "tail_sampling")).toBe(true);
57
+ });
58
+
59
+ test("passes invert_match: false and a drop policy", () => {
60
+ expect(
61
+ run(
62
+ otel119,
63
+ tailSampling([
64
+ { name: "a", type: "boolean_attribute", boolean_attribute: { key: "k", value: true, invert_match: false } },
65
+ { name: "drop-health", type: "drop", drop: { drop_sub_policy: [{ name: "h", type: "string_attribute", string_attribute: { key: "http.route", values: ["/health"] } }] } },
66
+ ]),
67
+ ),
68
+ ).toEqual([]);
69
+ });
70
+
71
+ test("reports service.telemetry.metrics.address and passes readers", () => {
72
+ const base = withExporters({ debug: {} });
73
+ const address = run(otel119, { ...base, service: { ...base.service, telemetry: { metrics: { address: "localhost:8889" } } } });
74
+ expect(address).toHaveLength(1);
75
+ expect(address[0].message).toContain("deprecated since collector v0.111.0");
76
+ const readers = { readers: [{ pull: { exporter: { prometheus: { host: "localhost", port: 8889 } } } }] };
77
+ expect(run(otel119, { ...base, service: { ...base.service, telemetry: { metrics: readers } } })).toEqual([]);
78
+ });
79
+
80
+ test("reports spanmetrics dimensions_cache_size", () => {
81
+ const config = {
82
+ receivers: { otlp: { protocols: { grpc: {} } } },
83
+ exporters: { debug: {} },
84
+ connectors: { "spanmetrics/a": { dimensions_cache_size: 1000 }, "spanmetrics/b": { aggregation_cardinality_limit: 1000 } },
85
+ service: {
86
+ pipelines: {
87
+ traces: { receivers: ["otlp"], exporters: ["spanmetrics/a", "spanmetrics/b"] },
88
+ metrics: { receivers: ["spanmetrics/a", "spanmetrics/b"], exporters: ["debug"] },
89
+ },
90
+ },
91
+ };
92
+ const diags = run(otel119, config);
93
+ expect(diags.map((d) => d.entity)).toEqual(["spanmetrics/a"]);
94
+ expect(diags[0].message).toContain("aggregation_cardinality_limit");
95
+ });
96
+ });
97
+
98
+ describe("OTEL120 literal credential in the config", () => {
99
+ test("reports a literal header, key and nested password, as an error", () => {
100
+ const diags = run(
101
+ otel120,
102
+ withExporters({
103
+ "otlphttp/vendor": { endpoint: "https://api.vendor.example", headers: { authorization: "Bearer abc123" } },
104
+ "splunk_hec/x": { endpoint: "https://splunk.example:8088", token: "00000000-0000" },
105
+ }),
106
+ );
107
+ expect(diags.map((d) => [d.entity, d.message.match(/at (\S+);/)?.[1]])).toEqual([
108
+ ["otlphttp/vendor", "headers.authorization"],
109
+ ["splunk_hec/x", "token"],
110
+ ]);
111
+ expect(diags[0]).toMatchObject({ checkId: "OTEL120", severity: "error" });
112
+ });
113
+
114
+ test("looks in every section and inside lists", () => {
115
+ const config = {
116
+ receivers: { prometheus: { config: { scrape_configs: [{ job_name: "x", basic_auth: { username: "u", password: "hunter2" } }] } } },
117
+ exporters: { debug: {} },
118
+ extensions: { bearertokenauth: { token: "literal" } },
119
+ service: { extensions: ["bearertokenauth"], pipelines: { metrics: { receivers: ["prometheus"], exporters: ["debug"] } } },
120
+ };
121
+ expect(run(otel120, config).map((d) => d.message.match(/at (\S+);/)?.[1])).toEqual(["config.scrape_configs.basic_auth.password", "token"]);
122
+ });
123
+
124
+ test("passes ${env:...} and ${file:...} references, _file keys, and empty values", () => {
125
+ expect(
126
+ run(
127
+ otel120,
128
+ withExporters({
129
+ "otlphttp/vendor": { endpoint: "https://api.vendor.example", headers: { authorization: "Bearer ${env:TOKEN}", "api-key": "${file:/secrets/key}" } },
130
+ "otlp/b": { endpoint: "backend:4317", tls: { ca_file: "/ca.pem", key_file: "/key.pem" }, headers: { "x-api-key": "" } },
131
+ }),
132
+ ),
133
+ ).toEqual([]);
134
+ });
135
+
136
+ test("WK8604's entry point reports it for a config in a ConfigMap", () => {
137
+ const config = withExporters({ "otlphttp/vendor": { endpoint: "https://api.vendor.example", headers: { "api-key": "abc" } } });
138
+ const configMap = { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "otel-config", namespace: "observability" }, data: { "config.yaml": dump(config) } };
139
+ const diags = collectorConfigDiagnostics(makePostSynthCtx("k8s", dump(configMap, { lineWidth: -1 })), { configMapsOnly: true }).filter((d) => d.checkId === "OTEL120");
140
+ expect(diags).toHaveLength(1);
141
+ expect(diags[0].message).toMatch(/^ConfigMap observability\/otel-config, key config\.yaml: exporter "otlphttp\/vendor" has a literal credential at headers\.api-key/);
142
+ });
143
+ });
144
+
145
+ describe("OTEL121 credential over plaintext", () => {
146
+ test("reports an auth header over tls.insecure and an api key over http://", () => {
147
+ const diags = run(
148
+ otel121,
149
+ withExporters({
150
+ "otlp/gw": { endpoint: "gateway.observability.svc:4317", tls: { insecure: true }, headers: { authorization: "Bearer ${env:TOKEN}" } },
151
+ "otlphttp/v": { endpoint: "http://vendor.example:4318", headers: { "x-api-key": "${env:KEY}" } },
152
+ }),
153
+ );
154
+ expect(diags.map((d) => [d.entity, d.severity])).toEqual([
155
+ ["otlp/gw", "warning"],
156
+ ["otlphttp/v", "warning"],
157
+ ]);
158
+ expect(diags[0].message).toContain("sends headers.authorization to gateway.observability.svc:4317 over tls.insecure: true");
159
+ expect(diags[1].message).toContain("over an http:// endpoint");
160
+ });
161
+
162
+ test("passes TLS, a credential-free plaintext exporter, loopback, and https with tls.insecure", () => {
163
+ expect(
164
+ run(
165
+ otel121,
166
+ withExporters({
167
+ "otlp/tls": { endpoint: "backend:4317", headers: { authorization: "Bearer ${env:T}" } },
168
+ "otlp/plain": { endpoint: "tempo:4317", tls: { insecure: true } },
169
+ "otlphttp/local": { endpoint: "http://localhost:4318", headers: { authorization: "Bearer ${env:T}" } },
170
+ "otlphttp/https": { endpoint: "https://vendor.example", tls: { insecure: true }, headers: { authorization: "Bearer ${env:T}" } },
171
+ }),
172
+ ),
173
+ ).toEqual([]);
174
+ });
175
+ });
176
+
177
+ describe("OTEL122 zpages and pprof off loopback", () => {
178
+ const withExtensions = (extensions: Record<string, object>, enabled = Object.keys(extensions)) => ({
179
+ ...withExporters({ debug: {} }),
180
+ extensions,
181
+ service: { extensions: enabled, pipelines: { traces: { receivers: ["otlp"], processors: ["batch"], exporters: ["debug"] } } },
182
+ });
183
+
184
+ test("reports zpages on 0.0.0.0 and pprof on a pod IP", () => {
185
+ const diags = run(otel122, withExtensions({ zpages: { endpoint: "0.0.0.0:55679" }, "pprof/x": { endpoint: "${env:POD_IP}:1777" } }));
186
+ expect(diags.map((d) => [d.entity, d.severity])).toEqual([
187
+ ["zpages", "warning"],
188
+ ["pprof/x", "warning"],
189
+ ]);
190
+ expect(diags[0].message).toContain("listens on 0.0.0.0:55679");
191
+ });
192
+
193
+ test("passes the localhost defaults, 127.0.0.1, an extension that never starts, and health_check on 0.0.0.0", () => {
194
+ expect(
195
+ run(
196
+ otel122,
197
+ withExtensions(
198
+ { zpages: {}, pprof: { endpoint: "127.0.0.1:1777" }, "zpages/off": { endpoint: "0.0.0.0:55680" }, health_check: { endpoint: "0.0.0.0:13133" } },
199
+ ["zpages", "pprof", "health_check"],
200
+ ),
201
+ ),
202
+ ).toEqual([]);
203
+ });
204
+ });
205
+
206
+ describe("OTEL123 detailed debug beside a real exporter", () => {
207
+ test("reports debug at detailed in a pipeline that also sends to a backend", () => {
208
+ const diags = run(otel123, withExporters({ debug: { verbosity: "detailed" }, "otlp/b": { endpoint: "backend:4317" } }));
209
+ expect(diags).toHaveLength(1);
210
+ expect(diags[0]).toMatchObject({ checkId: "OTEL123", severity: "warning", entity: "debug" });
211
+ expect(diags[0].message).toContain('exports to "otlp/b" and to "debug" at verbosity: detailed');
212
+ });
213
+
214
+ test("passes detailed debug on its own, and basic debug beside a backend", () => {
215
+ expect(run(otel123, withExporters({ debug: { verbosity: "detailed" } }))).toEqual([]);
216
+ expect(run(otel123, withExporters({ debug: { verbosity: "basic" }, "otlp/b": { endpoint: "backend:4317" } }))).toEqual([]);
217
+ });
218
+ });
219
+
220
+ describe("OTEL124 queue or retry off for a remote exporter", () => {
221
+ test("reports each one turned off", () => {
222
+ const diags = run(otel124, withExporters({ "otlp/b": { endpoint: "backend:4317", sending_queue: { enabled: false }, retry_on_failure: { enabled: false } } }));
223
+ expect(diags.map((d) => d.message)).toEqual([
224
+ expect.stringContaining("sending_queue.enabled: false"),
225
+ expect.stringContaining("retry_on_failure.enabled: false"),
226
+ ]);
227
+ expect(diags.every((d) => d.severity === "warning")).toBe(true);
228
+ });
229
+
230
+ test("passes a loopback endpoint, a prometheus exporter, and the defaults", () => {
231
+ expect(
232
+ run(
233
+ otel124,
234
+ withExporters({
235
+ "otlp/sidecar": { endpoint: "localhost:4317", sending_queue: { enabled: false } },
236
+ prometheus: { endpoint: "0.0.0.0:8889", sending_queue: { enabled: false } },
237
+ "otlp/b": { endpoint: "backend:4317", sending_queue: { enabled: true, queue_size: 5000 } },
238
+ }),
239
+ ),
240
+ ).toEqual([]);
241
+ });
242
+ });
243
+
244
+ describe("OTEL125 no batching before a remote otlp exporter", () => {
245
+ test("reports otlp and otlphttp in a pipeline with no batch processor", () => {
246
+ const diags = run(otel125, withExporters({ "otlp/b": { endpoint: "backend:4317" }, otlphttp: { endpoint: "${env:OTLP_ENDPOINT}" } }, { processors: [] }));
247
+ expect(diags.map((d) => [d.entity, d.severity])).toEqual([
248
+ ["otlp/b", "warning"],
249
+ ["otlphttp", "warning"],
250
+ ]);
251
+ expect(diags[0].message).toContain('pipeline "traces" sends to "otlp/b" (backend:4317) with no batch processor');
252
+ });
253
+
254
+ test("passes a batch processor, named or not, sending_queue.batch, loopback, and other exporters", () => {
255
+ expect(run(otel125, withExporters({ "otlp/b": { endpoint: "backend:4317" } }))).toEqual([]);
256
+ const named = withExporters({ "otlp/b": { endpoint: "backend:4317" } }, { processors: ["batch/big"] });
257
+ expect(run(otel125, { ...named, processors: { "batch/big": {} } })).toEqual([]);
258
+ expect(
259
+ run(
260
+ otel125,
261
+ withExporters(
262
+ {
263
+ "otlp/q": { endpoint: "backend:4317", sending_queue: { batch: { flush_timeout: "1s" } } },
264
+ "otlp/local": { endpoint: "127.0.0.1:4317" },
265
+ "splunk_hec/x": { endpoint: "https://splunk.example:8088" },
266
+ },
267
+ { processors: [] },
268
+ ),
269
+ ),
270
+ ).toEqual([]);
271
+ });
272
+ });
273
+
274
+ describe("OTEL126 k8sattributes metadata fields", () => {
275
+ const k8s = (metadata: string[]) => ({
276
+ ...withExporters({ debug: {} }),
277
+ processors: { k8sattributes: { extract: { metadata } } },
278
+ service: { pipelines: { traces: { receivers: ["otlp"], processors: ["k8sattributes"], exporters: ["debug"] } } },
279
+ });
280
+
281
+ test("reports a field k8sattributes can't extract, as an error", () => {
282
+ const diags = run(otel126, k8s(["k8s.pod.name", "k8s.pod.labels", "k8s.namespace.uid"]));
283
+ expect(diags.map((d) => d.message.match(/extracts "([^"]+)"/)?.[1])).toEqual(["k8s.pod.labels", "k8s.namespace.uid"]);
284
+ expect(diags[0]).toMatchObject({ checkId: "OTEL126", severity: "error", entity: "k8sattributes" });
285
+ });
286
+
287
+ test("passes every supported field", async () => {
288
+ const { K8S_ATTRIBUTES_METADATA } = await import("../../config-hygiene");
289
+ expect(run(otel126, k8s([...K8S_ATTRIBUTES_METADATA]))).toEqual([]);
290
+ });
291
+ });
292
+
293
+ describe("OTEL127 resourcedetection detectors", () => {
294
+ const rd = (detectors: string[], started = true) => ({
295
+ ...withExporters({ debug: {} }),
296
+ processors: { resourcedetection: { detectors } },
297
+ service: { pipelines: { traces: { receivers: ["otlp"], processors: started ? ["resourcedetection"] : [], exporters: ["debug"] } } },
298
+ });
299
+
300
+ test("reports an unknown detector, with a hint for elasticbeanstalk", () => {
301
+ const diags = run(otel127, rd(["env", "elasticbeanstalk", "k8s"]));
302
+ expect(diags.map((d) => d.message.match(/detector "([^"]+)"/)?.[1])).toEqual(["elasticbeanstalk", "k8s"]);
303
+ expect(diags[0].message).toContain('(the detector is "elastic_beanstalk")');
304
+ expect(diags[0]).toMatchObject({ checkId: "OTEL127", severity: "error" });
305
+ });
306
+
307
+ test("passes every registered detector, an ${env:...} entry, and a processor nothing starts", async () => {
308
+ const { RESOURCE_DETECTORS } = await import("../../config-hygiene");
309
+ expect(run(otel127, rd([...RESOURCE_DETECTORS, "${env:DETECTORS}"]))).toEqual([]);
310
+ expect(run(otel127, rd(["nope"], false))).toEqual([]);
311
+ });
312
+ });
313
+
314
+ // The issue's acceptance bar: what chant itself produces reports none of these.
315
+ // The init templates and examples are covered by init-templates.test.ts and
316
+ // examples/examples.test.ts, which require every check to pass.
317
+ describe("OTEL119-OTEL127 on chant's own collector configs", () => {
318
+ const backend = () => new OtlpExporter({ name: "backend", endpoint: "tempo:4317", tls: { insecure: true } });
319
+ const configs: Array<[string, () => Declarable[]]> = [
320
+ ["otlpCollector()", () => otlpCollector()],
321
+ ["otlpCollector() to an otlp backend", () => otlpCollector({ exporters: [backend()] })],
322
+ ["genAiPipeline()", () => genAiPipeline()],
323
+ [
324
+ "genAiPipeline() to otlp backends",
325
+ () => genAiPipeline({ traceExporters: [backend()], metricExporters: [new OtlpHttpExporter({ name: "metrics", endpoint: "https://metrics.example" })], logExporters: [backend()] }),
326
+ ],
327
+ ["NodeAgent", () => Object.values(NodeAgent({ exporters: [backend()], metricExporters: [new PrometheusExporter({ endpoint: "0.0.0.0:8889" })], clusterName: "c" }).members) as Declarable[]],
328
+ ];
329
+
330
+ test.each(configs)("%s reports none", (_name, entities) => {
331
+ const config = load(collectorYaml(entities())) as CollectorConfig;
332
+ expect(validateCollectorConfig(config).filter((i) => HYGIENE.includes(i.code))).toEqual([]);
333
+ });
334
+ });