@intentius/chant-lexicon-otel 0.100.0 → 0.101.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +8 -6
  2. package/dist/codegen/docs.d.ts.map +1 -1
  3. package/dist/components/common.d.ts +2 -0
  4. package/dist/components/common.d.ts.map +1 -1
  5. package/dist/components/connectors.d.ts +109 -1
  6. package/dist/components/connectors.d.ts.map +1 -1
  7. package/dist/components/exporters.d.ts.map +1 -1
  8. package/dist/components/extensions.d.ts +26 -2
  9. package/dist/components/extensions.d.ts.map +1 -1
  10. package/dist/components/processors.d.ts +15 -2
  11. package/dist/components/processors.d.ts.map +1 -1
  12. package/dist/components/receivers.d.ts.map +1 -1
  13. package/dist/composites/catalog.d.ts +3 -0
  14. package/dist/composites/catalog.d.ts.map +1 -0
  15. package/dist/composites/index.d.ts +6 -0
  16. package/dist/composites/index.d.ts.map +1 -0
  17. package/dist/composites/node-agent.d.ts +110 -0
  18. package/dist/composites/node-agent.d.ts.map +1 -0
  19. package/dist/configmap.d.ts +35 -0
  20. package/dist/configmap.d.ts.map +1 -0
  21. package/dist/define.d.ts +9 -0
  22. package/dist/define.d.ts.map +1 -1
  23. package/dist/genai.d.ts +145 -2
  24. package/dist/genai.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/init-templates.d.ts +24 -0
  28. package/dist/init-templates.d.ts.map +1 -0
  29. package/dist/integrity.json +10 -5
  30. package/dist/lint/audit-catalog.d.ts +1 -1
  31. package/dist/lint/audit-catalog.d.ts.map +1 -1
  32. package/dist/lint/post-synth/index.d.ts.map +1 -1
  33. package/dist/lint/post-synth/otel-helpers.d.ts +32 -9
  34. package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
  35. package/dist/lint/post-synth/otel113.d.ts +8 -0
  36. package/dist/lint/post-synth/otel113.d.ts.map +1 -0
  37. package/dist/lint/post-synth/otel114.d.ts +8 -0
  38. package/dist/lint/post-synth/otel114.d.ts.map +1 -0
  39. package/dist/lint/post-synth/otel115.d.ts +8 -0
  40. package/dist/lint/post-synth/otel115.d.ts.map +1 -0
  41. package/dist/lint/post-synth/otel116.d.ts +8 -0
  42. package/dist/lint/post-synth/otel116.d.ts.map +1 -0
  43. package/dist/lint/post-synth/otel117.d.ts +8 -0
  44. package/dist/lint/post-synth/otel117.d.ts.map +1 -0
  45. package/dist/manifest.json +1 -1
  46. package/dist/meta.json +15 -0
  47. package/dist/okf/index.md +8 -0
  48. package/dist/okf/rules/OTEL113.md +11 -0
  49. package/dist/okf/rules/OTEL114.md +11 -0
  50. package/dist/okf/rules/OTEL115.md +11 -0
  51. package/dist/okf/rules/OTEL116.md +11 -0
  52. package/dist/okf/rules/OTEL117.md +11 -0
  53. package/dist/okf/types/DeltaToCumulativeProcessor.md +9 -0
  54. package/dist/okf/types/K8sLeaderElectorExtension.md +9 -0
  55. package/dist/okf/types/SignalToMetricsConnector.md +9 -0
  56. package/dist/plugin.d.ts.map +1 -1
  57. package/dist/rules/otel-helpers.ts +43 -11
  58. package/dist/rules/otel113.ts +17 -0
  59. package/dist/rules/otel114.ts +17 -0
  60. package/dist/rules/otel115.ts +17 -0
  61. package/dist/rules/otel116.ts +17 -0
  62. package/dist/rules/otel117.ts +17 -0
  63. package/dist/skills/chant-otel.md +11 -5
  64. package/dist/topology.d.ts +5 -0
  65. package/dist/topology.d.ts.map +1 -1
  66. package/dist/validate-config.d.ts +6 -2
  67. package/dist/validate-config.d.ts.map +1 -1
  68. package/dist/validate.d.ts.map +1 -1
  69. package/package.json +2 -2
  70. package/src/codegen/docs.ts +9 -4
  71. package/src/components/common.ts +17 -0
  72. package/src/components/connectors.ts +209 -1
  73. package/src/components/exporters.ts +2 -0
  74. package/src/components/extensions.test.ts +70 -0
  75. package/src/components/extensions.ts +60 -2
  76. package/src/components/processors.test.ts +54 -0
  77. package/src/components/processors.ts +41 -2
  78. package/src/components/receivers.ts +5 -0
  79. package/src/composites/catalog.test.ts +38 -0
  80. package/src/composites/catalog.ts +81 -0
  81. package/src/composites/composites.test.ts +138 -0
  82. package/src/composites/index.ts +6 -0
  83. package/src/composites/node-agent.ts +219 -0
  84. package/src/configmap.ts +67 -0
  85. package/src/connectors.test.ts +197 -1
  86. package/src/define.ts +9 -0
  87. package/src/genai.test.ts +301 -6
  88. package/src/genai.ts +323 -8
  89. package/src/generated/lexicon-otel.json +15 -0
  90. package/src/import/roundtrip.test.ts +21 -1
  91. package/src/import/testdata/fixtures.ts +37 -5
  92. package/src/import/testdata/k8s-leader-elector.yaml +35 -0
  93. package/src/import/testdata/signaltometrics.yaml +83 -0
  94. package/src/index.ts +16 -0
  95. package/src/init-templates.test.ts +72 -0
  96. package/src/init-templates.ts +146 -0
  97. package/src/lint/audit-catalog.ts +41 -1
  98. package/src/lint/post-synth/connector-graph.test.ts +207 -0
  99. package/src/lint/post-synth/index.ts +10 -0
  100. package/src/lint/post-synth/otel-helpers.ts +43 -11
  101. package/src/lint/post-synth/otel113.ts +17 -0
  102. package/src/lint/post-synth/otel114.ts +17 -0
  103. package/src/lint/post-synth/otel115.ts +17 -0
  104. package/src/lint/post-synth/otel116.ts +17 -0
  105. package/src/lint/post-synth/otel117.ts +17 -0
  106. package/src/lint/post-synth/post-synth.test.ts +223 -1
  107. package/src/lint/post-synth/testdata/otel113-fail.yaml +23 -0
  108. package/src/lint/post-synth/testdata/otel113-pass.yaml +21 -0
  109. package/src/lint/post-synth/testdata/otel114-fail.yaml +25 -0
  110. package/src/lint/post-synth/testdata/otel114-pass.yaml +23 -0
  111. package/src/lint/post-synth/testdata/otel115-fail.yaml +29 -0
  112. package/src/lint/post-synth/testdata/otel115-pass.yaml +29 -0
  113. package/src/plugin.test.ts +18 -0
  114. package/src/plugin.ts +22 -0
  115. package/src/skills/chant-otel.md +11 -5
  116. package/src/testdata/genai-configured.yaml +108 -0
  117. package/src/testdata/genai-default.yaml +92 -0
  118. package/src/topology.test.ts +12 -0
  119. package/src/topology.ts +16 -0
  120. package/src/typecheck.test.ts +112 -0
  121. package/src/validate-config.ts +415 -2
  122. package/src/validate.ts +17 -0
