@intentius/chant-lexicon-otel 0.124.31 → 0.124.33

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 (96) hide show
  1. package/dist/attribution.js +391 -0
  2. package/dist/catalog.js +42 -0
  3. package/dist/codegen/docs-cli.js +3 -0
  4. package/dist/codegen/docs.js +101 -0
  5. package/dist/codegen/generate-cli.js +7 -0
  6. package/dist/codegen/generate.js +43 -0
  7. package/dist/codegen/package.js +28 -0
  8. package/dist/collector.js +150 -0
  9. package/dist/components/common.js +50 -0
  10. package/dist/components/connectors.js +242 -0
  11. package/dist/components/exporters.js +57 -0
  12. package/dist/components/extensions.js +58 -0
  13. package/dist/components/filtering.js +106 -0
  14. package/dist/components/index.js +9 -0
  15. package/dist/components/k8s-receivers.js +36 -0
  16. package/dist/components/processors.js +103 -0
  17. package/dist/components/receivers.js +61 -0
  18. package/dist/components/sampling.js +257 -0
  19. package/dist/composites/catalog.js +368 -0
  20. package/dist/composites/genai-pipeline.js +28 -0
  21. package/dist/composites/index.js +8 -0
  22. package/dist/composites/node-agent.js +156 -0
  23. package/dist/composites/otlp-collector.js +57 -0
  24. package/dist/composites/red-metrics.js +95 -0
  25. package/dist/composites/tail-sampling-tier.js +132 -0
  26. package/dist/config-hygiene.js +464 -0
  27. package/dist/configmap.js +86 -0
  28. package/dist/define.js +204 -0
  29. package/dist/detect.js +22 -0
  30. package/dist/genai.js +569 -0
  31. package/dist/import/embedded.js +57 -0
  32. package/dist/import/generator.js +459 -0
  33. package/dist/import/parser.js +199 -0
  34. package/dist/index.js +34 -0
  35. package/dist/init-templates.js +119 -0
  36. package/dist/integrity.json +2 -2
  37. package/dist/lint/audit-catalog.js +49 -0
  38. package/dist/lint/post-synth/index.js +52 -0
  39. package/dist/lint/post-synth/otel-helpers.js +103 -0
  40. package/dist/lint/post-synth/otel101.js +13 -0
  41. package/dist/lint/post-synth/otel102.js +13 -0
  42. package/dist/lint/post-synth/otel103.js +13 -0
  43. package/dist/lint/post-synth/otel104.js +13 -0
  44. package/dist/lint/post-synth/otel105.js +13 -0
  45. package/dist/lint/post-synth/otel106.js +13 -0
  46. package/dist/lint/post-synth/otel107.js +13 -0
  47. package/dist/lint/post-synth/otel108.js +13 -0
  48. package/dist/lint/post-synth/otel109.js +13 -0
  49. package/dist/lint/post-synth/otel112.js +13 -0
  50. package/dist/lint/post-synth/otel113.js +13 -0
  51. package/dist/lint/post-synth/otel114.js +13 -0
  52. package/dist/lint/post-synth/otel115.js +13 -0
  53. package/dist/lint/post-synth/otel116.js +13 -0
  54. package/dist/lint/post-synth/otel117.js +13 -0
  55. package/dist/lint/post-synth/otel118.js +13 -0
  56. package/dist/lint/post-synth/otel119.js +13 -0
  57. package/dist/lint/post-synth/otel120.js +13 -0
  58. package/dist/lint/post-synth/otel121.js +13 -0
  59. package/dist/lint/post-synth/otel122.js +13 -0
  60. package/dist/lint/post-synth/otel123.js +13 -0
  61. package/dist/lint/post-synth/otel124.js +13 -0
  62. package/dist/lint/post-synth/otel125.js +13 -0
  63. package/dist/lint/post-synth/otel126.js +13 -0
  64. package/dist/lint/post-synth/otel127.js +13 -0
  65. package/dist/lint/rules/component-id-syntax.js +66 -0
  66. package/dist/lint/rules/index.js +6 -0
  67. package/dist/lint/rules/literal-credential.js +91 -0
  68. package/dist/lint/rules/otel-ast.js +30 -0
  69. package/dist/lsp/completions.js +11 -0
  70. package/dist/lsp/hover.js +23 -0
  71. package/dist/manifest.json +1 -1
  72. package/dist/metric-names.js +244 -0
  73. package/dist/model.js +90 -0
  74. package/dist/op/activities/collector-audit.js +213 -0
  75. package/dist/op/activities/collector-health.js +176 -0
  76. package/dist/op/activities/exec.js +16 -0
  77. package/dist/op/activities/github.js +110 -0
  78. package/dist/op/activities/index.js +16 -0
  79. package/dist/op/activities/otelcol.js +173 -0
  80. package/dist/op/activity-contracts.js +64 -0
  81. package/dist/op/audit-op.js +33 -0
  82. package/dist/op/builders.js +38 -0
  83. package/dist/package-cli.js +14 -0
  84. package/dist/pipeline.js +31 -0
  85. package/dist/platform.js +144 -0
  86. package/dist/plugin.js +126 -0
  87. package/dist/semconv.js +50 -0
  88. package/dist/serializer.js +26 -0
  89. package/dist/skill-defs.js +34 -0
  90. package/dist/topology.js +104 -0
  91. package/dist/validate-cli.js +7 -0
  92. package/dist/validate-config.js +706 -0
  93. package/dist/validate.js +85 -0
  94. package/dist/yaml.js +157 -0
  95. package/package.json +8 -8
  96. package/src/codegen/package.ts +2 -2
