@intentius/chant-lexicon-otel 0.100.0 → 0.101.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +8 -6
  2. package/dist/codegen/docs.d.ts.map +1 -1
  3. package/dist/components/common.d.ts +2 -0
  4. package/dist/components/common.d.ts.map +1 -1
  5. package/dist/components/connectors.d.ts +109 -1
  6. package/dist/components/connectors.d.ts.map +1 -1
  7. package/dist/components/exporters.d.ts.map +1 -1
  8. package/dist/components/extensions.d.ts +26 -2
  9. package/dist/components/extensions.d.ts.map +1 -1
  10. package/dist/components/processors.d.ts +15 -2
  11. package/dist/components/processors.d.ts.map +1 -1
  12. package/dist/components/receivers.d.ts.map +1 -1
  13. package/dist/composites/catalog.d.ts +3 -0
  14. package/dist/composites/catalog.d.ts.map +1 -0
  15. package/dist/composites/index.d.ts +6 -0
  16. package/dist/composites/index.d.ts.map +1 -0
  17. package/dist/composites/node-agent.d.ts +110 -0
  18. package/dist/composites/node-agent.d.ts.map +1 -0
  19. package/dist/configmap.d.ts +35 -0
  20. package/dist/configmap.d.ts.map +1 -0
  21. package/dist/define.d.ts +9 -0
  22. package/dist/define.d.ts.map +1 -1
  23. package/dist/genai.d.ts +145 -2
  24. package/dist/genai.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/init-templates.d.ts +24 -0
  28. package/dist/init-templates.d.ts.map +1 -0
  29. package/dist/integrity.json +10 -5
  30. package/dist/lint/audit-catalog.d.ts +1 -1
  31. package/dist/lint/audit-catalog.d.ts.map +1 -1
  32. package/dist/lint/post-synth/index.d.ts.map +1 -1
  33. package/dist/lint/post-synth/otel-helpers.d.ts +32 -9
  34. package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
  35. package/dist/lint/post-synth/otel113.d.ts +8 -0
  36. package/dist/lint/post-synth/otel113.d.ts.map +1 -0
  37. package/dist/lint/post-synth/otel114.d.ts +8 -0
  38. package/dist/lint/post-synth/otel114.d.ts.map +1 -0
  39. package/dist/lint/post-synth/otel115.d.ts +8 -0
  40. package/dist/lint/post-synth/otel115.d.ts.map +1 -0
  41. package/dist/lint/post-synth/otel116.d.ts +8 -0
  42. package/dist/lint/post-synth/otel116.d.ts.map +1 -0
  43. package/dist/lint/post-synth/otel117.d.ts +8 -0
  44. package/dist/lint/post-synth/otel117.d.ts.map +1 -0
  45. package/dist/manifest.json +1 -1
  46. package/dist/meta.json +15 -0
  47. package/dist/okf/index.md +8 -0
  48. package/dist/okf/rules/OTEL113.md +11 -0
  49. package/dist/okf/rules/OTEL114.md +11 -0
  50. package/dist/okf/rules/OTEL115.md +11 -0
  51. package/dist/okf/rules/OTEL116.md +11 -0
  52. package/dist/okf/rules/OTEL117.md +11 -0
  53. package/dist/okf/types/DeltaToCumulativeProcessor.md +9 -0
  54. package/dist/okf/types/K8sLeaderElectorExtension.md +9 -0
  55. package/dist/okf/types/SignalToMetricsConnector.md +9 -0
  56. package/dist/plugin.d.ts.map +1 -1
  57. package/dist/rules/otel-helpers.ts +43 -11
  58. package/dist/rules/otel113.ts +17 -0
  59. package/dist/rules/otel114.ts +17 -0
  60. package/dist/rules/otel115.ts +17 -0
  61. package/dist/rules/otel116.ts +17 -0
  62. package/dist/rules/otel117.ts +17 -0
  63. package/dist/skills/chant-otel.md +11 -5
  64. package/dist/topology.d.ts +5 -0
  65. package/dist/topology.d.ts.map +1 -1
  66. package/dist/validate-config.d.ts +6 -2
  67. package/dist/validate-config.d.ts.map +1 -1
  68. package/dist/validate.d.ts.map +1 -1
  69. package/package.json +2 -2
  70. package/src/codegen/docs.ts +9 -4
  71. package/src/components/common.ts +17 -0
  72. package/src/components/connectors.ts +209 -1
  73. package/src/components/exporters.ts +2 -0
  74. package/src/components/extensions.test.ts +70 -0
  75. package/src/components/extensions.ts +60 -2
  76. package/src/components/processors.test.ts +54 -0
  77. package/src/components/processors.ts +41 -2
  78. package/src/components/receivers.ts +5 -0
  79. package/src/composites/catalog.test.ts +38 -0
  80. package/src/composites/catalog.ts +81 -0
  81. package/src/composites/composites.test.ts +138 -0
  82. package/src/composites/index.ts +6 -0
  83. package/src/composites/node-agent.ts +219 -0
  84. package/src/configmap.ts +67 -0
  85. package/src/connectors.test.ts +197 -1
  86. package/src/define.ts +9 -0
  87. package/src/genai.test.ts +301 -6
  88. package/src/genai.ts +323 -8
  89. package/src/generated/lexicon-otel.json +15 -0
  90. package/src/import/roundtrip.test.ts +21 -1
  91. package/src/import/testdata/fixtures.ts +37 -5
  92. package/src/import/testdata/k8s-leader-elector.yaml +35 -0
  93. package/src/import/testdata/signaltometrics.yaml +83 -0
  94. package/src/index.ts +16 -0
  95. package/src/init-templates.test.ts +72 -0
  96. package/src/init-templates.ts +146 -0
  97. package/src/lint/audit-catalog.ts +41 -1
  98. package/src/lint/post-synth/connector-graph.test.ts +207 -0
  99. package/src/lint/post-synth/index.ts +10 -0
  100. package/src/lint/post-synth/otel-helpers.ts +43 -11
  101. package/src/lint/post-synth/otel113.ts +17 -0
  102. package/src/lint/post-synth/otel114.ts +17 -0
  103. package/src/lint/post-synth/otel115.ts +17 -0
  104. package/src/lint/post-synth/otel116.ts +17 -0
  105. package/src/lint/post-synth/otel117.ts +17 -0
  106. package/src/lint/post-synth/post-synth.test.ts +223 -1
  107. package/src/lint/post-synth/testdata/otel113-fail.yaml +23 -0
  108. package/src/lint/post-synth/testdata/otel113-pass.yaml +21 -0
  109. package/src/lint/post-synth/testdata/otel114-fail.yaml +25 -0
  110. package/src/lint/post-synth/testdata/otel114-pass.yaml +23 -0
  111. package/src/lint/post-synth/testdata/otel115-fail.yaml +29 -0
  112. package/src/lint/post-synth/testdata/otel115-pass.yaml +29 -0
  113. package/src/plugin.test.ts +18 -0
  114. package/src/plugin.ts +22 -0
  115. package/src/skills/chant-otel.md +11 -5
  116. package/src/testdata/genai-configured.yaml +108 -0
  117. package/src/testdata/genai-default.yaml +92 -0
  118. package/src/topology.test.ts +12 -0
  119. package/src/topology.ts +16 -0
  120. package/src/typecheck.test.ts +112 -0
  121. package/src/validate-config.ts +415 -2
  122. package/src/validate.ts +17 -0
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 entities = genAiPipeline();
270
- expect(collectorTopology(load(collectorYaml(entities)) as CollectorConfig)).toEqual(collectorTopologyOf(entities));
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
- async function runCollector(options: GenAiPipelineOptions): Promise<{ output: string; metrics: string }> {
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",