@@ -8,16 +8,32 @@ import type { SerializerResult } from "@intentius/chant/serializer";
8
8
  import { loadAll } from "js-yaml";
9
9
  import type { Declarable } from "@intentius/chant/declarable";
10
10
  import { looksLikeCollectorConfig, type CollectorConfig } from "../../model";
11
+ import { configMapCollectorConfigs, describeConfigMapConfig } from "../../configmap";
11
12
  import { validateCollectorConfig, validateCollectorEntities, type CollectorIssueCode, type CollectorIssue } from "../../validate-config";
12
13
 
14
+ /** Where a collector config was found in the build output. */
15
+ export interface FoundCollectorConfig {
16
+ /** The lexicon output, or the output file, the config came from. */
17
+ source: string;
18
+ config: CollectorConfig;
19
+ /** Set when the config is a value in a Kubernetes ConfigMap (chant #2930). */
20
+ configMap?: { namespace: string; name: string; key: string };
21
+ }
22
+
13
23
  /**
14
- * Every collector config in the output: the otel lexicon's own, and any YAML
15
- * document shaped like one. Parsed with js-yaml rather than `ctx.docs`,
16
- * because collector configs are written with flow lists (`[otlp, batch]`)
17
- * that core's small YAML reader keeps as strings.
24
+ * Every collector config in the output: the otel lexicon's own, any YAML
25
+ * document shaped like one, and any ConfigMap data value that parses as one
26
+ * (how `OtelCollector`, `OtelCollectorGateway` and `GkeOtelCollector` carry
27
+ * theirs). Parsed with js-yaml rather than `ctx.docs`, because collector
28
+ * configs are written with flow lists (`[otlp, batch]`) that core's small
29
+ * YAML reader keeps as strings.
30
+ *
31
+ * `chant build` hands each lexicon's checks only that lexicon's output, so
32
+ * the otel checks see ConfigMaps only in a context that includes k8s output.
33
+ * The k8s lexicon's WK8604 runs the same config checks over its own output.
18
34
  */