package/dist/genai.js ADDED
@@ -0,0 +1,569 @@
1
+ /**
2
+ * A collector preset for workloads that emit OpenTelemetry GenAI spans:
3
+ * content removal, and agent RED and token metrics.
4
+ *
5
+ * Attribute keys follow `GENAI_SEMCONV_PIN`. Content (prompts, completions,
6
+ * system instructions, tool-call arguments and results, retrieval queries and
7
+ * documents) is deleted from spans, span events and log records unless the
8
+ * caller passes `keepContent: true`. A `transform` processor deletes the keys;
9
+ * `redaction` cannot delete one named key while keeping the rest, so it runs
10
+ * after the transform as a masking backstop on the same keys and for any
11
+ * value patterns the caller adds.
12
+ *
13
+ * Metrics come from a branch taken before any sampling: the traces pipeline
14
+ * forwards every span to `traces/genai`, which keeps only spans with a
15
+ * `gen_ai.operation.name`, and feeds `spanmetrics` (calls, errors and
16
+ * duration by operation, model, tool and error type) and `sum` (input and
17
+ * output tokens by model). `spanmetrics` counts spans and
18
+ * cannot sum an attribute, which is why token usage comes from the `sum`
19
+ * connector.
20
+ *
21
+ * With `clientMetrics: "derive"`, a `signaltometrics` connector on the same
22
+ * branch also emits the two client metrics the conventions define at the pin,
23
+ * `gen_ai.client.operation.duration` and `gen_ai.client.token.usage`, under
24
+ * their own names, attributes, units and buckets. `clientMetrics:
25
+ * "passthrough"` derives nothing and relies on the SDK to send them. Either
26
+ * way a `metrics` pipeline passes the SDK's OTLP metrics through. Without the
27
+ * option the output is what it was before the option existed.
28
+ *
29
+ * `sum` emits delta sums. The `prometheus` exporter accumulates them itself,
30
+ * but `prometheusremotewrite` drops them, and some OTLP backends store only
31
+ * cumulative data; `signaltometrics` emits delta histograms too.
32
+ * `deltaToCumulative` puts a `deltatocumulative` processor on `metrics/genai`
33
+ * for those. It is on by default only with `clientMetrics: "derive"`, so a
34
+ * config built without either option is unchanged.
35
+ */
36
+ import { GENAI_SEMCONV_PIN } from "./define.js";
37
+ import { OtlpReceiver } from "./components/receivers.js";
38
+ import { BatchProcessor, DeltaToCumulativeProcessor, MemoryLimiterProcessor } from "./components/processors.js";
39
+ import { DebugExporter } from "./components/exporters.js";
40
+ import { HealthCheckExtension } from "./components/extensions.js";
41
+ import { FilterProcessor, RedactionProcessor, TransformProcessor, } from "./components/filtering.js";
42
+ import { ForwardConnector, SignalToMetricsConnector, SpanMetricsConnector, SumConnector, } from "./components/connectors.js";
43
+ import { Pipeline } from "./pipeline.js";
44
+ import { prometheusMetricName, SPANMETRICS_DEFAULT_DIMENSIONS } from "./metric-names.js";
45
+ // ── The GenAI attribute vocabulary, at GENAI_SEMCONV_PIN ─────────────
46
+ /** GenAI attributes the preset reads. */
47
+ export const GENAI_ATTRIBUTES = Object.freeze({
48
+ operationName: "gen_ai.operation.name",
49
+ providerName: "gen_ai.provider.name",
50
+ requestModel: "gen_ai.request.model",
51
+ responseModel: "gen_ai.response.model",
52
+ agentName: "gen_ai.agent.name",
53
+ toolName: "gen_ai.tool.name",
54
+ inputTokens: "gen_ai.usage.input_tokens",
55
+ outputTokens: "gen_ai.usage.output_tokens",
56
+ /** From the general conventions; GenAI spans set it on failure. */
57
+ errorType: "error.type",
58
+ /** A metric attribute only: `input` or `output` on `gen_ai.client.token.usage`. */
59
+ tokenType: "gen_ai.token.type",
60
+ /** From the general conventions: the GenAI server's host and port. */
61
+ serverAddress: "server.address",
62
+ serverPort: "server.port",
63
+ });
64
+ /**
65
+ * Attributes that carry content: what users and models said, and what tools
66
+ * were given and returned. The conventions mark each opt-in and sensitive.
67
+ * `gen_ai.prompt` and `gen_ai.completion` are deprecated, but older
68
+ * instrumentation still sets them.
69
+ */
70
+ export const GENAI_CONTENT_ATTRIBUTES = Object.freeze([
71
+ "gen_ai.system_instructions",
72
+ "gen_ai.input.messages",
73
+ "gen_ai.output.messages",
74
+ "gen_ai.tool.call.arguments",
75
+ "gen_ai.tool.call.result",
76
+ "gen_ai.retrieval.query.text",
77
+ "gen_ai.retrieval.documents",
78
+ "gen_ai.prompt",
79
+ "gen_ai.completion",
80
+ ]);
81
+ /**
82
+ * Indexed content keys some instrumentation libraries write outside the
83
+ * conventions (`gen_ai.prompt.0.content`, `gen_ai.completion.1.role`). The
84
+ * index keeps `gen_ai.prompt.name`, a convention attribute, out of it.
85
+ */
86
+ export const GENAI_INDEXED_CONTENT_PATTERN = "^gen_ai\\.(prompt|completion)\\.[0-9]+\\..+$";
87
+ /**
88
+ * Attributes that take a new value per response, tool call, conversation,
89
+ * session or user. As a metric attribute, each one starts new time series
90
+ * for every value, so OTEL116 reports them (and the content keys above)
91
+ * wherever a connector splits a metric by one.
92
+ *
93
+ * At `GENAI_SEMCONV_PIN` (semantic-conventions v1.41.1) the conventions
94
+ * define `gen_ai.conversation.id`, `gen_ai.response.id` and
95
+ * `gen_ai.tool.call.id` in `model/gen-ai/registry.yaml`, `session.id` in
96
+ * `model/session/registry.yaml`, `user.id` in `model/user/registry.yaml` and
97
+ * `enduser.id` in `model/enduser/registry.yaml`. None of them is an attribute
98
+ * of any GenAI metric there (`model/gen-ai/metrics.yaml`).
99
+ * `gen_ai.request.previous_response.id` and `gen_ai.memory.record.id` are
100
+ * not at the pin: they come from the unreleased main of
101
+ * github.com/open-telemetry/semantic-conventions-genai, and are listed
102
+ * because instrumentation already sets them. Keys added with that pin
103
+ * (chant #3044) go here too.
104
+ */
105
+ export const GENAI_HIGH_CARDINALITY_ATTRIBUTES = Object.freeze([
106
+ "gen_ai.conversation.id",
107
+ "gen_ai.response.id",
108
+ "gen_ai.tool.call.id",
109
+ "gen_ai.request.previous_response.id",
110
+ "gen_ai.memory.record.id",
111
+ "session.id",
112
+ "user.id",
113
+ "enduser.id",
114
+ ]);
115
+ const INDEXED_CONTENT = new RegExp(GENAI_INDEXED_CONTENT_PATTERN);
116
+ /**
117
+ * Why a key must not be a metric attribute: `"identifier"` for a key in
118
+ * `GENAI_HIGH_CARDINALITY_ATTRIBUTES`, `"content"` for a content key
119
+ * (`GENAI_CONTENT_ATTRIBUTES` or the indexed pattern), which is unbounded
120
+ * and sensitive too. Undefined for any other key.
121
+ */
122
+ export function genAiCardinalityRisk(key) {
123
+ if (GENAI_HIGH_CARDINALITY_ATTRIBUTES.includes(key))
124
+ return "identifier";
125
+ if (GENAI_CONTENT_ATTRIBUTES.includes(key) || INDEXED_CONTENT.test(key))
126
+ return "content";
127
+ return undefined;
128
+ }
129
+ /**
130
+ * Deprecated content events. Their content is in the log record body, not in
131
+ * attributes, so the preset deletes `content`, `message` and `tool_calls`
132
+ * from the body of these events.
133
+ */
134
+ export const GENAI_CONTENT_EVENTS = Object.freeze([
135
+ "gen_ai.system.message",
136
+ "gen_ai.user.message",
137
+ "gen_ai.assistant.message",
138
+ "gen_ai.tool.message",
139
+ "gen_ai.choice",
140
+ ]);
141
+ // ── Metrics ──────────────────────────────────────────────────────────
142
+ /** The spanmetrics dimensions the preset adds to service.name, span.name, span.kind and status.code. */
143
+ export const GENAI_SPAN_METRIC_DIMENSIONS = Object.freeze([
144
+ GENAI_ATTRIBUTES.operationName,
145
+ GENAI_ATTRIBUTES.requestModel,
146
+ GENAI_ATTRIBUTES.toolName,
147
+ GENAI_ATTRIBUTES.errorType,
148
+ ]);
149
+ /**
150
+ * The attribute token sums are split by. One only: the pinned `sum`
151
+ * connector adds each value once per attribute, so a second one would double
152
+ * every sum.
153
+ */
154
+ export const GENAI_TOKEN_DIMENSIONS = Object.freeze([GENAI_ATTRIBUTES.requestModel]);
155
+ /** The model label on token sums for a span that names no model, so its tokens are counted rather than skipped. */
156
+ export const GENAI_UNKNOWN_MODEL = "unknown";
157
+ /** Default duration buckets, sized for model and tool calls rather than HTTP handlers. */
158
+ export const GENAI_DURATION_BUCKETS = Object.freeze([
159
+ "100ms",
160
+ "250ms",
161
+ "500ms",
162
+ "1s",
163
+ "2s",
164
+ "5s",
165
+ "10s",
166
+ "20s",
167
+ "40s",
168
+ "80s",
169
+ ]);
170
+ /**
171
+ * Dimensions `providerDimensions: true` adds to `genai.calls` and
172
+ * `genai.duration`. The token sums keep the model alone (see
173
+ * `GENAI_TOKEN_DIMENSIONS`); `gen_ai.client.token.usage` splits tokens by both.
174
+ */
175
+ export const GENAI_PROVIDER_DIMENSIONS = Object.freeze([
176
+ GENAI_ATTRIBUTES.providerName,
177
+ GENAI_ATTRIBUTES.responseModel,
178
+ ]);
179
+ // ── The conventions' client metrics, at GENAI_SEMCONV_PIN ────────────
180
+ /** The client metrics the conventions define that one span carries enough to derive. */
181
+ export const GENAI_CLIENT_METRIC_NAMES = Object.freeze({
182
+ operationDuration: "gen_ai.client.operation.duration",
183
+ tokenUsage: "gen_ai.client.token.usage",
184
+ });
185
+ /** The values of `gen_ai.token.type` on `gen_ai.client.token.usage`. */
186
+ export const GENAI_TOKEN_TYPES = Object.freeze({ input: "input", output: "output" });
187
+ /**
188
+ * The attributes the conventions give both client metrics: required
189
+ * (`gen_ai.operation.name`, `gen_ai.provider.name`), conditionally required
190
+ * (`gen_ai.request.model`, `error.type`) and recommended
191
+ * (`gen_ai.response.model`, `server.address`, `server.port`).
192
+ * `gen_ai.client.token.usage` adds `gen_ai.token.type`.
193
+ */
194
+ export const GENAI_CLIENT_METRIC_ATTRIBUTES = Object.freeze([
195
+ GENAI_ATTRIBUTES.operationName,
196
+ GENAI_ATTRIBUTES.providerName,
197
+ GENAI_ATTRIBUTES.requestModel,
198
+ GENAI_ATTRIBUTES.responseModel,
199
+ GENAI_ATTRIBUTES.serverAddress,
200
+ GENAI_ATTRIBUTES.serverPort,
201
+ GENAI_ATTRIBUTES.errorType,
202
+ ]);
203
+ /** The bucket boundaries the conventions advise for `gen_ai.client.operation.duration`, in seconds. */
204
+ export const GENAI_CLIENT_DURATION_BUCKETS = Object.freeze([
205
+ 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,
206
+ ]);
207
+ /** The bucket boundaries the conventions advise for `gen_ai.client.token.usage`, in tokens. */
208
+ export const GENAI_CLIENT_TOKEN_BUCKETS = Object.freeze([
209
+ 1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864,
210
+ ]);
211
+ const DEFAULT_NAMESPACE = "genai";
212
+ /**
213
+ * The metrics `genAiPipeline()` emits for the given options: names,
214
+ * Prometheus names and dimensions. A dashboard reads this instead of
215
+ * repeating the names, so renaming the namespace moves the panels with it.
216
+ */
217
+ export function genAiMetrics(options = {}) {
218
+ const ns = options.namespace ?? DEFAULT_NAMESPACE;
219
+ const spanDims = [
220
+ ...SPANMETRICS_DEFAULT_DIMENSIONS,
221
+ ...spanMetricDimensions(options).map((d) => d.name),
222
+ ];
223
+ const tokenDims = [...GENAI_TOKEN_DIMENSIONS];
224
+ const source = clientMetricsSource(options);
225
+ return {
226
+ calls: { name: `${ns}.calls`, prometheus: prometheusMetricName(`${ns}.calls`, "sum"), type: "sum", dimensions: spanDims },
227
+ duration: {
228
+ name: `${ns}.duration`,
229
+ prometheus: prometheusMetricName(`${ns}.duration`, "histogram", "s"),
230
+ type: "histogram",
231
+ unit: "s",
232
+ dimensions: spanDims,
233
+ },
234
+ inputTokens: {
235
+ name: `${ns}.tokens.input`,
236
+ prometheus: prometheusMetricName(`${ns}.tokens.input`, "sum"),
237
+ type: "sum",
238
+ dimensions: tokenDims,
239
+ },
240
+ outputTokens: {
241
+ name: `${ns}.tokens.output`,
242
+ prometheus: prometheusMetricName(`${ns}.tokens.output`, "sum"),
243
+ type: "sum",
244
+ dimensions: tokenDims,
245
+ },
246
+ ...(source ? { client: clientMetrics(source) } : {}),
247
+ };
248
+ }
249
+ function clientMetricsSource(options) {
250
+ const source = options.clientMetrics;
251
+ if (source === undefined)
252
+ return undefined;
253
+ if (source !== "derive" && source !== "passthrough") {
254
+ throw new Error(`genAi: clientMetrics must be "derive" or "passthrough", got ${JSON.stringify(source)}`);
255
+ }
256
+ return source;
257
+ }
258
+ function clientMetrics(source) {
259
+ const { operationDuration, tokenUsage } = GENAI_CLIENT_METRIC_NAMES;
260
+ return {
261
+ source,
262
+ operationDuration: {
263
+ name: operationDuration,
264
+ prometheus: prometheusMetricName(operationDuration, "histogram", "s"),
265
+ type: "histogram",
266
+ unit: "s",
267
+ dimensions: [...GENAI_CLIENT_METRIC_ATTRIBUTES],
268
+ },
269
+ tokenUsage: {
270
+ name: tokenUsage,
271
+ prometheus: prometheusMetricName(tokenUsage, "histogram", "{token}"),
272
+ type: "histogram",
273
+ unit: "{token}",
274
+ dimensions: [...GENAI_CLIENT_METRIC_ATTRIBUTES, GENAI_ATTRIBUTES.tokenType],
275
+ },
276
+ };
277
+ }
278
+ /** The spanmetrics dimensions beyond the connector's defaults, in config order. */
279
+ function spanMetricDimensions(options) {
280
+ return [
281
+ ...GENAI_SPAN_METRIC_DIMENSIONS.map((name) => ({ name })),
282
+ ...(options.providerDimensions ? GENAI_PROVIDER_DIMENSIONS.map((name) => ({ name })) : []),
283
+ ...(options.dimensions ?? []),
284
+ ];
285
+ }
286
+ function quote(s) {
287
+ return JSON.stringify(s);
288
+ }
289
+ function escapeRe2(s) {
290
+ return s.replace(/[\\^$.|?*+()[\]{}]/g, "\\$&");
291
+ }
292
+ /** OTTL that deletes the content keys from one context's attributes. */
293
+ function deleteStatements(target, keys) {
294
+ return [
295
+ ...keys.map((k) => `delete_key(${target}, ${quote(k)})`),
296
+ `delete_matching_keys(${target}, ${quote(GENAI_INDEXED_CONTENT_PATTERN)})`,
297
+ ];
298
+ }
299
+ /** The pieces of the GenAI preset, for wiring into pipelines you declare yourself. */
300
+ export function genAiComponents(options = {}) {
301
+ const keepContent = options.keepContent === true;
302
+ const contentKeys = [...GENAI_CONTENT_ATTRIBUTES, ...(options.contentAttributes ?? [])];
303
+ const metrics = genAiMetrics(options);
304
+ let contentRemoval;
305
+ if (!keepContent) {
306
+ // Older SDKs name the event in an `event.name` attribute instead of the record's event_name.
307
+ const events = quote(`^(${GENAI_CONTENT_EVENTS.map(escapeRe2).join("|")})$`);
308
+ const eventMatch = `IsMatch(log.event_name, ${events}) or IsMatch(log.attributes["event.name"], ${events})`;
309
+ const traceGroups = [
310
+ { context: "span", statements: deleteStatements("span.attributes", contentKeys) },
311
+ { context: "spanevent", statements: deleteStatements("spanevent.attributes", contentKeys) },
312
+ ];
313
+ const logGroups = [
314
+ {
315
+ context: "log",
316
+ statements: [
317
+ ...deleteStatements("log.attributes", contentKeys),
318
+ `delete_matching_keys(log.body, "^(content|message|tool_calls)$") where IsMap(log.body) and (${eventMatch})`,
319
+ `set(log.body, "") where IsString(log.body) and (${eventMatch})`,
320
+ ],
321
+ },
322
+ ];
323
+ contentRemoval = new TransformProcessor({
324
+ name: "genai_content",
325
+ error_mode: "ignore",
326
+ trace_statements: traceGroups,
327
+ log_statements: logGroups,
328
+ });
329
+ }
330
+ const blockedKeys = keepContent
331
+ ? []
332
+ : [`^(${contentKeys.map(escapeRe2).join("|")})$`, GENAI_INDEXED_CONTENT_PATTERN];
333
+ const maskValues = options.maskValues ?? [];
334
+ let redaction;
335
+ if (blockedKeys.length > 0 || maskValues.length > 0) {
336
+ redaction = new RedactionProcessor({
337
+ name: "genai_content",
338
+ allow_all_keys: true,
339
+ ...(blockedKeys.length > 0 ? { blocked_key_patterns: blockedKeys } : {}),
340
+ ...(maskValues.length > 0 ? { blocked_values: maskValues } : {}),
341
+ ...(options.hashFunction ? { hash_function: options.hashFunction } : {}),
342
+ });
343
+ }
344
+ const forward = new ForwardConnector({ name: "genai" });
345
+ const genAiSpans = new FilterProcessor({
346
+ name: "genai_spans",
347
+ error_mode: "ignore",
348
+ traces: { span: [`attributes[${quote(GENAI_ATTRIBUTES.operationName)}] == nil`] },
349
+ });
350
+ const flush = options.metricsFlushInterval ?? "15s";
351
+ const spanMetrics = new SpanMetricsConnector({
352
+ name: "genai",
353
+ namespace: options.namespace ?? DEFAULT_NAMESPACE,
354
+ dimensions: spanMetricDimensions(options),
355
+ histogram: { unit: "s", explicit: { buckets: [...(options.buckets ?? GENAI_DURATION_BUCKETS)] } },
356
+ metrics_flush_interval: flush,
357
+ });
358
+ // An in-process invoke_agent or invoke_workflow span may carry the usage of
359
+ // the model calls under it, which are counted on their own spans.
360
+ const notAggregate = `not (kind == SPAN_KIND_INTERNAL and (attributes[${quote(GENAI_ATTRIBUTES.operationName)}] == "invoke_agent" or attributes[${quote(GENAI_ATTRIBUTES.operationName)}] == "invoke_workflow"))`;
361
+ const tokenAttributes = GENAI_TOKEN_DIMENSIONS.map((key) => ({ key, default_value: GENAI_UNKNOWN_MODEL }));
362
+ const tokenUsage = new SumConnector({
363
+ name: "genai_tokens",
364
+ spans: {
365
+ [metrics.inputTokens.name]: {
366
+ source_attribute: GENAI_ATTRIBUTES.inputTokens,
367
+ description: "Input tokens used by GenAI operations",
368
+ conditions: [`attributes[${quote(GENAI_ATTRIBUTES.inputTokens)}] != nil and ${notAggregate}`],
369
+ attributes: tokenAttributes,
370
+ },
371
+ [metrics.outputTokens.name]: {
372
+ source_attribute: GENAI_ATTRIBUTES.outputTokens,
373
+ description: "Output tokens used by GenAI operations",
374
+ conditions: [`attributes[${quote(GENAI_ATTRIBUTES.outputTokens)}] != nil and ${notAggregate}`],
375
+ attributes: tokenAttributes,
376
+ },
377
+ },
378
+ });
379
+ let clientMetricsConnector;
380
+ let sdkClientMetricsFilter;
381
+ if (metrics.client?.source === "derive") {
382
+ clientMetricsConnector = deriveClientMetrics(metrics.client, notAggregate);
383
+ const names = [metrics.client.operationDuration.name, metrics.client.tokenUsage.name];
384
+ sdkClientMetricsFilter = new FilterProcessor({
385
+ name: "genai_sdk_client",
386
+ error_mode: "ignore",
387
+ metrics: { metric: names.map((n) => `name == ${quote(n)}`) },
388
+ });
389
+ }
390
+ const processors = [];
391
+ if (contentRemoval)
392
+ processors.push(contentRemoval);
393
+ if (redaction)
394
+ processors.push(redaction);
395
+ return {
396
+ ...(contentRemoval ? { contentRemoval } : {}),
397
+ ...(redaction ? { redaction } : {}),
398
+ processors,
399
+ forward,
400
+ genAiSpans,
401
+ spanMetrics,
402
+ tokenUsage,
403
+ ...(clientMetricsConnector ? { clientMetrics: clientMetricsConnector } : {}),
404
+ ...(sdkClientMetricsFilter ? { sdkClientMetricsFilter } : {}),
405
+ metrics,
406
+ semconv: GENAI_SEMCONV_PIN,
407
+ };
408
+ }
409
+ /**
410
+ * The `signaltometrics` connector for the conventions' client metrics. Every
411
+ * GenAI span records its duration. Token usage is one histogram fed by two
412
+ * entries, input and output, each setting `gen_ai.token.type` through the
413
+ * attribute's `default_value` (spans don't carry that key); the connector
414
+ * merges entries with the same name, unit and description into one metric.
415
+ * Token entries skip the in-process agent and workflow spans the `sum`
416
+ * connector skips, and spans whose count is not a number, which would
417
+ * otherwise fail the batch.
418
+ */
419
+ function deriveClientMetrics(client, notAggregate) {
420
+ const [operation, ...rest] = GENAI_CLIENT_METRIC_ATTRIBUTES;
421
+ // Spans always have the operation (filter/genai_spans keeps no other); the
422
+ // rest are optional, so a span that lacks one, such as a tool call without
423
+ // a provider, is still counted.
424
+ const attributes = [{ key: operation }, ...rest.map((key) => ({ key, optional: true }))];
425
+ const duration = {
426
+ name: client.operationDuration.name,
427
+ description: "GenAI operation duration.",
428
+ unit: client.operationDuration.unit,
429
+ attributes,
430
+ histogram: {
431
+ buckets: [...GENAI_CLIENT_DURATION_BUCKETS],
432
+ value: "Double(Microseconds(end_time - start_time)) / 1000000.0",
433
+ },
434
+ };
435
+ const tokens = (tokenType, source) => {
436
+ const attr = `attributes[${quote(source)}]`;
437
+ return {
438
+ name: client.tokenUsage.name,
439
+ description: "Number of input and output tokens used.",
440
+ unit: client.tokenUsage.unit,
441
+ attributes: [...attributes, { key: GENAI_ATTRIBUTES.tokenType, default_value: tokenType }],
442
+ conditions: [`(IsInt(${attr}) or IsDouble(${attr})) and ${notAggregate}`],
443
+ histogram: { buckets: [...GENAI_CLIENT_TOKEN_BUCKETS], value: attr },
444
+ };
445
+ };
446
+ return new SignalToMetricsConnector({
447
+ name: "genai_client",
448
+ spans: [
449
+ duration,
450
+ tokens(GENAI_TOKEN_TYPES.input, GENAI_ATTRIBUTES.inputTokens),
451
+ tokens(GENAI_TOKEN_TYPES.output, GENAI_ATTRIBUTES.outputTokens),
452
+ ],
453
+ });
454
+ }
455
+ // ── The whole collector ──────────────────────────────────────────────
456
+ /**
457
+ * Metric exporters that take delta sums and histograms as they are: the
458
+ * `prometheus` exporter accumulates them itself, and `debug` prints them.
459
+ * Any other exporter gets cumulative data under `deltaToCumulative: "auto"`.
460
+ * `prometheusremotewrite`, for one, drops non-cumulative monotonic sums,
461
+ * histograms and summaries (its README at collector-contrib v0.130.0).
462
+ */
463
+ export const DELTA_READY_EXPORTERS = Object.freeze(["prometheus", "debug"]);
464
+ /** Whether `deltaToCumulative` puts a `deltatocumulative` processor on `metrics/genai` for these exporters. */
465
+ export function genAiNeedsDeltaToCumulative(setting, metricExporters) {
466
+ if (setting === true)
467
+ return true;
468
+ if (setting === "auto")
469
+ return metricExporters.some((e) => !DELTA_READY_EXPORTERS.includes(e.componentType));
470
+ if (setting === undefined || setting === false)
471
+ return false;
472
+ throw new Error(`genAiPipeline: deltaToCumulative must be true, false or "auto", got ${JSON.stringify(setting)}`);
473
+ }
474
+ /**
475
+ * The entities of an OTLP collector for GenAI workloads: an `otlp` receiver
476
+ * on 4317 and 4318; `memory_limiter`, content removal and `batch` on traces
477
+ * and logs; a `traces/genai` branch that turns GenAI spans into metrics
478
+ * before any sampling; and a `metrics/genai` pipeline that exports them. Pass
479
+ * the result to `collectorYaml`, or use `genAiComponents()` to wire the
480
+ * pieces into pipelines of your own.
481
+ */
482
+ export function genAiPipeline(options = {}) {
483
+ return Object.values(genAiPipelineParts(options));
484
+ }
485
+ /** `genAiPipeline()`'s entities by name, in the order it returns them. */
486
+ export function genAiPipelineParts(options = {}) {
487
+ const debug = new DebugExporter({ verbosity: "basic" });
488
+ const { traceExporters = [debug], metricExporters = [debug], logExporters = [debug], logs = true, sampling = [], healthCheck = true } = options;
489
+ const parts = genAiComponents(options);
490
+ const client = parts.metrics.client;
491
+ // derive mode is opt-in and newer than the option, so it can default to "auto".
492
+ const cumulativeSetting = options.deltaToCumulative ?? (client?.source === "derive" ? "auto" : false);
493
+ const cumulative = genAiNeedsDeltaToCumulative(cumulativeSetting, metricExporters)
494
+ ? [new DeltaToCumulativeProcessor({ name: "genai" })]
495
+ : [];
496
+ const otlp = new OtlpReceiver({
497
+ protocols: {
498
+ grpc: { endpoint: "0.0.0.0:4317" },
499
+ http: { endpoint: "0.0.0.0:4318" },
500
+ },
501
+ });
502
+ const memoryLimiter = new MemoryLimiterProcessor({
503
+ check_interval: "1s",
504
+ limit_percentage: 80,
505
+ spike_limit_percentage: 20,
506
+ });
507
+ const batch = new BatchProcessor({});
508
+ // Insertion order is the order genAiPipeline() has always returned.
509
+ const out = { otlp };
510
+ if (sampling.length === 0) {
511
+ out.traces = new Pipeline({
512
+ signal: "traces",
513
+ receivers: [otlp],
514
+ processors: [memoryLimiter, ...parts.processors, batch],
515
+ exporters: [...traceExporters, parts.forward],
516
+ });
517
+ }
518
+ else {
519
+ const sampled = new ForwardConnector({ name: "sampled" });
520
+ out.traces = new Pipeline({
521
+ signal: "traces",
522
+ receivers: [otlp],
523
+ processors: [memoryLimiter, ...parts.processors],
524
+ exporters: [parts.forward, sampled],
525
+ });
526
+ out.sampled = new Pipeline({
527
+ signal: "traces",
528
+ name: "sampled",
529
+ receivers: [sampled],
530
+ processors: [...sampling, batch],
531
+ exporters: traceExporters,
532
+ });
533
+ }
534
+ out.genAiTraces = new Pipeline({
535
+ signal: "traces",
536
+ name: "genai",
537
+ receivers: [parts.forward],
538
+ processors: [parts.genAiSpans],
539
+ exporters: [parts.spanMetrics, parts.tokenUsage, ...(parts.clientMetrics ? [parts.clientMetrics] : [])],
540
+ });
541
+ out.genAiMetrics = new Pipeline({
542
+ signal: "metrics",
543
+ name: "genai",
544
+ receivers: [parts.spanMetrics, parts.tokenUsage, ...(parts.clientMetrics ? [parts.clientMetrics] : [])],
545
+ processors: [...cumulative, batch],
546
+ exporters: metricExporters,
547
+ });
548
+ if (client) {
549
+ // The SDK's own metrics: the client metrics in passthrough, and the
550
+ // ones no span carries (time to first chunk, gen_ai.server.*) either way.
551
+ out.sdkMetrics = new Pipeline({
552
+ signal: "metrics",
553
+ receivers: [otlp],
554
+ processors: [memoryLimiter, ...(parts.sdkClientMetricsFilter ? [parts.sdkClientMetricsFilter] : []), batch],
555
+ exporters: metricExporters,
556
+ });
557
+ }
558
+ if (logs) {
559
+ out.logs = new Pipeline({
560
+ signal: "logs",
561
+ receivers: [otlp],
562
+ processors: [memoryLimiter, ...parts.processors, batch],
563
+ exporters: logExporters,
564
+ });
565
+ }
566
+ if (healthCheck)
567
+ out.health = new HealthCheckExtension({ endpoint: "0.0.0.0:13133" });
568
+ return out;
569
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A collector config embedded in another lexicon's resource, e.g. the
3
+ * `config.yaml` of a k8s ConfigMap, for `chant import` (#2962).
4
+ *
5
+ * The config is imported exactly as `chant import config.yaml` would import
6
+ * it, into a directory of its own, and the host's value becomes
7
+ * `collectorYaml([...])` over every component, pipeline and service the
8
+ * import declares, which renders the same config text at build time.
9
+ *
10
+ * A host that holds the config as an object, the OpenTelemetry Operator's
11
+ * `OpenTelemetryCollector` `spec.config`, offers it as `{ config, header }`
12
+ * with `select: "config"`. The config is printed back to the text the
13
+ * standalone import reads, with the `# chant:` header lines the host kept in
14
+ * an annotation put back on top so custom-component pins and semconv use
15
+ * survive. The host's value becomes `collectorConfig([...])`, an object, so
16
+ * the field keeps the type its CRD gives it.
17
+ */
18
+ import { exportedNames } from "@intentius/chant/import/embedded";
19
+ import { looksLikeCollectorConfig } from "../model.js";
20
+ import { emitCollectorYaml } from "../yaml.js";
21
+ import { OtelCollectorParser } from "./parser.js";
22
+ import { OtelCollectorGenerator } from "./generator.js";
23
+ const PACKAGE = "@intentius/chant-lexicon-otel";
24
+ /** The module holding `defineComponent` classes: types, not config entities. */
25
+ const CUSTOM_COMPONENTS = "custom-components.ts";
26
+ function selectedConfig(content) {
27
+ const doc = content.document;
28
+ return typeof doc === "object" && doc !== null ? doc.config : undefined;
29
+ }
30
+ /** The text a `{ config, header }` document prints as: the header lines as comments, then the config. */
31
+ function objectConfigText(content) {
32
+ const header = content.document.header;
33
+ const lines = Array.isArray(header) ? header.filter((l) => typeof l === "string") : [];
34
+ return emitCollectorYaml(selectedConfig(content), { header: lines });
35
+ }
36
+ export const collectorConfigImporter = {
37
+ what: "an OpenTelemetry Collector config",
38
+ matches(content) {
39
+ if (content.select === "config")
40
+ return looksLikeCollectorConfig(selectedConfig(content));
41
+ return typeof content.text === "string" && content.select === undefined && looksLikeCollectorConfig(content.document);
42
+ },
43
+ import(content) {
44
+ const asObject = content.select === "config";
45
+ const text = asObject ? objectConfigText(content) : content.text;
46
+ const ir = new OtelCollectorParser().parse(text);
47
+ const files = new OtelCollectorGenerator().generate(ir);
48
+ const bindings = files
49
+ .filter((f) => f.path !== CUSTOM_COMPONENTS)
50
+ .flatMap((f) => exportedNames(f.content).map((name) => ({ from: f.path, name })));
51
+ return {
52
+ files,
53
+ value: { bindings, shape: "list", through: { from: PACKAGE, name: asObject ? "collectorConfig" : "collectorYaml" } },
54
+ warnings: ir.warnings ?? [],
55
+ };
56
+ },
57
+ };