@intentius/chant-lexicon-otel 0.106.4 → 0.108.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 +1 -1
- package/dist/collector.d.ts +6 -0
- package/dist/collector.d.ts.map +1 -1
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/genai-pipeline.d.ts +33 -0
- package/dist/composites/genai-pipeline.d.ts.map +1 -0
- package/dist/composites/index.d.ts +8 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/otlp-collector.d.ts +43 -0
- package/dist/composites/otlp-collector.d.ts.map +1 -0
- package/dist/composites/red-metrics.d.ts +77 -0
- package/dist/composites/red-metrics.d.ts.map +1 -0
- package/dist/composites/tail-sampling-tier.d.ts +90 -0
- package/dist/composites/tail-sampling-tier.d.ts.map +1 -0
- package/dist/configmap.d.ts +16 -0
- package/dist/configmap.d.ts.map +1 -1
- package/dist/genai.d.ts +19 -0
- package/dist/genai.d.ts.map +1 -1
- package/dist/import/embedded.d.ts +8 -0
- package/dist/import/embedded.d.ts.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- package/dist/lint/post-synth/otel-helpers.d.ts +9 -2
- package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/metric-names.d.ts +68 -0
- package/dist/metric-names.d.ts.map +1 -1
- package/dist/op/activities/collector-audit.d.ts +95 -0
- package/dist/op/activities/collector-audit.d.ts.map +1 -0
- package/dist/op/activities/collector-health.d.ts +74 -0
- package/dist/op/activities/collector-health.d.ts.map +1 -0
- package/dist/op/activities/exec.d.ts +19 -0
- package/dist/op/activities/exec.d.ts.map +1 -0
- package/dist/op/activities/github.d.ts +55 -0
- package/dist/op/activities/github.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +20 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/op/activities/otelcol.d.ts +96 -0
- package/dist/op/activities/otelcol.d.ts.map +1 -0
- package/dist/op/activity-contracts.d.ts +96 -0
- package/dist/op/activity-contracts.d.ts.map +1 -0
- package/dist/op/audit-op.d.ts +35 -0
- package/dist/op/audit-op.d.ts.map +1 -0
- package/dist/op/builders.d.ts +43 -0
- package/dist/op/builders.d.ts.map +1 -0
- package/dist/platform.d.ts +14 -0
- package/dist/platform.d.ts.map +1 -1
- package/dist/rules/otel-helpers.ts +18 -5
- package/package.json +14 -3
- package/src/collector.ts +9 -0
- package/src/composites/catalog.test.ts +1 -1
- package/src/composites/catalog.ts +292 -0
- package/src/composites/genai-pipeline.test.ts +53 -0
- package/src/composites/genai-pipeline.ts +40 -0
- package/src/composites/index.ts +8 -0
- package/src/composites/otlp-collector.test.ts +43 -0
- package/src/composites/otlp-collector.ts +70 -0
- package/src/composites/red-metrics.test.ts +131 -0
- package/src/composites/red-metrics.ts +148 -0
- package/src/composites/tail-sampling-tier.test.ts +100 -0
- package/src/composites/tail-sampling-tier.ts +182 -0
- package/src/configmap-operator.test.ts +44 -0
- package/src/configmap.ts +35 -0
- package/src/detect.ts +13 -1
- package/src/genai.ts +72 -59
- package/src/import/embedded.test.ts +36 -0
- package/src/import/embedded.ts +28 -4
- package/src/index.ts +57 -2
- package/src/lint/post-synth/otel-helpers.ts +18 -5
- package/src/metric-names.ts +141 -0
- package/src/op/activities/collector-audit.test.ts +166 -0
- package/src/op/activities/collector-audit.ts +265 -0
- package/src/op/activities/collector-health.test.ts +115 -0
- package/src/op/activities/collector-health.ts +211 -0
- package/src/op/activities/exec.ts +33 -0
- package/src/op/activities/github.ts +127 -0
- package/src/op/activities/index.ts +19 -0
- package/src/op/activities/otelcol.test.ts +162 -0
- package/src/op/activities/otelcol.ts +233 -0
- package/src/op/activity-contracts.test.ts +103 -0
- package/src/op/activity-contracts.ts +92 -0
- package/src/op/audit-op.ts +53 -0
- package/src/op/builders.ts +52 -0
- package/src/op/testdata/otelcol-components-v0.130.0.yaml +82 -0
- package/src/platform.ts +45 -8
|
@@ -8,7 +8,7 @@ import type { SerializerResult } from "@intentius/chant/serializer";
|
|
|
8
8
|
import { loadAll } from "js-yaml";
|
|
9
9
|
import type { Declarable } from "@intentius/chant/declarable";
|
|
10
10
|
import { looksLikeCollectorConfig, type CollectorConfig } from "../../model";
|
|
11
|
-
import { configMapCollectorConfigs, describeConfigMapConfig } from "../../configmap";
|
|
11
|
+
import { configMapCollectorConfigs, describeConfigMapConfig, describeOperatorCollectorConfig, operatorCollectorConfig } from "../../configmap";
|
|
12
12
|
import { validateCollectorConfig, validateCollectorEntities, type CollectorIssueCode, type CollectorIssue } from "../../validate-config";
|
|
13
13
|
import { attributionIssues } from "../../attribution";
|
|
14
14
|
|
|
@@ -19,13 +19,16 @@ export interface FoundCollectorConfig {
|
|
|
19
19
|
config: CollectorConfig;
|
|
20
20
|
/** Set when the config is a value in a Kubernetes ConfigMap (chant #2930). */
|
|
21
21
|
configMap?: { namespace: string; name: string; key: string };
|
|
22
|
+
/** Set when the config is the `spec.config` of an OpenTelemetry Operator `OpenTelemetryCollector` (chant #3367). */
|
|
23
|
+
operator?: { namespace: string; name: string };
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Every collector config in the output: the otel lexicon's own, any YAML
|
|
26
28
|
* document shaped like one, and any ConfigMap data value that parses as one
|
|
27
29
|
* (how `OtelCollector`, `OtelCollectorGateway` and `GkeOtelCollector` carry
|
|
28
|
-
* theirs)
|
|
30
|
+
* theirs), and the `spec.config` of an OpenTelemetry Operator
|
|
31
|
+
* `OpenTelemetryCollector` (chant #3367). Parsed with js-yaml rather than `ctx.docs`, because collector
|
|
29
32
|
* configs are written with flow lists (`[otlp, batch]`) that core's small
|
|
30
33
|
* YAML reader keeps as strings.
|
|
31
34
|
*
|
|
@@ -57,6 +60,8 @@ export function collectorConfigs(ctx: PostSynthContext): FoundCollectorConfig[]
|
|
|
57
60
|
for (const { config, ...configMap } of configMapCollectorConfigs(doc)) {
|
|
58
61
|
out.push({ source, config, configMap });
|
|
59
62
|
}
|
|
63
|
+
const cr = operatorCollectorConfig(doc);
|
|
64
|
+
if (cr) out.push({ source, config: cr.config, operator: { namespace: cr.namespace, name: cr.name } });
|
|
60
65
|
}
|
|
61
66
|
}
|
|
62
67
|
}
|
|
@@ -64,7 +69,11 @@ export function collectorConfigs(ctx: PostSynthContext): FoundCollectorConfig[]
|
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
function toDiagnostic(issue: CollectorIssue, found?: Omit<FoundCollectorConfig, "config">): PostSynthDiagnostic {
|
|
67
|
-
const where = found?.configMap
|
|
72
|
+
const where = found?.configMap
|
|
73
|
+
? describeConfigMapConfig(found.configMap)
|
|
74
|
+
: found?.operator
|
|
75
|
+
? describeOperatorCollectorConfig(found.operator)
|
|
76
|
+
: found && found.source !== "otel" ? found.source : undefined;
|
|
68
77
|
return {
|
|
69
78
|
checkId: issue.code,
|
|
70
79
|
severity: issue.severity,
|
|
@@ -87,11 +96,15 @@ function configIssues(ctx: PostSynthContext, config: CollectorConfig): Collector
|
|
|
87
96
|
/**
|
|
88
97
|
* Every config-level diagnostic (OTEL101-OTEL106, OTEL112-OTEL127 and any later
|
|
89
98
|
* config check) over the collector configs in the output. `configMapsOnly`
|
|
90
|
-
* keeps the configs held in ConfigMaps
|
|
99
|
+
* keeps the configs held in ConfigMaps. `hostedOnly` keeps those and the `spec.config` of an operator `OpenTelemetryCollector`, which is what WK8604 reads.
|
|
91
100
|
*/
|
|
92
|
-
export function collectorConfigDiagnostics(
|
|
101
|
+
export function collectorConfigDiagnostics(
|
|
102
|
+
ctx: PostSynthContext,
|
|
103
|
+
opts: { configMapsOnly?: boolean; hostedOnly?: boolean } = {},
|
|
104
|
+
): PostSynthDiagnostic[] {
|
|
93
105
|
return collectorConfigs(ctx)
|
|
94
106
|
.filter((found) => !opts.configMapsOnly || found.configMap)
|
|
107
|
+
.filter((found) => !opts.hostedOnly || found.configMap || found.operator)
|
|
95
108
|
.flatMap(({ config, ...found }) => configIssues(ctx, config).map((i) => toDiagnostic(i, found)));
|
|
96
109
|
}
|
|
97
110
|
|
package/src/metric-names.ts
CHANGED
|
@@ -239,3 +239,144 @@ export function serviceGraphNames(
|
|
|
239
239
|
clientDuration: metric("request_client", "histogram", "s"),
|
|
240
240
|
};
|
|
241
241
|
}
|
|
242
|
+
|
|
243
|
+
// ── RED queries over span metrics ────────────────────────────────────
|
|
244
|
+
//
|
|
245
|
+
// The PromQL a RED dashboard and RED alerts share. They live here, next to
|
|
246
|
+
// the names they read, so the grafana lexicon (panels) and the prometheus
|
|
247
|
+
// lexicon (alerting rules) build the same expressions without either one
|
|
248
|
+
// importing the other.
|
|
249
|
+
|
|
250
|
+
/** A label matcher: `[label, op, value]`. */
|
|
251
|
+
export type PromMatcher = [label: string, op: "=" | "!=" | "=~" | "!~", value: string];
|
|
252
|
+
|
|
253
|
+
/** `metric{a="x", b=~"y"}`; just `metric` with no matchers. */
|
|
254
|
+
export function promSelector(metric: string, matchers: PromMatcher[]): string {
|
|
255
|
+
if (matchers.length === 0) return metric;
|
|
256
|
+
return `${metric}{${matchers.map(([l, op, v]) => `${l}${op}${JSON.stringify(v)}`).join(", ")}}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** `sum by (labels) (rate(sel[range]))`, or `sum (...)` with no labels. */
|
|
260
|
+
export function promSumRate(sel: string, by: string[], range: string): string {
|
|
261
|
+
return `sum${by.length ? ` by (${by.join(", ")})` : ""} (rate(${sel}[${range}]))`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* `errors / all` as rates summed by `by`, with the numerator padded to zero:
|
|
266
|
+
* `(errors or all * 0) / all`. A group with calls but no error series gets 0
|
|
267
|
+
* instead of dropping out of the result.
|
|
268
|
+
*/
|
|
269
|
+
export function promErrorRatio(errorSel: string, allSel: string, by: string[], range: string): string {
|
|
270
|
+
const all = promSumRate(allSel, by, range);
|
|
271
|
+
return `(\n${promSumRate(errorSel, by, range)}\nor\n${all} * 0\n)\n/\n${all}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A number as PromQL writes it, without float noise. */
|
|
275
|
+
export function promNumber(n: number): string {
|
|
276
|
+
return String(Number(n.toPrecision(10)));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** `histogram_quantile(q, sum by (le, labels) (rate(bucket[range])))`. */
|
|
280
|
+
export function promQuantile(q: number, bucketSel: string, by: string[], range: string): string {
|
|
281
|
+
return `histogram_quantile(${promNumber(q)}, ${promSumRate(bucketSel, ["le", ...by], range)})`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The `span_kind` label values the spanmetrics connector writes. */
|
|
285
|
+
export type SpanMetricsKind =
|
|
286
|
+
| "SPAN_KIND_UNSPECIFIED"
|
|
287
|
+
| "SPAN_KIND_INTERNAL"
|
|
288
|
+
| "SPAN_KIND_SERVER"
|
|
289
|
+
| "SPAN_KIND_CLIENT"
|
|
290
|
+
| "SPAN_KIND_PRODUCER"
|
|
291
|
+
| "SPAN_KIND_CONSUMER";
|
|
292
|
+
|
|
293
|
+
/** Every `span_kind` value, in the order the spec lists them. */
|
|
294
|
+
export const SPAN_METRICS_KINDS: readonly SpanMetricsKind[] = Object.freeze([
|
|
295
|
+
"SPAN_KIND_UNSPECIFIED",
|
|
296
|
+
"SPAN_KIND_INTERNAL",
|
|
297
|
+
"SPAN_KIND_SERVER",
|
|
298
|
+
"SPAN_KIND_CLIENT",
|
|
299
|
+
"SPAN_KIND_PRODUCER",
|
|
300
|
+
"SPAN_KIND_CONSUMER",
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
/** The kinds RED queries count unless told otherwise: the spans that serve a request or consume a message. */
|
|
304
|
+
export const RED_DEFAULT_SPAN_KINDS: readonly SpanMetricsKind[] = Object.freeze(["SPAN_KIND_SERVER", "SPAN_KIND_CONSUMER"]);
|
|
305
|
+
|
|
306
|
+
/** What `spanMetricsRedQueries` takes besides the names. */
|
|
307
|
+
export interface RedQueryOptions {
|
|
308
|
+
/** The range every `rate` reads: `$__rate_interval` on a dashboard, `5m` in a rule. */
|
|
309
|
+
range: string;
|
|
310
|
+
/** Duration quantiles, one expression each (default p50, p95 and p99). */
|
|
311
|
+
quantiles?: number[];
|
|
312
|
+
/** A regex the service label must match, e.g. `$service`. Unset: no service matcher. */
|
|
313
|
+
serviceMatch?: string;
|
|
314
|
+
/**
|
|
315
|
+
* The span kinds counted (default server and consumer spans). `[]` counts
|
|
316
|
+
* every kind. With the default, a connector that excludes `span.kind`
|
|
317
|
+
* gets no kind filter; naming kinds for such a connector is an error.
|
|
318
|
+
*/
|
|
319
|
+
spanKinds?: readonly SpanMetricsKind[];
|
|
320
|
+
/** Labels the results are split by besides the service label. */
|
|
321
|
+
by?: string[];
|
|
322
|
+
/** The name errors are reported under, e.g. `RedDashboard`. */
|
|
323
|
+
owner?: string;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The RED expressions for one spanmetrics connector. */
|
|
327
|
+
export interface RedQueries {
|
|
328
|
+
/** The service label the results are split by. */
|
|
329
|
+
service: string;
|
|
330
|
+
/** The span-kind matcher, or none. */
|
|
331
|
+
kindMatchers: PromMatcher[];
|
|
332
|
+
/** Counted spans per second. */
|
|
333
|
+
rate: string;
|
|
334
|
+
/** Errors over counted spans, 0 for a service with none. */
|
|
335
|
+
errorRatio: string;
|
|
336
|
+
/** One expression per quantile, in the histogram's unit. Empty when the connector has no histogram. */
|
|
337
|
+
duration: Array<{ quantile: number; expr: string }>;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const DEFAULT_RED_QUANTILES = [0.5, 0.95, 0.99];
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The span-kind matcher for `kinds`, or none: none for `[]`, and none for
|
|
344
|
+
* the default kinds when the connector excludes `span.kind`.
|
|
345
|
+
*/
|
|
346
|
+
export function spanKindMatchers(names: SpanMetricsNames, kinds: readonly SpanMetricsKind[] | undefined, owner = "spanKindMatchers"): PromMatcher[] {
|
|
347
|
+
const label = names.labels.spanKind;
|
|
348
|
+
if (kinds === undefined) return label ? [[label, "=~", RED_DEFAULT_SPAN_KINDS.join("|")]] : [];
|
|
349
|
+
for (const k of kinds) {
|
|
350
|
+
if (!SPAN_METRICS_KINDS.includes(k)) throw new Error(`${owner}: unknown span kind ${JSON.stringify(k)}; use one of ${SPAN_METRICS_KINDS.join(", ")}`);
|
|
351
|
+
}
|
|
352
|
+
if (kinds.length === 0) return [];
|
|
353
|
+
if (!label) throw new Error(`${owner}: the connector excludes span.kind, so the metrics can't be filtered by spanKinds`);
|
|
354
|
+
return [[label, "=~", [...new Set(kinds)].join("|")]];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Rate, error ratio and duration quantiles per service, from a spanmetrics
|
|
359
|
+
* connector's names. The grafana lexicon's `RedDashboard` and the prometheus
|
|
360
|
+
* lexicon's `RedAlerts` both build their queries here.
|
|
361
|
+
*/
|
|
362
|
+
export function spanMetricsRedQueries(names: SpanMetricsNames, options: RedQueryOptions): RedQueries {
|
|
363
|
+
const owner = options.owner ?? "spanMetricsRedQueries";
|
|
364
|
+
const svc = names.labels.service;
|
|
365
|
+
const status = names.labels.statusCode;
|
|
366
|
+
if (!svc) throw new Error(`${owner}: the connector excludes service.name, so there is no service to break the metrics down by`);
|
|
367
|
+
if (!status) throw new Error(`${owner}: the connector excludes status.code, so errors can't be told from successes`);
|
|
368
|
+
const kind = spanKindMatchers(names, options.spanKinds, owner);
|
|
369
|
+
const scope: PromMatcher[] = [...(options.serviceMatch !== undefined ? [[svc, "=~", options.serviceMatch] as PromMatcher] : []), ...kind];
|
|
370
|
+
const by = [svc, ...(options.by ?? [])];
|
|
371
|
+
const calls = promSelector(names.calls.prometheus, scope);
|
|
372
|
+
const errors = promSelector(names.calls.prometheus, [...scope, [status, "=", names.errorStatus]]);
|
|
373
|
+
const buckets = names.duration ? promSelector(`${names.duration.prometheus}_bucket`, scope) : undefined;
|
|
374
|
+
const quantiles = options.quantiles ?? DEFAULT_RED_QUANTILES;
|
|
375
|
+
return {
|
|
376
|
+
service: svc,
|
|
377
|
+
kindMatchers: kind,
|
|
378
|
+
rate: promSumRate(calls, by, options.range),
|
|
379
|
+
errorRatio: promErrorRatio(errors, calls, by, options.range),
|
|
380
|
+
duration: buckets ? quantiles.map((q) => ({ quantile: q, expr: promQuantile(q, buckets, by, options.range) })) : [],
|
|
381
|
+
};
|
|
382
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { COLLECTOR_PIN, GENAI_SEMCONV_PIN } from "../../define";
|
|
5
|
+
import {
|
|
6
|
+
builtinComponents,
|
|
7
|
+
bumpPins,
|
|
8
|
+
collectorAudit,
|
|
9
|
+
componentDir,
|
|
10
|
+
parseStability,
|
|
11
|
+
stabilityFindings,
|
|
12
|
+
} from "./collector-audit";
|
|
13
|
+
import { compareSemver } from "./github";
|
|
14
|
+
import type { CommandRunner } from "./github";
|
|
15
|
+
|
|
16
|
+
const releases = (tags: string[]) => tags.map((t) => ({ tag_name: t, draft: false, prerelease: false }));
|
|
17
|
+
|
|
18
|
+
/** Answers the two release lists, and metadata.yaml for the components in `meta` (keyed by `<repo>/<tag>/<dir>`). */
|
|
19
|
+
function fakeFetch(contrib: string[], semconv: string[], meta: Record<string, string> = {}): { f: typeof fetch; urls: string[] } {
|
|
20
|
+
const urls: string[] = [];
|
|
21
|
+
const f = (async (input: string | URL | Request) => {
|
|
22
|
+
const url = String(input);
|
|
23
|
+
urls.push(url);
|
|
24
|
+
if (url.includes("opentelemetry-collector-contrib/releases")) return Response.json(releases(contrib));
|
|
25
|
+
if (url.includes("semantic-conventions/releases")) return Response.json(releases(semconv));
|
|
26
|
+
const m = /raw\.githubusercontent\.com\/[^/]+\/([^/]+)\/([^/]+)\/(.+)\/metadata\.yaml$/.exec(url);
|
|
27
|
+
const key = m ? `${m[1]}/${m[2]}/${m[3]}` : "";
|
|
28
|
+
if (meta[key] !== undefined) return new Response(meta[key]);
|
|
29
|
+
return new Response("not found", { status: 404 });
|
|
30
|
+
}) as typeof fetch;
|
|
31
|
+
return { f, urls };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const stability = (levels: Record<string, string[]>) =>
|
|
35
|
+
`type: x\nstatus:\n class: receiver\n stability:\n${Object.entries(levels)
|
|
36
|
+
.map(([l, s]) => ` ${l}: [${s.join(", ")}]`)
|
|
37
|
+
.join("\n")}\n`;
|
|
38
|
+
|
|
39
|
+
describe("collectorAudit's pieces", () => {
|
|
40
|
+
test("the built-in components, and where each lives upstream", () => {
|
|
41
|
+
const comps = builtinComponents();
|
|
42
|
+
expect(comps.length).toBeGreaterThan(10);
|
|
43
|
+
expect(comps).toContainEqual({ kind: "receiver", type: "otlp" });
|
|
44
|
+
expect(componentDir({ kind: "receiver", type: "k8s_cluster" })).toBe("receiver/k8sclusterreceiver");
|
|
45
|
+
expect(componentDir({ kind: "extension", type: "health_check" })).toBe("extension/healthcheckextension");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("stability by signal from metadata.yaml", () => {
|
|
49
|
+
expect(parseStability(stability({ beta: ["traces", "metrics"], deprecated: ["logs"] }))).toEqual({ traces: "beta", metrics: "beta", logs: "deprecated" });
|
|
50
|
+
expect(parseStability("type: x\n")).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a deprecated signal and a changed level are findings; an unchanged component is not", () => {
|
|
54
|
+
const c = { kind: "receiver" as const, type: "x" };
|
|
55
|
+
expect(stabilityFindings(c, { traces: "beta" }, { traces: "beta" }, "v1.0.0", "v1.1.0")).toEqual([]);
|
|
56
|
+
expect(stabilityFindings(c, { traces: "alpha" }, { traces: "beta" }, "v1.0.0", "v1.1.0")).toEqual([
|
|
57
|
+
{ kind: "stability-changed", subject: "receiver/x", detail: "traces alpha to beta (v1.0.0 to v1.1.0)" },
|
|
58
|
+
]);
|
|
59
|
+
expect(stabilityFindings(c, { logs: "beta" }, { logs: "deprecated" }, "v1.0.0", "v1.1.0")).toEqual([
|
|
60
|
+
{ kind: "deprecated", subject: "receiver/x", detail: "logs deprecated at v1.1.0" },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("bumpPins moves only the two pin versions in define.ts", () => {
|
|
65
|
+
const text = readFileSync(join(import.meta.dirname, "..", "..", "define.ts"), "utf8");
|
|
66
|
+
const out = bumpPins(text, { collector: "v0.140.0", genai: "v1.50.0" });
|
|
67
|
+
const block = (text: string, name: string) => new RegExp(`export const ${name}: SchemaPin = Object\\.freeze\\(\\{[^}]*\\}`).exec(text)![0];
|
|
68
|
+
expect(block(out, "COLLECTOR_PIN")).toContain('version: "v0.140.0"');
|
|
69
|
+
expect(block(out, "GENAI_SEMCONV_PIN")).toContain('version: "v1.50.0"');
|
|
70
|
+
// SEMCONV_PIN, and the custom-component pin in define.ts's doc comment, are left alone.
|
|
71
|
+
expect(block(out, "SEMCONV_PIN")).toBe(block(text, "SEMCONV_PIN"));
|
|
72
|
+
const changed = out.split("\n").filter((l, i) => l !== text.split("\n")[i]);
|
|
73
|
+
expect(changed).toHaveLength(2);
|
|
74
|
+
expect(() => bumpPins("nothing", { collector: "v1.0.0" })).toThrow(/no COLLECTOR_PIN/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("semver ordering", () => {
|
|
78
|
+
expect(compareSemver("v0.131.0", "v0.130.0")).toBeGreaterThan(0);
|
|
79
|
+
expect(compareSemver("v0.99.0", "v0.130.0")).toBeLessThan(0);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("collectorAudit", () => {
|
|
84
|
+
test("current pins and no stability reading: no findings, report mode does nothing else", async () => {
|
|
85
|
+
const { f } = fakeFetch([COLLECTOR_PIN.version, "v0.129.0"], [GENAI_SEMCONV_PIN.version]);
|
|
86
|
+
const r = await collectorAudit({ stability: false, _fetch: f, _run: async () => { throw new Error("no git in report mode"); } });
|
|
87
|
+
expect(r.findings).toEqual([]);
|
|
88
|
+
expect(r.collector).toEqual({ pin: COLLECTOR_PIN.version, latest: COLLECTOR_PIN.version, behind: 0 });
|
|
89
|
+
expect(r.summary).toContain(`COLLECTOR_PIN ${COLLECTOR_PIN.version}: current`);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("pins behind the newest releases are findings, prereleases ignored", async () => {
|
|
93
|
+
const { f } = fakeFetch(["v0.132.0", "v0.131.0", COLLECTOR_PIN.version], ["v1.42.0", GENAI_SEMCONV_PIN.version]);
|
|
94
|
+
const r = await collectorAudit({ stability: false, _fetch: f });
|
|
95
|
+
expect(r.findings.map((x) => x.kind)).toEqual(["collector-pin-behind", "semconv-pin-behind"]);
|
|
96
|
+
expect(r.collector).toEqual({ pin: COLLECTOR_PIN.version, latest: "v0.132.0", behind: 2 });
|
|
97
|
+
expect(r.semconv.behind).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("component stability at the pin and at the newest release, contrib then core, within the budget", async () => {
|
|
101
|
+
const meta = {
|
|
102
|
+
// otlp is a core component: contrib answers 404 for it.
|
|
103
|
+
[`opentelemetry-collector/v0.131.0/receiver/otlpreceiver`]: stability({ stable: ["traces"], deprecated: ["logs"] }),
|
|
104
|
+
[`opentelemetry-collector/${COLLECTOR_PIN.version}/receiver/otlpreceiver`]: stability({ stable: ["traces", "logs"] }),
|
|
105
|
+
};
|
|
106
|
+
const { f, urls } = fakeFetch(["v0.131.0", COLLECTOR_PIN.version], [GENAI_SEMCONV_PIN.version], meta);
|
|
107
|
+
const r = await collectorAudit({ _fetch: f, stabilityBudget: 500 });
|
|
108
|
+
expect(r.findings).toContainEqual({ kind: "deprecated", subject: "receiver/otlp", detail: "logs deprecated at v0.131.0" });
|
|
109
|
+
expect(r.unchecked).not.toContain("receiver/otlp");
|
|
110
|
+
expect(r.unchecked.length).toBe(builtinComponents().length - 1);
|
|
111
|
+
// Looked for in contrib first, then core.
|
|
112
|
+
const otlp = urls.filter((u) => u.includes("/v0.131.0/receiver/otlpreceiver/"));
|
|
113
|
+
expect(otlp[0]).toContain("opentelemetry-collector-contrib");
|
|
114
|
+
expect(otlp[1]).toContain("open-telemetry/opentelemetry-collector/");
|
|
115
|
+
|
|
116
|
+
const capped = fakeFetch(["v0.131.0", COLLECTOR_PIN.version], [GENAI_SEMCONV_PIN.version], meta);
|
|
117
|
+
await collectorAudit({ _fetch: capped.f, stabilityBudget: 3 });
|
|
118
|
+
expect(capped.urls.filter((u) => u.includes("raw.githubusercontent.com"))).toHaveLength(3);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("an unreadable release list is no finding", async () => {
|
|
122
|
+
const f = (async () => new Response("rate limited", { status: 403 })) as unknown as typeof fetch;
|
|
123
|
+
const r = await collectorAudit({ stability: false, _fetch: f });
|
|
124
|
+
expect(r.findings).toEqual([]);
|
|
125
|
+
expect(r.collector.latest).toBeNull();
|
|
126
|
+
expect(r.summary).toContain("release list unreadable");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("pull-request mode bumps the pins in a worktree and opens a pull request", async () => {
|
|
130
|
+
const { f } = fakeFetch(["v0.131.0", COLLECTOR_PIN.version], [GENAI_SEMCONV_PIN.version]);
|
|
131
|
+
const calls: string[] = [];
|
|
132
|
+
const lexiconDir = join(import.meta.dirname, "..", "..", "..");
|
|
133
|
+
const run: CommandRunner = async (bin, args) => {
|
|
134
|
+
calls.push(`${bin} ${args.join(" ")}`);
|
|
135
|
+
if (args[0] === "rev-parse") return join(lexiconDir, "..", "..") + "\n";
|
|
136
|
+
if (args[0] === "symbolic-ref") return "origin/main\n";
|
|
137
|
+
if (args[0] === "worktree" && args[1] === "add") throw new Error("stop before writing files");
|
|
138
|
+
return "";
|
|
139
|
+
};
|
|
140
|
+
await expect(collectorAudit({ mode: "pull-request", stability: false, lexiconDir, _fetch: f, _run: run })).rejects.toThrow(/stop before/);
|
|
141
|
+
expect(calls.some((c) => c.startsWith("git worktree add --force -B chant/otel-pins"))).toBe(true);
|
|
142
|
+
expect(calls.some((c) => c.startsWith("git worktree remove"))).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("pull-request mode end to end against a recorded git and gh", async () => {
|
|
146
|
+
const { f } = fakeFetch(["v0.131.0", COLLECTOR_PIN.version], [GENAI_SEMCONV_PIN.version]);
|
|
147
|
+
const calls: string[] = [];
|
|
148
|
+
const lexiconDir = join(import.meta.dirname, "..", "..", "..");
|
|
149
|
+
const { mkdirSync } = await import("node:fs");
|
|
150
|
+
const run: CommandRunner = async (bin, args) => {
|
|
151
|
+
calls.push(`${bin} ${args.join(" ")}`);
|
|
152
|
+
if (args[0] === "rev-parse") return join(lexiconDir, "..", "..") + "\n";
|
|
153
|
+
if (args[0] === "symbolic-ref") return "origin/main\n";
|
|
154
|
+
if (args[0] === "worktree" && args[1] === "add") mkdirSync(join(args[5], "lexicons", "otel", "src"), { recursive: true });
|
|
155
|
+
if (bin === "gh" && args[1] === "list") return "[]";
|
|
156
|
+
if (bin === "gh" && args[1] === "create") return "https://github.com/INTENTIUS/chant/pull/1\n";
|
|
157
|
+
return "";
|
|
158
|
+
};
|
|
159
|
+
const r = await collectorAudit({ mode: "pull-request", stability: false, lexiconDir, _fetch: f, _run: run });
|
|
160
|
+
expect(r.prUrl).toBe("https://github.com/INTENTIUS/chant/pull/1");
|
|
161
|
+
expect(calls).toContain("git add lexicons/otel/src/define.ts");
|
|
162
|
+
expect(calls.some((c) => c.startsWith("git commit -m chore(otel): move COLLECTOR_PIN to v0.131.0"))).toBe(true);
|
|
163
|
+
expect(calls.some((c) => c.startsWith("git push --force origin HEAD:refs/heads/chant/otel-pins"))).toBe(true);
|
|
164
|
+
expect(calls.some((c) => c.startsWith("gh pr create --head chant/otel-pins --base main"))).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `collectorAudit`: the otel lexicon's pins against what upstream has
|
|
3
|
+
* released since (#3369), the activity behind `CollectorAuditOp`.
|
|
4
|
+
*
|
|
5
|
+
* Three readings, each against a moving upstream:
|
|
6
|
+
*
|
|
7
|
+
* - the contrib release list against `COLLECTOR_PIN`: how many releases
|
|
8
|
+
* the pin is behind, and the newest one.
|
|
9
|
+
* - component stability from each built-in component's `metadata.yaml`, at
|
|
10
|
+
* the pin and at the newest release: a signal whose stability level
|
|
11
|
+
* changed between the two, and any signal that is `deprecated` or
|
|
12
|
+
* `unmaintained` at the newest release. A component's file is looked up
|
|
13
|
+
* in contrib, then in core, at `<kind>/<type><kind>/metadata.yaml`; one
|
|
14
|
+
* found in neither is listed as unchecked, never as a finding.
|
|
15
|
+
* - `GENAI_SEMCONV_PIN` against the semantic-conventions releases.
|
|
16
|
+
*
|
|
17
|
+
* `mode` says what to do with the findings: `report` returns them;
|
|
18
|
+
* `issue` keeps one open issue current; `pull-request` also bumps the pins
|
|
19
|
+
* in the lexicon's `src/define.ts` on a proposal branch and opens (or
|
|
20
|
+
* edits) a pull request with the findings as its body. Only the pin values
|
|
21
|
+
* change: the config types written against the old pin are the reviewer's
|
|
22
|
+
* to move, which the body says.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { join, relative, resolve } from "node:path";
|
|
28
|
+
import { load } from "js-yaml";
|
|
29
|
+
import { COLLECTOR_PIN, GENAI_SEMCONV_PIN, type ComponentClass } from "../../define";
|
|
30
|
+
import * as components from "../../components";
|
|
31
|
+
import type { ComponentKind } from "../../model";
|
|
32
|
+
import { compareSemver, defaultRunner, fetchReleases, proposePullRequest, stickyIssue, type CommandRunner, type Release } from "./github";
|
|
33
|
+
|
|
34
|
+
export type CollectorAuditMode = "report" | "issue" | "pull-request";
|
|
35
|
+
|
|
36
|
+
export const CONTRIB_REPO = "open-telemetry/opentelemetry-collector-contrib";
|
|
37
|
+
export const CORE_REPO = "open-telemetry/opentelemetry-collector";
|
|
38
|
+
export const SEMCONV_REPO = "open-telemetry/semantic-conventions";
|
|
39
|
+
|
|
40
|
+
export interface CollectorAuditArgs {
|
|
41
|
+
mode?: CollectorAuditMode;
|
|
42
|
+
/**
|
|
43
|
+
* The otel lexicon's directory, whose `src/define.ts` a pull request
|
|
44
|
+
* edits. Default `<cwd>/lexicons/otel`.
|
|
45
|
+
*/
|
|
46
|
+
lexiconDir?: string;
|
|
47
|
+
/** Check component stability. Default true. */
|
|
48
|
+
stability?: boolean;
|
|
49
|
+
/** At most this many `metadata.yaml` reads. Default 200. */
|
|
50
|
+
stabilityBudget?: number;
|
|
51
|
+
/** The proposal branch. Default `chant/otel-pins`. */
|
|
52
|
+
branch?: string;
|
|
53
|
+
/** Replaces fetch. For tests. */
|
|
54
|
+
_fetch?: typeof fetch;
|
|
55
|
+
/** Replaces git and gh. For tests. */
|
|
56
|
+
_run?: CommandRunner;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type CollectorAuditFindingKind = "collector-pin-behind" | "semconv-pin-behind" | "stability-changed" | "deprecated";
|
|
60
|
+
|
|
61
|
+
export interface CollectorAuditFinding {
|
|
62
|
+
kind: CollectorAuditFindingKind;
|
|
63
|
+
/** The pin or the component (`receiver/otlp`) the finding is about. */
|
|
64
|
+
subject: string;
|
|
65
|
+
detail: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CollectorAuditResult {
|
|
69
|
+
mode: CollectorAuditMode;
|
|
70
|
+
collector: { pin: string; latest: string | null; behind: number };
|
|
71
|
+
semconv: { pin: string; latest: string | null; behind: number };
|
|
72
|
+
findings: CollectorAuditFinding[];
|
|
73
|
+
/** Components whose metadata.yaml was in neither repository, or past the budget. */
|
|
74
|
+
unchecked: string[];
|
|
75
|
+
summary: string;
|
|
76
|
+
issueUrl?: string;
|
|
77
|
+
prUrl?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A built-in component's kind and collector type. */
|
|
81
|
+
export interface BuiltinComponent {
|
|
82
|
+
kind: ComponentKind;
|
|
83
|
+
type: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function builtinComponents(): BuiltinComponent[] {
|
|
87
|
+
const out: BuiltinComponent[] = [];
|
|
88
|
+
const seen = new Set<string>();
|
|
89
|
+
for (const value of Object.values(components) as unknown[]) {
|
|
90
|
+
if (typeof value !== "function") continue;
|
|
91
|
+
const def = (value as Partial<ComponentClass>).definition;
|
|
92
|
+
if (!def || !def.builtin || typeof def.type !== "string") continue;
|
|
93
|
+
const key = `${def.kind}/${def.type}`;
|
|
94
|
+
if (seen.has(key)) continue;
|
|
95
|
+
seen.add(key);
|
|
96
|
+
out.push({ kind: def.kind, type: def.type });
|
|
97
|
+
}
|
|
98
|
+
return out.sort((a, b) => `${a.kind}/${a.type}`.localeCompare(`${b.kind}/${b.type}`));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The directory a component lives in upstream: `receiver/k8s_cluster` is `receiver/k8sclusterreceiver`. */
|
|
102
|
+
export function componentDir(c: BuiltinComponent): string {
|
|
103
|
+
return `${c.kind}/${c.type.replace(/_/g, "")}${c.kind}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Stability level by signal, from a `metadata.yaml`'s `status.stability`. */
|
|
107
|
+
export type Stability = Record<string, string>;
|
|
108
|
+
|
|
109
|
+
export function parseStability(text: string): Stability | undefined {
|
|
110
|
+
let doc: unknown;
|
|
111
|
+
try {
|
|
112
|
+
doc = load(text);
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
const status = (doc as { status?: { stability?: unknown } } | undefined)?.status;
|
|
117
|
+
const stability = status?.stability;
|
|
118
|
+
if (!stability || typeof stability !== "object") return undefined;
|
|
119
|
+
const out: Stability = {};
|
|
120
|
+
for (const [level, signals] of Object.entries(stability as Record<string, unknown>)) {
|
|
121
|
+
for (const s of Array.isArray(signals) ? signals : []) if (typeof s === "string") out[s] = level;
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function fetchText(f: typeof fetch, url: string): Promise<string | undefined> {
|
|
127
|
+
const res = await f(url);
|
|
128
|
+
if (res.status === 404) return undefined;
|
|
129
|
+
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
|
130
|
+
return res.text();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A component's stability at `tag`, from contrib or core; undefined when neither has its metadata.yaml. */
|
|
134
|
+
async function stabilityAt(f: typeof fetch, c: BuiltinComponent, tag: string, budget: { left: number }): Promise<Stability | undefined> {
|
|
135
|
+
for (const repo of [CONTRIB_REPO, CORE_REPO]) {
|
|
136
|
+
if (budget.left <= 0) return undefined;
|
|
137
|
+
budget.left--;
|
|
138
|
+
const text = await fetchText(f, `https://raw.githubusercontent.com/${repo}/${tag}/${componentDir(c)}/metadata.yaml`);
|
|
139
|
+
if (text !== undefined) return parseStability(text);
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const RETIRED = new Set(["deprecated", "unmaintained"]);
|
|
145
|
+
|
|
146
|
+
/** Findings for one component, from its stability at the pin and at the newest release. */
|
|
147
|
+
export function stabilityFindings(c: BuiltinComponent, atPin: Stability | undefined, atLatest: Stability, pin: string, latest: string): CollectorAuditFinding[] {
|
|
148
|
+
const subject = `${c.kind}/${c.type}`;
|
|
149
|
+
const out: CollectorAuditFinding[] = [];
|
|
150
|
+
const retired = Object.entries(atLatest).filter(([, level]) => RETIRED.has(level));
|
|
151
|
+
if (retired.length > 0) {
|
|
152
|
+
out.push({ kind: "deprecated", subject, detail: `${retired.map(([s, l]) => `${s} ${l}`).join(", ")} at ${latest}` });
|
|
153
|
+
}
|
|
154
|
+
if (atPin && pin !== latest) {
|
|
155
|
+
const changed = [...new Set([...Object.keys(atPin), ...Object.keys(atLatest)])]
|
|
156
|
+
.filter((s) => atPin[s] !== atLatest[s] && !RETIRED.has(atLatest[s] ?? ""))
|
|
157
|
+
.map((s) => `${s} ${atPin[s] ?? "absent"} to ${atLatest[s] ?? "absent"}`);
|
|
158
|
+
if (changed.length > 0) out.push({ kind: "stability-changed", subject, detail: `${changed.join(", ")} (${pin} to ${latest})` });
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function behindCount(releases: Release[], pin: string): number {
|
|
164
|
+
return releases.filter((r) => compareSemver(r.tag, pin) > 0).length;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** `src/define.ts` with `COLLECTOR_PIN` and `GENAI_SEMCONV_PIN` moved to the given versions. */
|
|
168
|
+
export function bumpPins(text: string, to: { collector?: string; genai?: string }): string {
|
|
169
|
+
let out = text;
|
|
170
|
+
const bump = (name: string, version: string) => {
|
|
171
|
+
const re = new RegExp(`(export const ${name}: SchemaPin = Object\\.freeze\\(\\{[^}]*?version:\\s*")([^"]+)(")`);
|
|
172
|
+
if (!re.test(out)) throw new Error(`define.ts has no ${name} with a version to bump`);
|
|
173
|
+
out = out.replace(re, `$1${version}$3`);
|
|
174
|
+
};
|
|
175
|
+
if (to.collector) bump("COLLECTOR_PIN", to.collector);
|
|
176
|
+
if (to.genai) bump("GENAI_SEMCONV_PIN", to.genai);
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function renderCollectorAuditSummary(r: Omit<CollectorAuditResult, "summary" | "mode">): string {
|
|
181
|
+
let out = "## Collector audit\n\n";
|
|
182
|
+
out += `- COLLECTOR_PIN ${r.collector.pin}: ${r.collector.latest === null ? "release list unreadable" : r.collector.behind === 0 ? "current" : `${r.collector.behind} release(s) behind ${r.collector.latest}`}\n`;
|
|
183
|
+
out += `- GENAI_SEMCONV_PIN ${r.semconv.pin}: ${r.semconv.latest === null ? "release list unreadable" : r.semconv.behind === 0 ? "current" : `${r.semconv.behind} release(s) behind ${r.semconv.latest}`}\n`;
|
|
184
|
+
const comps = r.findings.filter((f) => f.kind === "deprecated" || f.kind === "stability-changed");
|
|
185
|
+
if (comps.length > 0) {
|
|
186
|
+
out += "\n| Component | Finding | Detail |\n|---|---|---|\n";
|
|
187
|
+
for (const f of comps) out += `| ${f.subject} | ${f.kind} | ${f.detail} |\n`;
|
|
188
|
+
} else {
|
|
189
|
+
out += "\nNo component stability findings.\n";
|
|
190
|
+
}
|
|
191
|
+
if (r.unchecked.length > 0) out += `\nUnchecked (no metadata.yaml found, or past the budget): ${r.unchecked.join(", ")}\n`;
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Audit the otel lexicon's pins and its built-in components against upstream releases. */
|
|
196
|
+
export async function collectorAudit(args: CollectorAuditArgs = {}): Promise<CollectorAuditResult> {
|
|
197
|
+
const f = args._fetch ?? fetch;
|
|
198
|
+
const run = args._run ?? defaultRunner;
|
|
199
|
+
const mode = args.mode ?? "report";
|
|
200
|
+
const findings: CollectorAuditFinding[] = [];
|
|
201
|
+
const unchecked: string[] = [];
|
|
202
|
+
|
|
203
|
+
const contrib = await fetchReleases(CONTRIB_REPO, f).catch(() => null);
|
|
204
|
+
const semconv = await fetchReleases(SEMCONV_REPO, f).catch(() => null);
|
|
205
|
+
const collector = { pin: COLLECTOR_PIN.version, latest: contrib?.[0]?.tag ?? null, behind: contrib ? behindCount(contrib, COLLECTOR_PIN.version) : 0 };
|
|
206
|
+
const sem = { pin: GENAI_SEMCONV_PIN.version, latest: semconv?.[0]?.tag ?? null, behind: semconv ? behindCount(semconv, GENAI_SEMCONV_PIN.version) : 0 };
|
|
207
|
+
if (collector.latest && collector.behind > 0) {
|
|
208
|
+
findings.push({ kind: "collector-pin-behind", subject: "COLLECTOR_PIN", detail: `${collector.pin} is ${collector.behind} release(s) behind ${collector.latest}` });
|
|
209
|
+
}
|
|
210
|
+
if (sem.latest && sem.behind > 0) {
|
|
211
|
+
findings.push({ kind: "semconv-pin-behind", subject: "GENAI_SEMCONV_PIN", detail: `${sem.pin} is ${sem.behind} release(s) behind ${sem.latest}` });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (args.stability !== false) {
|
|
215
|
+
const latest = collector.latest ?? collector.pin;
|
|
216
|
+
const budget = { left: args.stabilityBudget ?? 200 };
|
|
217
|
+
for (const c of builtinComponents()) {
|
|
218
|
+
const atLatest = await stabilityAt(f, c, latest, budget).catch(() => undefined);
|
|
219
|
+
if (!atLatest) {
|
|
220
|
+
unchecked.push(`${c.kind}/${c.type}`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const atPin = latest === collector.pin ? atLatest : await stabilityAt(f, c, collector.pin, budget).catch(() => undefined);
|
|
224
|
+
findings.push(...stabilityFindings(c, atPin, atLatest, collector.pin, latest));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const base = { collector, semconv: sem, findings, unchecked };
|
|
229
|
+
const summary = renderCollectorAuditSummary(base);
|
|
230
|
+
const result: CollectorAuditResult = { mode, ...base, summary };
|
|
231
|
+
if (findings.length === 0 || mode === "report") return result;
|
|
232
|
+
|
|
233
|
+
const cwd = process.cwd();
|
|
234
|
+
if (mode === "issue") {
|
|
235
|
+
result.issueUrl = await stickyIssue(run, cwd, "otel: collector audit findings", summary);
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// pull-request: bump the pins that are behind.
|
|
240
|
+
const to = {
|
|
241
|
+
...(collector.latest && collector.behind > 0 ? { collector: collector.latest } : {}),
|
|
242
|
+
...(sem.latest && sem.behind > 0 ? { genai: sem.latest } : {}),
|
|
243
|
+
};
|
|
244
|
+
if (!to.collector && !to.genai) {
|
|
245
|
+
result.issueUrl = await stickyIssue(run, cwd, "otel: collector audit findings", summary);
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
const lexiconDir = resolve(args.lexiconDir ?? join(cwd, "lexicons", "otel"));
|
|
249
|
+
const root = (await run("git", ["rev-parse", "--show-toplevel"], lexiconDir)).trim();
|
|
250
|
+
const definePath = join(lexiconDir, "src", "define.ts");
|
|
251
|
+
const content = bumpPins(readFileSync(definePath, "utf8"), to);
|
|
252
|
+
const moved = [to.collector ? `COLLECTOR_PIN to ${to.collector}` : "", to.genai ? `GENAI_SEMCONV_PIN to ${to.genai}` : ""].filter(Boolean).join(" and ");
|
|
253
|
+
result.prUrl = await proposePullRequest(run, {
|
|
254
|
+
root,
|
|
255
|
+
branch: args.branch ?? "chant/otel-pins",
|
|
256
|
+
title: `chore(otel): move ${moved}`,
|
|
257
|
+
body:
|
|
258
|
+
`${summary}\n\nThis moves the pin values only. The config types, defaults and checks written against ${collector.pin} ` +
|
|
259
|
+
"still need reading against the release notes before this merges.\n",
|
|
260
|
+
files: [{ path: relative(root, definePath), content }],
|
|
261
|
+
commitMessage: `chore(otel): move ${moved}`,
|
|
262
|
+
worktreeDir: join(mkdtempSync(join(tmpdir(), "chant-otel-pins-")), "wt"),
|
|
263
|
+
});
|
|
264
|
+
return result;
|
|
265
|
+
}
|