@intentius/chant-lexicon-otel 0.124.46 → 0.124.49

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 (211) hide show
  1. package/README.md +2 -2
  2. package/dist/build/capability-plugin.d.ts +10 -0
  3. package/dist/build/capability-plugin.d.ts.map +1 -0
  4. package/dist/build/capability-plugin.js +19 -0
  5. package/dist/build/ocb-build.d.ts +38 -0
  6. package/dist/build/ocb-build.d.ts.map +1 -0
  7. package/dist/build/ocb-build.js +49 -0
  8. package/dist/clickhouse-tables.d.ts +73 -0
  9. package/dist/clickhouse-tables.d.ts.map +1 -0
  10. package/dist/clickhouse-tables.js +199 -0
  11. package/dist/codegen/docs.d.ts.map +1 -1
  12. package/dist/codegen/docs.js +4 -2
  13. package/dist/codegen/generate.d.ts +29 -10
  14. package/dist/codegen/generate.d.ts.map +1 -1
  15. package/dist/codegen/generate.js +82 -17
  16. package/dist/components/common.d.ts +3 -1
  17. package/dist/components/common.d.ts.map +1 -1
  18. package/dist/components/common.js +2 -0
  19. package/dist/components/connectors.d.ts +25 -90
  20. package/dist/components/connectors.d.ts.map +1 -1
  21. package/dist/components/connectors.js +3 -0
  22. package/dist/components/exporters.d.ts +78 -34
  23. package/dist/components/exporters.d.ts.map +1 -1
  24. package/dist/components/exporters.js +26 -1
  25. package/dist/components/extensions.d.ts +16 -33
  26. package/dist/components/extensions.d.ts.map +1 -1
  27. package/dist/components/filtering.d.ts +13 -60
  28. package/dist/components/filtering.d.ts.map +1 -1
  29. package/dist/components/k8s-receivers.d.ts +5 -53
  30. package/dist/components/k8s-receivers.d.ts.map +1 -1
  31. package/dist/components/overlay.d.ts +25 -0
  32. package/dist/components/overlay.d.ts.map +1 -0
  33. package/dist/components/overlay.js +19 -0
  34. package/dist/components/processors.d.ts +18 -79
  35. package/dist/components/processors.d.ts.map +1 -1
  36. package/dist/components/receivers.d.ts +17 -71
  37. package/dist/components/receivers.d.ts.map +1 -1
  38. package/dist/components/receivers.js +3 -1
  39. package/dist/components/sampling.d.ts +19 -35
  40. package/dist/components/sampling.d.ts.map +1 -1
  41. package/dist/config-hygiene.d.ts +10 -8
  42. package/dist/config-hygiene.d.ts.map +1 -1
  43. package/dist/config-hygiene.js +36 -13
  44. package/dist/coverage.d.ts +50 -0
  45. package/dist/coverage.d.ts.map +1 -0
  46. package/dist/coverage.js +92 -0
  47. package/dist/define.d.ts +8 -5
  48. package/dist/define.d.ts.map +1 -1
  49. package/dist/define.js +10 -6
  50. package/dist/distribution.d.ts +125 -0
  51. package/dist/distribution.d.ts.map +1 -0
  52. package/dist/distribution.js +227 -0
  53. package/dist/generated/index.d.ts +4234 -0
  54. package/dist/generated/index.d.ts.map +1 -0
  55. package/dist/generated/index.js +23 -0
  56. package/dist/index.d.ts +7 -1
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +9 -1
  59. package/dist/init-templates.js +2 -2
  60. package/dist/integrity.json +11 -9
  61. package/dist/lint/audit-catalog.d.ts +3 -0
  62. package/dist/lint/audit-catalog.d.ts.map +1 -1
  63. package/dist/lint/audit-catalog.js +5 -0
  64. package/dist/lint/post-synth/index.d.ts.map +1 -1
  65. package/dist/lint/post-synth/index.js +4 -0
  66. package/dist/lint/post-synth/otel-helpers.d.ts +2 -1
  67. package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
  68. package/dist/lint/post-synth/otel-helpers.js +1 -1
  69. package/dist/lint/post-synth/otel119.d.ts +1 -1
  70. package/dist/lint/post-synth/otel119.js +1 -1
  71. package/dist/lint/post-synth/otel125.d.ts +1 -1
  72. package/dist/lint/post-synth/otel125.js +1 -1
  73. package/dist/lint/post-synth/otel126.d.ts +1 -1
  74. package/dist/lint/post-synth/otel126.js +1 -1
  75. package/dist/lint/post-synth/otel127.d.ts +1 -1
  76. package/dist/lint/post-synth/otel127.js +1 -1
  77. package/dist/lint/post-synth/otel128.d.ts +8 -0
  78. package/dist/lint/post-synth/otel128.d.ts.map +1 -0
  79. package/dist/lint/post-synth/otel128.js +17 -0
  80. package/dist/lint/post-synth/otel129.d.ts +15 -0
  81. package/dist/lint/post-synth/otel129.d.ts.map +1 -0
  82. package/dist/lint/post-synth/otel129.js +92 -0
  83. package/dist/lsp/completions.d.ts +10 -1
  84. package/dist/lsp/completions.d.ts.map +1 -1
  85. package/dist/lsp/completions.js +29 -1
  86. package/dist/lsp/hover.d.ts +5 -1
  87. package/dist/lsp/hover.d.ts.map +1 -1
  88. package/dist/lsp/hover.js +16 -3
  89. package/dist/lsp/properties.d.ts +34 -0
  90. package/dist/lsp/properties.d.ts.map +1 -0
  91. package/dist/lsp/properties.js +135 -0
  92. package/dist/manifest.json +1 -1
  93. package/dist/meta.json +5 -0
  94. package/dist/metric-names.d.ts +36 -8
  95. package/dist/metric-names.d.ts.map +1 -1
  96. package/dist/metric-names.js +42 -9
  97. package/dist/model.d.ts +1 -1
  98. package/dist/model.js +1 -1
  99. package/dist/okf/index.md +3 -0
  100. package/dist/okf/rules/OTEL128.md +11 -0
  101. package/dist/okf/rules/OTEL129.md +11 -0
  102. package/dist/okf/types/ClickHouseExporter.md +9 -0
  103. package/dist/op/activities/collector-audit.d.ts +18 -6
  104. package/dist/op/activities/collector-audit.d.ts.map +1 -1
  105. package/dist/op/activities/collector-audit.js +37 -21
  106. package/dist/plugin.d.ts.map +1 -1
  107. package/dist/plugin.js +51 -8
  108. package/dist/rules/otel-helpers.ts +1 -1
  109. package/dist/rules/otel119.ts +1 -1
  110. package/dist/rules/otel125.ts +1 -1
  111. package/dist/rules/otel126.ts +1 -1
  112. package/dist/rules/otel127.ts +1 -1
  113. package/dist/rules/otel128.ts +22 -0
  114. package/dist/rules/otel129.ts +99 -0
  115. package/dist/serializer.d.ts +3 -0
  116. package/dist/serializer.d.ts.map +1 -1
  117. package/dist/serializer.js +12 -2
  118. package/dist/spec/builtins.d.ts +17 -0
  119. package/dist/spec/builtins.d.ts.map +1 -0
  120. package/dist/spec/builtins.js +45 -0
  121. package/dist/spec/fetch.d.ts +120 -0
  122. package/dist/spec/fetch.d.ts.map +1 -0
  123. package/dist/spec/fetch.js +282 -0
  124. package/dist/spec/parse.d.ts +77 -0
  125. package/dist/spec/parse.d.ts.map +1 -0
  126. package/dist/spec/parse.js +337 -0
  127. package/dist/spec/pin.d.ts +82 -0
  128. package/dist/spec/pin.d.ts.map +1 -0
  129. package/dist/spec/pin.js +126 -0
  130. package/dist/spec/snapshot-cli.d.ts +13 -0
  131. package/dist/spec/snapshot-cli.d.ts.map +1 -0
  132. package/dist/spec/snapshot-cli.js +31 -0
  133. package/dist/spec/snapshot.d.ts +9 -0
  134. package/dist/spec/snapshot.d.ts.map +1 -0
  135. package/dist/spec/snapshot.js +14 -0
  136. package/dist/types/index.d.ts +3 -2
  137. package/dist/validate-config.d.ts +1 -1
  138. package/dist/validate-config.d.ts.map +1 -1
  139. package/dist/validate.d.ts.map +1 -1
  140. package/dist/validate.js +1 -0
  141. package/dist/workspace-links.d.ts +25 -0
  142. package/dist/workspace-links.d.ts.map +1 -0
  143. package/dist/workspace-links.js +49 -0
  144. package/package.json +3 -2
  145. package/src/build/capability-plugin.ts +30 -0
  146. package/src/build/ocb-build.ts +73 -0
  147. package/src/clickhouse-exporter.e2e.test.ts +208 -0
  148. package/src/clickhouse-tables.test.ts +216 -0
  149. package/src/clickhouse-tables.ts +223 -0
  150. package/src/codegen/docs.ts +4 -2
  151. package/src/codegen/generate.ts +95 -19
  152. package/src/components/common.ts +4 -2
  153. package/src/components/connectors.ts +30 -77
  154. package/src/components/exporters.ts +128 -61
  155. package/src/components/extensions.ts +22 -36
  156. package/src/components/filtering.ts +17 -58
  157. package/src/components/k8s-receivers.test.ts +1 -1
  158. package/src/components/k8s-receivers.ts +3 -45
  159. package/src/components/overlay.ts +24 -0
  160. package/src/components/processors.ts +18 -63
  161. package/src/components/receivers.ts +20 -60
  162. package/src/components/sampling.ts +24 -37
  163. package/src/config-hygiene.ts +36 -13
  164. package/src/coverage.test.ts +37 -0
  165. package/src/coverage.ts +121 -0
  166. package/src/define.test.ts +2 -0
  167. package/src/define.ts +10 -6
  168. package/src/distribution.test.ts +250 -0
  169. package/src/distribution.ts +305 -0
  170. package/src/generated/index.ts +4576 -0
  171. package/src/generated/lexicon-otel.json +5 -0
  172. package/src/generated/properties.json +1 -0
  173. package/src/import/roundtrip.test.ts +16 -0
  174. package/src/import/testdata/clickhouse.yaml +37 -0
  175. package/src/import/testdata/fixtures.ts +17 -2
  176. package/src/import/testdata/upstream/clickhouse-exporter.yaml +60 -0
  177. package/src/index.ts +69 -0
  178. package/src/init-templates.ts +2 -2
  179. package/src/lint/audit-catalog.ts +15 -0
  180. package/src/lint/post-synth/index.ts +4 -0
  181. package/src/lint/post-synth/otel-helpers.ts +1 -1
  182. package/src/lint/post-synth/otel119.ts +1 -1
  183. package/src/lint/post-synth/otel125.ts +1 -1
  184. package/src/lint/post-synth/otel126.ts +1 -1
  185. package/src/lint/post-synth/otel127.ts +1 -1
  186. package/src/lint/post-synth/otel128.ts +22 -0
  187. package/src/lint/post-synth/otel129.ts +99 -0
  188. package/src/lsp/completions.ts +30 -1
  189. package/src/lsp/hover.ts +14 -3
  190. package/src/lsp/properties.test.ts +72 -0
  191. package/src/lsp/properties.ts +141 -0
  192. package/src/metric-names.test.ts +11 -0
  193. package/src/metric-names.ts +64 -16
  194. package/src/model.ts +1 -1
  195. package/src/op/activities/collector-audit.test.ts +29 -22
  196. package/src/op/activities/collector-audit.ts +38 -18
  197. package/src/plugin.test.ts +2 -0
  198. package/src/plugin.ts +57 -6
  199. package/src/serializer.ts +11 -2
  200. package/src/spec/builtins.ts +56 -0
  201. package/src/spec/fetch.ts +332 -0
  202. package/src/spec/otel-schemas.snapshot.json +13932 -0
  203. package/src/spec/parse.ts +364 -0
  204. package/src/spec/pin.ts +155 -0
  205. package/src/spec/snapshot-cli.ts +32 -0
  206. package/src/spec/snapshot.ts +19 -0
  207. package/src/spec/spec.test.ts +144 -0
  208. package/src/validate-config.ts +4 -3
  209. package/src/validate.ts +1 -0
  210. package/src/workspace-links.e2e.test.ts +91 -0
  211. package/src/workspace-links.ts +50 -0
