@intentius/chant-lexicon-otel 0.100.0 → 0.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +8 -6
  2. package/dist/codegen/docs.d.ts.map +1 -1
  3. package/dist/components/common.d.ts +2 -0
  4. package/dist/components/common.d.ts.map +1 -1
  5. package/dist/components/connectors.d.ts +109 -1
  6. package/dist/components/connectors.d.ts.map +1 -1
  7. package/dist/components/exporters.d.ts.map +1 -1
  8. package/dist/components/extensions.d.ts +26 -2
  9. package/dist/components/extensions.d.ts.map +1 -1
  10. package/dist/components/processors.d.ts +15 -2
  11. package/dist/components/processors.d.ts.map +1 -1
  12. package/dist/components/receivers.d.ts.map +1 -1
  13. package/dist/composites/catalog.d.ts +3 -0
  14. package/dist/composites/catalog.d.ts.map +1 -0
  15. package/dist/composites/index.d.ts +6 -0
  16. package/dist/composites/index.d.ts.map +1 -0
  17. package/dist/composites/node-agent.d.ts +111 -0
  18. package/dist/composites/node-agent.d.ts.map +1 -0
  19. package/dist/configmap.d.ts +35 -0
  20. package/dist/configmap.d.ts.map +1 -0
  21. package/dist/define.d.ts +9 -0
  22. package/dist/define.d.ts.map +1 -1
  23. package/dist/genai.d.ts +145 -2
  24. package/dist/genai.d.ts.map +1 -1
  25. package/dist/import/generator.d.ts.map +1 -1
  26. package/dist/import/parser.d.ts.map +1 -1
  27. package/dist/index.d.ts +3 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/init-templates.d.ts +24 -0
  30. package/dist/init-templates.d.ts.map +1 -0
  31. package/dist/integrity.json +10 -5
  32. package/dist/lint/audit-catalog.d.ts +1 -1
  33. package/dist/lint/audit-catalog.d.ts.map +1 -1
  34. package/dist/lint/post-synth/index.d.ts.map +1 -1
  35. package/dist/lint/post-synth/otel-helpers.d.ts +32 -9
  36. package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
  37. package/dist/lint/post-synth/otel113.d.ts +8 -0
  38. package/dist/lint/post-synth/otel113.d.ts.map +1 -0
  39. package/dist/lint/post-synth/otel114.d.ts +8 -0
  40. package/dist/lint/post-synth/otel114.d.ts.map +1 -0
  41. package/dist/lint/post-synth/otel115.d.ts +8 -0
  42. package/dist/lint/post-synth/otel115.d.ts.map +1 -0
  43. package/dist/lint/post-synth/otel116.d.ts +8 -0
  44. package/dist/lint/post-synth/otel116.d.ts.map +1 -0
  45. package/dist/lint/post-synth/otel117.d.ts +8 -0
  46. package/dist/lint/post-synth/otel117.d.ts.map +1 -0
  47. package/dist/manifest.json +1 -1
  48. package/dist/meta.json +15 -0
  49. package/dist/model.d.ts.map +1 -1
  50. package/dist/okf/index.md +8 -0
  51. package/dist/okf/rules/OTEL113.md +11 -0
  52. package/dist/okf/rules/OTEL114.md +11 -0
  53. package/dist/okf/rules/OTEL115.md +11 -0
  54. package/dist/okf/rules/OTEL116.md +11 -0
  55. package/dist/okf/rules/OTEL117.md +11 -0
  56. package/dist/okf/types/DeltaToCumulativeProcessor.md +9 -0
  57. package/dist/okf/types/K8sLeaderElectorExtension.md +9 -0
  58. package/dist/okf/types/SignalToMetricsConnector.md +9 -0
  59. package/dist/platform.d.ts +6 -2
  60. package/dist/platform.d.ts.map +1 -1
  61. package/dist/plugin.d.ts.map +1 -1
  62. package/dist/rules/otel-helpers.ts +43 -11
  63. package/dist/rules/otel113.ts +17 -0
  64. package/dist/rules/otel114.ts +17 -0
  65. package/dist/rules/otel115.ts +17 -0
  66. package/dist/rules/otel116.ts +17 -0
  67. package/dist/rules/otel117.ts +17 -0
  68. package/dist/skill-defs.d.ts +1 -1
  69. package/dist/skill-defs.d.ts.map +1 -1
  70. package/dist/skills/chant-otel.md +11 -5
  71. package/dist/topology.d.ts +5 -0
  72. package/dist/topology.d.ts.map +1 -1
  73. package/dist/validate-config.d.ts +14 -2
  74. package/dist/validate-config.d.ts.map +1 -1
  75. package/dist/validate.d.ts.map +1 -1
  76. package/package.json +3 -3
  77. package/src/codegen/docs.ts +9 -4
  78. package/src/components/common.ts +17 -0
  79. package/src/components/connectors.ts +209 -1
  80. package/src/components/exporters.ts +2 -0
  81. package/src/components/extensions.test.ts +70 -0
  82. package/src/components/extensions.ts +60 -2
  83. package/src/components/processors.test.ts +54 -0
  84. package/src/components/processors.ts +41 -2
  85. package/src/components/receivers.ts +5 -0
  86. package/src/composites/catalog.test.ts +38 -0
  87. package/src/composites/catalog.ts +81 -0
  88. package/src/composites/composites.test.ts +138 -0
  89. package/src/composites/index.ts +6 -0
  90. package/src/composites/node-agent.ts +220 -0
  91. package/src/configmap.ts +67 -0
  92. package/src/connectors.test.ts +197 -1
  93. package/src/define.ts +9 -0
  94. package/src/genai.test.ts +301 -6
  95. package/src/genai.ts +323 -8
  96. package/src/generated/lexicon-otel.json +15 -0
  97. package/src/import/roundtrip.test.ts +21 -1
  98. package/src/import/testdata/fixtures.ts +37 -5
  99. package/src/import/testdata/k8s-leader-elector.yaml +35 -0
  100. package/src/import/testdata/signaltometrics.yaml +83 -0
  101. package/src/index.ts +16 -0
  102. package/src/init-templates.test.ts +72 -0
  103. package/src/init-templates.ts +148 -0
  104. package/src/lint/audit-catalog.ts +41 -1
  105. package/src/lint/post-synth/connector-graph.test.ts +207 -0
  106. package/src/lint/post-synth/index.ts +10 -0
  107. package/src/lint/post-synth/otel-helpers.ts +43 -11
  108. package/src/lint/post-synth/otel113.ts +17 -0
  109. package/src/lint/post-synth/otel114.ts +17 -0
  110. package/src/lint/post-synth/otel115.ts +17 -0
  111. package/src/lint/post-synth/otel116.ts +17 -0
  112. package/src/lint/post-synth/otel117.ts +17 -0
  113. package/src/lint/post-synth/post-synth.test.ts +236 -1
  114. package/src/lint/post-synth/testdata/otel113-fail.yaml +23 -0
  115. package/src/lint/post-synth/testdata/otel113-pass.yaml +21 -0
  116. package/src/lint/post-synth/testdata/otel114-fail.yaml +25 -0
  117. package/src/lint/post-synth/testdata/otel114-pass.yaml +23 -0
  118. package/src/lint/post-synth/testdata/otel115-fail.yaml +29 -0
  119. package/src/lint/post-synth/testdata/otel115-pass.yaml +29 -0
  120. package/src/platform.test.ts +26 -0
  121. package/src/platform.ts +17 -13
  122. package/src/plugin.test.ts +18 -0
  123. package/src/plugin.ts +22 -0
  124. package/src/skills/chant-otel.md +11 -5
  125. package/src/testdata/genai-configured.yaml +108 -0
  126. package/src/testdata/genai-default.yaml +92 -0
  127. package/src/topology.test.ts +12 -0
  128. package/src/topology.ts +16 -0
  129. package/src/typecheck.test.ts +112 -0
  130. package/src/validate-config.ts +426 -2
  131. package/src/validate.ts +17 -0
