@intentius/chant-lexicon-otel 0.100.0 → 0.101.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/components/common.d.ts +2 -0
- package/dist/components/common.d.ts.map +1 -1
- package/dist/components/connectors.d.ts +109 -1
- package/dist/components/connectors.d.ts.map +1 -1
- package/dist/components/exporters.d.ts.map +1 -1
- package/dist/components/extensions.d.ts +26 -2
- package/dist/components/extensions.d.ts.map +1 -1
- package/dist/components/processors.d.ts +15 -2
- package/dist/components/processors.d.ts.map +1 -1
- package/dist/components/receivers.d.ts.map +1 -1
- package/dist/composites/catalog.d.ts +3 -0
- package/dist/composites/catalog.d.ts.map +1 -0
- package/dist/composites/index.d.ts +6 -0
- package/dist/composites/index.d.ts.map +1 -0
- package/dist/composites/node-agent.d.ts +110 -0
- package/dist/composites/node-agent.d.ts.map +1 -0
- package/dist/configmap.d.ts +35 -0
- package/dist/configmap.d.ts.map +1 -0
- package/dist/define.d.ts +9 -0
- package/dist/define.d.ts.map +1 -1
- package/dist/genai.d.ts +145 -2
- package/dist/genai.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/init-templates.d.ts +24 -0
- package/dist/init-templates.d.ts.map +1 -0
- package/dist/integrity.json +10 -5
- package/dist/lint/audit-catalog.d.ts +1 -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 +32 -9
- package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/otel113.d.ts +8 -0
- package/dist/lint/post-synth/otel113.d.ts.map +1 -0
- package/dist/lint/post-synth/otel114.d.ts +8 -0
- package/dist/lint/post-synth/otel114.d.ts.map +1 -0
- package/dist/lint/post-synth/otel115.d.ts +8 -0
- package/dist/lint/post-synth/otel115.d.ts.map +1 -0
- package/dist/lint/post-synth/otel116.d.ts +8 -0
- package/dist/lint/post-synth/otel116.d.ts.map +1 -0
- package/dist/lint/post-synth/otel117.d.ts +8 -0
- package/dist/lint/post-synth/otel117.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +15 -0
- package/dist/okf/index.md +8 -0
- package/dist/okf/rules/OTEL113.md +11 -0
- package/dist/okf/rules/OTEL114.md +11 -0
- package/dist/okf/rules/OTEL115.md +11 -0
- package/dist/okf/rules/OTEL116.md +11 -0
- package/dist/okf/rules/OTEL117.md +11 -0
- package/dist/okf/types/DeltaToCumulativeProcessor.md +9 -0
- package/dist/okf/types/K8sLeaderElectorExtension.md +9 -0
- package/dist/okf/types/SignalToMetricsConnector.md +9 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/otel-helpers.ts +43 -11
- package/dist/rules/otel113.ts +17 -0
- package/dist/rules/otel114.ts +17 -0
- package/dist/rules/otel115.ts +17 -0
- package/dist/rules/otel116.ts +17 -0
- package/dist/rules/otel117.ts +17 -0
- package/dist/skills/chant-otel.md +11 -5
- package/dist/topology.d.ts +5 -0
- 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 +2 -2
- package/src/codegen/docs.ts +9 -4
- package/src/components/common.ts +17 -0
- package/src/components/connectors.ts +209 -1
- package/src/components/exporters.ts +2 -0
- package/src/components/extensions.test.ts +70 -0
- package/src/components/extensions.ts +60 -2
- package/src/components/processors.test.ts +54 -0
- package/src/components/processors.ts +41 -2
- package/src/components/receivers.ts +5 -0
- package/src/composites/catalog.test.ts +38 -0
- package/src/composites/catalog.ts +81 -0
- package/src/composites/composites.test.ts +138 -0
- package/src/composites/index.ts +6 -0
- package/src/composites/node-agent.ts +219 -0
- package/src/configmap.ts +67 -0
- package/src/connectors.test.ts +197 -1
- package/src/define.ts +9 -0
- package/src/genai.test.ts +301 -6
- package/src/genai.ts +323 -8
- package/src/generated/lexicon-otel.json +15 -0
- package/src/import/roundtrip.test.ts +21 -1
- package/src/import/testdata/fixtures.ts +37 -5
- package/src/import/testdata/k8s-leader-elector.yaml +35 -0
- package/src/import/testdata/signaltometrics.yaml +83 -0
- package/src/index.ts +16 -0
- package/src/init-templates.test.ts +72 -0
- package/src/init-templates.ts +146 -0
- package/src/lint/audit-catalog.ts +41 -1
- package/src/lint/post-synth/connector-graph.test.ts +207 -0
- package/src/lint/post-synth/index.ts +10 -0
- package/src/lint/post-synth/otel-helpers.ts +43 -11
- package/src/lint/post-synth/otel113.ts +17 -0
- package/src/lint/post-synth/otel114.ts +17 -0
- package/src/lint/post-synth/otel115.ts +17 -0
- package/src/lint/post-synth/otel116.ts +17 -0
- package/src/lint/post-synth/otel117.ts +17 -0
- package/src/lint/post-synth/post-synth.test.ts +223 -1
- package/src/lint/post-synth/testdata/otel113-fail.yaml +23 -0
- package/src/lint/post-synth/testdata/otel113-pass.yaml +21 -0
- package/src/lint/post-synth/testdata/otel114-fail.yaml +25 -0
- package/src/lint/post-synth/testdata/otel114-pass.yaml +23 -0
- package/src/lint/post-synth/testdata/otel115-fail.yaml +29 -0
- package/src/lint/post-synth/testdata/otel115-pass.yaml +29 -0
- package/src/plugin.test.ts +18 -0
- package/src/plugin.ts +22 -0
- package/src/skills/chant-otel.md +11 -5
- package/src/testdata/genai-configured.yaml +108 -0
- package/src/testdata/genai-default.yaml +92 -0
- package/src/topology.test.ts +12 -0
- package/src/topology.ts +16 -0
- package/src/typecheck.test.ts +112 -0
- package/src/validate-config.ts +415 -2
- package/src/validate.ts +17 -0
package/src/genai.test.ts
CHANGED
|
@@ -10,19 +10,22 @@
|
|
|
10
10
|
|
|
11
11
|
import { describe, expect, test } from "vitest";
|
|
12
12
|
import { spawn, spawnSync } from "child_process";
|
|
13
|
-
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
13
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
14
14
|
import { createServer } from "net";
|
|
15
15
|
import { tmpdir } from "os";
|
|
16
16
|
import { join } from "path";
|
|
17
17
|
import { load } from "js-yaml";
|
|
18
18
|
import type { Declarable } from "@intentius/chant/declarable";
|
|
19
19
|
import { collectorYaml, buildCollectorConfig } from "./collector";
|
|
20
|
-
import { COLLECTOR_PIN, GENAI_SEMCONV_PIN } from "./define";
|
|
20
|
+
import { COLLECTOR_PIN, GENAI_SEMCONV_PIN, defineComponent } from "./define";
|
|
21
21
|
import { collectorTopology, collectorTopologyOf } from "./topology";
|
|
22
22
|
import { validateCollectorConfig, validateCollectorEntities } from "./validate-config";
|
|
23
23
|
import type { CollectorConfig } from "./model";
|
|
24
24
|
import { DebugExporter, FilterProcessor, OtlpExporter, PrometheusExporter, SumConnector } from "./components";
|
|
25
25
|
import {
|
|
26
|
+
GENAI_CLIENT_DURATION_BUCKETS,
|
|
27
|
+
GENAI_CLIENT_METRIC_ATTRIBUTES,
|
|
28
|
+
GENAI_CLIENT_TOKEN_BUCKETS,
|
|
26
29
|
GENAI_CONTENT_ATTRIBUTES,
|
|
27
30
|
GENAI_SPAN_METRIC_DIMENSIONS,
|
|
28
31
|
genAiComponents,
|
|
@@ -66,6 +69,13 @@ function parsed(entities: Declarable[]): any {
|
|
|
66
69
|
return load(collectorYaml(entities));
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
/** Not a chant built-in; declared the way a project would. */
|
|
73
|
+
const RemoteWriteExporter = defineComponent<{ endpoint: string }>()({
|
|
74
|
+
kind: "exporter",
|
|
75
|
+
type: "prometheusremotewrite",
|
|
76
|
+
pin: { source: "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/prometheusremotewriteexporter", version: "v0.130.0" },
|
|
77
|
+
});
|
|
78
|
+
|
|
69
79
|
describe("genAiPipeline: content", () => {
|
|
70
80
|
test("content keys are deleted from spans, span events and log records by default", () => {
|
|
71
81
|
const config = parsed(genAiPipeline());
|
|
@@ -221,6 +231,45 @@ describe("genAiPipeline: metrics", () => {
|
|
|
221
231
|
});
|
|
222
232
|
});
|
|
223
233
|
|
|
234
|
+
test("deltaToCumulative unset or false leaves the output as it was, whatever the exporters", () => {
|
|
235
|
+
const remoteWrite = new RemoteWriteExporter({ endpoint: "http://mimir:9009/api/v1/push" });
|
|
236
|
+
for (const metricExporters of [undefined, [remoteWrite], [new OtlpExporter({ endpoint: "backend:4317" })]]) {
|
|
237
|
+
const before = collectorYaml(genAiPipeline({ metricExporters }));
|
|
238
|
+
expect(before).not.toContain("deltatocumulative");
|
|
239
|
+
expect(collectorYaml(genAiPipeline({ metricExporters, deltaToCumulative: false }))).toBe(before);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('deltaToCumulative "auto" puts deltatocumulative before batch unless every exporter takes deltas', () => {
|
|
244
|
+
const remoteWrite = new RemoteWriteExporter({ endpoint: "http://mimir:9009/api/v1/push" });
|
|
245
|
+
const prometheus = new PrometheusExporter({ endpoint: "0.0.0.0:8889" });
|
|
246
|
+
const withRemote = parsed(genAiPipeline({ metricExporters: [prometheus, remoteWrite], deltaToCumulative: "auto" }));
|
|
247
|
+
expect(withRemote.service.pipelines["metrics/genai"]).toEqual({
|
|
248
|
+
receivers: ["spanmetrics/genai", "sum/genai_tokens"],
|
|
249
|
+
processors: ["deltatocumulative/genai", "batch"],
|
|
250
|
+
exporters: ["prometheus", "prometheusremotewrite"],
|
|
251
|
+
});
|
|
252
|
+
expect(withRemote.processors).toHaveProperty("deltatocumulative/genai");
|
|
253
|
+
for (const p of ["traces", "traces/genai", "logs"]) {
|
|
254
|
+
expect(withRemote.service.pipelines[p].processors).not.toContain("deltatocumulative/genai");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const promOnly = genAiPipeline({ metricExporters: [prometheus], deltaToCumulative: "auto" });
|
|
258
|
+
expect(collectorYaml(promOnly)).toBe(collectorYaml(genAiPipeline({ metricExporters: [prometheus] })));
|
|
259
|
+
expect(collectorYaml(genAiPipeline({ deltaToCumulative: "auto" }))).toBe(collectorYaml(genAiPipeline()));
|
|
260
|
+
const otlp = parsed(genAiPipeline({ metricExporters: [new OtlpExporter({ endpoint: "backend:4317" })], deltaToCumulative: "auto" }));
|
|
261
|
+
expect(otlp.service.pipelines["metrics/genai"].processors).toEqual(["deltatocumulative/genai", "batch"]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("deltaToCumulative: true inserts it for any exporter, such as otlp to a cumulative backend", () => {
|
|
265
|
+
const config = parsed(genAiPipeline({ metricExporters: [new OtlpExporter({ endpoint: "backend:4317" })], deltaToCumulative: true }));
|
|
266
|
+
expect(config.service.pipelines["metrics/genai"].processors).toEqual(["deltatocumulative/genai", "batch"]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("deltaToCumulative rejects other values", () => {
|
|
270
|
+
expect(() => genAiPipeline({ deltaToCumulative: "yes" as never })).toThrow(/deltaToCumulative/);
|
|
271
|
+
});
|
|
272
|
+
|
|
224
273
|
test("logs: false leaves the logs pipeline out", () => {
|
|
225
274
|
expect(Object.keys(parsed(genAiPipeline({ logs: false })).service.pipelines)).toEqual([
|
|
226
275
|
"traces",
|
|
@@ -230,12 +279,185 @@ describe("genAiPipeline: metrics", () => {
|
|
|
230
279
|
});
|
|
231
280
|
});
|
|
232
281
|
|
|
282
|
+
describe("genAiPipeline: the conventions' client metrics", () => {
|
|
283
|
+
// Rendered from the preset before clientMetrics and providerDimensions existed.
|
|
284
|
+
const fixture = (name: string) => readFileSync(join(import.meta.dirname, "testdata", name), "utf-8");
|
|
285
|
+
const configured = (extra: GenAiPipelineOptions = {}): GenAiPipelineOptions => ({
|
|
286
|
+
namespace: "agents.support",
|
|
287
|
+
dimensions: [{ name: "gen_ai.agent.name" }],
|
|
288
|
+
sampling: [new FilterProcessor({ name: "sampler", traces: { span: ["status.code != STATUS_CODE_ERROR"] } })],
|
|
289
|
+
traceExporters: [new OtlpExporter({ name: "tempo", endpoint: "tempo:4317", tls: { insecure: true } })],
|
|
290
|
+
metricExporters: [new PrometheusExporter({ endpoint: "0.0.0.0:8889" })],
|
|
291
|
+
maskValues: ["secret-[a-z]+"],
|
|
292
|
+
...extra,
|
|
293
|
+
});
|
|
294
|
+
const prometheus = () => [new PrometheusExporter({ endpoint: "0.0.0.0:8889" })];
|
|
295
|
+
|
|
296
|
+
test("without the options the YAML is byte for byte what it was", () => {
|
|
297
|
+
expect(collectorYaml(genAiPipeline())).toBe(fixture("genai-default.yaml"));
|
|
298
|
+
expect(collectorYaml(genAiPipeline({ clientMetrics: undefined, providerDimensions: false }))).toBe(fixture("genai-default.yaml"));
|
|
299
|
+
expect(collectorYaml(genAiPipeline(configured()))).toBe(fixture("genai-configured.yaml"));
|
|
300
|
+
expect(genAiMetrics()).not.toHaveProperty("client");
|
|
301
|
+
const parts = genAiComponents();
|
|
302
|
+
expect(parts).not.toHaveProperty("clientMetrics");
|
|
303
|
+
expect(parts).not.toHaveProperty("sdkClientMetricsFilter");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('clientMetrics: "derive" emits both metrics with the attributes, units and buckets of v1.41.1', () => {
|
|
307
|
+
const config = parsed(genAiPipeline({ clientMetrics: "derive", metricExporters: prometheus() }));
|
|
308
|
+
const [duration, input, output] = config.connectors["signaltometrics/genai_client"].spans;
|
|
309
|
+
const optional = GENAI_CLIENT_METRIC_ATTRIBUTES.slice(1).map((key) => ({ key, optional: true }));
|
|
310
|
+
expect(duration).toEqual({
|
|
311
|
+
name: "gen_ai.client.operation.duration",
|
|
312
|
+
description: "GenAI operation duration.",
|
|
313
|
+
unit: "s",
|
|
314
|
+
attributes: [{ key: "gen_ai.operation.name" }, ...optional],
|
|
315
|
+
histogram: {
|
|
316
|
+
buckets: [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92],
|
|
317
|
+
value: "Double(Microseconds(end_time - start_time)) / 1000000.0",
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
expect(GENAI_CLIENT_METRIC_ATTRIBUTES).toEqual([
|
|
321
|
+
"gen_ai.operation.name",
|
|
322
|
+
"gen_ai.provider.name",
|
|
323
|
+
"gen_ai.request.model",
|
|
324
|
+
"gen_ai.response.model",
|
|
325
|
+
"server.address",
|
|
326
|
+
"server.port",
|
|
327
|
+
"error.type",
|
|
328
|
+
]);
|
|
329
|
+
for (const [entry, type, source] of [
|
|
330
|
+
[input, "input", "gen_ai.usage.input_tokens"],
|
|
331
|
+
[output, "output", "gen_ai.usage.output_tokens"],
|
|
332
|
+
] as const) {
|
|
333
|
+
expect(entry.name).toBe("gen_ai.client.token.usage");
|
|
334
|
+
expect(entry.unit).toBe("{token}");
|
|
335
|
+
// One metric: the connector merges entries with the same name, unit and description.
|
|
336
|
+
expect(entry.description).toBe("Number of input and output tokens used.");
|
|
337
|
+
expect(entry.attributes).toEqual([{ key: "gen_ai.operation.name" }, ...optional, { key: "gen_ai.token.type", default_value: type }]);
|
|
338
|
+
expect(entry.histogram).toEqual({
|
|
339
|
+
buckets: [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864],
|
|
340
|
+
value: `attributes["${source}"]`,
|
|
341
|
+
});
|
|
342
|
+
// Numeric counts only, and not the in-process agent and workflow spans the sum connector skips too.
|
|
343
|
+
expect(entry.conditions).toHaveLength(1);
|
|
344
|
+
expect(entry.conditions[0]).toContain(`IsInt(attributes["${source}"]) or IsDouble(attributes["${source}"])`);
|
|
345
|
+
expect(entry.conditions[0]).toContain(config.connectors["sum/genai_tokens"].spans["genai.tokens.input"].conditions[0].split(" and ").slice(1).join(" and "));
|
|
346
|
+
}
|
|
347
|
+
expect([...GENAI_CLIENT_DURATION_BUCKETS]).toEqual(duration.histogram.buckets);
|
|
348
|
+
expect([...GENAI_CLIENT_TOKEN_BUCKETS]).toEqual(input.histogram.buckets);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('clientMetrics: "derive" wires the connector into the metrics branch and passes the SDK\'s metrics through without its copies', () => {
|
|
352
|
+
const config = parsed(genAiPipeline({ clientMetrics: "derive", metricExporters: prometheus() }));
|
|
353
|
+
const p = config.service.pipelines;
|
|
354
|
+
expect(p["traces/genai"].exporters).toEqual(["spanmetrics/genai", "sum/genai_tokens", "signaltometrics/genai_client"]);
|
|
355
|
+
expect(p["metrics/genai"].receivers).toEqual(["spanmetrics/genai", "sum/genai_tokens", "signaltometrics/genai_client"]);
|
|
356
|
+
expect(p.metrics).toEqual({
|
|
357
|
+
receivers: ["otlp"],
|
|
358
|
+
processors: ["memory_limiter", "filter/genai_sdk_client", "batch"],
|
|
359
|
+
exporters: ["prometheus"],
|
|
360
|
+
});
|
|
361
|
+
expect(config.processors["filter/genai_sdk_client"]).toEqual({
|
|
362
|
+
error_mode: "ignore",
|
|
363
|
+
metrics: { metric: ['name == "gen_ai.client.operation.duration"', 'name == "gen_ai.client.token.usage"'] },
|
|
364
|
+
});
|
|
365
|
+
// The genai.* metrics are unchanged.
|
|
366
|
+
const plain = parsed(genAiPipeline({ metricExporters: prometheus() }));
|
|
367
|
+
expect(config.connectors["spanmetrics/genai"]).toEqual(plain.connectors["spanmetrics/genai"]);
|
|
368
|
+
expect(config.connectors["sum/genai_tokens"]).toEqual(plain.connectors["sum/genai_tokens"]);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('clientMetrics: "passthrough" derives nothing and passes the SDK\'s metrics through', () => {
|
|
372
|
+
const tempo = new OtlpExporter({ name: "mimir", endpoint: "mimir:4317" });
|
|
373
|
+
const config = parsed(genAiPipeline({ clientMetrics: "passthrough", metricExporters: [tempo] }));
|
|
374
|
+
expect(Object.keys(config.connectors)).toEqual(["forward/genai", "spanmetrics/genai", "sum/genai_tokens"]);
|
|
375
|
+
expect(config.processors["filter/genai_sdk_client"]).toBeUndefined();
|
|
376
|
+
expect(config.service.pipelines.metrics).toEqual({ receivers: ["otlp"], processors: ["memory_limiter", "batch"], exporters: ["otlp/mimir"] });
|
|
377
|
+
const parts = genAiComponents({ clientMetrics: "passthrough" });
|
|
378
|
+
expect(parts.clientMetrics).toBeUndefined();
|
|
379
|
+
expect(parts.metrics.client?.source).toBe("passthrough");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("genAiMetrics reports the conventions' names, Prometheus names and attributes", () => {
|
|
383
|
+
for (const source of ["derive", "passthrough"] as const) {
|
|
384
|
+
const m = genAiMetrics({ clientMetrics: source, namespace: "agents" });
|
|
385
|
+
expect(m.client).toEqual({
|
|
386
|
+
source,
|
|
387
|
+
operationDuration: {
|
|
388
|
+
name: "gen_ai.client.operation.duration",
|
|
389
|
+
prometheus: "gen_ai_client_operation_duration_seconds",
|
|
390
|
+
type: "histogram",
|
|
391
|
+
unit: "s",
|
|
392
|
+
dimensions: [...GENAI_CLIENT_METRIC_ATTRIBUTES],
|
|
393
|
+
},
|
|
394
|
+
tokenUsage: {
|
|
395
|
+
name: "gen_ai.client.token.usage",
|
|
396
|
+
prometheus: "gen_ai_client_token_usage",
|
|
397
|
+
type: "histogram",
|
|
398
|
+
unit: "{token}",
|
|
399
|
+
dimensions: [...GENAI_CLIENT_METRIC_ATTRIBUTES, "gen_ai.token.type"],
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
// The preset's own metrics keep their namespace and shape.
|
|
403
|
+
const { client: _client, ...rest } = m;
|
|
404
|
+
expect(rest).toEqual(genAiMetrics({ namespace: "agents" }));
|
|
405
|
+
}
|
|
406
|
+
expect(genAiComponents({ clientMetrics: "derive" }).metrics).toEqual(genAiMetrics({ clientMetrics: "derive" }));
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test('clientMetrics: "derive" converts its deltas for exporters other than prometheus and debug', () => {
|
|
410
|
+
const otlp = new OtlpExporter({ name: "mimir", endpoint: "mimir:4317" });
|
|
411
|
+
const config = parsed(genAiPipeline({ clientMetrics: "derive", metricExporters: [otlp] }));
|
|
412
|
+
expect(config.service.pipelines["metrics/genai"]).toEqual({
|
|
413
|
+
receivers: ["spanmetrics/genai", "sum/genai_tokens", "signaltometrics/genai_client"],
|
|
414
|
+
processors: ["deltatocumulative/genai", "batch"],
|
|
415
|
+
exporters: ["otlp/mimir"],
|
|
416
|
+
});
|
|
417
|
+
// The SDK's own metrics carry the SDK's temporality and are left alone.
|
|
418
|
+
expect(config.service.pipelines.metrics.processors).not.toContain("deltatocumulative/genai");
|
|
419
|
+
|
|
420
|
+
const prom = new PrometheusExporter({ endpoint: "0.0.0.0:8889" });
|
|
421
|
+
expect(collectorYaml(genAiPipeline({ clientMetrics: "derive", metricExporters: [prom] }))).not.toContain("deltatocumulative");
|
|
422
|
+
expect(collectorYaml(genAiPipeline({ clientMetrics: "derive" }))).not.toContain("deltatocumulative");
|
|
423
|
+
const off = parsed(genAiPipeline({ clientMetrics: "derive", metricExporters: [otlp], deltaToCumulative: false }));
|
|
424
|
+
expect(off.service.pipelines["metrics/genai"].processors).toEqual(["batch"]);
|
|
425
|
+
expect(collectorYaml(genAiPipeline({ clientMetrics: "passthrough", metricExporters: [otlp] }))).not.toContain("deltatocumulative");
|
|
426
|
+
expect(() => genAiPipeline({ clientMetrics: "semconv" as never })).toThrow(/"derive" or "passthrough"/);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("providerDimensions adds the provider and response model to genai.calls and genai.duration only", () => {
|
|
430
|
+
const config = parsed(genAiPipeline({ providerDimensions: true, dimensions: [{ name: "gen_ai.agent.name" }] }));
|
|
431
|
+
expect(config.connectors["spanmetrics/genai"].dimensions.map((d: { name: string }) => d.name)).toEqual([
|
|
432
|
+
...GENAI_SPAN_METRIC_DIMENSIONS,
|
|
433
|
+
"gen_ai.provider.name",
|
|
434
|
+
"gen_ai.response.model",
|
|
435
|
+
"gen_ai.agent.name",
|
|
436
|
+
]);
|
|
437
|
+
expect(config.connectors["sum/genai_tokens"]).toEqual(parsed(genAiPipeline()).connectors["sum/genai_tokens"]);
|
|
438
|
+
const m = genAiMetrics({ providerDimensions: true });
|
|
439
|
+
expect(m.calls.dimensions).toEqual(expect.arrayContaining(["gen_ai.provider.name", "gen_ai.response.model"]));
|
|
440
|
+
expect(m.duration.dimensions).toEqual(m.calls.dimensions);
|
|
441
|
+
expect(m.inputTokens.dimensions).toEqual(["gen_ai.request.model"]);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
233
445
|
describe("genAiPipeline: checks and topology", () => {
|
|
234
446
|
const optionSets: Array<[string, GenAiPipelineOptions]> = [
|
|
235
447
|
["default", {}],
|
|
236
448
|
["keepContent", { keepContent: true }],
|
|
237
449
|
["masked and sampled", { maskValues: ["secret-[a-z]+"], sampling: [new FilterProcessor({ name: "s", traces: { span: ["false"] } })] }],
|
|
238
450
|
["prometheus", { metricExporters: [new PrometheusExporter({ endpoint: "0.0.0.0:8889" })], logs: false, healthCheck: false }],
|
|
451
|
+
[
|
|
452
|
+
"remote write with deltatocumulative",
|
|
453
|
+
{ metricExporters: [new RemoteWriteExporter({ endpoint: "http://mimir:9009/api/v1/push" })], deltaToCumulative: "auto" },
|
|
454
|
+
],
|
|
455
|
+
["client metrics derived", { clientMetrics: "derive", providerDimensions: true, metricExporters: [new PrometheusExporter({ endpoint: "0.0.0.0:8889" })] }],
|
|
456
|
+
["client metrics passed through", { clientMetrics: "passthrough" }],
|
|
457
|
+
[
|
|
458
|
+
"client metrics derived to remote write",
|
|
459
|
+
{ clientMetrics: "derive", metricExporters: [new RemoteWriteExporter({ endpoint: "http://mimir:9009/api/v1/push" })] },
|
|
460
|
+
],
|
|
239
461
|
];
|
|
240
462
|
|
|
241
463
|
test.each(optionSets)("%s passes the lexicon's own checks", (_label, options) => {
|
|
@@ -266,8 +488,10 @@ describe("genAiPipeline: checks and topology", () => {
|
|
|
266
488
|
});
|
|
267
489
|
|
|
268
490
|
test("a parsed YAML file gives the same topology as the declaration", () => {
|
|
269
|
-
const
|
|
270
|
-
|
|
491
|
+
for (const options of [{}, { clientMetrics: "derive" }] satisfies GenAiPipelineOptions[]) {
|
|
492
|
+
const entities = genAiPipeline(options);
|
|
493
|
+
expect(collectorTopology(load(collectorYaml(entities)) as CollectorConfig)).toEqual(collectorTopologyOf(entities));
|
|
494
|
+
}
|
|
271
495
|
});
|
|
272
496
|
|
|
273
497
|
test("the YAML says which semconv version its keys follow", () => {
|
|
@@ -335,6 +559,29 @@ describe.skipIf(!OTELCOL)(`otelcol${OTELCOL ? "" : " (skipped: no otelcol-contri
|
|
|
335
559
|
const kept = await runCollector({ keepContent: true });
|
|
336
560
|
for (const secret of SECRETS) expect(kept.output).toContain(secret);
|
|
337
561
|
}, 60_000);
|
|
562
|
+
|
|
563
|
+
test("derived client metrics split tokens by provider and model without doubling, and skip agent aggregates", async () => {
|
|
564
|
+
const { metrics } = await runCollector({ clientMetrics: "derive" }, clientPayload());
|
|
565
|
+
const client = genAiMetrics({ clientMetrics: "derive" }).client!;
|
|
566
|
+
const value = (series: string, labels: Record<string, string>): number[] =>
|
|
567
|
+
metrics
|
|
568
|
+
.split("\n")
|
|
569
|
+
.filter((l) => l.startsWith(`${series}{`) && Object.entries(labels).every(([k, v]) => l.includes(`${k}="${v}"`)))
|
|
570
|
+
.map((l) => Number(l.slice(l.lastIndexOf(" ") + 1)));
|
|
571
|
+
const tokens = `${client.tokenUsage.prometheus}_sum`;
|
|
572
|
+
// Two openai m1 calls of 100 and 10, one anthropic m2 call of 50 and 5; the invoke_agent span's 1000 are not added.
|
|
573
|
+
expect(value(tokens, { gen_ai_provider_name: "openai", gen_ai_request_model: "m1", gen_ai_token_type: "input" })).toEqual([200]);
|
|
574
|
+
expect(value(tokens, { gen_ai_provider_name: "openai", gen_ai_request_model: "m1", gen_ai_token_type: "output" })).toEqual([20]);
|
|
575
|
+
expect(value(tokens, { gen_ai_provider_name: "anthropic", gen_ai_request_model: "m2", gen_ai_token_type: "input" })).toEqual([50]);
|
|
576
|
+
expect(value(tokens, { gen_ai_token_type: "input" }).reduce((a, b) => a + b, 0)).toBe(250);
|
|
577
|
+
expect(value(tokens, { gen_ai_operation_name: "invoke_agent" })).toEqual([]);
|
|
578
|
+
const count = `${client.operationDuration.prometheus}_count`;
|
|
579
|
+
expect(value(count, { gen_ai_provider_name: "openai", gen_ai_request_model: "m1", gen_ai_response_model: "m1-2025", server_port: "443" })).toEqual([2]);
|
|
580
|
+
// A span without a provider is still timed, and the agent span is timed as its own operation.
|
|
581
|
+
expect(value(count, { gen_ai_operation_name: "execute_tool", error_type: "timeout" })).toEqual([1]);
|
|
582
|
+
expect(value(count, { gen_ai_operation_name: "invoke_agent" })).toEqual([1]);
|
|
583
|
+
expect(value(`${client.operationDuration.prometheus}_bucket`, { gen_ai_request_model: "m2", le: "0.04" })).toEqual([1]);
|
|
584
|
+
}, 60_000);
|
|
338
585
|
});
|
|
339
586
|
|
|
340
587
|
function variants(): Array<[string, GenAiPipelineOptions]> {
|
|
@@ -344,6 +591,8 @@ function variants(): Array<[string, GenAiPipelineOptions]> {
|
|
|
344
591
|
["masked", { maskValues: ["secret-[a-z]+"], hashFunction: "sha3" }],
|
|
345
592
|
["sampled", { sampling: [new FilterProcessor({ name: "sampler", traces: { span: ["status.code != STATUS_CODE_ERROR"] } })] }],
|
|
346
593
|
["namespaced", { namespace: "agents.support", dimensions: [{ name: "gen_ai.agent.name" }], logs: false }],
|
|
594
|
+
["client metrics derived", { clientMetrics: "derive", providerDimensions: true }],
|
|
595
|
+
["client metrics passed through", { clientMetrics: "passthrough" }],
|
|
347
596
|
];
|
|
348
597
|
}
|
|
349
598
|
|
|
@@ -477,7 +726,52 @@ async function waitFor<T>(fn: () => Promise<T | undefined>, timeoutMs: number):
|
|
|
477
726
|
}
|
|
478
727
|
}
|
|
479
728
|
|
|
480
|
-
|
|
729
|
+
/** Spans for the client metrics: known token counts across two providers, an agent aggregate and a tool call. */
|
|
730
|
+
function clientPayload() {
|
|
731
|
+
const { traces } = payloads();
|
|
732
|
+
const [base] = traces.resourceSpans[0]!.scopeSpans[0]!.spans as Array<Record<string, any>>;
|
|
733
|
+
const now = BigInt(base!.startTimeUnixNano);
|
|
734
|
+
let id = 0;
|
|
735
|
+
const span = (name: string, kind: number, attributes: unknown[], ms: number, extra: Record<string, unknown> = {}) => ({
|
|
736
|
+
traceId: "6b8efff798038103d269b633813fc60c",
|
|
737
|
+
spanId: (0xeee19b7ec3c1b200n + BigInt(id++)).toString(16),
|
|
738
|
+
name,
|
|
739
|
+
kind,
|
|
740
|
+
startTimeUnixNano: String(now),
|
|
741
|
+
endTimeUnixNano: String(now + BigInt(ms) * 1_000_000n),
|
|
742
|
+
attributes,
|
|
743
|
+
...extra,
|
|
744
|
+
});
|
|
745
|
+
const chat = (provider: string, model: string, input: number, output: number, ms: number) =>
|
|
746
|
+
span(`chat ${model}`, 3, [
|
|
747
|
+
str("gen_ai.operation.name", "chat"),
|
|
748
|
+
str("gen_ai.provider.name", provider),
|
|
749
|
+
str("gen_ai.request.model", model),
|
|
750
|
+
str("gen_ai.response.model", `${model}-2025`),
|
|
751
|
+
str("server.address", "api.example.com"),
|
|
752
|
+
int("server.port", 443),
|
|
753
|
+
int("gen_ai.usage.input_tokens", input),
|
|
754
|
+
int("gen_ai.usage.output_tokens", output),
|
|
755
|
+
], ms);
|
|
756
|
+
const spans = [
|
|
757
|
+
span("invoke_agent planner", 1, [
|
|
758
|
+
str("gen_ai.operation.name", "invoke_agent"),
|
|
759
|
+
str("gen_ai.provider.name", "openai"),
|
|
760
|
+
str("gen_ai.request.model", "m1"),
|
|
761
|
+
int("gen_ai.usage.input_tokens", 1000),
|
|
762
|
+
int("gen_ai.usage.output_tokens", 1000),
|
|
763
|
+
], 5000),
|
|
764
|
+
chat("openai", "m1", 100, 10, 1500),
|
|
765
|
+
chat("openai", "m1", 100, 10, 300),
|
|
766
|
+
chat("anthropic", "m2", 50, 5, 30),
|
|
767
|
+
span("execute_tool search", 1, [str("gen_ai.operation.name", "execute_tool"), str("gen_ai.tool.name", "search"), str("error.type", "timeout")], 70, {
|
|
768
|
+
status: { code: 2 },
|
|
769
|
+
}),
|
|
770
|
+
];
|
|
771
|
+
return { resourceSpans: [{ resource: { attributes: [str("service.name", "agent-demo")] }, scopeSpans: [{ scope: { name: "test" }, spans }] }] };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async function runCollector(options: GenAiPipelineOptions, tracesOverride?: unknown): Promise<{ output: string; metrics: string }> {
|
|
481
775
|
const [grpc, http, prom] = [await freePort(), await freePort(), await freePort()];
|
|
482
776
|
const detail = new DebugExporter({ name: "detail", verbosity: "detailed" });
|
|
483
777
|
const yaml = collectorYaml(
|
|
@@ -500,7 +794,8 @@ async function runCollector(options: GenAiPipelineOptions): Promise<{ output: st
|
|
|
500
794
|
child.stdout.on("data", (d) => (output += String(d)));
|
|
501
795
|
child.stderr.on("data", (d) => (output += String(d)));
|
|
502
796
|
try {
|
|
503
|
-
const { traces, logs } = payloads();
|
|
797
|
+
const { traces: defaultTraces, logs } = payloads();
|
|
798
|
+
const traces = tracesOverride ?? defaultTraces;
|
|
504
799
|
const post = (path: string, body: unknown) =>
|
|
505
800
|
fetch(`http://127.0.0.1:${http}${path}`, {
|
|
506
801
|
method: "POST",
|