package/dist/validate.js CHANGED
@@ -33,6 +33,7 @@ export const REQUIRED_NAMES = [
33
33
  "PrometheusExporter",
34
34
  "GoogleCloudExporter",
35
35
  "LoadBalancingExporter",
36
+ "ClickHouseExporter",
36
37
  "SpanMetricsConnector",
37
38
  "ServiceGraphConnector",
38
39
  "RoutingConnector",
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The workspace links an otel member's declarations state (#4397): each
3
+ * table a `clickhouse` exporter writes, and each column its insert names, as
4
+ * a link to the sql member that declares them (#3931).
5
+ *
6
+ * | Link | Output |
7
+ * |---|---|
8
+ * | the table | `<database>.<table>` |
9
+ * | a column | `<database>.<table>.<column>`, anchored on the table |
10
+ *
11
+ * The exporter's config does not name the sql member, so each link is
12
+ * matched to the one member that exposes the table (`resolveLinks` in
13
+ * chant's `workspace/links.ts`). `chant workspace check` then fails (WSP093)
14
+ * when a table the exporter writes, or a column its insert names, is renamed
15
+ * or dropped in the sql member. The tables and columns come from
16
+ * `clickhouse-tables.ts`, which OTEL129 reads in one build root.
17
+ *
18
+ * A config value chant can't read without the environment, such as a
19
+ * database set from `${env:CH_DB}`, is a gap (WSP099), and the tables it
20
+ * names are not linked.
21
+ */
22
+ import type { Declarable } from "@intentius/chant/declarable";
23
+ import type { LexiconWorkspaceLinks } from "@intentius/chant/graph-ir";
24
+ export declare function otelWorkspaceLinks(entities: ReadonlyMap<string, Declarable>): LexiconWorkspaceLinks | undefined;
25
+ //# sourceMappingURL=workspace-links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-links.d.ts","sourceRoot":"","sources":["../src/workspace-links.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAwB,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAK7F,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,UAAU,CAAC,GAAG,qBAAqB,GAAG,SAAS,CAqB/G"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The workspace links an otel member's declarations state (#4397): each
3
+ * table a `clickhouse` exporter writes, and each column its insert names, as
4
+ * a link to the sql member that declares them (#3931).
5
+ *
6
+ * | Link | Output |
7
+ * |---|---|
8
+ * | the table | `<database>.<table>` |
9
+ * | a column | `<database>.<table>.<column>`, anchored on the table |
10
+ *
11
+ * The exporter's config does not name the sql member, so each link is
12
+ * matched to the one member that exposes the table (`resolveLinks` in
13
+ * chant's `workspace/links.ts`). `chant workspace check` then fails (WSP093)
14
+ * when a table the exporter writes, or a column its insert names, is renamed
15
+ * or dropped in the sql member. The tables and columns come from
16
+ * `clickhouse-tables.ts`, which OTEL129 reads in one build root.
17
+ *
18
+ * A config value chant can't read without the environment, such as a
19
+ * database set from `${env:CH_DB}`, is a gap (WSP099), and the tables it
20
+ * names are not linked.
21
+ */
22
+ import { clickhouseExportersOf } from "./clickhouse-tables.js";
23
+ import { collectorConfig } from "./collector.js";
24
+ export function otelWorkspaceLinks(entities) {
25
+ const keyOf = new Map();
26
+ for (const [key, e] of entities) {
27
+ const c = e;
28
+ if (c.componentKind === "exporter" && c.componentType === "clickhouse" && typeof c.componentId === "string")
29
+ keyOf.set(c.componentId, key);
30
+ }
31
+ if (keyOf.size === 0)
32
+ return undefined;
33
+ const links = [];
34
+ const gaps = [];
35
+ for (const exp of clickhouseExportersOf(collectorConfig(entities.values()))) {
36
+ const entity = keyOf.get(exp.id) ?? exp.id;
37
+ for (const u of exp.unread)
38
+ gaps.push(`exporter ${exp.id} (${entity}) sets ${u}, which is read from the environment, so the tables it names are not linked`);
39
+ for (const t of exp.tables) {
40
+ const table = `${exp.database}.${t.name}`;
41
+ links.push({ output: table, entity });
42
+ for (const column of t.columns)
43
+ links.push({ output: `${table}.${column}`, anchor: table, entity });
44
+ }
45
+ }
46
+ if (links.length === 0 && gaps.length === 0)
47
+ return undefined;
48
+ return { links, ...(gaps.length ? { gaps } : {}) };
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-otel",
3
- "version": "0.124.46",
3
+ "version": "0.124.49",
4
4
  "description": "OpenTelemetry Collector lexicon for chant — typed receivers, processors, exporters, connectors, extensions and pipelines, serialized to collector YAML",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -74,11 +74,12 @@
74
74
  },
75
75
  "devDependencies": {
76
76
  "@intentius/chant": "*",
77
+ "@intentius/chant-lexicon-sql": "*",
77
78
  "@types/js-yaml": "^4.0.9",
78
79
  "tsc-alias": "^1.8.17",
79
80
  "typescript": "^5.9.3"
80
81
  },
81
82
  "peerDependencies": {
82
- "@intentius/chant": "^0.124.46"
83
+ "@intentius/chant": "^0.124.49"
83
84
  }
84
85
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `otelCapabilityPlugin`, the otel lexicon's capability plugin (#3374). One
3
+ * build verb, `ocb-build`, contributed through core's `CapabilityPlugin`
4
+ * contract as helm contributes `helm-upgrade`: a project declaring
5
+ * `lexicons: ["otel"]` gets it registered when it runs components.
6
+ */
7
+ import type { Capability } from "@intentius/chant/components/capability";
8
+ import type { BuildSpec } from "@intentius/chant/components/component";
9
+ import { ownPackageVersion, type CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
10
+ import { ocbBuildCapability, type OcbBuildInput } from "./ocb-build";
11
+
12
+ export const OTEL_VERB_FAMILIES = {
13
+ build: ["ocb-build"],
14
+ } as const;
15
+
16
+ export const otelCapabilityPlugin: CapabilityPlugin = {
17
+ name: "otel",
18
+ version: ownPackageVersion(import.meta.url),
19
+ capabilities(): Array<Capability<never, unknown>> {
20
+ return [ocbBuildCapability as Capability<never, unknown>];
21
+ },
22
+ families(): Record<string, readonly string[]> {
23
+ return OTEL_VERB_FAMILIES;
24
+ },
25
+ };
26
+
27
+ /** A component's `build` field for `ocb-build`: `build: ocbBuild({ config: "dist/builder-config.yaml" })`. */
28
+ export function ocbBuild(input: OcbBuildInput): BuildSpec {
29
+ return { kind: "ocb-build", ...input };
30
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `ocb-build`, the build verb for a custom collector (#3374): run the
3
+ * OpenTelemetry Collector Builder over the `builder-config.yaml` a
4
+ * `Distribution` writes, and hand the directory it generates on as a `dir`
5
+ * artifact. That directory holds the generated sources and the binary, so a
6
+ * `docker-build` step whose `context` is the directory (with a Dockerfile the
7
+ * component provides) builds the collector image, and the rest of the
8
+ * component's path (SBOM, sign, attest, publish by digest, deploy) is the
9
+ * one any image takes.
10
+ *
11
+ * The output directory is the manifest's `dist.output_path`. ocb's own
12
+ * default is a fresh temporary directory, which a later step could not name,
13
+ * so a manifest without one is refused before ocb runs.
14
+ */
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { createHash } from "node:crypto";
18
+ import { dirname, isAbsolute, resolve } from "node:path";
19
+ import { load } from "js-yaml";
20
+ import type { Capability } from "@intentius/chant/components/capability";
21
+ import { defaultProcessRunner, q, requireTool, type ProcessRunner } from "@intentius/chant/components/verbs/process-runner";
22
+
23
+ export interface OcbBuildInput {
24
+ /** The builder manifest, e.g. `dist/builder-config.yaml`. */
25
+ config: string;
26
+ /** The builder binary. Default: `ocb`. */
27
+ ocb?: string;
28
+ /** Generate the sources without compiling them (`--skip-compilation`). */
29
+ skipCompilation?: boolean;
30
+ }
31
+
32
+ export interface OcbBuildOutput {
33
+ /** The artifact kind: a directory, for `docker-build`'s `context`. */
34
+ kind: "dir";
35
+ /** The directory ocb wrote, the manifest's `dist.output_path` resolved against the manifest's directory. */
36
+ dir: string;
37
+ /** `sha256:` over the manifest's bytes: the same manifest names the same build. */
38
+ manifestDigest: string;
39
+ }
40
+
41
+ /** The output directory a manifest names, resolved the way ocb resolves it: relative to the working directory ocb runs in. */
42
+ export function ocbOutputPath(manifestText: string, cwd: string): string | undefined {
43
+ const doc = load(manifestText) as { dist?: { output_path?: unknown } } | undefined;
44
+ const out = doc?.dist?.output_path;
45
+ if (typeof out !== "string" || out.trim() === "") return undefined;
46
+ return isAbsolute(out) ? out : resolve(cwd, out);
47
+ }
48
+
49
+ export function createOcbBuildCapability(runner: ProcessRunner = defaultProcessRunner()): Capability<OcbBuildInput, OcbBuildOutput> {
50
+ return {
51
+ kind: "ocb-build",
52
+ async run(_ctx, input) {
53
+ const text = readFileSync(input.config, "utf8");
54
+ // ocb resolves output_path against its working directory; run it in the manifest's.
55
+ const cwd = dirname(resolve(input.config));
56
+ const dir = ocbOutputPath(text, cwd);
57
+ if (!dir) {
58
+ throw new Error(
59
+ `ocb-build: ${input.config} sets no dist.output_path, so ocb would write to a temporary directory no later step can name. Set dist.output_path on the Distribution.`,
60
+ );
61
+ }
62
+ const ocb = input.ocb ?? "ocb";
63
+ await requireTool(runner, ocb, "build a custom collector from a Distribution");
64
+ const flags = input.skipCompilation ? " --skip-compilation" : "";
65
+ await runner.run(`${q(ocb)} --config ${q(resolve(input.config))}${flags}`, { cwd });
66
+ const manifestDigest = `sha256:${createHash("sha256").update(text).digest("hex")}`;
67
+ return { kind: "dir", dir, manifestDigest };
68
+ },
69
+ };
70
+ }
71
+
72
+ /** Default `ocb-build` capability, backed by the real process runner. */
73
+ export const ocbBuildCapability: Capability<OcbBuildInput, OcbBuildOutput> = createOcbBuildCapability();
@@ -0,0 +1,208 @@
1
+ /**
2
+ * A contrib v0.130.0 collector with `create_schema: false` writes logs,
3
+ * traces and every metric type into tables built from the sql lexicon's
4
+ * `OtelTables` (#4397).
5
+ *
6
+ * A scratch ClickHouse of the sql lexicon's pinned image gets the tables
7
+ * `OtelTables` declares, created from their DDL in the serializer's apply
8
+ * order, so the exporter finds them and creates nothing. A collector of
9
+ * `COLLECTOR_IMAGE` on the same Docker network runs the config chant builds
10
+ * from a `ClickHouseExporter`. One OTLP/HTTP request per signal goes in, and
11
+ * every table, the trace-id lookup table filled by its materialized view
12
+ * included, must then hold a row. Skips cleanly without Docker.
13
+ */
14
+
15
+ import { execFile } from "node:child_process";
16
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { promisify } from "node:util";
20
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
21
+ import { expandComposite } from "@intentius/chant/composite";
22
+ import type { Declarable } from "@intentius/chant/declarable";
23
+ import type { SerializerResult } from "@intentius/chant/serializer";
24
+ import { OtelTables } from "@intentius/chant-lexicon-sql/clickhouse";
25
+ import { sqlSerializer } from "@intentius/chant-lexicon-sql/serializer";
26
+ import { dockerAvailable, startScratchServer, type ScratchServer } from "@intentius/chant-lexicon-sql/clickhouse/container";
27
+ import { clickhouseQuery } from "@intentius/chant-lexicon-sql/clickhouse/http";
28
+ import { clickhouseImage } from "@intentius/chant-lexicon-sql/spec/pin";
29
+ import { collectorYaml } from "./collector";
30
+ import { ClickHouseExporter, OtlpReceiver } from "./components";
31
+ import { Pipeline } from "./pipeline";
32
+ import { COLLECTOR_IMAGE } from "./platform";
33
+
34
+ const exec = promisify(execFile);
35
+ const enabled = await dockerAvailable();
36
+ const DB = "otel_4397";
37
+ const suffix = `${Date.now().toString(36)}-${process.pid}`;
38
+ const network = `chant-otel-4397-${suffix}`;
39
+ const collectorName = `chant-otel-4397-col-${suffix}`;
40
+ const dir = mkdtempSync(join(tmpdir(), "chant-otel-4397-"));
41
+ let server: ScratchServer | undefined;
42
+ let otlpUrl = "";
43
+
44
+ const q = <T = Record<string, unknown>>(sql: string) => clickhouseQuery<T>(server!.endpoint, sql);
45
+
46
+ /** The tables, from `OtelTables`, in the order the sql serializer applies them. */
47
+ function tablesDdl(): string[] {
48
+ const entities = new Map<string, Declarable>();
49
+ for (const [k, v] of expandComposite("otel", OtelTables({ database: DB }))) entities.set(k, v);
50
+ const primary = (sqlSerializer.serialize(entities) as SerializerResult).primary;
51
+ const order = (JSON.parse(primary) as { applyOrder: string[] }).applyOrder;
52
+ return order.map((k) => (entities.get(k) as unknown as { props: { ddl: string } }).props.ddl);
53
+ }
54
+
55
+ beforeAll(async () => {
56
+ if (!enabled) return;
57
+ server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-otel-4397-ch" });
58
+ await q(`CREATE DATABASE ${DB}`);
59
+ for (const ddl of tablesDdl()) await q(ddl);
60
+
61
+ const otlp = new OtlpReceiver({ protocols: { http: { endpoint: "0.0.0.0:4318" } } });
62
+ const clickhouse = new ClickHouseExporter({ endpoint: `tcp://${server.name}:9000`, database: DB, create_schema: false, async_insert: false });
63
+ const yaml = collectorYaml([
64
+ otlp,
65
+ clickhouse,
66
+ ...(["logs", "traces", "metrics"] as const).map((signal) => new Pipeline({ signal, receivers: [otlp], exporters: [clickhouse] })),
67
+ ]);
68
+ writeFileSync(join(dir, "config.yaml"), yaml);
69
+
70
+ await exec("docker", ["network", "create", network]);
71
+ await exec("docker", ["network", "connect", network, server.name]);
72
+ await exec(
73
+ "docker",
74
+ ["run", "-d", "--name", collectorName, "--network", network, "-p", "127.0.0.1::4318", "-v", `${dir}:/etc/otelcol-contrib:ro`, COLLECTOR_IMAGE, "--config=/etc/otelcol-contrib/config.yaml"],
75
+ { timeout: 600_000 },
76
+ );
77
+ const { stdout } = await exec("docker", ["port", collectorName, "4318/tcp"]);
78
+ otlpUrl = `http://${stdout.trim().split("\n")[0]}`;
79
+ }, 900_000);
80
+
81
+ afterAll(async () => {
82
+ await exec("docker", ["rm", "-f", collectorName]).catch(() => undefined);
83
+ await server?.stop();
84
+ await exec("docker", ["network", "rm", network]).catch(() => undefined);
85
+ rmSync(dir, { recursive: true, force: true });
86
+ });
87
+
88
+ const now = BigInt(Date.now()) * 1_000_000n;
89
+ const ns = (offsetMs = 0) => String(now + BigInt(offsetMs) * 1_000_000n);
90
+ const resource = { attributes: [{ key: "service.name", value: { stringValue: "chant-e2e" } }] };
91
+ const TRACE_ID = "5b8efff798038103d269b633813fc60c";
92
+
93
+ const PAYLOADS: Record<string, unknown> = {
94
+ "/v1/logs": {
95
+ resourceLogs: [{ resource, scopeLogs: [{ scope: { name: "e2e" }, logRecords: [{ timeUnixNano: ns(), severityText: "INFO", severityNumber: 9, body: { stringValue: "hello" }, traceId: TRACE_ID }] }] }],
96
+ },
97
+ "/v1/traces": {
98
+ resourceSpans: [
99
+ {
100
+ resource,
101
+ scopeSpans: [
102
+ {
103
+ scope: { name: "e2e" },
104
+ spans: [
105
+ {
106
+ traceId: TRACE_ID,
107
+ spanId: "eee19b7ec3c1b174",
108
+ name: "op",
109
+ kind: 2,
110
+ startTimeUnixNano: ns(),
111
+ endTimeUnixNano: ns(5),
112
+ events: [{ timeUnixNano: ns(1), name: "evt" }],
113
+ status: { code: 1 },
114
+ },
115
+ ],
116
+ },
117
+ ],
118
+ },
119
+ ],
120
+ },
121
+ "/v1/metrics": {
122
+ resourceMetrics: [
123
+ {
124
+ resource,
125
+ scopeMetrics: [
126
+ {
127
+ scope: { name: "e2e" },
128
+ metrics: [
129
+ { name: "g", gauge: { dataPoints: [{ timeUnixNano: ns(), asDouble: 1 }] } },
130
+ { name: "s", sum: { aggregationTemporality: 2, isMonotonic: true, dataPoints: [{ startTimeUnixNano: ns(-1000), timeUnixNano: ns(), asInt: "3" }] } },
131
+ {
132
+ name: "h",
133
+ histogram: { aggregationTemporality: 2, dataPoints: [{ startTimeUnixNano: ns(-1000), timeUnixNano: ns(), count: "2", sum: 3, bucketCounts: ["1", "1"], explicitBounds: [1] }] },
134
+ },
135
+ {
136
+ name: "eh",
137
+ exponentialHistogram: {
138
+ aggregationTemporality: 2,
139
+ dataPoints: [{ startTimeUnixNano: ns(-1000), timeUnixNano: ns(), count: "2", sum: 3, scale: 0, zeroCount: "0", positive: { offset: 0, bucketCounts: ["2"] } }],
140
+ },
141
+ },
142
+ { name: "sm", summary: { dataPoints: [{ startTimeUnixNano: ns(-1000), timeUnixNano: ns(), count: "2", sum: 3, quantileValues: [{ quantile: 0.5, value: 1 }] }] } },
143
+ ],
144
+ },
145
+ ],
146
+ },
147
+ ],
148
+ },
149
+ };
150
+
151
+ /** POST until the collector takes it: it is still starting for the first few seconds. */
152
+ async function send(path: string, body: unknown): Promise<void> {
153
+ const deadline = Date.now() + 120_000;
154
+ let last = "";
155
+ while (Date.now() < deadline) {
156
+ try {
157
+ const res = await fetch(`${otlpUrl}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
158
+ if (res.ok) return;
159
+ last = `${res.status} ${await res.text()}`;
160
+ } catch (err) {
161
+ last = String(err);
162
+ }
163
+ await new Promise((r) => setTimeout(r, 1000));
164
+ }
165
+ throw new Error(`the collector did not take ${path}: ${last}`);
166
+ }
167
+
168
+ const TABLES = [
169
+ "otel_logs",
170
+ "otel_traces",
171
+ "otel_traces_trace_id_ts",
172
+ "otel_metrics_gauge",
173
+ "otel_metrics_sum",
174
+ "otel_metrics_summary",
175
+ "otel_metrics_histogram",
176
+ "otel_metrics_exponential_histogram",
177
+ ];
178
+
179
+ describe.skipIf(!enabled)(`a ${COLLECTOR_IMAGE} clickhouse exporter writes into OtelTables (#4397)`, () => {
180
+ test(
181
+ "logs, traces and every metric type land in the declared tables, and the exporter created none",
182
+ async () => {
183
+ for (const [path, body] of Object.entries(PAYLOADS)) await send(path, body);
184
+
185
+ const counts = async () => Object.fromEntries(await Promise.all(TABLES.map(async (t) => [t, Number((await q<{ n: string }>(`SELECT count() AS n FROM ${DB}.${t}`))[0].n)] as const)));
186
+ const deadline = Date.now() + 120_000;
187
+ let seen = await counts();
188
+ while (Object.values(seen).some((n) => n === 0) && Date.now() < deadline) {
189
+ await new Promise((r) => setTimeout(r, 1000));
190
+ seen = await counts();
191
+ }
192
+ if (Object.values(seen).some((n) => n === 0)) {
193
+ const logs = await exec("docker", ["logs", "--tail", "60", collectorName]).catch((e) => ({ stdout: "", stderr: String(e) }));
194
+ console.error(logs.stdout, logs.stderr);
195
+ }
196
+ expect(Object.entries(seen).filter(([, n]) => n === 0).map(([t]) => t)).toEqual([]);
197
+
198
+ const [span] = await q<{ ServiceName: string; SpanName: string; events: string }>(`SELECT ServiceName, SpanName, length(Events.Name) AS events FROM ${DB}.otel_traces`);
199
+ expect(span).toMatchObject({ ServiceName: "chant-e2e", SpanName: "op" });
200
+ expect(Number(span.events)).toBe(1);
201
+
202
+ // create_schema: false: the database holds the declared tables and view, and nothing else.
203
+ const objects = (await q<{ name: string }>(`SELECT name FROM system.tables WHERE database = '${DB}' ORDER BY name`)).map((r) => r.name);
204
+ expect(objects).toEqual([...TABLES, "otel_traces_trace_id_ts_mv"].sort());
205
+ },
206
+ 300_000,
207
+ );
208
+ });
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The clickhouse exporter's tables (#4397): which tables and columns an
3
+ * exporter config writes, OTEL129 over a build root that also declares them
4
+ * with the sql lexicon's `OtelTables`, and the workspace links the otel
5
+ * lexicon states for them.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { build } from "@intentius/chant/build";
12
+ import type { Declarable } from "@intentius/chant/declarable";
13
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
14
+ import { OtelTables } from "@intentius/chant-lexicon-sql/clickhouse";
15
+ import { sqlSerializer } from "@intentius/chant-lexicon-sql/serializer";
16
+ import { CLICKHOUSE_EXPORTER_COLUMNS, clickhouseExportersOf, clickhouseExporterTargets } from "./clickhouse-tables";
17
+ import { collectorYaml } from "./collector";
18
+ import { COLLECTOR_PIN } from "./define";
19
+ import { ClickHouseExporter, OtlpReceiver, BatchProcessor } from "./components";
20
+ import { Pipeline } from "./pipeline";
21
+ import { otel129 } from "./lint/post-synth/otel129";
22
+ import { otelPlugin } from "./plugin";
23
+ import { otelSerializer } from "./serializer";
24
+ import { otelWorkspaceLinks } from "./workspace-links";
25
+ import { pkgDir } from "./import/testdata/fixtures";
26
+
27
+ const ALL = new Set(["logs", "traces", "metrics"]);
28
+
29
+ describe("clickhouseExporterTargets", () => {
30
+ test("the exporter's defaults at v0.130.0: database default, otel_* tables, create_schema true", () => {
31
+ const t = clickhouseExporterTargets("clickhouse", { endpoint: "tcp://ch:9000" }, ALL);
32
+ expect(t.database).toBe("default");
33
+ expect(t.createSchema).toBe(true);
34
+ expect(t.tables.map((x) => x.name)).toEqual([
35
+ "otel_logs",
36
+ "otel_traces",
37
+ "otel_metrics_gauge",
38
+ "otel_metrics_sum",
39
+ "otel_metrics_summary",
40
+ "otel_metrics_histogram",
41
+ "otel_metrics_exponential_histogram",
42
+ ]);
43
+ });
44
+
45
+ test("database: the option wins unless it is default, then the endpoint's path", () => {
46
+ expect(clickhouseExporterTargets("c", { endpoint: "tcp://ch:9000/fromdsn", database: "otel" }, ALL).database).toBe("otel");
47
+ expect(clickhouseExporterTargets("c", { endpoint: "tcp://ch:9000/fromdsn", database: "default" }, ALL).database).toBe("fromdsn");
48
+ expect(clickhouseExporterTargets("c", { endpoint: "tcp://ch:9000/fromdsn" }, ALL).database).toBe("fromdsn");
49
+ });
50
+
51
+ test("only the tables of the signals whose pipelines use the exporter", () => {
52
+ const t = clickhouseExporterTargets("c", { endpoint: "tcp://ch:9000", traces_table_name: "spans" }, new Set(["traces"]));
53
+ expect(t.tables.map((x) => [x.key, x.name])).toEqual([["traces_table_name", "spans"]]);
54
+ });
55
+
56
+ test("metric names: metrics_tables per type; the deprecated metrics_table_name is a prefix only when none is set", () => {
57
+ const named = clickhouseExporterTargets("c", { endpoint: "tcp://ch:9000", metrics_table_name: "m", metrics_tables: { sum: { name: "s" } } }, new Set(["metrics"]));
58
+ expect(named.tables.map((x) => x.name)).toEqual(["otel_metrics_gauge", "s", "otel_metrics_summary", "otel_metrics_histogram", "otel_metrics_exponential_histogram"]);
59
+ const prefixed = clickhouseExporterTargets("c", { endpoint: "tcp://ch:9000", metrics_table_name: "m" }, new Set(["metrics"]));
60
+ expect(prefixed.tables.map((x) => x.name)).toEqual(["m_gauge", "m_sum", "m_summary", "m_histogram", "m_exponential_histogram"]);
61
+ });
62
+
63
+ test("a value read from the environment is not guessed", () => {
64
+ const t = clickhouseExporterTargets("c", { endpoint: "tcp://ch:9000", database: "${env:CH_DB}" }, ALL);
65
+ expect(t.tables).toEqual([]);
66
+ expect(t.unread).toEqual(["database ${env:CH_DB}"]);
67
+ const named = clickhouseExporterTargets("c", { endpoint: "tcp://ch:9000", logs_table_name: "${env:T}" }, new Set(["logs"]));
68
+ expect(named.tables).toEqual([]);
69
+ expect(named.unread).toEqual(["logs_table_name ${env:T}"]);
70
+ });
71
+
72
+ test("the insert columns are columns OtelTables declares, Nested fields under their column", () => {
73
+ const t = OtelTables({});
74
+ const cols = (e: unknown) => (e as { props: { columns: Array<{ name: string }> } }).props.columns.map((c) => c.name);
75
+ expect(cols(t.logs)).toEqual(expect.arrayContaining([...CLICKHOUSE_EXPORTER_COLUMNS.logs]));
76
+ expect(cols(t.traces)).toEqual(expect.arrayContaining([...CLICKHOUSE_EXPORTER_COLUMNS.traces]));
77
+ expect(cols(t.metricsGauge)).toEqual(expect.arrayContaining([...CLICKHOUSE_EXPORTER_COLUMNS.gauge]));
78
+ expect(cols(t.metricsSum)).toEqual(expect.arrayContaining([...CLICKHOUSE_EXPORTER_COLUMNS.sum]));
79
+ expect(cols(t.metricsSummary)).toEqual(expect.arrayContaining([...CLICKHOUSE_EXPORTER_COLUMNS.summary]));
80
+ expect(cols(t.metricsHistogram)).toEqual(expect.arrayContaining([...CLICKHOUSE_EXPORTER_COLUMNS.histogram]));
81
+ expect(cols(t.metricsExponentialHistogram)).toEqual(expect.arrayContaining([...CLICKHOUSE_EXPORTER_COLUMNS.exponential_histogram]));
82
+ });
83
+ });
84
+
85
+ describe("ClickHouseExporter", () => {
86
+ test("is a built-in at COLLECTOR_PIN, with an endpoint check", () => {
87
+ expect(ClickHouseExporter.definition.builtin).toBe(true);
88
+ expect(ClickHouseExporter.definition.pin.version).toBe(COLLECTOR_PIN.version);
89
+ const validate = ClickHouseExporter.definition.validate as (c: unknown) => string[];
90
+ expect(validate({ endpoint: "" })).toEqual(["endpoint is empty; the exporter requires it"]);
91
+ expect(validate({ endpoint: "clickhouse 9000" })[0]).toMatch(/is not a URL/);
92
+ expect(validate({ endpoint: "${env:CH_URL}" })).toEqual([]);
93
+ expect(validate({ endpoint: "tcp://ch:9000" })).toEqual([]);
94
+ });
95
+ });
96
+
97
+ /** A collector writing every signal through one clickhouse exporter. */
98
+ function collector(exporter: Record<string, unknown>): Declarable[] {
99
+ const otlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });
100
+ const batch = new BatchProcessor({});
101
+ const clickhouse = new ClickHouseExporter({ endpoint: "tcp://clickhouse:9000", ...exporter } as never);
102
+ return [
103
+ otlp,
104
+ batch,
105
+ clickhouse,
106
+ ...(["logs", "traces", "metrics"] as const).map((signal) => new Pipeline({ signal, receivers: [otlp], processors: [batch], exporters: [clickhouse] })),
107
+ ];
108
+ }
109
+
110
+ function ctxOf(otel: Declarable[], sql: object): PostSynthContext {
111
+ const entities = new Map<string, Declarable>();
112
+ otel.forEach((e, i) => entities.set(`otel${i}`, e));
113
+ for (const [k, e] of Object.entries(sql)) if (e) entities.set(k, e as Declarable);
114
+ return { outputs: new Map([["otel", collectorYaml(otel)]]), entities, buildResult: { outputs: new Map(), entities, warnings: [], errors: [], sourceFileCount: 1 } } as PostSynthContext;
115
+ }
116
+
117
+ describe("OTEL129: the exporter's tables against the sql build in the same root", () => {
118
+ test("silent when the build declares the tables and create_schema is false", () => {
119
+ expect(otel129.check(ctxOf(collector({ database: "otel", create_schema: false }), OtelTables({ database: "otel" })))).toEqual([]);
120
+ });
121
+
122
+ test("a renamed table fails, naming the exporter's key and the declared tables", () => {
123
+ const diags = otel129.check(ctxOf(collector({ database: "otel", create_schema: false }), OtelTables({ database: "otel", tables: { logs: "app_logs" } })));
124
+ expect(diags.map((d) => [d.checkId, d.severity, d.entity])).toEqual([["OTEL129", "error", "clickhouse"]]);
125
+ expect(diags[0].message).toMatch(/writes logs to otel\.otel_logs \(logs_table_name\), which the sql build does not declare/);
126
+ expect(diags[0].message).toContain("otel.app_logs");
127
+ });
128
+
129
+ test("another database fails every table", () => {
130
+ const diags = otel129.check(ctxOf(collector({ create_schema: false }), OtelTables({ database: "otel" })));
131
+ expect(diags).toHaveLength(7);
132
+ expect(diags.every((d) => d.severity === "error" && /default\.otel_/.test(d.message))).toBe(true);
133
+ });
134
+
135
+ test("a column the insert names but the table lacks fails", () => {
136
+ const tables = OtelTables({}) as unknown as Record<string, Declarable>;
137
+ const logs = tables.logs as unknown as { props: { columns: Array<{ name: string }> } };
138
+ logs.props.columns = logs.props.columns.filter((c) => c.name !== "Body" && c.name !== "LogAttributes");
139
+ const diags = otel129.check(ctxOf(collector({ create_schema: false }), tables));
140
+ expect(diags.map((d) => d.message)).toEqual([expect.stringMatching(/inserts logs into default\.otel_logs with columns Body, LogAttributes, which the declared table does not have/)]);
141
+ });
142
+
143
+ test("create_schema left at true warns for the declared tables", () => {
144
+ const diags = otel129.check(ctxOf(collector({}), OtelTables({})));
145
+ expect(diags.map((d) => [d.severity, d.entity])).toEqual([["warning", "clickhouse"]]);
146
+ expect(diags[0].message).toMatch(/create_schema true \(the default\).*default\.otel_logs/);
147
+ });
148
+
149
+ test("a root with no sql ClickHouse tables is not checked", () => {
150
+ expect(otel129.check(ctxOf(collector({}), {}))).toEqual([]);
151
+ });
152
+
153
+ test("runs in a real build of one root that uses both lexicons, and fails when a table is renamed", async () => {
154
+ // A fresh project per build: a module once imported is not read again.
155
+ const run = async (logs: string) => {
156
+ const dir = mkdtempSync(join(pkgDir, ".roundtrip-tmp-"));
157
+ try {
158
+ mkdirSync(join(dir, "src"));
159
+ writeFileSync(join(dir, "chant.config.ts"), 'export default { lexicons: ["otel", "sql"] };\n');
160
+ writeFileSync(join(dir, "package.json"), '{ "name": "otel-clickhouse-root", "private": true, "type": "module" }\n');
161
+ writeFileSync(
162
+ join(dir, "src", "main.ts"),
163
+ [
164
+ 'import { ClickHouseExporter, OtlpReceiver, Pipeline } from "@intentius/chant-lexicon-otel";',
165
+ 'import { OtelTables } from "@intentius/chant-lexicon-sql/clickhouse";',
166
+ `export const otelTables = OtelTables({ database: "otel", tables: { logs: "${logs}" } });`,
167
+ 'export const otlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });',
168
+ 'export const clickhouse = new ClickHouseExporter({ endpoint: "tcp://clickhouse:9000", database: "otel", create_schema: false });',
169
+ 'export const logs = new Pipeline({ signal: "logs", receivers: [otlp], exporters: [clickhouse] });',
170
+ "",
171
+ ].join("\n"),
172
+ );
173
+ const result = await build(join(dir, "src"), [otelSerializer, sqlSerializer]);
174
+ expect(result.errors).toEqual([]);
175
+ // chant build hands each lexicon's checks its own output and every entity.
176
+ return otel129.check({ outputs: new Map([["otel", result.outputs.get("otel")!]]), entities: result.entities, buildResult: result } as PostSynthContext);
177
+ } finally {
178
+ rmSync(dir, { recursive: true, force: true });
179
+ }
180
+ };
181
+ expect(await run("otel_logs")).toEqual([]);
182
+ expect((await run("otel_logs_v2")).map((d) => [d.checkId, d.severity])).toEqual([["OTEL129", "error"]]);
183
+ });
184
+ });
185
+
186
+ describe("the otel lexicon's workspace links", () => {
187
+ test("each table the exporter writes, and each column its insert names anchored on the table", () => {
188
+ const entities = new Map<string, Declarable>();
189
+ collector({ database: "otel", create_schema: false }).forEach((e, i) => entities.set(i === 2 ? "clickhouse" : `e${i}`, e));
190
+ const links = otelPlugin.workspaceLinks!(entities)!;
191
+ expect(links.gaps).toBeUndefined();
192
+ const tables = links.links.filter((l) => !l.anchor).map((l) => l.output);
193
+ expect(tables).toEqual([
194
+ "otel.otel_logs",
195
+ "otel.otel_traces",
196
+ "otel.otel_metrics_gauge",
197
+ "otel.otel_metrics_sum",
198
+ "otel.otel_metrics_summary",
199
+ "otel.otel_metrics_histogram",
200
+ "otel.otel_metrics_exponential_histogram",
201
+ ]);
202
+ expect(links.links).toContainEqual({ output: "otel.otel_traces.Events", anchor: "otel.otel_traces", entity: "clickhouse" });
203
+ expect(links.links.every((l) => l.entity === "clickhouse")).toBe(true);
204
+ });
205
+
206
+ test("an environment-set database is a gap; a project with no clickhouse exporter states none", () => {
207
+ const entities = new Map<string, Declarable>();
208
+ collector({ database: "${env:CH_DB}" }).forEach((e, i) => entities.set(`e${i}`, e));
209
+ expect(otelWorkspaceLinks(entities)).toEqual({ links: [], gaps: [expect.stringMatching(/sets database \$\{env:CH_DB\}, which is read from the environment/)] });
210
+ expect(otelWorkspaceLinks(new Map([["otlp", new OtlpReceiver({})]]))).toBeUndefined();
211
+ });
212
+
213
+ test("clickhouseExportersOf skips an exporter no pipeline uses", () => {
214
+ expect(clickhouseExportersOf({ exporters: { clickhouse: { endpoint: "tcp://x:9000" } }, service: { pipelines: {} } })).toEqual([]);
215
+ });
216
+ });