@intentius/chant-lexicon-otel 0.95.1 → 0.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -9
- package/dist/catalog.d.ts +2 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/collector.d.ts +4 -1
- package/dist/collector.d.ts.map +1 -1
- package/dist/components/connectors.d.ts +156 -0
- package/dist/components/connectors.d.ts.map +1 -0
- package/dist/components/filtering.d.ts +95 -0
- package/dist/components/filtering.d.ts.map +1 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/k8s-receivers.d.ts +74 -0
- package/dist/components/k8s-receivers.d.ts.map +1 -0
- package/dist/components/sampling.d.ts +260 -0
- package/dist/components/sampling.d.ts.map +1 -0
- package/dist/define.d.ts +31 -4
- package/dist/define.d.ts.map +1 -1
- package/dist/genai.d.ts +165 -0
- package/dist/genai.d.ts.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +8 -7
- package/dist/lint/audit-catalog.d.ts +2 -1
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/otel-helpers.d.ts +1 -1
- package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/otel101.d.ts +1 -1
- package/dist/lint/post-synth/otel103.d.ts +1 -1
- package/dist/lint/post-synth/otel112.d.ts +8 -0
- package/dist/lint/post-synth/otel112.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +70 -0
- package/dist/metric-names.d.ts +121 -0
- package/dist/metric-names.d.ts.map +1 -0
- package/dist/model.d.ts +23 -4
- package/dist/model.d.ts.map +1 -1
- package/dist/okf/index.md +15 -0
- package/dist/okf/rules/OTEL112.md +11 -0
- package/dist/okf/types/CountConnector.md +9 -0
- package/dist/okf/types/FilterProcessor.md +9 -0
- package/dist/okf/types/ForwardConnector.md +9 -0
- package/dist/okf/types/K8sClusterReceiver.md +9 -0
- package/dist/okf/types/KubeletStatsReceiver.md +9 -0
- package/dist/okf/types/LoadBalancingExporter.md +9 -0
- package/dist/okf/types/ProbabilisticSamplerProcessor.md +9 -0
- package/dist/okf/types/RedactionProcessor.md +9 -0
- package/dist/okf/types/RoutingConnector.md +9 -0
- package/dist/okf/types/ServiceGraphConnector.md +9 -0
- package/dist/okf/types/SpanMetricsConnector.md +9 -0
- package/dist/okf/types/SumConnector.md +9 -0
- package/dist/okf/types/TailSamplingProcessor.md +9 -0
- package/dist/okf/types/TransformProcessor.md +9 -0
- package/dist/pipeline.d.ts +9 -3
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/rules/otel-helpers.ts +1 -1
- package/dist/rules/otel101.ts +1 -1
- package/dist/rules/otel103.ts +1 -1
- package/dist/rules/otel112.ts +17 -0
- package/dist/semconv.d.ts +33 -0
- package/dist/semconv.d.ts.map +1 -0
- package/dist/serializer.d.ts +1 -1
- package/dist/skills/chant-otel.md +28 -5
- package/dist/topology.d.ts +34 -5
- package/dist/topology.d.ts.map +1 -1
- package/dist/validate-config.d.ts +6 -2
- package/dist/validate-config.d.ts.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/catalog.ts +2 -1
- package/src/codegen/docs.ts +14 -8
- package/src/collector.ts +9 -1
- package/src/components/connectors.ts +276 -0
- package/src/components/filtering.test.ts +192 -0
- package/src/components/filtering.ts +192 -0
- package/src/components/index.ts +4 -0
- package/src/components/k8s-receivers.test.ts +123 -0
- package/src/components/k8s-receivers.ts +107 -0
- package/src/components/sampling.test.ts +464 -0
- package/src/components/sampling.ts +521 -0
- package/src/connectors.test.ts +388 -0
- package/src/define.ts +36 -4
- package/src/genai.test.ts +526 -0
- package/src/genai.ts +466 -0
- package/src/generated/lexicon-otel.json +70 -0
- package/src/index.ts +39 -0
- package/src/lint/audit-catalog.ts +11 -2
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/otel-helpers.ts +1 -1
- package/src/lint/post-synth/otel101.ts +1 -1
- package/src/lint/post-synth/otel103.ts +1 -1
- package/src/lint/post-synth/otel112.ts +17 -0
- package/src/metric-names.test.ts +80 -0
- package/src/metric-names.ts +176 -0
- package/src/model.ts +26 -4
- package/src/otelcol-validate.test.ts +143 -0
- package/src/pipeline.ts +9 -3
- package/src/plugin.test.ts +1 -0
- package/src/plugin.ts +1 -1
- package/src/semconv.ts +63 -0
- package/src/serializer.ts +1 -1
- package/src/skills/chant-otel.md +28 -5
- package/src/topology.ts +54 -8
- package/src/validate-config.ts +92 -4
- package/src/validate.ts +8 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GenAI preset: content removal unless the caller opts in, agent RED and
|
|
3
|
+
* token metrics, and the GenAI semconv pin in the topology.
|
|
4
|
+
*
|
|
5
|
+
* The `otelcol` tests run when `otelcol-contrib` is on PATH (or `OTELCOL_BIN`
|
|
6
|
+
* names a contrib build) and skip otherwise; CI does not install it. One of
|
|
7
|
+
* them runs the collector, sends GenAI spans and events through it, and reads
|
|
8
|
+
* what comes out. The structural tests run everywhere.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "vitest";
|
|
12
|
+
import { spawn, spawnSync } from "child_process";
|
|
13
|
+
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
14
|
+
import { createServer } from "net";
|
|
15
|
+
import { tmpdir } from "os";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { load } from "js-yaml";
|
|
18
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
19
|
+
import { collectorYaml, buildCollectorConfig } from "./collector";
|
|
20
|
+
import { COLLECTOR_PIN, GENAI_SEMCONV_PIN } from "./define";
|
|
21
|
+
import { collectorTopology, collectorTopologyOf } from "./topology";
|
|
22
|
+
import { validateCollectorConfig, validateCollectorEntities } from "./validate-config";
|
|
23
|
+
import type { CollectorConfig } from "./model";
|
|
24
|
+
import { DebugExporter, FilterProcessor, OtlpExporter, PrometheusExporter, SumConnector } from "./components";
|
|
25
|
+
import {
|
|
26
|
+
GENAI_CONTENT_ATTRIBUTES,
|
|
27
|
+
GENAI_SPAN_METRIC_DIMENSIONS,
|
|
28
|
+
genAiComponents,
|
|
29
|
+
genAiMetrics,
|
|
30
|
+
genAiPipeline,
|
|
31
|
+
type GenAiPipelineOptions,
|
|
32
|
+
} from "./genai";
|
|
33
|
+
import { semconvUsage } from "./semconv";
|
|
34
|
+
import { otlpCollector } from "./platform";
|
|
35
|
+
|
|
36
|
+
function findOtelcol(): string | undefined {
|
|
37
|
+
const candidates = [process.env.OTELCOL_BIN, "otelcol-contrib"].filter((c): c is string => !!c);
|
|
38
|
+
for (const bin of candidates) {
|
|
39
|
+
const r = spawnSync(bin, ["--version"], { encoding: "utf-8" });
|
|
40
|
+
if (r.status === 0) return bin;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const OTELCOL = findOtelcol();
|
|
46
|
+
|
|
47
|
+
function withConfigFile<T>(yaml: string, fn: (file: string) => T): T {
|
|
48
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-genai-"));
|
|
49
|
+
try {
|
|
50
|
+
const file = join(dir, "config.yaml");
|
|
51
|
+
writeFileSync(file, yaml);
|
|
52
|
+
return fn(file);
|
|
53
|
+
} finally {
|
|
54
|
+
rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function otelcolValidate(yaml: string): { ok: boolean; output: string } {
|
|
59
|
+
return withConfigFile(yaml, (file) => {
|
|
60
|
+
const r = spawnSync(OTELCOL!, ["validate", `--config=${file}`], { encoding: "utf-8", timeout: 60_000 });
|
|
61
|
+
return { ok: r.status === 0, output: `${r.stdout ?? ""}${r.stderr ?? ""}` };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parsed(entities: Declarable[]): any {
|
|
66
|
+
return load(collectorYaml(entities));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("genAiPipeline: content", () => {
|
|
70
|
+
test("content keys are deleted from spans, span events and log records by default", () => {
|
|
71
|
+
const config = parsed(genAiPipeline());
|
|
72
|
+
const transform = config.processors["transform/genai_content"];
|
|
73
|
+
expect(transform.error_mode).toBe("ignore");
|
|
74
|
+
const [span, spanevent] = transform.trace_statements;
|
|
75
|
+
const [log] = transform.log_statements;
|
|
76
|
+
expect(span.context).toBe("span");
|
|
77
|
+
expect(spanevent.context).toBe("spanevent");
|
|
78
|
+
expect(log.context).toBe("log");
|
|
79
|
+
for (const key of GENAI_CONTENT_ATTRIBUTES) {
|
|
80
|
+
expect(span.statements).toContain(`delete_key(span.attributes, "${key}")`);
|
|
81
|
+
expect(spanevent.statements).toContain(`delete_key(spanevent.attributes, "${key}")`);
|
|
82
|
+
expect(log.statements).toContain(`delete_key(log.attributes, "${key}")`);
|
|
83
|
+
}
|
|
84
|
+
// The newer message attributes and the event-based content are both covered.
|
|
85
|
+
expect(GENAI_CONTENT_ATTRIBUTES).toEqual(
|
|
86
|
+
expect.arrayContaining(["gen_ai.input.messages", "gen_ai.output.messages", "gen_ai.system_instructions"]),
|
|
87
|
+
);
|
|
88
|
+
expect(log.statements.some((s: string) => s.startsWith("delete_matching_keys(log.body,") && s.includes("gen_ai"))).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("the transform runs before redaction, which masks the same keys as a backstop", () => {
|
|
92
|
+
const config = parsed(genAiPipeline());
|
|
93
|
+
for (const id of ["traces", "logs"]) {
|
|
94
|
+
expect(config.service.pipelines[id].processors).toEqual([
|
|
95
|
+
"memory_limiter",
|
|
96
|
+
"transform/genai_content",
|
|
97
|
+
"redaction/genai_content",
|
|
98
|
+
"batch",
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
const redaction = config.processors["redaction/genai_content"];
|
|
102
|
+
expect(redaction.allow_all_keys).toBe(true);
|
|
103
|
+
const pattern = new RegExp(redaction.blocked_key_patterns[0]);
|
|
104
|
+
for (const key of GENAI_CONTENT_ATTRIBUTES) expect(pattern.test(key)).toBe(true);
|
|
105
|
+
// Convention attributes that are not content survive.
|
|
106
|
+
for (const key of ["gen_ai.prompt.name", "gen_ai.request.model", "gen_ai.operation.name"]) {
|
|
107
|
+
expect(redaction.blocked_key_patterns.some((p: string) => new RegExp(p).test(key))).toBe(false);
|
|
108
|
+
}
|
|
109
|
+
expect(redaction.blocked_key_patterns.some((p: string) => new RegExp(p).test("gen_ai.prompt.0.content"))).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("keepContent: true is the only way content stays", () => {
|
|
113
|
+
const config = parsed(genAiPipeline({ keepContent: true }));
|
|
114
|
+
expect(Object.keys(config.processors)).toEqual(["memory_limiter", "batch", "filter/genai_spans"]);
|
|
115
|
+
expect(config.service.pipelines.traces.processors).toEqual(["memory_limiter", "batch"]);
|
|
116
|
+
const parts = genAiComponents({ keepContent: true });
|
|
117
|
+
expect(parts.contentRemoval).toBeUndefined();
|
|
118
|
+
expect(parts.redaction).toBeUndefined();
|
|
119
|
+
expect(parts.processors).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("maskValues masks values whether content is kept or not", () => {
|
|
123
|
+
const kept = parsed(genAiPipeline({ keepContent: true, maskValues: ["[0-9]{16}"], hashFunction: "sha3" }));
|
|
124
|
+
expect(kept.processors["redaction/genai_content"]).toEqual({
|
|
125
|
+
allow_all_keys: true,
|
|
126
|
+
blocked_values: ["[0-9]{16}"],
|
|
127
|
+
hash_function: "sha3",
|
|
128
|
+
});
|
|
129
|
+
expect(kept.processors["transform/genai_content"]).toBeUndefined();
|
|
130
|
+
const removed = parsed(genAiPipeline({ maskValues: ["[0-9]{16}"] }));
|
|
131
|
+
expect(removed.processors["redaction/genai_content"].blocked_values).toEqual(["[0-9]{16}"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("contentAttributes adds keys to delete", () => {
|
|
135
|
+
const config = parsed(genAiPipeline({ contentAttributes: ["llm.input_messages"] }));
|
|
136
|
+
expect(config.processors["transform/genai_content"].trace_statements[0].statements).toContain(
|
|
137
|
+
'delete_key(span.attributes, "llm.input_messages")',
|
|
138
|
+
);
|
|
139
|
+
expect(new RegExp(config.processors["redaction/genai_content"].blocked_key_patterns[0]).test("llm.input_messages")).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("genAiPipeline: metrics", () => {
|
|
144
|
+
test("a traces/genai branch feeds spanmetrics and sum from every GenAI span", () => {
|
|
145
|
+
const config = parsed(genAiPipeline());
|
|
146
|
+
expect(config.service.pipelines).toEqual({
|
|
147
|
+
traces: {
|
|
148
|
+
receivers: ["otlp"],
|
|
149
|
+
processors: ["memory_limiter", "transform/genai_content", "redaction/genai_content", "batch"],
|
|
150
|
+
exporters: ["debug", "forward/genai"],
|
|
151
|
+
},
|
|
152
|
+
"traces/genai": {
|
|
153
|
+
receivers: ["forward/genai"],
|
|
154
|
+
processors: ["filter/genai_spans"],
|
|
155
|
+
exporters: ["spanmetrics/genai", "sum/genai_tokens"],
|
|
156
|
+
},
|
|
157
|
+
"metrics/genai": { receivers: ["spanmetrics/genai", "sum/genai_tokens"], processors: ["batch"], exporters: ["debug"] },
|
|
158
|
+
logs: {
|
|
159
|
+
receivers: ["otlp"],
|
|
160
|
+
processors: ["memory_limiter", "transform/genai_content", "redaction/genai_content", "batch"],
|
|
161
|
+
exporters: ["debug"],
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
expect(config.processors["filter/genai_spans"].traces.span).toEqual(['attributes["gen_ai.operation.name"] == nil']);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("spanmetrics carries the GenAI dimensions", () => {
|
|
168
|
+
const sm = parsed(genAiPipeline()).connectors["spanmetrics/genai"];
|
|
169
|
+
expect(sm.namespace).toBe("genai");
|
|
170
|
+
expect(sm.dimensions.map((d: { name: string }) => d.name)).toEqual([
|
|
171
|
+
"gen_ai.operation.name",
|
|
172
|
+
"gen_ai.request.model",
|
|
173
|
+
"gen_ai.tool.name",
|
|
174
|
+
"error.type",
|
|
175
|
+
]);
|
|
176
|
+
expect(sm.histogram.unit).toBe("s");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("sum adds up input and output tokens per model, skipping in-process agent spans", () => {
|
|
180
|
+
const sum = parsed(genAiPipeline()).connectors["sum/genai_tokens"];
|
|
181
|
+
expect(Object.keys(sum.spans)).toEqual(["genai.tokens.input", "genai.tokens.output"]);
|
|
182
|
+
expect(sum.spans["genai.tokens.input"].source_attribute).toBe("gen_ai.usage.input_tokens");
|
|
183
|
+
expect(sum.spans["genai.tokens.output"].source_attribute).toBe("gen_ai.usage.output_tokens");
|
|
184
|
+
expect(sum.spans["genai.tokens.input"].attributes).toEqual([{ key: "gen_ai.request.model", default_value: "unknown" }]);
|
|
185
|
+
expect(sum.spans["genai.tokens.input"].conditions[0]).toContain('"invoke_agent"');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("genAiMetrics names what the pipeline emits, and follows the namespace", () => {
|
|
189
|
+
const m = genAiMetrics();
|
|
190
|
+
expect([m.calls.prometheus, m.duration.prometheus, m.inputTokens.prometheus, m.outputTokens.prometheus]).toEqual([
|
|
191
|
+
"genai_calls_total",
|
|
192
|
+
"genai_duration_seconds",
|
|
193
|
+
"genai_tokens_input_total",
|
|
194
|
+
"genai_tokens_output_total",
|
|
195
|
+
]);
|
|
196
|
+
expect(m.calls.dimensions).toEqual(["service.name", "span.name", "span.kind", "status.code", ...GENAI_SPAN_METRIC_DIMENSIONS]);
|
|
197
|
+
|
|
198
|
+
const renamed = genAiMetrics({ namespace: "agents.support" });
|
|
199
|
+
expect(renamed.duration.prometheus).toBe("agents_support_duration_seconds");
|
|
200
|
+
const config = parsed(genAiPipeline({ namespace: "agents.support" }));
|
|
201
|
+
expect(config.connectors["spanmetrics/genai"].namespace).toBe("agents.support");
|
|
202
|
+
expect(Object.keys(config.connectors["sum/genai_tokens"].spans)).toEqual([
|
|
203
|
+
renamed.inputTokens.name,
|
|
204
|
+
renamed.outputTokens.name,
|
|
205
|
+
]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("sampling processors run in traces/sampled, after the metrics branch", () => {
|
|
209
|
+
const keepErrors = new FilterProcessor({ name: "sampler", traces: { span: ["status.code != STATUS_CODE_ERROR"] } });
|
|
210
|
+
const tempo = new OtlpExporter({ name: "tempo", endpoint: "tempo:4317", tls: { insecure: true } });
|
|
211
|
+
const config = parsed(genAiPipeline({ sampling: [keepErrors], traceExporters: [tempo] }));
|
|
212
|
+
expect(config.service.pipelines.traces).toEqual({
|
|
213
|
+
receivers: ["otlp"],
|
|
214
|
+
processors: ["memory_limiter", "transform/genai_content", "redaction/genai_content"],
|
|
215
|
+
exporters: ["forward/genai", "forward/sampled"],
|
|
216
|
+
});
|
|
217
|
+
expect(config.service.pipelines["traces/sampled"]).toEqual({
|
|
218
|
+
receivers: ["forward/sampled"],
|
|
219
|
+
processors: ["filter/sampler", "batch"],
|
|
220
|
+
exporters: ["otlp/tempo"],
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("logs: false leaves the logs pipeline out", () => {
|
|
225
|
+
expect(Object.keys(parsed(genAiPipeline({ logs: false })).service.pipelines)).toEqual([
|
|
226
|
+
"traces",
|
|
227
|
+
"traces/genai",
|
|
228
|
+
"metrics/genai",
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("genAiPipeline: checks and topology", () => {
|
|
234
|
+
const optionSets: Array<[string, GenAiPipelineOptions]> = [
|
|
235
|
+
["default", {}],
|
|
236
|
+
["keepContent", { keepContent: true }],
|
|
237
|
+
["masked and sampled", { maskValues: ["secret-[a-z]+"], sampling: [new FilterProcessor({ name: "s", traces: { span: ["false"] } })] }],
|
|
238
|
+
["prometheus", { metricExporters: [new PrometheusExporter({ endpoint: "0.0.0.0:8889" })], logs: false, healthCheck: false }],
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
test.each(optionSets)("%s passes the lexicon's own checks", (_label, options) => {
|
|
242
|
+
const entities = genAiPipeline(options);
|
|
243
|
+
expect(validateCollectorEntities(entities)).toEqual([]);
|
|
244
|
+
expect(validateCollectorConfig(load(collectorYaml(entities)) as CollectorConfig)).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("collectorTopology reports the GenAI semconv pin for the components that use it", () => {
|
|
248
|
+
const topo = collectorTopologyOf(genAiPipeline());
|
|
249
|
+
expect(topo.semconv).toEqual([
|
|
250
|
+
{
|
|
251
|
+
namespace: "gen_ai",
|
|
252
|
+
source: GENAI_SEMCONV_PIN.source,
|
|
253
|
+
version: GENAI_SEMCONV_PIN.version,
|
|
254
|
+
components: ["transform/genai_content", "redaction/genai_content", "filter/genai_spans", "spanmetrics/genai", "sum/genai_tokens"],
|
|
255
|
+
},
|
|
256
|
+
]);
|
|
257
|
+
expect(GENAI_SEMCONV_PIN).toEqual({ source: "github.com/open-telemetry/semantic-conventions", version: "v1.41.1" });
|
|
258
|
+
// Component types keep the collector pin.
|
|
259
|
+
expect(topo.components.find((c) => c.id === "sum/genai_tokens")?.schema).toEqual(COLLECTOR_PIN);
|
|
260
|
+
// With content kept the metrics still use the vocabulary.
|
|
261
|
+
expect(collectorTopologyOf(genAiPipeline({ keepContent: true })).semconv[0].components).toEqual([
|
|
262
|
+
"filter/genai_spans",
|
|
263
|
+
"spanmetrics/genai",
|
|
264
|
+
"sum/genai_tokens",
|
|
265
|
+
]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("a parsed YAML file gives the same topology as the declaration", () => {
|
|
269
|
+
const entities = genAiPipeline();
|
|
270
|
+
expect(collectorTopology(load(collectorYaml(entities)) as CollectorConfig)).toEqual(collectorTopologyOf(entities));
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("the YAML says which semconv version its keys follow", () => {
|
|
274
|
+
const yaml = collectorYaml(genAiPipeline());
|
|
275
|
+
expect(yaml.split("\n")[0]).toBe(
|
|
276
|
+
"# 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)",
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("a config without gen_ai keys has no semconv entry and no header", () => {
|
|
281
|
+
const entities = otlpCollector();
|
|
282
|
+
expect(collectorTopologyOf(entities).semconv).toEqual([]);
|
|
283
|
+
expect(buildCollectorConfig(entities).header).toEqual([]);
|
|
284
|
+
expect(semconvUsage({ processors: { "attributes/x": { actions: [{ key: "my_gen_ai.x", action: "delete" }] } } })).toEqual([]);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("SumConnector", () => {
|
|
289
|
+
const problems = (c: InstanceType<typeof SumConnector>) =>
|
|
290
|
+
validateCollectorEntities([c]).filter((i) => i.code === "OTEL107").map((i) => i.message);
|
|
291
|
+
|
|
292
|
+
test("needs a source attribute and at least one metric", () => {
|
|
293
|
+
expect(problems(new SumConnector({}))).toEqual(['connector "sum": no metric is configured, so the connector emits nothing']);
|
|
294
|
+
expect(problems(new SumConnector({ spans: { x: { source_attribute: "" } } }))).toEqual([
|
|
295
|
+
'connector "sum": spans.x: source_attribute is missing',
|
|
296
|
+
]);
|
|
297
|
+
expect(
|
|
298
|
+
problems(new SumConnector({ datapoints: { y: { source_attribute: "v" } }, metrics: { z: { source_attribute: "v", attributes: [{ key: "k" }] } } })),
|
|
299
|
+
).toEqual(['connector "sum": metrics.z: attributes are not supported when summing metrics']);
|
|
300
|
+
expect(problems(new SumConnector({ spans: { t: { source_attribute: "v", attributes: [{ key: "a" }, { key: "b" }] } } }))).toEqual([
|
|
301
|
+
'connector "sum": spans.t: more than one attribute multiplies each sum by the number of attributes in the pinned collector; split by one attribute',
|
|
302
|
+
]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("connects traces, metrics and logs to metrics", () => {
|
|
306
|
+
expect(SumConnector.definition.connects).toEqual([
|
|
307
|
+
{ from: "traces", to: "metrics" },
|
|
308
|
+
{ from: "metrics", to: "metrics" },
|
|
309
|
+
{ from: "logs", to: "metrics" },
|
|
310
|
+
]);
|
|
311
|
+
expect(SumConnector.definition.pin).toBe(COLLECTOR_PIN);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe.skipIf(!OTELCOL)(`otelcol${OTELCOL ? "" : " (skipped: no otelcol-contrib on PATH and no OTELCOL_BIN)"}`, () => {
|
|
316
|
+
test.each(variants())("otelcol validate accepts the %s preset", (_label, options) => {
|
|
317
|
+
const { ok, output } = otelcolValidate(collectorYaml(genAiPipeline(options)));
|
|
318
|
+
expect(output).toBe("");
|
|
319
|
+
expect(ok).toBe(true);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("the running collector drops content unless kept, and emits the named metrics", async () => {
|
|
323
|
+
const removed = await runCollector({});
|
|
324
|
+
for (const secret of SECRETS) expect(removed.output).not.toContain(secret);
|
|
325
|
+
expect(removed.output).toContain("gen_ai.prompt.name: Str(keep-me)");
|
|
326
|
+
const m = genAiMetrics();
|
|
327
|
+
expect(removed.metrics).toMatch(new RegExp(`^${m.calls.prometheus}\\{.*gen_ai_tool_name="search".*status_code="STATUS_CODE_ERROR".*\\} 1$`, "m"));
|
|
328
|
+
expect(removed.metrics).toMatch(new RegExp(`^${m.duration.prometheus}_count\\{.*gen_ai_operation_name="chat".*\\} 1$`, "m"));
|
|
329
|
+
// 120 from the chat span; the in-process invoke_agent span's aggregate is not added again.
|
|
330
|
+
expect(removed.metrics).toMatch(new RegExp(`^${m.inputTokens.prometheus}\\{.*gen_ai_request_model="m1".*\\} 120$`, "m"));
|
|
331
|
+
expect(removed.metrics).toMatch(new RegExp(`^${m.outputTokens.prometheus}\\{.*\\} 30$`, "m"));
|
|
332
|
+
// Spans without gen_ai.operation.name are not counted.
|
|
333
|
+
expect(removed.metrics).not.toContain('span_name="GET /health"');
|
|
334
|
+
|
|
335
|
+
const kept = await runCollector({ keepContent: true });
|
|
336
|
+
for (const secret of SECRETS) expect(kept.output).toContain(secret);
|
|
337
|
+
}, 60_000);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
function variants(): Array<[string, GenAiPipelineOptions]> {
|
|
341
|
+
return [
|
|
342
|
+
["default", {}],
|
|
343
|
+
["keepContent", { keepContent: true }],
|
|
344
|
+
["masked", { maskValues: ["secret-[a-z]+"], hashFunction: "sha3" }],
|
|
345
|
+
["sampled", { sampling: [new FilterProcessor({ name: "sampler", traces: { span: ["status.code != STATUS_CODE_ERROR"] } })] }],
|
|
346
|
+
["namespaced", { namespace: "agents.support", dimensions: [{ name: "gen_ai.agent.name" }], logs: false }],
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── A live run ───────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
const SECRETS = ["SECRET-PROMPT", "SECRET-ANSWER", "SECRET-SYS", "SECRET-LEGACY", "SECRET-EVENT", "SECRET-ARGS", "SECRET-USER-EVENT", "SECRET-DETAILS"];
|
|
353
|
+
|
|
354
|
+
async function freePort(): Promise<number> {
|
|
355
|
+
return new Promise((resolve, reject) => {
|
|
356
|
+
const srv = createServer();
|
|
357
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
358
|
+
const port = (srv.address() as { port: number }).port;
|
|
359
|
+
srv.close(() => resolve(port));
|
|
360
|
+
});
|
|
361
|
+
srv.on("error", reject);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const str = (key: string, value: string) => ({ key, value: { stringValue: value } });
|
|
366
|
+
const int = (key: string, value: number) => ({ key, value: { intValue: String(value) } });
|
|
367
|
+
|
|
368
|
+
function payloads() {
|
|
369
|
+
const now = BigInt(Date.now()) * 1_000_000n;
|
|
370
|
+
const span = (spanId: string, name: string, kind: number, attributes: unknown[], extra: Record<string, unknown> = {}) => ({
|
|
371
|
+
traceId: "5b8efff798038103d269b633813fc60c",
|
|
372
|
+
spanId,
|
|
373
|
+
name,
|
|
374
|
+
kind,
|
|
375
|
+
startTimeUnixNano: String(now),
|
|
376
|
+
endTimeUnixNano: String(now + 1_500_000_000n),
|
|
377
|
+
attributes,
|
|
378
|
+
...extra,
|
|
379
|
+
});
|
|
380
|
+
const traces = {
|
|
381
|
+
resourceSpans: [
|
|
382
|
+
{
|
|
383
|
+
resource: { attributes: [str("service.name", "agent-demo")] },
|
|
384
|
+
scopeSpans: [
|
|
385
|
+
{
|
|
386
|
+
scope: { name: "test" },
|
|
387
|
+
spans: [
|
|
388
|
+
span("eee19b7ec3c1b174", "invoke_agent planner", 1, [
|
|
389
|
+
str("gen_ai.operation.name", "invoke_agent"),
|
|
390
|
+
str("gen_ai.request.model", "m1"),
|
|
391
|
+
int("gen_ai.usage.input_tokens", 1000),
|
|
392
|
+
int("gen_ai.usage.output_tokens", 1000),
|
|
393
|
+
str("gen_ai.input.messages", "SECRET-PROMPT"),
|
|
394
|
+
]),
|
|
395
|
+
span(
|
|
396
|
+
"eee19b7ec3c1b175",
|
|
397
|
+
"chat m1",
|
|
398
|
+
3,
|
|
399
|
+
[
|
|
400
|
+
str("gen_ai.operation.name", "chat"),
|
|
401
|
+
str("gen_ai.request.model", "m1"),
|
|
402
|
+
str("gen_ai.prompt.name", "keep-me"),
|
|
403
|
+
str("gen_ai.prompt.0.content", "SECRET-LEGACY"),
|
|
404
|
+
int("gen_ai.usage.input_tokens", 120),
|
|
405
|
+
int("gen_ai.usage.output_tokens", 30),
|
|
406
|
+
str("gen_ai.output.messages", "SECRET-ANSWER"),
|
|
407
|
+
str("gen_ai.system_instructions", "SECRET-SYS"),
|
|
408
|
+
],
|
|
409
|
+
{
|
|
410
|
+
parentSpanId: "eee19b7ec3c1b174",
|
|
411
|
+
events: [
|
|
412
|
+
{
|
|
413
|
+
name: "gen_ai.client.inference.operation.details",
|
|
414
|
+
timeUnixNano: String(now),
|
|
415
|
+
attributes: [str("gen_ai.input.messages", "SECRET-EVENT")],
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
},
|
|
419
|
+
),
|
|
420
|
+
span(
|
|
421
|
+
"eee19b7ec3c1b176",
|
|
422
|
+
"execute_tool search",
|
|
423
|
+
1,
|
|
424
|
+
[
|
|
425
|
+
str("gen_ai.operation.name", "execute_tool"),
|
|
426
|
+
str("gen_ai.tool.name", "search"),
|
|
427
|
+
str("error.type", "timeout"),
|
|
428
|
+
str("gen_ai.tool.call.arguments", "SECRET-ARGS"),
|
|
429
|
+
],
|
|
430
|
+
{ parentSpanId: "eee19b7ec3c1b174", status: { code: 2 } },
|
|
431
|
+
),
|
|
432
|
+
span("eee19b7ec3c1b177", "GET /health", 2, []),
|
|
433
|
+
],
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
};
|
|
439
|
+
const logs = {
|
|
440
|
+
resourceLogs: [
|
|
441
|
+
{
|
|
442
|
+
resource: { attributes: [str("service.name", "agent-demo")] },
|
|
443
|
+
scopeLogs: [
|
|
444
|
+
{
|
|
445
|
+
scope: { name: "test" },
|
|
446
|
+
logRecords: [
|
|
447
|
+
{
|
|
448
|
+
timeUnixNano: String(now),
|
|
449
|
+
eventName: "gen_ai.user.message",
|
|
450
|
+
body: { kvlistValue: { values: [str("role", "user"), str("content", "SECRET-USER-EVENT")] } },
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
timeUnixNano: String(now),
|
|
454
|
+
eventName: "gen_ai.client.inference.operation.details",
|
|
455
|
+
attributes: [str("gen_ai.output.messages", "SECRET-DETAILS")],
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
},
|
|
459
|
+
],
|
|
460
|
+
},
|
|
461
|
+
],
|
|
462
|
+
};
|
|
463
|
+
return { traces, logs };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function waitFor<T>(fn: () => Promise<T | undefined>, timeoutMs: number): Promise<T> {
|
|
467
|
+
const deadline = Date.now() + timeoutMs;
|
|
468
|
+
for (;;) {
|
|
469
|
+
try {
|
|
470
|
+
const v = await fn();
|
|
471
|
+
if (v !== undefined) return v;
|
|
472
|
+
} catch {
|
|
473
|
+
// not up yet
|
|
474
|
+
}
|
|
475
|
+
if (Date.now() > deadline) throw new Error("timed out waiting for the collector");
|
|
476
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function runCollector(options: GenAiPipelineOptions): Promise<{ output: string; metrics: string }> {
|
|
481
|
+
const [grpc, http, prom] = [await freePort(), await freePort(), await freePort()];
|
|
482
|
+
const detail = new DebugExporter({ name: "detail", verbosity: "detailed" });
|
|
483
|
+
const yaml = collectorYaml(
|
|
484
|
+
genAiPipeline({
|
|
485
|
+
...options,
|
|
486
|
+
traceExporters: [detail],
|
|
487
|
+
logExporters: [detail],
|
|
488
|
+
metricExporters: [new PrometheusExporter({ endpoint: `127.0.0.1:${prom}` })],
|
|
489
|
+
metricsFlushInterval: "500ms",
|
|
490
|
+
healthCheck: false,
|
|
491
|
+
}),
|
|
492
|
+
)
|
|
493
|
+
.replace("0.0.0.0:4317", `127.0.0.1:${grpc}`)
|
|
494
|
+
.replace("0.0.0.0:4318", `127.0.0.1:${http}`);
|
|
495
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-genai-run-"));
|
|
496
|
+
const file = join(dir, "config.yaml");
|
|
497
|
+
writeFileSync(file, yaml);
|
|
498
|
+
const child = spawn(OTELCOL!, [`--config=${file}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
499
|
+
let output = "";
|
|
500
|
+
child.stdout.on("data", (d) => (output += String(d)));
|
|
501
|
+
child.stderr.on("data", (d) => (output += String(d)));
|
|
502
|
+
try {
|
|
503
|
+
const { traces, logs } = payloads();
|
|
504
|
+
const post = (path: string, body: unknown) =>
|
|
505
|
+
fetch(`http://127.0.0.1:${http}${path}`, {
|
|
506
|
+
method: "POST",
|
|
507
|
+
headers: { "content-type": "application/json" },
|
|
508
|
+
body: JSON.stringify(body),
|
|
509
|
+
});
|
|
510
|
+
await waitFor(async () => ((await post("/v1/traces", traces)).ok ? true : undefined), 20_000);
|
|
511
|
+
await post("/v1/logs", logs);
|
|
512
|
+
const metrics = await waitFor(async () => {
|
|
513
|
+
const text = await (await fetch(`http://127.0.0.1:${prom}/metrics`)).text();
|
|
514
|
+
// The prometheus exporter serves a new cumulative series at 0 on its first scrape.
|
|
515
|
+
return /^genai_calls_total\{[^}]*span_name="execute_tool search"[^}]*\} 1$/m.test(text) &&
|
|
516
|
+
text.includes("genai_tokens_output_total")
|
|
517
|
+
? text
|
|
518
|
+
: undefined;
|
|
519
|
+
}, 20_000);
|
|
520
|
+
await waitFor(async () => (output.includes("gen_ai.user.message") ? true : undefined), 10_000);
|
|
521
|
+
return { output, metrics };
|
|
522
|
+
} finally {
|
|
523
|
+
child.kill();
|
|
524
|
+
rmSync(dir, { recursive: true, force: true });
|
|
525
|
+
}
|
|
526
|
+
}
|