@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.
Files changed (86) hide show
  1. package/README.md +1 -1
  2. package/dist/collector.d.ts +6 -0
  3. package/dist/collector.d.ts.map +1 -1
  4. package/dist/composites/catalog.d.ts.map +1 -1
  5. package/dist/composites/genai-pipeline.d.ts +33 -0
  6. package/dist/composites/genai-pipeline.d.ts.map +1 -0
  7. package/dist/composites/index.d.ts +8 -0
  8. package/dist/composites/index.d.ts.map +1 -1
  9. package/dist/composites/otlp-collector.d.ts +43 -0
  10. package/dist/composites/otlp-collector.d.ts.map +1 -0
  11. package/dist/composites/red-metrics.d.ts +77 -0
  12. package/dist/composites/red-metrics.d.ts.map +1 -0
  13. package/dist/composites/tail-sampling-tier.d.ts +90 -0
  14. package/dist/composites/tail-sampling-tier.d.ts.map +1 -0
  15. package/dist/configmap.d.ts +16 -0
  16. package/dist/configmap.d.ts.map +1 -1
  17. package/dist/genai.d.ts +19 -0
  18. package/dist/genai.d.ts.map +1 -1
  19. package/dist/import/embedded.d.ts +8 -0
  20. package/dist/import/embedded.d.ts.map +1 -1
  21. package/dist/index.d.ts +8 -5
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/integrity.json +3 -3
  24. package/dist/lint/post-synth/otel-helpers.d.ts +9 -2
  25. package/dist/lint/post-synth/otel-helpers.d.ts.map +1 -1
  26. package/dist/manifest.json +1 -1
  27. package/dist/metric-names.d.ts +68 -0
  28. package/dist/metric-names.d.ts.map +1 -1
  29. package/dist/op/activities/collector-audit.d.ts +95 -0
  30. package/dist/op/activities/collector-audit.d.ts.map +1 -0
  31. package/dist/op/activities/collector-health.d.ts +74 -0
  32. package/dist/op/activities/collector-health.d.ts.map +1 -0
  33. package/dist/op/activities/exec.d.ts +19 -0
  34. package/dist/op/activities/exec.d.ts.map +1 -0
  35. package/dist/op/activities/github.d.ts +55 -0
  36. package/dist/op/activities/github.d.ts.map +1 -0
  37. package/dist/op/activities/index.d.ts +20 -0
  38. package/dist/op/activities/index.d.ts.map +1 -0
  39. package/dist/op/activities/otelcol.d.ts +96 -0
  40. package/dist/op/activities/otelcol.d.ts.map +1 -0
  41. package/dist/op/activity-contracts.d.ts +96 -0
  42. package/dist/op/activity-contracts.d.ts.map +1 -0
  43. package/dist/op/audit-op.d.ts +35 -0
  44. package/dist/op/audit-op.d.ts.map +1 -0
  45. package/dist/op/builders.d.ts +43 -0
  46. package/dist/op/builders.d.ts.map +1 -0
  47. package/dist/platform.d.ts +14 -0
  48. package/dist/platform.d.ts.map +1 -1
  49. package/dist/rules/otel-helpers.ts +18 -5
  50. package/package.json +14 -3
  51. package/src/collector.ts +9 -0
  52. package/src/composites/catalog.test.ts +1 -1
  53. package/src/composites/catalog.ts +292 -0
  54. package/src/composites/genai-pipeline.test.ts +53 -0
  55. package/src/composites/genai-pipeline.ts +40 -0
  56. package/src/composites/index.ts +8 -0
  57. package/src/composites/otlp-collector.test.ts +43 -0
  58. package/src/composites/otlp-collector.ts +70 -0
  59. package/src/composites/red-metrics.test.ts +131 -0
  60. package/src/composites/red-metrics.ts +148 -0
  61. package/src/composites/tail-sampling-tier.test.ts +100 -0
  62. package/src/composites/tail-sampling-tier.ts +182 -0
  63. package/src/configmap-operator.test.ts +44 -0
  64. package/src/configmap.ts +35 -0
  65. package/src/detect.ts +13 -1
  66. package/src/genai.ts +72 -59
  67. package/src/import/embedded.test.ts +36 -0
  68. package/src/import/embedded.ts +28 -4
  69. package/src/index.ts +57 -2
  70. package/src/lint/post-synth/otel-helpers.ts +18 -5
  71. package/src/metric-names.ts +141 -0
  72. package/src/op/activities/collector-audit.test.ts +166 -0
  73. package/src/op/activities/collector-audit.ts +265 -0
  74. package/src/op/activities/collector-health.test.ts +115 -0
  75. package/src/op/activities/collector-health.ts +211 -0
  76. package/src/op/activities/exec.ts +33 -0
  77. package/src/op/activities/github.ts +127 -0
  78. package/src/op/activities/index.ts +19 -0
  79. package/src/op/activities/otelcol.test.ts +162 -0
  80. package/src/op/activities/otelcol.ts +233 -0
  81. package/src/op/activity-contracts.test.ts +103 -0
  82. package/src/op/activity-contracts.ts +92 -0
  83. package/src/op/audit-op.ts +53 -0
  84. package/src/op/builders.ts +52 -0
  85. package/src/op/testdata/otelcol-components-v0.130.0.yaml +82 -0
  86. package/src/platform.ts +45 -8
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `OtlpCollector`: the composite declares the config `otlpCollector()`
3
+ * returns, with each part under a member name.
4
+ */
5
+ import { describe, expect, test } from "vitest";
6
+ import { load } from "js-yaml";
7
+ import type { Declarable } from "@intentius/chant/declarable";
8
+ import { OtlpCollector, otlpCollectorPropsProblem, type OtlpCollectorProps } from "./index";
9
+ import { otlpCollector } from "../platform";
10
+ import { OtlpExporter } from "../components/exporters";
11
+ import { collectorYaml } from "../collector";
12
+ import { validateCollectorConfig } from "../validate-config";
13
+ import type { CollectorConfig, Signal } from "../model";
14
+
15
+ const backend = new OtlpExporter({ name: "backend", endpoint: "collector.example.com:4317" });
16
+
17
+ describe("OtlpCollector", () => {
18
+ const cases: Array<[string, OtlpCollectorProps]> = [
19
+ ["the defaults", {}],
20
+ ["an exporter and two signals", { exporters: [backend], signals: ["traces", "logs"] }],
21
+ ["no health check", { healthCheck: false }],
22
+ ];
23
+
24
+ test.each(cases)("%s: the same config as otlpCollector(), and it passes the config checks", (_label, props) => {
25
+ const yaml = collectorYaml(Object.values(OtlpCollector(props).members) as Declarable[]);
26
+ expect(yaml).toBe(collectorYaml(otlpCollector(props)));
27
+ expect(validateCollectorConfig(load(yaml) as CollectorConfig)).toEqual([]);
28
+ });
29
+
30
+ test("members: the default debug exporter only when no exporters are given, a pipeline per signal", () => {
31
+ expect(Object.keys(OtlpCollector({}).members).sort()).toEqual(["batch", "debug", "health", "logs", "memoryLimiter", "metrics", "otlp", "traces"]);
32
+ const c = OtlpCollector({ exporters: [backend], signals: ["traces"], healthCheck: false });
33
+ expect(Object.keys(c.members).sort()).toEqual(["batch", "memoryLimiter", "otlp", "traces"]);
34
+ expect(c.traces?.props.exporters).toEqual([backend]);
35
+ });
36
+
37
+ test("bad props are refused", () => {
38
+ expect(otlpCollectorPropsProblem({})).toBeUndefined();
39
+ expect(() => OtlpCollector({ exporters: [] })).toThrow(/OtlpCollector: exporters must name at least one exporter/);
40
+ expect(() => OtlpCollector({ signals: [] })).toThrow(/signals must name at least one signal/);
41
+ expect(() => OtlpCollector({ signals: ["profiles" as Signal] })).toThrow(/"profiles" is not traces, metrics or logs/);
42
+ });
43
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `OtlpCollector`: `otlpCollector()` as a composite.
3
+ *
4
+ * The small OTLP collector the docker, k8s and fly platform composites start
5
+ * from: an `otlp` receiver on 4317 (gRPC) and 4318 (HTTP), `memory_limiter`
6
+ * then `batch`, the given exporters (default: one `debug` exporter), one
7
+ * pipeline per signal, and a `health_check` extension on 0.0.0.0:13133.
8
+ * Each part is a member, so a project exports one declaration and hands
9
+ * the parts on by name.
10
+ */
11
+
12
+ import { Composite, type CompositeInstance } from "@intentius/chant/composite";
13
+ import type { OTelComponent } from "../define";
14
+ import type { PipelineEntity } from "../pipeline";
15
+ import { otlpCollectorParts, type OtlpCollectorOptions } from "../platform";
16
+
17
+ export type OtlpCollectorProps = OtlpCollectorOptions;
18
+
19
+ // A type alias, not an interface: a composite's members type needs the implicit index signature.
20
+ export type OtlpCollectorMembers = {
21
+ otlp: OTelComponent<"receiver", "otlp", object>;
22
+ memoryLimiter: OTelComponent<"processor", "memory_limiter", object>;
23
+ batch: OTelComponent<"processor", "batch", object>;
24
+ /** The default `debug` exporter, when no `exporters` are given. */
25
+ debug?: OTelComponent<"exporter", string, object>;
26
+ health?: OTelComponent<"extension", "health_check", object>;
27
+ traces?: PipelineEntity;
28
+ metrics?: PipelineEntity;
29
+ logs?: PipelineEntity;
30
+ };
31
+
32
+ export type OtlpCollectorInstance = CompositeInstance<OtlpCollectorMembers> & OtlpCollectorMembers;
33
+
34
+ /** Why a set of `OtlpCollector` props can't build, or undefined when they can. */
35
+ export function otlpCollectorPropsProblem(props: OtlpCollectorProps = {}): string | undefined {
36
+ if (props.exporters !== undefined && props.exporters.length === 0) return "exporters must name at least one exporter when set";
37
+ if (props.signals !== undefined) {
38
+ if (props.signals.length === 0) return "signals must name at least one signal when set";
39
+ const bad = props.signals.find((s) => s !== "traces" && s !== "metrics" && s !== "logs");
40
+ if (bad !== undefined) return `signals: "${String(bad)}" is not traces, metrics or logs`;
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ /**
46
+ * A small OTLP collector config: OTLP in, `memory_limiter` and `batch`, the
47
+ * given exporters out, for each signal.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * import { OtlpCollector, OtlpExporter } from "@intentius/chant-lexicon-otel";
52
+ *
53
+ * const backend = new OtlpExporter({ name: "backend", endpoint: "collector.example.com:4317" });
54
+ * export const collector = OtlpCollector({ exporters: [backend], signals: ["traces", "logs"] });
55
+ * ```
56
+ */
57
+ export const OtlpCollector = Composite<OtlpCollectorProps, OtlpCollectorMembers>((input) => {
58
+ const props = input ?? {};
59
+ const problem = otlpCollectorPropsProblem(props);
60
+ if (problem) throw new Error(`OtlpCollector: ${problem}`);
61
+ const p = otlpCollectorParts(props);
62
+ // A composite member must be a declarable, so the parts that are off are left out rather than undefined.
63
+ const members: OtlpCollectorMembers = { otlp: p.otlp, memoryLimiter: p.memoryLimiter, batch: p.batch };
64
+ if (props.exporters === undefined) members.debug = p.exporters[0];
65
+ if (p.health) members.health = p.health;
66
+ if (p.pipelines.traces) members.traces = p.pipelines.traces;
67
+ if (p.pipelines.metrics) members.metrics = p.pipelines.metrics;
68
+ if (p.pipelines.logs) members.logs = p.pipelines.logs;
69
+ return members;
70
+ }, "OtlpCollector");
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `RedMetrics`: the collector config it declares, the names it serves, and
3
+ * the RED queries `spanMetricsRedQueries()` builds from them for the grafana
4
+ * and prometheus lexicons.
5
+ */
6
+ import { describe, expect, test } from "vitest";
7
+ import { load } from "js-yaml";
8
+ import type { Declarable } from "@intentius/chant/declarable";
9
+ import { RedMetrics, redMetricsNames, redMetricsPropsProblem, type RedMetricsProps } from "./index";
10
+ import { ForwardConnector } from "../components/connectors";
11
+ import { OtlpExporter, PrometheusExporter } from "../components/exporters";
12
+ import { collectorYaml } from "../collector";
13
+ import { validateCollectorConfig } from "../validate-config";
14
+ import type { CollectorConfig } from "../model";
15
+ import { spanMetricsNames, spanMetricsRedQueries, RED_DEFAULT_SPAN_KINDS, SPAN_METRICS_KINDS, promErrorRatio } from "../metric-names";
16
+
17
+ function configOf(props: RedMetricsProps = {}): CollectorConfig {
18
+ return load(collectorYaml(Object.values(RedMetrics(props).members) as Declarable[])) as CollectorConfig;
19
+ }
20
+
21
+ describe("RedMetrics defaults", () => {
22
+ const config = configOf();
23
+
24
+ test("the config passes every config check", () => {
25
+ expect(validateCollectorConfig(config)).toEqual([]);
26
+ });
27
+
28
+ test("OTLP in, spanmetrics and servicegraph out of traces/red, metrics/red to a prometheus exporter on 8889", () => {
29
+ expect(Object.keys(config.receivers ?? {})).toEqual(["otlp"]);
30
+ expect(Object.keys(config.connectors ?? {}).sort()).toEqual(["servicegraph/red", "spanmetrics/red"]);
31
+ expect(config.exporters).toEqual({ "prometheus/red": { endpoint: "0.0.0.0:8889" } });
32
+ expect(config.service?.pipelines).toEqual({
33
+ "traces/red": { receivers: ["otlp"], processors: ["memory_limiter", "batch"], exporters: ["spanmetrics/red", "servicegraph/red"] },
34
+ "metrics/red": { receivers: ["spanmetrics/red", "servicegraph/red"], processors: ["batch"], exporters: ["prometheus/red"] },
35
+ });
36
+ });
37
+
38
+ test("memory_limiter defaults to a share of the container's memory", () => {
39
+ expect(config.processors?.memory_limiter).toEqual({ check_interval: "1s", limit_percentage: 80, spike_limit_percentage: 20 });
40
+ });
41
+
42
+ test("the served names are the connector defaults'", () => {
43
+ const names = redMetricsNames(RedMetrics({}));
44
+ expect(names.spans.calls.prometheus).toBe("traces_span_metrics_calls_total");
45
+ expect(names.spans.duration?.prometheus).toBe("traces_span_metrics_duration_milliseconds");
46
+ expect(names.serviceGraph?.requests.prometheus).toBe("traces_service_graph_request_total");
47
+ });
48
+ });
49
+
50
+ describe("RedMetrics options", () => {
51
+ test("the spanmetrics config, a trace backend and a named exporter are used as given", () => {
52
+ const tempo = new OtlpExporter({ name: "tempo", endpoint: "tempo:4317", tls: { insecure: true } });
53
+ const scrape = new PrometheusExporter({ name: "scrape", endpoint: "0.0.0.0:9464", namespace: "edge" });
54
+ const red = RedMetrics({ traceExporters: [tempo], exporter: scrape, spanMetrics: { namespace: "shop", histogram: { unit: "s" } }, name: "shop" });
55
+ const config = load(collectorYaml(Object.values(red.members) as Declarable[])) as CollectorConfig;
56
+ expect(validateCollectorConfig(config)).toEqual([]);
57
+ expect(config.connectors?.["spanmetrics/shop"]).toEqual({ namespace: "shop", histogram: { unit: "s" } });
58
+ expect(config.service?.pipelines?.["traces/shop"].exporters).toEqual(["spanmetrics/shop", "servicegraph/shop", "otlp/tempo"]);
59
+ expect(config.service?.pipelines?.["metrics/shop"].exporters).toEqual(["prometheus/scrape"]);
60
+ const names = redMetricsNames(red);
61
+ expect(names.spans.calls.prometheus).toBe("edge_shop_calls_total");
62
+ expect(names.spans.duration?.prometheus).toBe("edge_shop_duration_seconds");
63
+ expect(names.spans).toEqual(spanMetricsNames(red.spanMetrics, red.exporter));
64
+ });
65
+
66
+ test("serviceGraph: false leaves the servicegraph connector out", () => {
67
+ const red = RedMetrics({ serviceGraph: false });
68
+ expect(red.serviceGraph).toBeUndefined();
69
+ expect(Object.keys(red.members)).not.toContain("serviceGraph");
70
+ const config = configOf({ serviceGraph: false });
71
+ expect(Object.keys(config.connectors ?? {})).toEqual(["spanmetrics/red"]);
72
+ expect(redMetricsNames(red).serviceGraph).toBeUndefined();
73
+ expect(validateCollectorConfig(config)).toEqual([]);
74
+ });
75
+
76
+ test("receivers replace the otlp receiver, e.g. a connector from another traces pipeline", () => {
77
+ const fwd = new ForwardConnector({ name: "red" });
78
+ const red = RedMetrics({ receivers: [fwd] });
79
+ expect(red.otlp).toBeUndefined();
80
+ expect(red.traces.props.receivers).toEqual([fwd]);
81
+ });
82
+
83
+ test("memoryLimitMib sets a hard limit with a quarter of it as spike limit", () => {
84
+ expect(configOf({ memoryLimitMib: 512 }).processors?.memory_limiter).toEqual({ check_interval: "1s", limit_mib: 512, spike_limit_mib: 128 });
85
+ });
86
+
87
+ test("bad props are refused", () => {
88
+ expect(redMetricsPropsProblem({})).toBeUndefined();
89
+ expect(() => RedMetrics({ receivers: [] })).toThrow(/RedMetrics: receivers must name at least one receiver/);
90
+ expect(() => RedMetrics({ memoryLimitMib: 0 })).toThrow(/memoryLimitMib/);
91
+ expect(() => RedMetrics({ name: "a b" })).toThrow(/name "a b"/);
92
+ const notPrometheus = new OtlpExporter({ endpoint: "x:4317" }) as unknown as RedMetricsProps["exporter"];
93
+ expect(() => RedMetrics({ exporter: notPrometheus })).toThrow(/exporter must be a prometheus exporter/);
94
+ });
95
+ });
96
+
97
+ describe("spanMetricsRedQueries", () => {
98
+ const names = spanMetricsNames({ namespace: "shop", histogram: { unit: "s" } });
99
+
100
+ test("rate, error ratio and quantiles per service over server and consumer spans", () => {
101
+ const q = spanMetricsRedQueries(names, { range: "5m", quantiles: [0.95] });
102
+ const kinds = `span_kind=~"${RED_DEFAULT_SPAN_KINDS.join("|")}"`;
103
+ expect(q.service).toBe("service_name");
104
+ expect(q.rate).toBe(`sum by (service_name) (rate(shop_calls_total{${kinds}}[5m]))`);
105
+ expect(q.errorRatio).toBe(
106
+ promErrorRatio(`shop_calls_total{${kinds}, status_code="STATUS_CODE_ERROR"}`, `shop_calls_total{${kinds}}`, ["service_name"], "5m"),
107
+ );
108
+ expect(q.duration).toEqual([
109
+ { quantile: 0.95, expr: `histogram_quantile(0.95, sum by (le, service_name) (rate(shop_duration_seconds_bucket{${kinds}}[5m])))` },
110
+ ]);
111
+ });
112
+
113
+ test("a service match, extra labels and every span kind", () => {
114
+ const q = spanMetricsRedQueries(names, { range: "$__rate_interval", serviceMatch: "$service", spanKinds: [], by: ["env"] });
115
+ expect(q.kindMatchers).toEqual([]);
116
+ expect(q.rate).toBe('sum by (service_name, env) (rate(shop_calls_total{service_name=~"$service"}[$__rate_interval]))');
117
+ expect(q.duration.map((d) => d.quantile)).toEqual([0.5, 0.95, 0.99]);
118
+ });
119
+
120
+ test("errors name the caller", () => {
121
+ expect(() => spanMetricsRedQueries(spanMetricsNames({ exclude_dimensions: ["status.code"] }), { range: "5m", owner: "X" })).toThrow(
122
+ /^X: the connector excludes status.code/,
123
+ );
124
+ expect(() => spanMetricsRedQueries(names, { range: "5m", spanKinds: ["SPAN_KIND_NOPE" as never] })).toThrow(/unknown span kind/);
125
+ expect(SPAN_METRICS_KINDS).toHaveLength(6);
126
+ });
127
+
128
+ test("no histogram, no duration queries", () => {
129
+ expect(spanMetricsRedQueries(spanMetricsNames({ histogram: { disable: true } }), { range: "5m" }).duration).toEqual([]);
130
+ });
131
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `RedMetrics`: rate, errors and duration (RED) metrics from traces, served
3
+ * for Prometheus to scrape.
4
+ *
5
+ * Spans come in on an `otlp` receiver (or the receivers given) and go
6
+ * through `memory_limiter` and `batch` to a `spanmetrics` connector and, by
7
+ * default, a `servicegraph` connector. Both feed a `metrics` pipeline that
8
+ * ends at a `prometheus` exporter on 0.0.0.0:8889. Traces can also go on to
9
+ * a backend through `traceExporters`.
10
+ *
11
+ * The metric names follow from the declaration: `redMetricsNames(red)` reads
12
+ * them back with `spanMetricsNames()` and `serviceGraphNames()`, and the
13
+ * grafana lexicon's `RedDashboard` and the prometheus lexicon's `RedAlerts`
14
+ * take the connector and the exporter (`red.spanMetrics`, `red.exporter`),
15
+ * so a renamed namespace moves every query built from them.
16
+ */
17
+
18
+ import { Composite, type CompositeInstance } from "@intentius/chant/composite";
19
+ import { OtlpReceiver } from "../components/receivers";
20
+ import { BatchProcessor, MemoryLimiterProcessor } from "../components/processors";
21
+ import {
22
+ ServiceGraphConnector,
23
+ SpanMetricsConnector,
24
+ type ServiceGraphConnectorConfig,
25
+ type SpanMetricsConnectorConfig,
26
+ } from "../components/connectors";
27
+ import { PrometheusExporter, type PrometheusExporterConfig } from "../components/exporters";
28
+ import type { OTelComponent } from "../define";
29
+ import { Pipeline, type PipelineEntity } from "../pipeline";
30
+ import { serviceGraphNames, spanMetricsNames, type ServiceGraphNames, type SpanMetricsNames } from "../metric-names";
31
+
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ type Exporter = OTelComponent<"exporter", string, any>;
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ type TraceSource = OTelComponent<"receiver" | "connector", string, any>;
36
+ type Component<K extends "receiver" | "processor" | "exporter" | "connector"> = OTelComponent<K, string, object>;
37
+
38
+ export interface RedMetricsProps {
39
+ /** Where spans come from: receivers, or a connector from another traces pipeline. Default: an `otlp` receiver on 4317 (gRPC) and 4318 (HTTP). */
40
+ receivers?: TraceSource[];
41
+ /** Where traces go besides the connectors, e.g. a tracing backend. Default: nowhere. */
42
+ traceExporters?: Exporter[];
43
+ /** The `prometheus` exporter the metrics are served on. Default: one named after `name`, on 0.0.0.0:8889. */
44
+ exporter?: OTelComponent<"exporter", "prometheus", PrometheusExporterConfig>;
45
+ /** The `spanmetrics` connector's config: namespace, dimensions, histogram unit and buckets (default: the connector's defaults). */
46
+ spanMetrics?: SpanMetricsConnectorConfig;
47
+ /** Service-to-service edge metrics from a `servicegraph` connector (default: on). */
48
+ serviceGraph?: boolean | ServiceGraphConnectorConfig;
49
+ /** `memory_limiter`'s hard limit in MiB (default: 80% of the container's memory, with a 20% spike limit). */
50
+ memoryLimitMib?: number;
51
+ /** The instance name of the connectors, the exporter and the two pipelines (default `red`). */
52
+ name?: string;
53
+ }
54
+
55
+ // A type alias, not an interface: a composite's members type needs the implicit index signature.
56
+ export type RedMetricsMembers = {
57
+ otlp?: Component<"receiver">;
58
+ memoryLimiter: Component<"processor">;
59
+ batch: Component<"processor">;
60
+ spanMetrics: OTelComponent<"connector", "spanmetrics", SpanMetricsConnectorConfig>;
61
+ serviceGraph?: OTelComponent<"connector", "servicegraph", ServiceGraphConnectorConfig>;
62
+ exporter: OTelComponent<"exporter", "prometheus", PrometheusExporterConfig>;
63
+ traces: PipelineEntity;
64
+ metrics: PipelineEntity;
65
+ };
66
+
67
+ export type RedMetricsInstance = CompositeInstance<RedMetricsMembers> & RedMetricsMembers;
68
+
69
+ const NAME = /^[A-Za-z0-9_.-]+$/;
70
+
71
+ /** Why a set of `RedMetrics` props can't build, or undefined when they can. */
72
+ export function redMetricsPropsProblem(props: RedMetricsProps = {}): string | undefined {
73
+ if (props.receivers !== undefined && props.receivers.length === 0) return "receivers must name at least one receiver when set";
74
+ if (props.memoryLimitMib !== undefined && !(Number.isInteger(props.memoryLimitMib) && props.memoryLimitMib > 0)) {
75
+ return "memoryLimitMib must be a positive whole number";
76
+ }
77
+ if (props.name !== undefined && !NAME.test(props.name)) return `name "${props.name}" must be letters, digits, '.', '_' or '-'`;
78
+ if (props.exporter !== undefined && props.exporter.componentType !== "prometheus") {
79
+ return `exporter must be a prometheus exporter, got ${props.exporter.componentType}`;
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ /**
85
+ * RED metrics from traces: `spanmetrics` and `servicegraph` connectors
86
+ * feeding a `prometheus` exporter.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * import { RedMetrics, OtlpExporter } from "@intentius/chant-lexicon-otel";
91
+ *
92
+ * const tempo = new OtlpExporter({ name: "tempo", endpoint: "tempo:4317", tls: { insecure: true } });
93
+ * export const red = RedMetrics({ traceExporters: [tempo], spanMetrics: { namespace: "shop", histogram: { unit: "s" } } });
94
+ * // RedDashboard({ spanMetrics: red.spanMetrics, exporter: red.exporter, datasource }) in the grafana lexicon
95
+ * ```
96
+ */
97
+ export const RedMetrics = Composite<RedMetricsProps, RedMetricsMembers>((input) => {
98
+ const props = input ?? {};
99
+ const problem = redMetricsPropsProblem(props);
100
+ if (problem) throw new Error(`RedMetrics: ${problem}`);
101
+ const name = props.name ?? "red";
102
+
103
+ const otlp = props.receivers
104
+ ? undefined
105
+ : new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" }, http: { endpoint: "0.0.0.0:4318" } } });
106
+ const receivers: TraceSource[] = props.receivers ?? [otlp!];
107
+ const memoryLimiter = new MemoryLimiterProcessor(
108
+ props.memoryLimitMib !== undefined
109
+ ? { check_interval: "1s", limit_mib: props.memoryLimitMib, spike_limit_mib: Math.ceil(props.memoryLimitMib / 4) }
110
+ : { check_interval: "1s", limit_percentage: 80, spike_limit_percentage: 20 },
111
+ );
112
+ const batch = new BatchProcessor({});
113
+ const spanMetrics = new SpanMetricsConnector({ name, ...(props.spanMetrics ?? {}) });
114
+ const graphConfig = props.serviceGraph === undefined || props.serviceGraph === true ? {} : props.serviceGraph === false ? undefined : props.serviceGraph;
115
+ const serviceGraph = graphConfig ? new ServiceGraphConnector({ name, ...graphConfig }) : undefined;
116
+ const exporter = props.exporter ?? new PrometheusExporter({ name, endpoint: "0.0.0.0:8889" });
117
+ const connectors = [spanMetrics, ...(serviceGraph ? [serviceGraph] : [])];
118
+
119
+ const traces = new Pipeline({
120
+ signal: "traces",
121
+ name,
122
+ receivers,
123
+ processors: [memoryLimiter, batch],
124
+ exporters: [...connectors, ...(props.traceExporters ?? [])],
125
+ });
126
+ const metrics = new Pipeline({ signal: "metrics", name, receivers: connectors, processors: [batch], exporters: [exporter] });
127
+
128
+ // A composite member must be a declarable, so the components that are off are left out rather than undefined.
129
+ const members: RedMetricsMembers = { memoryLimiter, batch, spanMetrics, exporter, traces, metrics };
130
+ if (otlp) members.otlp = otlp;
131
+ if (serviceGraph) members.serviceGraph = serviceGraph;
132
+ return members;
133
+ }, "RedMetrics");
134
+
135
+ /** The Prometheus names of what a `RedMetrics` serves: the span metrics, and the service graph's when it is on. */
136
+ export interface RedMetricsNames {
137
+ spans: SpanMetricsNames;
138
+ serviceGraph?: ServiceGraphNames;
139
+ }
140
+
141
+ /** The metric names a `RedMetrics(...)` result serves, read from its connectors and exporter. */
142
+ export function redMetricsNames(red: RedMetricsInstance | RedMetricsMembers): RedMetricsNames {
143
+ if (!red?.spanMetrics || !red.exporter) throw new Error("redMetricsNames: pass a RedMetrics(...) result");
144
+ return {
145
+ spans: spanMetricsNames(red.spanMetrics, red.exporter),
146
+ ...(red.serviceGraph ? { serviceGraph: serviceGraphNames(red.serviceGraph, red.exporter) } : {}),
147
+ };
148
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `TailSamplingTier`: the sampling collector's config, its default
3
+ * policies, the switches, and the load-balancing exporter for the agents in
4
+ * front of it.
5
+ */
6
+ import { describe, expect, test } from "vitest";
7
+ import { load } from "js-yaml";
8
+ import type { Declarable } from "@intentius/chant/declarable";
9
+ import { TailSamplingTier, tailSamplingLoadBalancer, tailSamplingTierPropsProblem, type TailSamplingTierProps } from "./index";
10
+ import { OtlpExporter } from "../components/exporters";
11
+ import { collectorYaml } from "../collector";
12
+ import { validateCollectorConfig } from "../validate-config";
13
+ import type { CollectorConfig } from "../model";
14
+
15
+ type Loose = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
16
+
17
+ function configOf(props: TailSamplingTierProps = {}): CollectorConfig {
18
+ return load(collectorYaml(Object.values(TailSamplingTier(props).members) as Declarable[])) as CollectorConfig;
19
+ }
20
+
21
+ const tempo = new OtlpExporter({ name: "tempo", endpoint: "tempo:4317", tls: { insecure: true } });
22
+
23
+ describe("TailSamplingTier defaults", () => {
24
+ const config = configOf();
25
+
26
+ test("the config passes every config check", () => {
27
+ expect(validateCollectorConfig(config)).toEqual([]);
28
+ });
29
+
30
+ test("otlp in, memory_limiter, tail_sampling, batch, debug out", () => {
31
+ expect(config.service?.pipelines).toEqual({
32
+ traces: { receivers: ["otlp"], processors: ["memory_limiter", "tail_sampling", "batch"], exporters: ["debug"] },
33
+ });
34
+ expect(config.service?.extensions).toEqual(["health_check"]);
35
+ });
36
+
37
+ test("keeps errors, traces of a second or more, and 10% of the rest, after 10s", () => {
38
+ const ts = config.processors?.tail_sampling as Loose;
39
+ expect(ts.decision_wait).toBe("10s");
40
+ expect(ts.policies).toEqual([
41
+ { name: "errors", type: "status_code", status_code: { status_codes: ["ERROR"] } },
42
+ { name: "slow", type: "latency", latency: { threshold_ms: 1000 } },
43
+ { name: "baseline", type: "probabilistic", probabilistic: { sampling_percentage: 10 } },
44
+ ]);
45
+ expect(ts.num_traces).toBeUndefined();
46
+ });
47
+ });
48
+
49
+ describe("TailSamplingTier options", () => {
50
+ test("exporters, thresholds and extra policies", () => {
51
+ const config = configOf({
52
+ exporters: [tempo],
53
+ slowerThanMs: 500,
54
+ percentage: 5,
55
+ decisionWait: "5s",
56
+ numTraces: 100000,
57
+ policies: [{ name: "checkout", type: "string_attribute", string_attribute: { key: "service.name", values: ["checkout"] } }],
58
+ });
59
+ expect(validateCollectorConfig(config)).toEqual([]);
60
+ const ts = config.processors?.tail_sampling as Loose;
61
+ expect(ts.decision_wait).toBe("5s");
62
+ expect(ts.num_traces).toBe(100000);
63
+ expect(ts.policies.map((p: Loose) => p.name)).toEqual(["errors", "slow", "baseline", "checkout"]);
64
+ expect(ts.policies[1].latency).toEqual({ threshold_ms: 500 });
65
+ expect(config.service?.pipelines?.traces.exporters).toEqual(["otlp/tempo"]);
66
+ expect(config.exporters?.debug).toBeUndefined();
67
+ });
68
+
69
+ test("each default policy can be turned off", () => {
70
+ const ts = configOf({ errors: false, percentage: false }).processors?.tail_sampling as Loose;
71
+ expect(ts.policies.map((p: Loose) => p.name)).toEqual(["slow"]);
72
+ });
73
+
74
+ test("members leave out what is off", () => {
75
+ const tier = TailSamplingTier({ exporters: [tempo], healthCheck: false });
76
+ expect(Object.keys(tier.members).sort()).toEqual(["batch", "memoryLimiter", "otlp", "tailSampling", "traces"]);
77
+ });
78
+
79
+ test("bad props are refused", () => {
80
+ expect(tailSamplingTierPropsProblem({})).toBeUndefined();
81
+ expect(() => TailSamplingTier({ errors: false, slowerThanMs: false, percentage: false })).toThrow(/TailSamplingTier: every policy is off/);
82
+ expect(() => TailSamplingTier({ percentage: 0 })).toThrow(/percentage/);
83
+ expect(() => TailSamplingTier({ percentage: 101 })).toThrow(/percentage/);
84
+ expect(() => TailSamplingTier({ slowerThanMs: 1.5 })).toThrow(/slowerThanMs/);
85
+ expect(() => TailSamplingTier({ decisionWait: "soon" })).toThrow(/decisionWait/);
86
+ expect(() => TailSamplingTier({ exporters: [] })).toThrow(/exporters/);
87
+ });
88
+ });
89
+
90
+ describe("tailSamplingLoadBalancer", () => {
91
+ test("routes by trace id to the resolver's backends", () => {
92
+ const lb = tailSamplingLoadBalancer({ dns: { hostname: "sampler.internal" } }, "sampler");
93
+ expect(lb.componentId).toBe("loadbalancing/sampler");
94
+ expect(lb.props).toMatchObject({ routing_key: "traceID", resolver: { dns: { hostname: "sampler.internal" } } });
95
+ expect((lb.props as Loose).protocol).toBeUndefined();
96
+ const insecure = tailSamplingLoadBalancer({ static: { hostnames: ["a:4317", "b:4317"] } }, undefined, { otlp: { tls: { insecure: true } } });
97
+ expect(insecure.componentId).toBe("loadbalancing");
98
+ expect((insecure.props as Loose).protocol).toEqual({ otlp: { tls: { insecure: true } } });
99
+ });
100
+ });