package/src/platform.ts CHANGED
@@ -17,6 +17,7 @@ import { DebugExporter } from "./components/exporters";
17
17
  import { HealthCheckExtension } from "./components/extensions";
18
18
  import { Pipeline } from "./pipeline";
19
19
  import { SIGNALS, parseComponentId, type CollectorConfig, type Signal } from "./model";
20
+ import { receiverListenerPaths } from "./validate-config";
20
21
 
21
22
  /** The contrib collector image at the version the built-in components are typed against. */
22
23
  export const COLLECTOR_IMAGE = `otel/opentelemetry-collector-contrib:${COLLECTOR_PIN.version.replace(/^v/, "")}`;
@@ -95,35 +96,38 @@ function portName(parts: string[]): string {
95
96
  return name.slice(0, 15).replace(/-$/, "");
96
97
  }
97
98
 
98
- /** Every `endpoint` value under a receiver's config, with the key path that led to it. */
99
- function receiverEndpoints(value: unknown, path: string[], out: Array<{ path: string[]; endpoint: unknown }>): void {
100
- if (!value || typeof value !== "object" || Array.isArray(value)) return;
101
- for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
102
- if (key === "endpoint") out.push({ path, endpoint: child });
103
- else receiverEndpoints(child, [...path, key], out);
99
+ /** The value at a key path, if every key on the way is there. */
100
+ function valueAt(value: unknown, path: string[]): unknown {
101
+ let cur: unknown = value;
102
+ for (const key of path) {
103
+ if (!cur || typeof cur !== "object" || Array.isArray(cur)) return undefined;
104
+ cur = (cur as Record<string, unknown>)[key];
104
105
  }
106
+ return cur;
105
107
  }
106
108
 
107
109
  const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
108
110
 
109
111
  /**
110
- * The ports a built collector config listens on: every receiver `endpoint`,
112
+ * The ports a built collector config listens on: the endpoint of every
113
+ * receiver that listens (otlp, zipkin, jaeger; the receivers OTEL117 knows),
111
114
  * named after the receiver and the protocol key above it (`otlp-grpc`,
112
- * `otlp-http`), and the `health_check` port if the service enables it.
115
+ * `otlp-http`), and the `health_check` port if the service enables it. A
116
+ * receiver whose `endpoint` is a server it connects to, such as
117
+ * kubeletstats' kubelet, adds no port, and neither does a receiver type the
118
+ * table doesn't list.
113
119
  */
114
120
  export function collectorEndpoints(config: CollectorConfig): CollectorEndpoints {
115
121
  const ports: CollectorPort[] = [];
116
122
  const seen = new Set<number>();
117
123
  for (const [id, receiverConfig] of Object.entries(config.receivers ?? {})) {
118
- const found: Array<{ path: string[]; endpoint: unknown }> = [];
119
- receiverEndpoints(receiverConfig, [], found);
120
124
  const parsed = parseComponentId(id);
121
125
  const base = parsed ? [parsed.type, ...(parsed.name ? [parsed.name] : [])] : [id];
122
- for (const { path, endpoint } of found) {
123
- const p = portOf(endpoint);
126
+ for (const path of receiverListenerPaths(parsed?.type ?? id)) {
127
+ const p = portOf(valueAt(receiverConfig, path));
124
128
  if (!p || seen.has(p.port)) continue;
125
129
  seen.add(p.port);
126
- const protocol = path.filter((k) => k !== "protocols");
130
+ const protocol = path.slice(0, -1).filter((k) => k !== "protocols");
127
131
  ports.push({ name: portName([...base, ...protocol]), port: p.port });
128
132
  }
129
133
  }
@@ -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
  });
package/src/plugin.ts CHANGED
@@ -14,7 +14,10 @@ import { OtelCollectorGenerator } from "./import/generator";
14
14
  import { collectorConfigImporter } from "./import/embedded";
15
15
  import { otelSkills } from "./skill-defs";
16
16
  import { BUILTIN_CATALOG } from "./catalog";
17
+ import { compositeCatalog } from "./composites/catalog";
18
+ import { DEFAULT_TEMPLATE, GENAI_TEMPLATE, K8S_AGENT_TEMPLATE } from "./init-templates";
17
19
  import { COLLECTOR_PIN } from "./define";
20
+ import { collectorTopologyOf } from "./topology";
18
21
 
19
22
  const catalogResource: McpResourceContribution = {
20
23
  uri: "otel:resource-catalog",
@@ -77,6 +80,14 @@ export const otelPlugin: LexiconPlugin = {
77
80
  return rules;
78
81
  },
79
82
 
83
+ // Where this project's telemetry goes, for `chant workspace graph` (#2559).
84
+ // A project with no collector pipeline and no component reports nothing.
85
+ graphMeta(entities) {
86
+ const topology = collectorTopologyOf(entities.values());
87
+ if (topology.pipelines.length === 0 && topology.components.length === 0) return undefined;
88
+ return { collector: topology };
89
+ },
90
+
80
91
  postSynthChecks() {
81
92
  return postSynthChecks;
82
93
  },
@@ -87,6 +98,10 @@ export const otelPlugin: LexiconPlugin = {
87
98
 
88
99
  skills: otelSkills,
89
100
 
101
+ composites() {
102
+ return compositeCatalog;
103
+ },
104
+
90
105
  mcpTools() {
91
106
  return [createDiffTool(otelSerializer, "Compare current collector config output against the previous build", "otel")];
92
107
  },
@@ -95,6 +110,13 @@ export const otelPlugin: LexiconPlugin = {
95
110
  return [catalogResource];
96
111
  },
97
112
 
113
+ // `chant init --lexicon otel [--template k8s-agent|genai]`; see ./init-templates.ts.
114
+ initTemplates(template?: string) {
115
+ if (template === "k8s-agent") return K8S_AGENT_TEMPLATE;
116
+ if (template === "genai") return GENAI_TEMPLATE;
117
+ return DEFAULT_TEMPLATE;
118
+ },
119
+
98
120
  detectTemplate(data: unknown) {
99
121
  return detectTemplate(data);
100
122
  },
@@ -15,6 +15,10 @@ The otel lexicon (`@intentius/chant-lexicon-otel`) types collector config. Each
15
15
  export default { lexicons: ["otel"] };
16
16
  ```
17
17
 
18
+ `chant init --lexicon otel` scaffolds a collector; `--template k8s-agent` scaffolds a per-node Kubernetes agent and `--template genai` a GenAI collector.
19
+
20
+ For a per-node Kubernetes agent, `NodeAgent({ exporters, metricExporters?, clusterName?, kubeletStats? })` declares the whole config: OTLP, host metrics, container logs, `k8sattributes` filtered to the node, and `memory_limiter` first and `batch` last on every pipeline. Pass `Object.values(agent.members)` wherever an entity list is taken, such as the k8s lexicon's `OtelCollector` `config`, which adds what the config reads from the node: `K8S_NODE_NAME` from `spec.nodeName`, the host root at `/hostfs` and `/var/log/pods` read-only, group 0 for the log files, and the RBAC.
21
+
18
22
  ## Declaring components
19
23
 
20
24
  Every component class takes the collector's own config keys (snake_case, as in the collector docs) plus an optional `name`. The id is `type`, or `type/name` when `name` is set.
@@ -41,10 +45,10 @@ Emits `receivers.otlp`, `processors.memory_limiter` and `processors.batch`, `exp
41
45
  | Kind | Types |
42
46
  |---|---|
43
47
  | receivers | otlp, prometheus, hostmetrics, filelog, k8s_cluster, kubeletstats |
44
- | processors | batch, memory_limiter, resource, attributes, k8sattributes, resourcedetection, filter, transform, redaction, tail_sampling, probabilistic_sampler |
48
+ | processors | batch, memory_limiter, resource, attributes, k8sattributes, resourcedetection, filter, transform, redaction, tail_sampling, probabilistic_sampler, deltatocumulative |
45
49
  | exporters | otlp, otlphttp, debug, prometheus, googlecloud, loadbalancing |
46
- | connectors | spanmetrics, servicegraph, routing, forward, count, sum |
47
- | extensions | health_check, pprof, zpages |
50
+ | connectors | spanmetrics, servicegraph, routing, forward, count, sum, signaltometrics |
51
+ | extensions | health_check, pprof, zpages, k8s_leader_elector |
48
52
 
49
53
  Anything else goes through `defineComponent`; see the `chant-otel-custom-components` skill.
50
54
 
@@ -58,7 +62,7 @@ export const traces = new Pipeline({ signal: "traces", receivers: [otlp], export
58
62
  export const red = new Pipeline({ signal: "metrics", name: "red", receivers: [spanmetrics], exporters: [prom] });
59
63
  ```
60
64
 
61
- `spanmetrics` and `servicegraph` turn traces into metrics, `count` and `sum` turn any signal into metrics (`sum` adds up a numeric attribute), `routing` and `forward` keep the signal. A connector on one side only fails OTEL101; a pipeline whose signal the connector can't pair fails OTEL112.
65
+ `spanmetrics` and `servicegraph` turn traces into metrics, `count` and `sum` turn any signal into metrics (`sum` adds up a numeric attribute), `signaltometrics` builds metrics you name, of any type, from any signal with OTTL values, `routing` and `forward` keep the signal. A connector on one side only fails OTEL101; a pipeline whose signal the connector can't pair fails OTEL112. Pipelines that feed each other in a loop through connectors fail OTEL113, a connector id that is also a receiver or exporter id fails OTEL114, and every pipeline a `routing` connector routes to must list it in `receivers` (OTEL115).
62
66
 
63
67
  ## GenAI workloads
64
68
 
@@ -68,7 +72,7 @@ For services that emit GenAI spans, start from the preset instead of writing the
68
72
  export const collector = genAiPipeline({ traceExporters: [tempo], metricExporters: [prom] });
69
73
  ```
70
74
 
71
- It deletes prompt, completion, system-instruction and tool-call content from spans, span events and logs, and derives call, error, duration and token metrics (`genai_calls_total`, `genai_duration_seconds`, `genai_tokens_input_total`, `genai_tokens_output_total`) from every GenAI span before sampling. Keep content only when asked, with `keepContent: true`. `genAiComponents()` returns the pieces for hand-built pipelines.
75
+ It deletes prompt, completion, system-instruction and tool-call content from spans, span events and logs, and derives call, error, duration and token metrics (`genai_calls_total`, `genai_duration_seconds`, `genai_tokens_input_total`, `genai_tokens_output_total`) from every GenAI span before sampling. Keep content only when asked, with `keepContent: true`. When a dashboard or backend expects the semantic conventions' names, add `clientMetrics: "derive"` for `gen_ai.client.operation.duration` and `gen_ai.client.token.usage` from spans, or `"passthrough"` when the SDK already records them; never both. Read metric names from `genAiMetrics(options)` (`.client` for the conventions' ones) instead of writing them out. `genAiComponents()` returns the pieces for hand-built pipelines. The token sums and derived client metrics are deltas: `deltaToCumulative: "auto"` puts a `deltatocumulative` processor in front of any metric exporter other than `prometheus` or `debug` (the default with `clientMetrics: "derive"`), `true` always, `false` never.
72
76
 
73
77
  ## Rules
74
78
 
@@ -76,6 +80,8 @@ It deletes prompt, completion, system-instruction and tool-call content from spa
76
80
  - Put `memory_limiter` first in `processors` (OTEL105).
77
81
  - Never write a credential literally. Use `"${env:NAME}"` and the collector reads it at start-up (OTEL002).
78
82
  - Every pipeline needs at least one receiver and one exporter (OTEL102). A declared component no pipeline uses is a warning (OTEL103).
83
+ - Don't split metrics by a per-request id (`gen_ai.conversation.id`, `gen_ai.response.id`, `gen_ai.tool.call.id`, `session.id`, `user.id`, `enduser.id`) or a content key: each value starts new time series (OTEL116).
84
+ - Give every listener its own port. The collector's own metrics already use localhost:8888, so a `prometheus` exporter there makes it exit with "address already in use" (OTEL117).
79
85
  - Declared extensions are enabled in declaration order unless a `Service` lists `extensions` itself.
80
86
 
81
87
  ## Starting from an existing config
@@ -0,0 +1,108 @@
1
+ # chant: semconv gen_ai github.com/open-telemetry/semantic-conventions@v1.41.1 (transform/genai_content, redaction/genai_content, filter/genai_spans, spanmetrics/genai, sum/genai_tokens)
2
+
3
+ receivers:
4
+ otlp:
5
+ protocols:
6
+ grpc:
7
+ endpoint: 0.0.0.0:4317
8
+ http:
9
+ endpoint: 0.0.0.0:4318
10
+
11
+ processors:
12
+ memory_limiter:
13
+ check_interval: 1s
14
+ limit_percentage: 80
15
+ spike_limit_percentage: 20
16
+ transform/genai_content:
17
+ error_mode: ignore
18
+ trace_statements:
19
+ - context: span
20
+ statements: ["delete_key(span.attributes, \"gen_ai.system_instructions\")", "delete_key(span.attributes, \"gen_ai.input.messages\")", "delete_key(span.attributes, \"gen_ai.output.messages\")", "delete_key(span.attributes, \"gen_ai.tool.call.arguments\")", "delete_key(span.attributes, \"gen_ai.tool.call.result\")", "delete_key(span.attributes, \"gen_ai.retrieval.query.text\")", "delete_key(span.attributes, \"gen_ai.retrieval.documents\")", "delete_key(span.attributes, \"gen_ai.prompt\")", "delete_key(span.attributes, \"gen_ai.completion\")", "delete_matching_keys(span.attributes, \"^gen_ai\\\\.(prompt|completion)\\\\.[0-9]+\\\\..+$\")"]
21
+ - context: spanevent
22
+ statements: ["delete_key(spanevent.attributes, \"gen_ai.system_instructions\")", "delete_key(spanevent.attributes, \"gen_ai.input.messages\")", "delete_key(spanevent.attributes, \"gen_ai.output.messages\")", "delete_key(spanevent.attributes, \"gen_ai.tool.call.arguments\")", "delete_key(spanevent.attributes, \"gen_ai.tool.call.result\")", "delete_key(spanevent.attributes, \"gen_ai.retrieval.query.text\")", "delete_key(spanevent.attributes, \"gen_ai.retrieval.documents\")", "delete_key(spanevent.attributes, \"gen_ai.prompt\")", "delete_key(spanevent.attributes, \"gen_ai.completion\")", "delete_matching_keys(spanevent.attributes, \"^gen_ai\\\\.(prompt|completion)\\\\.[0-9]+\\\\..+$\")"]
23
+ log_statements:
24
+ - context: log
25
+ statements: ["delete_key(log.attributes, \"gen_ai.system_instructions\")", "delete_key(log.attributes, \"gen_ai.input.messages\")", "delete_key(log.attributes, \"gen_ai.output.messages\")", "delete_key(log.attributes, \"gen_ai.tool.call.arguments\")", "delete_key(log.attributes, \"gen_ai.tool.call.result\")", "delete_key(log.attributes, \"gen_ai.retrieval.query.text\")", "delete_key(log.attributes, \"gen_ai.retrieval.documents\")", "delete_key(log.attributes, \"gen_ai.prompt\")", "delete_key(log.attributes, \"gen_ai.completion\")", "delete_matching_keys(log.attributes, \"^gen_ai\\\\.(prompt|completion)\\\\.[0-9]+\\\\..+$\")", "delete_matching_keys(log.body, \"^(content|message|tool_calls)$\") where IsMap(log.body) and (IsMatch(log.event_name, \"^(gen_ai\\\\.system\\\\.message|gen_ai\\\\.user\\\\.message|gen_ai\\\\.assistant\\\\.message|gen_ai\\\\.tool\\\\.message|gen_ai\\\\.choice)$\") or IsMatch(log.attributes[\"event.name\"], \"^(gen_ai\\\\.system\\\\.message|gen_ai\\\\.user\\\\.message|gen_ai\\\\.assistant\\\\.message|gen_ai\\\\.tool\\\\.message|gen_ai\\\\.choice)$\"))", "set(log.body, \"\") where IsString(log.body) and (IsMatch(log.event_name, \"^(gen_ai\\\\.system\\\\.message|gen_ai\\\\.user\\\\.message|gen_ai\\\\.assistant\\\\.message|gen_ai\\\\.tool\\\\.message|gen_ai\\\\.choice)$\") or IsMatch(log.attributes[\"event.name\"], \"^(gen_ai\\\\.system\\\\.message|gen_ai\\\\.user\\\\.message|gen_ai\\\\.assistant\\\\.message|gen_ai\\\\.tool\\\\.message|gen_ai\\\\.choice)$\"))"]
26
+ redaction/genai_content:
27
+ allow_all_keys: true
28
+ blocked_key_patterns: [^(gen_ai\.system_instructions|gen_ai\.input\.messages|gen_ai\.output\.messages|gen_ai\.tool\.call\.arguments|gen_ai\.tool\.call\.result|gen_ai\.retrieval\.query\.text|gen_ai\.retrieval\.documents|gen_ai\.prompt|gen_ai\.completion)$, "^gen_ai\\.(prompt|completion)\\.[0-9]+\\..+$"]
29
+ blocked_values: ["secret-[a-z]+"]
30
+ filter/sampler:
31
+ traces:
32
+ span: [status.code != STATUS_CODE_ERROR]
33
+ batch: {}
34
+ filter/genai_spans:
35
+ error_mode: ignore
36
+ traces:
37
+ span: ["attributes[\"gen_ai.operation.name\"] == nil"]
38
+
39
+ exporters:
40
+ otlp/tempo:
41
+ endpoint: tempo:4317
42
+ tls:
43
+ insecure: true
44
+ prometheus:
45
+ endpoint: 0.0.0.0:8889
46
+ debug:
47
+ verbosity: basic
48
+
49
+ connectors:
50
+ forward/genai: {}
51
+ forward/sampled: {}
52
+ spanmetrics/genai:
53
+ namespace: agents.support
54
+ dimensions:
55
+ - name: gen_ai.operation.name
56
+ - name: gen_ai.request.model
57
+ - name: gen_ai.tool.name
58
+ - name: error.type
59
+ - name: gen_ai.agent.name
60
+ histogram:
61
+ unit: s
62
+ explicit:
63
+ buckets: [100ms, 250ms, 500ms, 1s, 2s, 5s, 10s, 20s, 40s, 80s]
64
+ metrics_flush_interval: 15s
65
+ sum/genai_tokens:
66
+ spans:
67
+ agents.support.tokens.input:
68
+ source_attribute: gen_ai.usage.input_tokens
69
+ description: Input tokens used by GenAI operations
70
+ conditions: ["attributes[\"gen_ai.usage.input_tokens\"] != nil and not (kind == SPAN_KIND_INTERNAL and (attributes[\"gen_ai.operation.name\"] == \"invoke_agent\" or attributes[\"gen_ai.operation.name\"] == \"invoke_workflow\"))"]
71
+ attributes:
72
+ - key: gen_ai.request.model
73
+ default_value: unknown
74
+ agents.support.tokens.output:
75
+ source_attribute: gen_ai.usage.output_tokens
76
+ description: Output tokens used by GenAI operations
77
+ conditions: ["attributes[\"gen_ai.usage.output_tokens\"] != nil and not (kind == SPAN_KIND_INTERNAL and (attributes[\"gen_ai.operation.name\"] == \"invoke_agent\" or attributes[\"gen_ai.operation.name\"] == \"invoke_workflow\"))"]
78
+ attributes:
79
+ - key: gen_ai.request.model
80
+ default_value: unknown
81
+
82
+ extensions:
83
+ health_check:
84
+ endpoint: 0.0.0.0:13133
85
+
86
+ service:
87
+ extensions: [health_check]
88
+ pipelines:
89
+ traces:
90
+ receivers: [otlp]
91
+ processors: [memory_limiter, transform/genai_content, redaction/genai_content]
92
+ exporters: [forward/genai, forward/sampled]
93
+ traces/sampled:
94
+ receivers: [forward/sampled]
95
+ processors: [filter/sampler, batch]
96
+ exporters: [otlp/tempo]
97
+ traces/genai:
98
+ receivers: [forward/genai]
99
+ processors: [filter/genai_spans]
100
+ exporters: [spanmetrics/genai, sum/genai_tokens]
101
+ metrics/genai:
102
+ receivers: [spanmetrics/genai, sum/genai_tokens]
103
+ processors: [batch]
104
+ exporters: [prometheus]
105
+ logs:
106
+ receivers: [otlp]
107
+ processors: [memory_limiter, transform/genai_content, redaction/genai_content, batch]
108
+ exporters: [debug]
@@ -0,0 +1,92 @@
1
+ # chant: semconv gen_ai github.com/open-telemetry/semantic-conventions@v1.41.1 (transform/genai_content, redaction/genai_content, filter/genai_spans, spanmetrics/genai, sum/genai_tokens)
2
+
3
+ receivers:
4
+ otlp:
5
+ protocols:
6
+ grpc:
7
+ endpoint: 0.0.0.0:4317
8
+ http:
9
+ endpoint: 0.0.0.0:4318
10
+
11
+ processors:
12
+ memory_limiter:
13
+ check_interval: 1s
14
+ limit_percentage: 80
15
+ spike_limit_percentage: 20
16
+ transform/genai_content:
17
+ error_mode: ignore
18
+ trace_statements:
19
+ - context: span
20
+ statements: ["delete_key(span.attributes, \"gen_ai.system_instructions\")", "delete_key(span.attributes, \"gen_ai.input.messages\")", "delete_key(span.attributes, \"gen_ai.output.messages\")", "delete_key(span.attributes, \"gen_ai.tool.call.arguments\")", "delete_key(span.attributes, \"gen_ai.tool.call.result\")", "delete_key(span.attributes, \"gen_ai.retrieval.query.text\")", "delete_key(span.attributes, \"gen_ai.retrieval.documents\")", "delete_key(span.attributes, \"gen_ai.prompt\")", "delete_key(span.attributes, \"gen_ai.completion\")", "delete_matching_keys(span.attributes, \"^gen_ai\\\\.(prompt|completion)\\\\.[0-9]+\\\\..+$\")"]
21
+ - context: spanevent
22
+ statements: ["delete_key(spanevent.attributes, \"gen_ai.system_instructions\")", "delete_key(spanevent.attributes, \"gen_ai.input.messages\")", "delete_key(spanevent.attributes, \"gen_ai.output.messages\")", "delete_key(spanevent.attributes, \"gen_ai.tool.call.arguments\")", "delete_key(spanevent.attributes, \"gen_ai.tool.call.result\")", "delete_key(spanevent.attributes, \"gen_ai.retrieval.query.text\")", "delete_key(spanevent.attributes, \"gen_ai.retrieval.documents\")", "delete_key(spanevent.attributes, \"gen_ai.prompt\")", "delete_key(spanevent.attributes, \"gen_ai.completion\")", "delete_matching_keys(spanevent.attributes, \"^gen_ai\\\\.(prompt|completion)\\\\.[0-9]+\\\\..+$\")"]
23
+ log_statements:
24
+ - context: log
25
+ statements: ["delete_key(log.attributes, \"gen_ai.system_instructions\")", "delete_key(log.attributes, \"gen_ai.input.messages\")", "delete_key(log.attributes, \"gen_ai.output.messages\")", "delete_key(log.attributes, \"gen_ai.tool.call.arguments\")", "delete_key(log.attributes, \"gen_ai.tool.call.result\")", "delete_key(log.attributes, \"gen_ai.retrieval.query.text\")", "delete_key(log.attributes, \"gen_ai.retrieval.documents\")", "delete_key(log.attributes, \"gen_ai.prompt\")", "delete_key(log.attributes, \"gen_ai.completion\")", "delete_matching_keys(log.attributes, \"^gen_ai\\\\.(prompt|completion)\\\\.[0-9]+\\\\..+$\")", "delete_matching_keys(log.body, \"^(content|message|tool_calls)$\") where IsMap(log.body) and (IsMatch(log.event_name, \"^(gen_ai\\\\.system\\\\.message|gen_ai\\\\.user\\\\.message|gen_ai\\\\.assistant\\\\.message|gen_ai\\\\.tool\\\\.message|gen_ai\\\\.choice)$\") or IsMatch(log.attributes[\"event.name\"], \"^(gen_ai\\\\.system\\\\.message|gen_ai\\\\.user\\\\.message|gen_ai\\\\.assistant\\\\.message|gen_ai\\\\.tool\\\\.message|gen_ai\\\\.choice)$\"))", "set(log.body, \"\") where IsString(log.body) and (IsMatch(log.event_name, \"^(gen_ai\\\\.system\\\\.message|gen_ai\\\\.user\\\\.message|gen_ai\\\\.assistant\\\\.message|gen_ai\\\\.tool\\\\.message|gen_ai\\\\.choice)$\") or IsMatch(log.attributes[\"event.name\"], \"^(gen_ai\\\\.system\\\\.message|gen_ai\\\\.user\\\\.message|gen_ai\\\\.assistant\\\\.message|gen_ai\\\\.tool\\\\.message|gen_ai\\\\.choice)$\"))"]
26
+ redaction/genai_content:
27
+ allow_all_keys: true
28
+ blocked_key_patterns: [^(gen_ai\.system_instructions|gen_ai\.input\.messages|gen_ai\.output\.messages|gen_ai\.tool\.call\.arguments|gen_ai\.tool\.call\.result|gen_ai\.retrieval\.query\.text|gen_ai\.retrieval\.documents|gen_ai\.prompt|gen_ai\.completion)$, "^gen_ai\\.(prompt|completion)\\.[0-9]+\\..+$"]
29
+ batch: {}
30
+ filter/genai_spans:
31
+ error_mode: ignore
32
+ traces:
33
+ span: ["attributes[\"gen_ai.operation.name\"] == nil"]
34
+
35
+ exporters:
36
+ debug:
37
+ verbosity: basic
38
+
39
+ connectors:
40
+ forward/genai: {}
41
+ spanmetrics/genai:
42
+ namespace: genai
43
+ dimensions:
44
+ - name: gen_ai.operation.name
45
+ - name: gen_ai.request.model
46
+ - name: gen_ai.tool.name
47
+ - name: error.type
48
+ histogram:
49
+ unit: s
50
+ explicit:
51
+ buckets: [100ms, 250ms, 500ms, 1s, 2s, 5s, 10s, 20s, 40s, 80s]
52
+ metrics_flush_interval: 15s
53
+ sum/genai_tokens:
54
+ spans:
55
+ genai.tokens.input:
56
+ source_attribute: gen_ai.usage.input_tokens
57
+ description: Input tokens used by GenAI operations
58
+ conditions: ["attributes[\"gen_ai.usage.input_tokens\"] != nil and not (kind == SPAN_KIND_INTERNAL and (attributes[\"gen_ai.operation.name\"] == \"invoke_agent\" or attributes[\"gen_ai.operation.name\"] == \"invoke_workflow\"))"]
59
+ attributes:
60
+ - key: gen_ai.request.model
61
+ default_value: unknown
62
+ genai.tokens.output:
63
+ source_attribute: gen_ai.usage.output_tokens
64
+ description: Output tokens used by GenAI operations
65
+ conditions: ["attributes[\"gen_ai.usage.output_tokens\"] != nil and not (kind == SPAN_KIND_INTERNAL and (attributes[\"gen_ai.operation.name\"] == \"invoke_agent\" or attributes[\"gen_ai.operation.name\"] == \"invoke_workflow\"))"]
66
+ attributes:
67
+ - key: gen_ai.request.model
68
+ default_value: unknown
69
+
70
+ extensions:
71
+ health_check:
72
+ endpoint: 0.0.0.0:13133
73
+
74
+ service:
75
+ extensions: [health_check]
76
+ pipelines:
77
+ traces:
78
+ receivers: [otlp]
79
+ processors: [memory_limiter, transform/genai_content, redaction/genai_content, batch]
80
+ exporters: [debug, forward/genai]
81
+ traces/genai:
82
+ receivers: [forward/genai]
83
+ processors: [filter/genai_spans]
84
+ exporters: [spanmetrics/genai, sum/genai_tokens]
85
+ metrics/genai:
86
+ receivers: [spanmetrics/genai, sum/genai_tokens]
87
+ processors: [batch]
88
+ exporters: [debug]
89
+ logs:
90
+ receivers: [otlp]
91
+ processors: [memory_limiter, transform/genai_content, redaction/genai_content, batch]
92
+ exporters: [debug]
@@ -56,6 +56,18 @@ describe("collectorTopology", () => {
56
56
  ]);
57
57
  });
58
58
 
59
+ test("reports the wire protocols a component speaks, and none for one that states none (#2558)", () => {
60
+ const topo = collectorTopologyOf(entities);
61
+ const protocols = (id: string) => topo.components.find((c) => c.id === id)!.protocols;
62
+ expect(protocols("otlp")).toEqual(["grpc", "http/protobuf", "http/json"]);
63
+ expect(protocols("otlphttp/honeycomb")).toEqual(["http/protobuf"]);
64
+ expect(protocols("health_check")).toEqual([]);
65
+ const json = collectorTopologyOf([new OtlpHttpExporter({ endpoint: "https://x", encoding: "json" })]);
66
+ expect(json.components[0]!.protocols).toEqual(["http/json"]);
67
+ const httpOnly = collectorTopologyOf([new OtlpReceiver({ protocols: { http: {} } })]);
68
+ expect(httpOnly.components[0]!.protocols).toEqual(["http/protobuf", "http/json"]);
69
+ });
70
+
59
71
  test("reports receiver listen addresses, defaults included, and extension endpoints", () => {
60
72
  const topo = collectorTopologyOf(entities);
61
73
  expect(topo.components.find((c) => c.id === "otlp")!.endpoints).toEqual(["0.0.0.0:4317", "localhost:4318"]);
package/src/topology.ts CHANGED
@@ -41,6 +41,11 @@ export interface TopologyComponent {
41
41
  schema?: SchemaPin;
42
42
  /** Addresses it listens on or sends to, as the config states them. Empty when the config names none. */
43
43
  endpoints: string[];
44
+ /**
45
+ * The wire protocols it speaks, as OTLP SDKs name them (`grpc`, `http/protobuf`, `http/json`).
46
+ * Empty when its definition states none, which a protocol check reads as unknown.
47
+ */
48
+ protocols: string[];
44
49
  /** The pipelines that use it. For a connector, those on either side; for an extension, empty. */
45
50
  pipelines: string[];
46
51
  }
@@ -87,6 +92,16 @@ export interface CollectorTopology {
87
92
  semconv: SemconvUsage[];
88
93
  }
89
94
 
95
+ function protocolsOf(kind: ComponentKind, type: string, config: Record<string, unknown> | null | undefined): string[] {
96
+ const def = definitionOf(kind, type);
97
+ if (!def?.protocols) return [];
98
+ try {
99
+ return def.protocols((config ?? {}) as never);
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
90
105
  function endpointsOf(kind: ComponentKind, type: string, config: Record<string, unknown> | null | undefined): string[] {
91
106
  const def = definitionOf(kind, type);
92
107
  const cfg = config ?? {};
@@ -131,6 +146,7 @@ export function collectorTopology(config: CollectorConfig): CollectorTopology {
131
146
  builtin: def?.builtin ?? false,
132
147
  ...(def ? { schema: { ...def.pin } } : {}),
133
148
  endpoints: endpointsOf(kind, type, cfg),
149
+ protocols: protocolsOf(kind, type, cfg),
134
150
  pipelines: inPipelines,
135
151
  });
136
152
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Type-level guarantees of the otel lexicon's hand-written types: what
3
+ * TypeScript rejects before a build runs. Each `@ts-expect-error` line is a
4
+ * mistake the types must keep catching; tsc fails on the directive if the
5
+ * line ever compiles (`tsconfig.typecheck.json`, scripts/typecheck.ts). The
6
+ * runtime assertions keep vitest's view of the file honest.
7
+ */
8
+ import { describe, expect, expectTypeOf, test } from "vitest";
9
+ import {
10
+ BatchProcessor,
11
+ DebugExporter,
12
+ HealthCheckExtension,
13
+ MemoryLimiterProcessor,
14
+ NodeAgent,
15
+ OtlpExporter,
16
+ OtlpReceiver,
17
+ Pipeline,
18
+ Service,
19
+ SpanMetricsConnector,
20
+ TailSamplingProcessor,
21
+ defineComponent,
22
+ type PipelineEntity,
23
+ type TailSamplingPolicy,
24
+ } from "./index";
25
+ import { generate } from "./codegen/generate";
26
+
27
+ const otlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });
28
+ const batch = new BatchProcessor({});
29
+ const debug = new DebugExporter({ verbosity: "basic" });
30
+ const health = new HealthCheckExtension({});
31
+ const spanmetrics = new SpanMetricsConnector({});
32
+
33
+ describe("pipelines take components of the right kind", () => {
34
+ test("receivers, processors and exporters in their own slots; a connector on either side", () => {
35
+ const traces = new Pipeline({ signal: "traces", receivers: [otlp], processors: [batch], exporters: [debug, spanmetrics] });
36
+ const metrics = new Pipeline({ signal: "metrics", receivers: [spanmetrics], exporters: [debug] });
37
+ expectTypeOf(traces).toEqualTypeOf<PipelineEntity>();
38
+ expect(traces.pipelineId).toBe("traces");
39
+ expect(metrics.pipelineId).toBe("metrics");
40
+ });
41
+
42
+ test("a component of the wrong kind is a type error", () => {
43
+ // @ts-expect-error a receiver is not an exporter
44
+ new Pipeline({ signal: "traces", receivers: [otlp], exporters: [otlp] });
45
+ // @ts-expect-error an exporter is not a processor
46
+ new Pipeline({ signal: "traces", receivers: [otlp], processors: [debug], exporters: [debug] });
47
+ // @ts-expect-error an extension is not a receiver
48
+ new Pipeline({ signal: "traces", receivers: [health], exporters: [debug] });
49
+ // @ts-expect-error a processor is not an extension
50
+ new Service({ extensions: [batch] });
51
+ expect(true).toBe(true);
52
+ });
53
+
54
+ test("the signal is one the collector knows", () => {
55
+ // @ts-expect-error "spans" is not a signal; "traces" is
56
+ new Pipeline({ signal: "spans", receivers: [otlp], exporters: [debug] });
57
+ expect(true).toBe(true);
58
+ });
59
+ });
60
+
61
+ describe("component configs are typed", () => {
62
+ test("a wrongly typed setting is a type error", () => {
63
+ // @ts-expect-error limit_mib is a number
64
+ new MemoryLimiterProcessor({ check_interval: "1s", limit_mib: "400" });
65
+ // @ts-expect-error verbosity is basic, normal or detailed
66
+ new DebugExporter({ verbosity: "verbose" });
67
+ // @ts-expect-error tls is an object, not a flag
68
+ new OtlpExporter({ endpoint: "tempo:4317", tls: true });
69
+ expect(true).toBe(true);
70
+ });
71
+
72
+ test("tail sampling policies are discriminated by type", () => {
73
+ const errors: TailSamplingPolicy = { name: "errors", type: "status_code", status_code: { status_codes: ["ERROR"] } };
74
+ // @ts-expect-error a latency policy carries its settings under `latency`
75
+ const wrong: TailSamplingPolicy = { name: "slow", type: "latency", probabilistic: { sampling_percentage: 10 } };
76
+ // @ts-expect-error decision_wait is required
77
+ new TailSamplingProcessor({ policies: [errors] });
78
+ expect(wrong.name).toBe("slow");
79
+ });
80
+
81
+ test("a custom component's config type follows its definition", () => {
82
+ const Vendor = defineComponent<{ token: string }>()({
83
+ kind: "exporter",
84
+ type: "vendor",
85
+ pin: { source: "github.com/example/vendorexporter", version: "v1.0.0" },
86
+ });
87
+ new Vendor({ token: "${env:VENDOR_TOKEN}" });
88
+ // @ts-expect-error token is a string
89
+ new Vendor({ token: 42 });
90
+ expect(Vendor.definition.type).toBe("vendor");
91
+ });
92
+ });
93
+
94
+ describe("NodeAgent props", () => {
95
+ test("exporters are required and must be exporters", () => {
96
+ const agent = NodeAgent({ exporters: [debug] });
97
+ expectTypeOf(agent.traces).toEqualTypeOf<PipelineEntity>();
98
+ // @ts-expect-error exporters is required
99
+ expect(() => NodeAgent({})).toThrow(/exporters/);
100
+ // @ts-expect-error a receiver is not an exporter
101
+ NodeAgent({ exporters: [otlp] });
102
+ // @ts-expect-error hostMetrics is a switch or its settings
103
+ NodeAgent({ exporters: [debug], hostMetrics: "on" });
104
+ });
105
+ });
106
+
107
+ describe("the packaged types entry", () => {
108
+ test("re-exports the package's own declarations", async () => {
109
+ const result = await generate();
110
+ expect(result.typesDTS).toContain('export * from "../index"');
111
+ });
112
+ });