19
- export function collectorConfigs(ctx: PostSynthContext): Array<{ source: string; config: CollectorConfig }> {
20
- const out: Array<{ source: string; config: CollectorConfig }> = [];
35
+ export function collectorConfigs(ctx: PostSynthContext): FoundCollectorConfig[] {
36
+ const out: FoundCollectorConfig[] = [];
21
37
  for (const [lexicon, output] of ctx.outputs) {
22
38
  const texts: Array<[string, string]> =
23
39
  typeof output === "string"
@@ -35,6 +51,10 @@ export function collectorConfigs(ctx: PostSynthContext): Array<{ source: string;
35
51
  if (typeof doc !== "object" || doc === null || Array.isArray(doc)) continue;
36
52
  if ((lexicon === "otel" && source === lexicon) || looksLikeCollectorConfig(doc)) {
37
53
  out.push({ source, config: doc as CollectorConfig });
54
+ continue;
55
+ }
56
+ for (const { config, ...configMap } of configMapCollectorConfigs(doc)) {
57
+ out.push({ source, config, configMap });
38
58
  }
39
59
  }
40
60
  }
@@ -42,22 +62,34 @@ export function collectorConfigs(ctx: PostSynthContext): Array<{ source: string;
42
62
  return out;
43
63
  }
44
64
 
45
- function toDiagnostic(issue: CollectorIssue, source?: string): PostSynthDiagnostic {
65
+ function toDiagnostic(issue: CollectorIssue, found?: Omit<FoundCollectorConfig, "config">): PostSynthDiagnostic {
66
+ const where = found?.configMap ? describeConfigMapConfig(found.configMap) : found && found.source !== "otel" ? found.source : undefined;
46
67
  return {
47
68
  checkId: issue.code,
48
69
  severity: issue.severity,
49
- message: source && source !== "otel" ? `${source}: ${issue.message}` : issue.message,
70
+ message: where ? `${where}: ${issue.message}` : issue.message,
50
71
  ...(issue.component ? { entity: issue.component } : issue.pipeline ? { entity: issue.pipeline } : {}),
51
72
  lexicon: "otel",
52
73
  };
53
74
  }
54
75
 
55
- /** Diagnostics for one config-level code (OTEL101-OTEL106, OTEL112) across every collector config in the output. */
76
+ /**
77
+ * Every config-level diagnostic (OTEL101-OTEL106, OTEL112-OTEL117 and any later
78
+ * config check) over the collector configs in the output. `configMapsOnly`
79
+ * keeps the configs held in ConfigMaps, which is what WK8604 reads.
80
+ */
81
+ export function collectorConfigDiagnostics(ctx: PostSynthContext, opts: { configMapsOnly?: boolean } = {}): PostSynthDiagnostic[] {
82
+ return collectorConfigs(ctx)
83
+ .filter((found) => !opts.configMapsOnly || found.configMap)
84
+ .flatMap(({ config, ...found }) => validateCollectorConfig(config).map((i) => toDiagnostic(i, found)));
85
+ }
86
+
87
+ /** Diagnostics for one config-level code (OTEL101-OTEL106, OTEL112-OTEL117) across every collector config in the output. */
56
88
  export function configDiagnostics(ctx: PostSynthContext, code: CollectorIssueCode): PostSynthDiagnostic[] {
57
- return collectorConfigs(ctx).flatMap(({ source, config }) =>
89
+ return collectorConfigs(ctx).flatMap(({ config, ...found }) =>
58
90
  validateCollectorConfig(config)
59
91
  .filter((i) => i.code === code)
60
- .map((i) => toDiagnostic(i, source)),
92
+ .map((i) => toDiagnostic(i, found)),
61
93
  );
62
94
  }
63
95
 
@@ -0,0 +1,17 @@
1
+ /**
2
+ * OTEL113: Pipelines form a cycle through connectors
3
+ *
4
+ * A connector carries data from the pipeline that lists it as an exporter to the pipeline that lists it as a receiver. When those hops lead back to a pipeline already on the path (traces/a feeds traces/b through one connector and traces/b feeds traces/a through another, or one pipeline lists the same `forward` connector on both sides), the collector refuses to start. The check walks the `edges` `collectorTopology()` returns and names one cycle per group of pipelines caught in it.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { configDiagnostics } from "./otel-helpers";
9
+
10
+ export const otel113: PostSynthCheck = {
11
+ id: "OTEL113",
12
+ description: "Pipelines form a cycle through connectors",
13
+
14
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ return configDiagnostics(ctx, "OTEL113");
16
+ },
17
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * OTEL114: A connector id is also declared as a receiver or exporter
3
+ *
4
+ * A pipeline names a connector the same way it names a receiver or an exporter, so the collector refuses a config where a connector id is also declared under `receivers` or `exporters`, whether or not any pipeline uses it.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { configDiagnostics } from "./otel-helpers";
9
+
10
+ export const otel114: PostSynthCheck = {
11
+ id: "OTEL114",
12
+ description: "A connector id is also declared as a receiver or exporter",
13
+
14
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ return configDiagnostics(ctx, "OTEL114");
16
+ },
17
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * OTEL115: A routing connector routes to a pipeline that does not receive from it
3
+ *
4
+ * Every pipeline a `routing` connector names in `table[].pipelines` or `default_pipelines` must list that connector in its receivers. The connector can only hand data to the pipelines it feeds, and the collector refuses to start when a route names any other pipeline or one that does not exist.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { configDiagnostics } from "./otel-helpers";
9
+
10
+ export const otel115: PostSynthCheck = {
11
+ id: "OTEL115",
12
+ description: "A routing connector routes to a pipeline that does not receive from it",
13
+
14
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ return configDiagnostics(ctx, "OTEL115");
16
+ },
17
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * OTEL116: A connector splits metrics by a high-cardinality GenAI attribute
3
+ *
4
+ * `gen_ai.conversation.id`, `gen_ai.response.id`, `gen_ai.tool.call.id`, `session.id`, `user.id`, `enduser.id` and the other keys in `GENAI_HIGH_CARDINALITY_ATTRIBUTES` take a new value per response, conversation or user, and the content keys (`gen_ai.input.messages` and the rest of `GENAI_CONTENT_ATTRIBUTES`) are unbounded. Used as a metric attribute in `spanmetrics` or `servicegraph` dimensions, `sum` or `count` attributes, or `signaltometrics` attributes or `include_resource_attributes`, each value starts new time series. The collector accepts such a config without a warning.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { configDiagnostics } from "./otel-helpers";
9
+
10
+ export const otel116: PostSynthCheck = {
11
+ id: "OTEL116",
12
+ description: "A connector splits metrics by a high-cardinality GenAI attribute",
13
+
14
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ return configDiagnostics(ctx, "OTEL116");
16
+ },
17
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * OTEL117: Two components the collector starts listen on the same address
3
+ *
4
+ * A receiver or exporter a pipeline lists, an extension in `service.extensions`, and the collector's own metrics endpoint (`localhost:8888` unless `service.telemetry.metrics` says otherwise) each bind an address when the collector starts. Two on the same port, on the same host or with either on a wildcard host (`0.0.0.0`, `::`), make the second bind fail with "address already in use", and the collector exits. `otelcol validate` does not bind, so it accepts such a config. The listeners checked are the otlp, zipkin and jaeger receivers, the prometheus exporter, and the health_check, zpages and pprof extensions, with their defaults at the pinned collector release.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { configDiagnostics } from "./otel-helpers";
9
+
10
+ export const otel117: PostSynthCheck = {
11
+ id: "OTEL117",
12
+ description: "Two components the collector starts listen on the same address",
13
+
14
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ return configDiagnostics(ctx, "OTEL117");
16
+ },
17
+ };
@@ -10,8 +10,21 @@ import { otel106 } from "./otel106";
10
10
  import { otel107 } from "./otel107";
11
11
  import { otel108 } from "./otel108";
12
12
  import { otel109 } from "./otel109";
13
- import { BatchProcessor, DebugExporter, FileLogReceiver, MemoryLimiterProcessor, OtlpReceiver } from "../../components";
13
+ import { otel116 } from "./otel116";
14
+ import { otel117 } from "./otel117";
15
+ import {
16
+ BatchProcessor,
17
+ DebugExporter,
18
+ FileLogReceiver,
19
+ MemoryLimiterProcessor,
20
+ OtlpReceiver,
21
+ SignalToMetricsConnector,
22
+ type SignalToMetricsConnectorConfig,
23
+ } from "../../components";
14
24
  import { defineComponent } from "../../define";
25
+ import { collectorConfigDiagnostics, collectorConfigs } from "./otel-helpers";
26
+ import { GENAI_CONTENT_ATTRIBUTES, GENAI_HIGH_CARDINALITY_ATTRIBUTES } from "../../genai";
27
+ import { dump } from "js-yaml";
15
28
  import { Pipeline } from "../../pipeline";
16
29
 
17
30
  const GOOD = `receivers:
@@ -162,6 +175,24 @@ describe("entity-level checks", () => {
162
175
  expect(otel107.check(makePostSynthCtx("otel", "", entities([limiter, otlp, new BatchProcessor({})])))).toEqual([]);
163
176
  });
164
177
 
178
+ test("OTEL107: a signaltometrics entry names exactly one metric type, with a value", () => {
179
+ const good = new SignalToMetricsConnector({
180
+ name: "good",
181
+ spans: [{ name: "span.duration", unit: "ms", histogram: { value: "Milliseconds(end_time - start_time)" } }],
182
+ });
183
+ const bad = new SignalToMetricsConnector({
184
+ name: "bad",
185
+ spans: [{ name: "both", sum: { value: "1" }, gauge: { value: "1" } }, { name: "nothing" }, { name: "h", histogram: { count: "1" } }],
186
+ } as unknown as SignalToMetricsConnectorConfig);
187
+ const diags = otel107.check(makePostSynthCtx("otel", "", entities([good, bad])));
188
+ expect(diags.map((d) => d.entity)).toEqual(["signaltometrics/bad", "signaltometrics/bad", "signaltometrics/bad"]);
189
+ expect(diags.map((d) => d.message.replace(/^.*?\): /, ""))).toEqual([
190
+ "name exactly one metric type (sum, gauge, histogram or exponential_histogram); found sum and gauge",
191
+ "name exactly one metric type (sum, gauge, histogram or exponential_histogram); found none",
192
+ "histogram.value is missing",
193
+ ]);
194
+ });
195
+
165
196
  test("OTEL108 flags duplicate component and pipeline ids", () => {
166
197
  const a = new DebugExporter({});
167
198
  const b = new DebugExporter({ verbosity: "detailed" });
@@ -182,3 +213,194 @@ describe("entity-level checks", () => {
182
213
  expect(diags.map((d) => d.checkId)).toEqual(["OTEL109"]);
183
214
  });
184
215
  });
216
+
217
+ describe("collector configs in Kubernetes ConfigMaps (chant #2930)", () => {
218
+ const manifests = (...docs: object[]) => docs.map((d) => dump(d, { lineWidth: -1 })).join("---\n");
219
+ const configMap = (data: Record<string, string>) => ({ apiVersion: "v1", kind: "ConfigMap", metadata: { name: "otel-agent-config", namespace: "observability" }, data });
220
+
221
+ test("collectorConfigs reads each ConfigMap value that parses as a config, with its namespace, name and key", () => {
222
+ const ctx = makePostSynthCtx("k8s", manifests(configMap({ "config.yaml": GOOD, "notes.txt": "not a config" }), { apiVersion: "v1", kind: "Service", metadata: { name: "x" } }));
223
+ const found = collectorConfigs(ctx);
224
+ expect(found.map((f) => [f.source, f.configMap])).toEqual([["k8s", { namespace: "observability", name: "otel-agent-config", key: "config.yaml" }]]);
225
+ expect(found[0]?.config.service?.pipelines?.traces?.processors).toEqual(["memory_limiter", "batch"]);
226
+ });
227
+
228
+ test("a config-level check reports a ConfigMap config's issue, naming the ConfigMap and key", () => {
229
+ const broken = GOOD.replace("exporters: [debug]", "exporters: [debug, otlp/backend]");
230
+ const diags = otel101.check(makePostSynthCtx("k8s", manifests(configMap({ "config.yaml": broken }))));
231
+ expect(diags.map((d) => d.checkId)).toEqual(["OTEL101"]);
232
+ expect(diags[0].message).toMatch(/^ConfigMap observability\/otel-agent-config, key config\.yaml: /);
233
+ });
234
+ });
235
+
236
+
237
+ describe("OTEL116 high-cardinality GenAI attributes as metric attributes", () => {
238
+ /** A config whose connectors split metrics by the given keys, in every field OTEL116 reads. */
239
+ function connectorsSplitBy(key: string) {
240
+ return {
241
+ receivers: { otlp: { protocols: { grpc: {} } } },
242
+ exporters: { debug: {} },
243
+ connectors: {
244
+ spanmetrics: {
245
+ dimensions: [{ name: key }],
246
+ calls_dimensions: [{ name: key }],
247
+ histogram: { dimensions: [{ name: key }] },
248
+ events: { enabled: true, dimensions: [{ name: key }] },
249
+ },
250
+ servicegraph: { dimensions: [key] },
251
+ count: { spans: { "genai.calls": { attributes: [{ key }] } } },
252
+ "sum/genai": { spans: { "genai.tokens": { source_attribute: "gen_ai.usage.input_tokens", attributes: [{ key, default_value: "none" }] } } },
253
+ signaltometrics: {
254
+ spans: [{ name: "gen_ai.client.operation.duration", histogram: { value: "Seconds(end_time - start_time)" }, attributes: [{ key }], include_resource_attributes: [{ key }] }],
255
+ },
256
+ },
257
+ service: {
258
+ pipelines: {
259
+ traces: { receivers: ["otlp"], exporters: ["spanmetrics", "servicegraph", "count", "sum/genai", "signaltometrics"] },
260
+ metrics: { receivers: ["spanmetrics", "servicegraph", "count", "sum/genai", "signaltometrics"], exporters: ["debug"] },
261
+ },
262
+ },
263
+ };
264
+ }
265
+ const FIELDS = [
266
+ ["spanmetrics", "dimensions"],
267
+ ["spanmetrics", "calls_dimensions"],
268
+ ["spanmetrics", "histogram.dimensions"],
269
+ ["spanmetrics", "events.dimensions"],
270
+ ["servicegraph", "dimensions"],
271
+ ["count", "spans.genai.calls.attributes"],
272
+ ["sum/genai", "spans.genai.tokens.attributes"],
273
+ ["signaltometrics", "spans[0] (gen_ai.client.operation.duration).attributes"],
274
+ ["signaltometrics", "spans[0] (gen_ai.client.operation.duration).include_resource_attributes"],
275
+ ];
276
+ const run = (config: object, lexicon = "otel") => otel116.check(makePostSynthCtx(lexicon, dump(config, { lineWidth: -1 })));
277
+
278
+ test.each(GENAI_HIGH_CARDINALITY_ATTRIBUTES.map((k) => [k]))("reports %s in every connector field", (key) => {
279
+ const diags = run(connectorsSplitBy(key));
280
+ expect(diags.map((d) => [d.entity, d.message.match(/\((.+)\);/)?.[1]])).toEqual(FIELDS);
281
+ expect(diags.every((d) => d.checkId === "OTEL116" && d.severity === "warning")).toBe(true);
282
+ expect(diags[0].message).toContain(`"${key}"`);
283
+ expect(diags[0].message).toContain("new value per request");
284
+ });
285
+
286
+ test.each([...GENAI_CONTENT_ATTRIBUTES, "gen_ai.prompt.0.content"].map((k) => [k]))("reports content key %s as unbounded and sensitive", (key) => {
287
+ const diags = run(connectorsSplitBy(key));
288
+ expect(diags).toHaveLength(FIELDS.length);
289
+ expect(diags[0].message).toContain("unbounded and sensitive");
290
+ });
291
+
292
+ test.each(["gen_ai.request.model", "gen_ai.provider.name", "gen_ai.prompt.name", "error.type"].map((k) => [k]))("passes bounded key %s", (key) => {
293
+ expect(run(connectorsSplitBy(key))).toEqual([]);
294
+ });
295
+
296
+ test("passes the v1.41.1 client metrics' attributes, and an empty include_resource_attributes", () => {
297
+ const semconv = ["gen_ai.operation.name", "gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.model", "server.address", "server.port", "error.type", "gen_ai.token.type"];
298
+ const config = {
299
+ ...connectorsSplitBy("gen_ai.request.model"),
300
+ connectors: {
301
+ signaltometrics: {
302
+ spans: [
303
+ {
304
+ name: "gen_ai.client.token.usage",
305
+ histogram: { value: 'attributes["gen_ai.usage.input_tokens"]' },
306
+ attributes: semconv.map((key) => ({ key, optional: true })),
307
+ include_resource_attributes: [],
308
+ },
309
+ ],
310
+ },
311
+ },
312
+ service: { pipelines: { traces: { receivers: ["otlp"], exporters: ["signaltometrics"] }, metrics: { receivers: ["signaltometrics"], exporters: ["debug"] } } },
313
+ };
314
+ expect(run(config)).toEqual([]);
315
+ });
316
+
317
+ test("WK8604's entry point reports it for a config in a ConfigMap", () => {
318
+ const configMap = { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "otel-agent-config", namespace: "observability" }, data: { "config.yaml": dump(connectorsSplitBy("gen_ai.conversation.id")) } };
319
+ const diags = collectorConfigDiagnostics(makePostSynthCtx("k8s", dump(configMap, { lineWidth: -1 })), { configMapsOnly: true }).filter((d) => d.checkId === "OTEL116");
320
+ expect(diags).toHaveLength(FIELDS.length);
321
+ expect(diags[0].message).toMatch(/^ConfigMap observability\/otel-agent-config, key config\.yaml: connector "spanmetrics" splits metrics by "gen_ai\.conversation\.id"/);
322
+ });
323
+ });
324
+
325
+ describe("OTEL117 two started components on one address", () => {
326
+ const run = (config: object) => otel117.check(makePostSynthCtx("otel", dump(config)));
327
+ const otlpOnly = { receivers: ["otlp"], exporters: ["debug"] };
328
+
329
+ test("reports a prometheus exporter on the collector's own default metrics port", () => {
330
+ const diags = run({
331
+ receivers: { otlp: { protocols: { grpc: { endpoint: "0.0.0.0:4317" } } } },
332
+ exporters: { prometheus: { endpoint: "0.0.0.0:8888" } },
333
+ service: { pipelines: { metrics: { receivers: ["otlp"], exporters: ["prometheus"] } } },
334
+ });
335
+ expect(diags).toHaveLength(1);
336
+ expect(diags[0]).toMatchObject({ checkId: "OTEL117", severity: "error" });
337
+ expect(diags[0].message).toContain('exporter "prometheus" (endpoint) listens on 0.0.0.0:8888');
338
+ expect(diags[0].message).toContain("the collector's own metrics (service.telemetry.metrics, by default) on localhost:8888");
339
+ });
340
+
341
+ test("reports two otlp receivers left on the default grpc port", () => {
342
+ const diags = run({
343
+ receivers: { otlp: { protocols: { grpc: {} } }, "otlp/2": { protocols: { grpc: {}, http: { endpoint: "0.0.0.0:4319" } } } },
344
+ exporters: { debug: {} },
345
+ service: { pipelines: { traces: { receivers: ["otlp"], exporters: ["debug"] }, logs: { receivers: ["otlp/2"], exporters: ["debug"] } } },
346
+ });
347
+ expect(diags.map((d) => d.message)).toEqual([
348
+ expect.stringMatching(/^receiver "otlp" \(protocols\.grpc\.endpoint\) listens on localhost:4317 and receiver "otlp\/2" \(protocols\.grpc\.endpoint\) on localhost:4317/),
349
+ ]);
350
+ });
351
+
352
+ test("a wildcard host overlaps a specific host on the same port, and an extension counts", () => {
353
+ const diags = run({
354
+ receivers: { zipkin: { endpoint: "127.0.0.1:13133" } },
355
+ exporters: { debug: {} },
356
+ extensions: { health_check: { endpoint: "0.0.0.0:13133" } },
357
+ service: { extensions: ["health_check"], pipelines: { traces: { receivers: ["zipkin"], exporters: ["debug"] } } },
358
+ });
359
+ expect(diags).toHaveLength(1);
360
+ expect(diags[0].message).toContain('extension "health_check" (endpoint) on 0.0.0.0:13133');
361
+ });
362
+
363
+ test("passes different hosts on one port, UDP beside TCP, and telemetry moved off 8888 or turned off", () => {
364
+ const base = {
365
+ receivers: {
366
+ otlp: { protocols: { grpc: { endpoint: "10.0.0.1:4317" }, http: { endpoint: "0.0.0.0:6831" } } },
367
+ "otlp/b": { protocols: { grpc: { endpoint: "10.0.0.2:4317" } } },
368
+ jaeger: { protocols: { thrift_compact: {} } },
369
+ },
370
+ exporters: { prometheus: { endpoint: "0.0.0.0:8888" } },
371
+ };
372
+ const pipelines = { traces: { receivers: ["otlp", "otlp/b", "jaeger"], exporters: ["prometheus"] } };
373
+ const reader = { pull: { exporter: { prometheus: { host: "0.0.0.0", port: 8889 } } } };
374
+ expect(run({ ...base, service: { telemetry: { metrics: { readers: [reader] } }, pipelines } })).toEqual([]);
375
+ expect(run({ ...base, service: { telemetry: { metrics: { level: "none" } }, pipelines } })).toEqual([]);
376
+ });
377
+
378
+ test("a component nothing starts doesn't count, nor does an endpoint the collector connects to", () => {
379
+ expect(
380
+ run({
381
+ receivers: { otlp: { protocols: { grpc: {} } }, "otlp/unused": { protocols: { grpc: {} } }, kubeletstats: { endpoint: "localhost:4317" } },
382
+ exporters: { debug: {}, prometheus: { endpoint: "localhost:8888" }, otlp: { endpoint: "localhost:4317" } },
383
+ extensions: { zpages: { endpoint: "localhost:4317" } },
384
+ service: { pipelines: { traces: otlpOnly, metrics: { receivers: ["kubeletstats"], exporters: ["otlp"] } } },
385
+ }),
386
+ ).toEqual([]);
387
+ });
388
+
389
+ test("reads the telemetry reader's address when one is set", () => {
390
+ const diags = run({
391
+ receivers: { otlp: { protocols: { http: { endpoint: "0.0.0.0:9000" } } } },
392
+ exporters: { debug: {} },
393
+ service: { telemetry: { metrics: { readers: [{ pull: { exporter: { prometheus: { host: "0.0.0.0", port: 9000 } } } }] } }, pipelines: { logs: { receivers: ["otlp"], exporters: ["debug"] } } },
394
+ });
395
+ expect(diags).toHaveLength(1);
396
+ expect(diags[0].message).toContain("service.telemetry.metrics.readers[0]");
397
+ });
398
+
399
+ test("WK8604's entry point reports it for a config in a ConfigMap", () => {
400
+ const config = { receivers: { otlp: { protocols: { grpc: {} } } }, exporters: { prometheus: { endpoint: ":8888" } }, service: { pipelines: { metrics: { receivers: ["otlp"], exporters: ["prometheus"] } } } };
401
+ const configMap = { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "otel-agent-config", namespace: "observability" }, data: { "config.yaml": dump(config) } };
402
+ const diags = collectorConfigDiagnostics(makePostSynthCtx("k8s", dump(configMap, { lineWidth: -1 })), { configMapsOnly: true }).filter((d) => d.checkId === "OTEL117");
403
+ expect(diags).toHaveLength(1);
404
+ expect(diags[0].message).toMatch(/^ConfigMap observability\/otel-agent-config, key config\.yaml: exporter "prometheus"/);
405
+ });
406
+ });
@@ -0,0 +1,23 @@
1
+ # OTEL113 failing fixture: traces/a feeds traces/b through forward/ab, and
2
+ # traces/b feeds traces/a back through forward/ba.
3
+ receivers:
4
+ otlp:
5
+ protocols:
6
+ grpc:
7
+ endpoint: 127.0.0.1:4317
8
+
9
+ exporters:
10
+ debug: {}
11
+
12
+ connectors:
13
+ forward/ab: {}
14
+ forward/ba: {}
15
+
16
+ service:
17
+ pipelines:
18
+ traces/a:
19
+ receivers: [otlp, forward/ba]
20
+ exporters: [forward/ab]
21
+ traces/b:
22
+ receivers: [forward/ab]
23
+ exporters: [forward/ba, debug]
@@ -0,0 +1,21 @@
1
+ # OTEL113 passing fixture: the same two pipelines chained one way only.
2
+ receivers:
3
+ otlp:
4
+ protocols:
5
+ grpc:
6
+ endpoint: 127.0.0.1:4317
7
+
8
+ exporters:
9
+ debug: {}
10
+
11
+ connectors:
12
+ forward/ab: {}
13
+
14
+ service:
15
+ pipelines:
16
+ traces/a:
17
+ receivers: [otlp]
18
+ exporters: [forward/ab]
19
+ traces/b:
20
+ receivers: [forward/ab]
21
+ exporters: [debug]
@@ -0,0 +1,25 @@
1
+ # OTEL114 failing fixture: the id "datadog" is declared both as a receiver and
2
+ # as a connector. collector-contrib ships a receiver and a connector of that
3
+ # type, so the real collector gets as far as comparing the ids.
4
+ receivers:
5
+ otlp:
6
+ protocols:
7
+ grpc:
8
+ endpoint: 127.0.0.1:4317
9
+ datadog:
10
+ endpoint: 127.0.0.1:8126
11
+
12
+ exporters:
13
+ debug: {}
14
+
15
+ connectors:
16
+ datadog: {}
17
+
18
+ service:
19
+ pipelines:
20
+ traces:
21
+ receivers: [otlp]
22
+ exporters: [datadog]
23
+ metrics:
24
+ receivers: [datadog]
25
+ exporters: [debug]
@@ -0,0 +1,23 @@
1
+ # OTEL114 passing fixture: the connector is renamed datadog/connector.
2
+ receivers:
3
+ otlp:
4
+ protocols:
5
+ grpc:
6
+ endpoint: 127.0.0.1:4317
7
+ datadog:
8
+ endpoint: 127.0.0.1:8126
9
+
10
+ exporters:
11
+ debug: {}
12
+
13
+ connectors:
14
+ datadog/connector: {}
15
+
16
+ service:
17
+ pipelines:
18
+ traces:
19
+ receivers: [otlp, datadog]
20
+ exporters: [datadog/connector]
21
+ metrics:
22
+ receivers: [datadog/connector]
23
+ exporters: [debug]
@@ -0,0 +1,29 @@
1
+ # OTEL115 failing fixture: the routing connector's default_pipelines names
2
+ # traces/other, which receives from otlp rather than from the connector.
3
+ receivers:
4
+ otlp:
5
+ protocols:
6
+ grpc:
7
+ endpoint: 127.0.0.1:4317
8
+
9
+ exporters:
10
+ debug: {}
11
+
12
+ connectors:
13
+ routing:
14
+ default_pipelines: [traces/other]
15
+ table:
16
+ - statement: route() where attributes["tenant"] == "acme"
17
+ pipelines: [traces/acme]
18
+
19
+ service:
20
+ pipelines:
21
+ traces/in:
22
+ receivers: [otlp]
23
+ exporters: [routing]
24
+ traces/acme:
25
+ receivers: [routing]
26
+ exporters: [debug]
27
+ traces/other:
28
+ receivers: [otlp]
29
+ exporters: [debug]
@@ -0,0 +1,29 @@
1
+ # OTEL115 passing fixture: both routes lead to pipelines that receive from the
2
+ # routing connector.
3
+ receivers:
4
+ otlp:
5
+ protocols:
6
+ grpc:
7
+ endpoint: 127.0.0.1:4317
8
+
9
+ exporters:
10
+ debug: {}
11
+
12
+ connectors:
13
+ routing:
14
+ default_pipelines: [traces/other]
15
+ table:
16
+ - statement: route() where attributes["tenant"] == "acme"
17
+ pipelines: [traces/acme]
18
+
19
+ service:
20
+ pipelines:
21
+ traces/in:
22
+ receivers: [otlp]
23
+ exporters: [routing]
24
+ traces/acme:
25
+ receivers: [routing]
26
+ exporters: [debug]
27
+ traces/other:
28
+ receivers: [routing]
29
+ exporters: [debug]
@@ -29,6 +29,11 @@ describe("otel plugin", () => {
29
29
  "OTEL108",
30
30
  "OTEL109",
31
31
  "OTEL112",
32
+ "OTEL113",
33
+ "OTEL114",
34
+ "OTEL115",
35
+ "OTEL116",
36
+ "OTEL117",
32
37
  ]);
33
38
  for (const id of ids) expect(id.startsWith("OTEL")).toBe(true);
34
39
  });
@@ -53,4 +58,17 @@ describe("otel plugin", () => {
53
58
  expect(otelPlugin.mcpTools!().map((t) => t.name)).toEqual(["otel:diff"]);
54
59
  expect(otelPlugin.mcpResources!().map((r) => r.uri)).toEqual(["otel:resource-catalog"]);
55
60
  });
61
+
62
+ it("reports the collector topology for the graph, and nothing for a project without one", async () => {
63
+ const { OtlpReceiver, DebugExporter } = await import("./components");
64
+ const { Pipeline } = await import("./pipeline");
65
+ expect(otelPlugin.graphMeta!(new Map())).toBeUndefined();
66
+ expect(otelPlugin.graphMeta!(new Map([["other", { lexicon: "k8s", entityType: "Pod" } as never]]))).toBeUndefined();
67
+ const otlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });
68
+ const debug = new DebugExporter({});
69
+ const meta = otelPlugin.graphMeta!(new Map<string, never>([["otlp", otlp as never], ["debug", debug as never], ["p", new Pipeline({ signal: "traces", receivers: [otlp], exporters: [debug] }) as never]]));
70
+ const collector = meta!.collector as { pipelines: { id: string }[]; exporters: { id: string; signals: string[] }[] };
71
+ expect(collector.pipelines.map((p) => p.id)).toEqual(["traces"]);
72
+ expect(collector.exporters.map((e) => [e.id, e.signals])).toEqual([["debug", ["traces"]]]);
73
+ });
56
74
  });