@loadstrike/loadstrike-sdk 1.0.31601 → 1.0.32601
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 +14 -2
- package/dist/cjs/internal/prometheus-remote-write.js +37 -0
- package/dist/cjs/internal/reporting-sink-http-error.js +17 -0
- package/dist/cjs/internal/vendor-metric-payloads.js +390 -0
- package/dist/cjs/iteration-observations.js +24 -8
- package/dist/cjs/local.js +48 -63
- package/dist/cjs/reporting-containment.js +242 -0
- package/dist/cjs/runtime.js +83 -3
- package/dist/cjs/sinks.js +1337 -38
- package/dist/cjs/transports.js +1339 -151
- package/dist/esm/internal/prometheus-remote-write.js +31 -0
- package/dist/esm/internal/reporting-sink-http-error.js +13 -0
- package/dist/esm/internal/vendor-metric-payloads.js +382 -0
- package/dist/esm/iteration-observations.js +24 -8
- package/dist/esm/local.js +49 -64
- package/dist/esm/reporting-containment.js +238 -0
- package/dist/esm/runtime.js +85 -5
- package/dist/esm/sinks.js +1334 -35
- package/dist/esm/transports.js +1335 -151
- package/dist/types/contracts.d.ts +1 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/internal/prometheus-remote-write.d.ts +2 -0
- package/dist/types/internal/reporting-sink-http-error.d.ts +6 -0
- package/dist/types/internal/vendor-metric-payloads.d.ts +48 -0
- package/dist/types/local.d.ts +0 -6
- package/dist/types/reporting-containment.d.ts +2 -0
- package/dist/types/runtime.d.ts +1 -0
- package/dist/types/sinks.d.ts +134 -17
- package/dist/types/transports.d.ts +2 -0
- package/package.json +9 -3
- package/dist/cjs/internal-build.js +0 -4
- package/dist/esm/internal-build.js +0 -1
- package/dist/types/internal-build.d.ts +0 -1
package/README.md
CHANGED
|
@@ -11,7 +11,9 @@ LoadStrike is a developer-first load testing SDK for Node.js applications, servi
|
|
|
11
11
|
- Split a single load profile across weighted scenario mixes.
|
|
12
12
|
- Generate local reports and, on Enterprise, publish observability data to supported sinks.
|
|
13
13
|
|
|
14
|
-
Built-in transport coverage includes HTTP, Kafka, RabbitMQ, NATS, Redis Streams, Azure Event Hubs, Push Diffusion, and delegate-based custom streams. Local report output supports HTML, Markdown, TXT, and CSV, and Enterprise can publish to InfluxDB, TimescaleDB, Grafana Loki, Datadog, Splunk HEC, OpenTelemetry Collector, and the
|
|
14
|
+
Built-in transport coverage includes HTTP, Kafka, RabbitMQ, NATS, Redis Streams, Azure Event Hubs, Push Diffusion, and delegate-based custom streams. Local report output supports HTML, Markdown, TXT, and CSV, and Enterprise can publish to InfluxDB, TimescaleDB, Grafana Loki, Datadog, Splunk HEC, OpenTelemetry Collector, and the other supported sinks described below.
|
|
15
|
+
|
|
16
|
+
gRPC endpoints require a matching synchronous or asynchronous Produce or Consume delegate. Without one, initialization fails with `Native gRPC execution is not available in this SDK version. Provide the endpoint Produce/Consume delegate instead.` On Node.js, WebSocket supports native or delegate-backed Produce and Consume; a matching delegate takes precedence over `NativeClient`.
|
|
15
17
|
|
|
16
18
|
## Requirements
|
|
17
19
|
|
|
@@ -78,7 +80,17 @@ If a fail-mode runtime policy callback fails after an attempt begins, the stream
|
|
|
78
80
|
|
|
79
81
|
Portal reporting sends compressed batches, while the JSONL and generic webhook sinks preserve the canonical observation records. Custom sinks can opt in with `saveIterationBatch` or `SaveIterationBatch` while keeping all existing aggregate lifecycle callbacks unchanged. Batches flush every five seconds; the defaults and the portal-compatible common shape are 50,000 observations or 8 MiB before compression. Runs without a portal sink may select the documented larger limits. Capture and sink queues are memory bounded; buffer pressure, a single record that cannot fit a batch, and sink pressure drop only the affected reporting observations with explicit warnings instead of changing application failures. Metric-only destinations disclose their reduced observation shape. The final observation flush and optional stream-completion callback finish before `saveRunResult` or `SaveRunResult`.
|
|
80
82
|
|
|
81
|
-
Every reporting-sink callback—including initialization, start, realtime statistics and metrics, final statistics and metrics, raw batches, completion markers, stop, and dispose—is attempted once and then retried up to three times by default, after 250 ms, 500 ms, and 1 second. Set `sinkRetryCount` and `sinkRetryBackoffMs`, or the matching Pascal-case configuration keys, to select a bounded policy of zero through 100 retries. A recovered callback adds no final sink error or delivery-failed warning. Only an exhausted raw-observation delivery counts as sink observation loss; other exhausted callbacks are reported against that sink without failing the workload. Custom sinks
|
|
83
|
+
Every reporting-sink callback—including initialization, start, realtime statistics and metrics, final statistics and metrics, raw batches, completion markers, stop, and dispose—is attempted once and then retried up to three times by default, after 250 ms, 500 ms, and 1 second. Set `sinkRetryCount` and `sinkRetryBackoffMs`, or the matching Pascal-case configuration keys, to select a bounded policy of zero through 100 retries. A recovered callback adds no final sink error or delivery-failed warning. Only an exhausted raw-observation delivery counts as sink observation loss; other exhausted callbacks are reported against that sink without failing the workload. Custom sinks may receive the same delivery more than once and should handle repeats safely. Stop and dispose remain best-effort cleanup, and an exhausted stop callback does not prevent dispose. Sanitized nested error details stay in the local run log rather than generator warnings, portable results, portal payloads, or HTML reports.
|
|
84
|
+
|
|
85
|
+
Prometheus Remote Write, CloudWatch, Dynatrace, and New Relic publish their provider-specific metric protocols and apply the shared [metric input limits](../README.md#metric-input-limits).
|
|
86
|
+
|
|
87
|
+
`ElasticsearchReportingSink` and `OpenSearchReportingSink` send one JSON document per reporting call. Their public options include `IndexName`, which defaults to `loadstrike-events` and produces `/{IndexName}/_doc`. An explicit `EndpointPath` remains the compatibility override and takes precedence. Elasticsearch `ApiKey` and OpenSearch `Username`/`Password` produce the matching `Authorization` header, while an explicit authorization header remains authoritative.
|
|
88
|
+
|
|
89
|
+
For direct index routing, `IndexName` must be nonblank lowercase text, at most 255 UTF-8 bytes, not `.` or `..`, and cannot start with `-`, `_`, or `+`. It rejects `\ / * ? " < > |`, ASCII space, comma, `#`, `:`, control characters, and malformed Unicode. Validation identifies the affected option or full configuration path without echoing the rejected value. An explicit `EndpointPath` makes `IndexName` dormant, so the unused name is not validated.
|
|
90
|
+
|
|
91
|
+
Expanded infra config fills only missing values for sinks already selected in code. Canonical `LoadStrike:ReportingSinks:*` keys include HTTP `BaseUrl`, `EndpointPath`, `TimeoutSeconds`, `Headers`, and `StaticTags`; provider credentials and namespace/region fields; Webhook `Url`/`Secret`; Kafka `BootstrapServers`/`Topic`/`StaticTags`; StatsD-family `Prefix`/`Host`/`Port`/`Tags`; and JSONL `FilePath`. Explicit constructor values win and dormant sections are ignored. Unknown selected keys retain their complete path in the diagnostic, while API keys, secrets, tokens, passwords, and sensitive header values are redacted.
|
|
92
|
+
|
|
93
|
+
`GenericWebhook` intentionally sends generic LoadStrike JSON, including gzip-compressed observation batches where applicable. It does not translate that body to a vendor metric protocol. When migrating an old generic metrics URL, use `GenericWebhookReportingSink` only if the receiver accepts the generic contract; otherwise choose the matching direct vendor sink.
|
|
82
94
|
|
|
83
95
|
Portal trend p50, p75, p95, and p99 values are calculated cumulatively from every final bombing-phase outcome received for that scenario and run. Separate successful and failed distributions remain available for diagnosis; SDK-calculated percentile fields are not sent as the authoritative portal or observation-capable sink result.
|
|
84
96
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.encodePrometheusRemoteWrite = encodePrometheusRemoteWrite;
|
|
7
|
+
const protobufjs_1 = __importDefault(require("protobufjs"));
|
|
8
|
+
const vendor_metric_payloads_js_1 = require("./vendor-metric-payloads.js");
|
|
9
|
+
const PrometheusLabel = new protobufjs_1.default.Type("Label")
|
|
10
|
+
.add(new protobufjs_1.default.Field("name", 1, "string"))
|
|
11
|
+
.add(new protobufjs_1.default.Field("value", 2, "string"));
|
|
12
|
+
const PrometheusSample = new protobufjs_1.default.Type("Sample")
|
|
13
|
+
.add(new protobufjs_1.default.Field("value", 1, "double"))
|
|
14
|
+
.add(new protobufjs_1.default.Field("timestamp", 2, "int64"));
|
|
15
|
+
const PrometheusTimeSeries = new protobufjs_1.default.Type("TimeSeries")
|
|
16
|
+
.add(new protobufjs_1.default.Field("labels", 1, "Label", "repeated"))
|
|
17
|
+
.add(new protobufjs_1.default.Field("samples", 2, "Sample", "repeated"))
|
|
18
|
+
.add(PrometheusLabel)
|
|
19
|
+
.add(PrometheusSample);
|
|
20
|
+
const PrometheusWriteRequest = new protobufjs_1.default.Type("WriteRequest")
|
|
21
|
+
.add(new protobufjs_1.default.Field("timeseries", 1, "TimeSeries", "repeated"))
|
|
22
|
+
.add(PrometheusTimeSeries);
|
|
23
|
+
function encodePrometheusRemoteWrite(points, staticLabels = {}) {
|
|
24
|
+
const prepared = (0, vendor_metric_payloads_js_1.prepareProtocolMetricPoints)("prometheus", points, staticLabels);
|
|
25
|
+
const timeseries = prepared.map((point) => {
|
|
26
|
+
const labels = [["__name__", point.metricName], ...point.attributes];
|
|
27
|
+
labels.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
|
|
28
|
+
return {
|
|
29
|
+
labels: labels.map(([name, value]) => ({ name, value })),
|
|
30
|
+
samples: [{ value: point.value, timestamp: point.occurredUtc.getTime() }]
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
contentType: "application/x-protobuf",
|
|
35
|
+
body: PrometheusWriteRequest.encode({ timeseries }).finish()
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ReportingSinkHttpError = void 0;
|
|
4
|
+
class ReportingSinkHttpError extends Error {
|
|
5
|
+
constructor(sinkName, status, statusText, requestId) {
|
|
6
|
+
const normalizedStatus = Number.isFinite(status) ? Math.max(Math.trunc(status), 0) : 0;
|
|
7
|
+
super(`${sinkName} write failed with HTTP status ${normalizedStatus}`
|
|
8
|
+
+ (statusText ? ` ${statusText}` : "")
|
|
9
|
+
+ (requestId ? ` (requestId=${requestId})` : "")
|
|
10
|
+
+ ".");
|
|
11
|
+
this.name = "ReportingSinkHttpError";
|
|
12
|
+
this.status = normalizedStatus;
|
|
13
|
+
this.statusText = statusText;
|
|
14
|
+
this.requestId = requestId;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
exports.ReportingSinkHttpError = ReportingSinkHttpError;
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createCloudWatchMetricData = createCloudWatchMetricData;
|
|
4
|
+
exports.encodeDynatraceMetrics = encodeDynatraceMetrics;
|
|
5
|
+
exports.encodeDynatraceMetricBatches = encodeDynatraceMetricBatches;
|
|
6
|
+
exports.encodeNewRelicMetricBatch = encodeNewRelicMetricBatch;
|
|
7
|
+
exports.encodeNewRelicMetricBatches = encodeNewRelicMetricBatches;
|
|
8
|
+
exports.prepareProtocolMetricPoints = prepareProtocolMetricPoints;
|
|
9
|
+
const MAXIMUM_METRIC_NAME_BYTES = 200;
|
|
10
|
+
const MAXIMUM_ATTRIBUTE_KEY_BYTES = 64;
|
|
11
|
+
const MAXIMUM_ATTRIBUTE_VALUE_BYTES = 256;
|
|
12
|
+
const MAXIMUM_DYNATRACE_ATTRIBUTE_VALUE_BYTES = 255;
|
|
13
|
+
const MAXIMUM_ATTRIBUTES = 20;
|
|
14
|
+
const MAXIMUM_ABSOLUTE_VALUE = 1e100;
|
|
15
|
+
const MAXIMUM_SIGNED_INT64 = 9223372036854775807n;
|
|
16
|
+
const RESERVED_ATTRIBUTES = new Set(["scenario", "status", "run_id"]);
|
|
17
|
+
function createCloudWatchMetricData(namespace, points, staticDimensions = {}) {
|
|
18
|
+
const normalizedNamespace = normalizeCloudWatchNamespace(namespace);
|
|
19
|
+
const prepared = prepareProtocolMetricPoints("cloudwatch", points, staticDimensions);
|
|
20
|
+
return {
|
|
21
|
+
Namespace: normalizedNamespace,
|
|
22
|
+
MetricData: prepared.map((point) => {
|
|
23
|
+
const unit = cloudWatchUnit(point.metricKind, point.unitOfMeasure);
|
|
24
|
+
return {
|
|
25
|
+
MetricName: point.metricName,
|
|
26
|
+
Dimensions: point.attributes.map(([Name, Value]) => ({ Name, Value })),
|
|
27
|
+
Value: point.value,
|
|
28
|
+
...(unit ? { Unit: unit } : {}),
|
|
29
|
+
Timestamp: new Date(point.occurredUtc.getTime())
|
|
30
|
+
};
|
|
31
|
+
})
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function encodeDynatraceMetrics(points, staticDimensions = {}) {
|
|
35
|
+
const prepared = prepareProtocolMetricPoints("dynatrace", points, staticDimensions);
|
|
36
|
+
return encodeDynatracePreparedMetrics(prepared);
|
|
37
|
+
}
|
|
38
|
+
function encodeDynatraceMetricBatches(points, staticDimensions = {}, maximumBodyBytesExclusive = 1000000) {
|
|
39
|
+
const prepared = prepareProtocolMetricPoints("dynatrace", points, staticDimensions);
|
|
40
|
+
return encodeTextBatches("dynatrace", "text/plain; charset=utf-8", prepared.map((point) => dynatraceMetricLine(point)), "", "", "\n", maximumBodyBytesExclusive);
|
|
41
|
+
}
|
|
42
|
+
function encodeNewRelicMetricBatch(points, staticAttributes = {}) {
|
|
43
|
+
const prepared = prepareProtocolMetricPoints("newrelic", points, staticAttributes);
|
|
44
|
+
return encodeNewRelicPreparedMetrics(prepared);
|
|
45
|
+
}
|
|
46
|
+
function encodeNewRelicMetricBatches(points, staticAttributes = {}, maximumBodyBytesExclusive = 1000000) {
|
|
47
|
+
const prepared = prepareProtocolMetricPoints("newrelic", points, staticAttributes);
|
|
48
|
+
return encodeTextBatches("newrelic", "application/json", prepared.map((point) => newRelicMetricJson(point)), '[{"metrics":[', "]}]", ",", maximumBodyBytesExclusive);
|
|
49
|
+
}
|
|
50
|
+
function prepareProtocolMetricPoints(sink, points, staticAttributes = {}) {
|
|
51
|
+
if (!Array.isArray(points) || points.length === 0) {
|
|
52
|
+
throw protocolError(sink, "metric batch");
|
|
53
|
+
}
|
|
54
|
+
if (!isPlainRecord(staticAttributes)) {
|
|
55
|
+
throw protocolError(sink, "attribute collection");
|
|
56
|
+
}
|
|
57
|
+
// Validate and copy every point before any protocol bytes or DTOs are emitted.
|
|
58
|
+
const prepared = [];
|
|
59
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
60
|
+
prepared.push(prepareProtocolMetricPoint(sink, points[index], staticAttributes));
|
|
61
|
+
}
|
|
62
|
+
return prepared;
|
|
63
|
+
}
|
|
64
|
+
function prepareProtocolMetricPoint(sink, point, staticAttributes) {
|
|
65
|
+
if (!point || typeof point !== "object") {
|
|
66
|
+
throw protocolError(sink, "metric point");
|
|
67
|
+
}
|
|
68
|
+
const metricKind = normalizeMetricKind(sink, point.metricKind);
|
|
69
|
+
if (!(point.occurredUtc instanceof Date) || !Number.isFinite(point.occurredUtc.getTime())) {
|
|
70
|
+
throw protocolError(sink, "timestamp");
|
|
71
|
+
}
|
|
72
|
+
if (!Number.isFinite(point.value) || point.value < -MAXIMUM_ABSOLUTE_VALUE || point.value > MAXIMUM_ABSOLUTE_VALUE) {
|
|
73
|
+
throw protocolError(sink, "value");
|
|
74
|
+
}
|
|
75
|
+
const intervalMs = metricKind === "count"
|
|
76
|
+
? normalizePositiveInt64(sink, point.intervalMs)
|
|
77
|
+
: undefined;
|
|
78
|
+
if (metricKind === "gauge" && point.intervalMs !== undefined) {
|
|
79
|
+
throw protocolError(sink, "interval");
|
|
80
|
+
}
|
|
81
|
+
const rawUnitOfMeasure = point.unitOfMeasure;
|
|
82
|
+
let unitOfMeasure;
|
|
83
|
+
if (rawUnitOfMeasure !== undefined && rawUnitOfMeasure !== null) {
|
|
84
|
+
if (typeof rawUnitOfMeasure !== "string" || !isWellFormedUnicode(rawUnitOfMeasure)) {
|
|
85
|
+
throw protocolError(sink, "unit of measure");
|
|
86
|
+
}
|
|
87
|
+
unitOfMeasure = rawUnitOfMeasure;
|
|
88
|
+
}
|
|
89
|
+
const metricName = normalizeMetricName(sink, point.metricName);
|
|
90
|
+
const authoritative = [
|
|
91
|
+
["scenario", validateAuthoritativeValue(sink, "scenario", point.scenario)],
|
|
92
|
+
["status", validateAuthoritativeValue(sink, "status", point.status)]
|
|
93
|
+
];
|
|
94
|
+
if (point.runId !== undefined) {
|
|
95
|
+
authoritative.push(["run_id", validateAuthoritativeValue(sink, "run_id", point.runId)]);
|
|
96
|
+
}
|
|
97
|
+
const emptyValuesAllowed = sink === "prometheus" || sink === "newrelic";
|
|
98
|
+
const normalizedStatic = new Map();
|
|
99
|
+
for (const [rawKey, rawValue] of Object.entries(staticAttributes)) {
|
|
100
|
+
const key = normalizeAttributeKey(sink, rawKey);
|
|
101
|
+
if (RESERVED_ATTRIBUTES.has(key)) {
|
|
102
|
+
throw protocolError(sink, key);
|
|
103
|
+
}
|
|
104
|
+
if (sink === "prometheus" && key === "__name__") {
|
|
105
|
+
throw protocolError(sink, "attribute key");
|
|
106
|
+
}
|
|
107
|
+
if (normalizedStatic.has(key)) {
|
|
108
|
+
throw protocolError(sink, "attribute key collision");
|
|
109
|
+
}
|
|
110
|
+
if (typeof rawValue !== "string" || !isWellFormedUnicode(rawValue) ||
|
|
111
|
+
(!emptyValuesAllowed && rawValue.length === 0) ||
|
|
112
|
+
utf8Length(rawValue) > maximumAttributeValueBytes(sink) ||
|
|
113
|
+
(sink === "dynatrace" && /[\r\n]/.test(rawValue)) ||
|
|
114
|
+
(sink === "cloudwatch" && !isCloudWatchDimensionValue(rawValue))) {
|
|
115
|
+
throw protocolError(sink, "attribute value");
|
|
116
|
+
}
|
|
117
|
+
normalizedStatic.set(key, rawValue);
|
|
118
|
+
}
|
|
119
|
+
if (authoritative.length + normalizedStatic.size > MAXIMUM_ATTRIBUTES) {
|
|
120
|
+
throw protocolError(sink, "attribute count");
|
|
121
|
+
}
|
|
122
|
+
const attributes = [...authoritative, ...normalizedStatic.entries()]
|
|
123
|
+
.sort(([left], [right]) => compareOrdinal(left, right));
|
|
124
|
+
return {
|
|
125
|
+
metricName,
|
|
126
|
+
metricKind,
|
|
127
|
+
occurredUtc: new Date(point.occurredUtc.getTime()),
|
|
128
|
+
value: point.value,
|
|
129
|
+
unitOfMeasure,
|
|
130
|
+
intervalMs,
|
|
131
|
+
attributes
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function encodeDynatracePreparedMetrics(prepared) {
|
|
135
|
+
return {
|
|
136
|
+
contentType: "text/plain; charset=utf-8",
|
|
137
|
+
body: new TextEncoder().encode(prepared.map((point) => dynatraceMetricLine(point)).join("\n"))
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function dynatraceMetricLine(point) {
|
|
141
|
+
const dimensions = point.attributes
|
|
142
|
+
.map(([key, value]) => `${key}="${escapeDynatraceDimensionValue(value)}"`)
|
|
143
|
+
.join(",");
|
|
144
|
+
const identity = dimensions.length > 0 ? `${point.metricName},${dimensions}` : point.metricName;
|
|
145
|
+
const payload = point.metricKind === "count"
|
|
146
|
+
? `count,delta=${formatFiniteNumber(point.value)}`
|
|
147
|
+
: `gauge,${formatFiniteNumber(point.value)}`;
|
|
148
|
+
return `${identity} ${payload} ${point.occurredUtc.getTime()}`;
|
|
149
|
+
}
|
|
150
|
+
function encodeNewRelicPreparedMetrics(prepared) {
|
|
151
|
+
return {
|
|
152
|
+
contentType: "application/json",
|
|
153
|
+
body: new TextEncoder().encode(`[{"metrics":[${prepared.map((point) => newRelicMetricJson(point)).join(",")}]}]`)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function newRelicMetricJson(point) {
|
|
157
|
+
const properties = [
|
|
158
|
+
`"name":${JSON.stringify(point.metricName)}`,
|
|
159
|
+
`"type":${JSON.stringify(point.metricKind)}`,
|
|
160
|
+
`"value":${JSON.stringify(point.value)}`,
|
|
161
|
+
`"timestamp":${point.occurredUtc.getTime()}`
|
|
162
|
+
];
|
|
163
|
+
if (point.metricKind === "count") {
|
|
164
|
+
if (point.intervalMs === undefined) {
|
|
165
|
+
throw protocolError("newrelic", "interval");
|
|
166
|
+
}
|
|
167
|
+
properties.push(`"interval.ms":${point.intervalMs.toString()}`);
|
|
168
|
+
}
|
|
169
|
+
properties.push(`"attributes":${JSON.stringify(Object.fromEntries(point.attributes))}`);
|
|
170
|
+
return `{${properties.join(",")}}`;
|
|
171
|
+
}
|
|
172
|
+
function encodeTextBatches(sink, contentType, items, prefix, suffix, separator, maximumBodyBytesExclusive) {
|
|
173
|
+
if (!Number.isSafeInteger(maximumBodyBytesExclusive) || maximumBodyBytesExclusive <= 0) {
|
|
174
|
+
throw protocolError(sink, "payload size limit");
|
|
175
|
+
}
|
|
176
|
+
const prefixBytes = utf8Length(prefix);
|
|
177
|
+
const suffixBytes = utf8Length(suffix);
|
|
178
|
+
const separatorBytes = utf8Length(separator);
|
|
179
|
+
const batches = [];
|
|
180
|
+
let current = [];
|
|
181
|
+
let currentBytes = prefixBytes + suffixBytes;
|
|
182
|
+
for (const item of items) {
|
|
183
|
+
const itemBytes = utf8Length(item);
|
|
184
|
+
if (prefixBytes + itemBytes + suffixBytes >= maximumBodyBytesExclusive) {
|
|
185
|
+
throw protocolError(sink, "payload size");
|
|
186
|
+
}
|
|
187
|
+
const candidateBytes = currentBytes + (current.length > 0 ? separatorBytes : 0) + itemBytes;
|
|
188
|
+
if (candidateBytes >= maximumBodyBytesExclusive) {
|
|
189
|
+
batches.push(current);
|
|
190
|
+
current = [item];
|
|
191
|
+
currentBytes = prefixBytes + itemBytes + suffixBytes;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
current.push(item);
|
|
195
|
+
currentBytes = candidateBytes;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (current.length > 0) {
|
|
199
|
+
batches.push(current);
|
|
200
|
+
}
|
|
201
|
+
const encoder = new TextEncoder();
|
|
202
|
+
return batches.map((batch) => ({
|
|
203
|
+
contentType,
|
|
204
|
+
body: encoder.encode(`${prefix}${batch.join(separator)}${suffix}`)
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
function normalizePositiveInt64(sink, value) {
|
|
208
|
+
let normalized;
|
|
209
|
+
if (typeof value === "bigint") {
|
|
210
|
+
normalized = value;
|
|
211
|
+
}
|
|
212
|
+
else if (typeof value === "number" && Number.isSafeInteger(value)) {
|
|
213
|
+
normalized = BigInt(value);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
throw protocolError(sink, "interval");
|
|
217
|
+
}
|
|
218
|
+
if (normalized <= 0n || normalized > MAXIMUM_SIGNED_INT64) {
|
|
219
|
+
throw protocolError(sink, "interval");
|
|
220
|
+
}
|
|
221
|
+
return normalized;
|
|
222
|
+
}
|
|
223
|
+
function normalizeMetricName(sink, value) {
|
|
224
|
+
if (typeof value !== "string" || !isWellFormedUnicode(value) || value.trim().length === 0) {
|
|
225
|
+
throw protocolError(sink, "metric name");
|
|
226
|
+
}
|
|
227
|
+
let normalized = sink === "prometheus"
|
|
228
|
+
? normalizeAsciiIdentifier(value.trim(), true)
|
|
229
|
+
: sink === "dynatrace"
|
|
230
|
+
? normalizeAsciiIdentifier(value.trim(), false)
|
|
231
|
+
: value.trim();
|
|
232
|
+
if (sink === "dynatrace" && utf8Length(normalized) < 3) {
|
|
233
|
+
normalized = `ls_${normalized}`;
|
|
234
|
+
}
|
|
235
|
+
if (normalized.length === 0 || utf8Length(normalized) > MAXIMUM_METRIC_NAME_BYTES) {
|
|
236
|
+
throw protocolError(sink, "metric name");
|
|
237
|
+
}
|
|
238
|
+
return normalized;
|
|
239
|
+
}
|
|
240
|
+
function normalizeMetricKind(sink, value) {
|
|
241
|
+
if (typeof value !== "string" || !isWellFormedUnicode(value)) {
|
|
242
|
+
throw protocolError(sink, "metric kind");
|
|
243
|
+
}
|
|
244
|
+
let normalized = "";
|
|
245
|
+
for (const character of value.trim()) {
|
|
246
|
+
normalized += character >= "A" && character <= "Z"
|
|
247
|
+
? String.fromCharCode(character.charCodeAt(0) + 0x20)
|
|
248
|
+
: character;
|
|
249
|
+
}
|
|
250
|
+
if (normalized !== "count" && normalized !== "gauge") {
|
|
251
|
+
throw protocolError(sink, "metric kind");
|
|
252
|
+
}
|
|
253
|
+
return normalized;
|
|
254
|
+
}
|
|
255
|
+
function normalizeAttributeKey(sink, value) {
|
|
256
|
+
if (typeof value !== "string" || !isWellFormedUnicode(value) || value.trim().length === 0) {
|
|
257
|
+
throw protocolError(sink, "attribute key");
|
|
258
|
+
}
|
|
259
|
+
if (sink === "cloudwatch" && !isPrintableASCII(value)) {
|
|
260
|
+
throw protocolError(sink, "attribute key");
|
|
261
|
+
}
|
|
262
|
+
let normalized;
|
|
263
|
+
if (sink === "prometheus")
|
|
264
|
+
normalized = normalizeAsciiIdentifier(value.trim(), false);
|
|
265
|
+
else if (sink === "dynatrace")
|
|
266
|
+
normalized = normalizeAsciiIdentifier(value.trim(), false).toLowerCase();
|
|
267
|
+
else if (sink === "newrelic")
|
|
268
|
+
normalized = normalizeNewRelicAttributeKey(value.trim());
|
|
269
|
+
else
|
|
270
|
+
normalized = value.trim();
|
|
271
|
+
if (normalized.length === 0 || utf8Length(normalized) > MAXIMUM_ATTRIBUTE_KEY_BYTES) {
|
|
272
|
+
throw protocolError(sink, "attribute key");
|
|
273
|
+
}
|
|
274
|
+
if (sink === "cloudwatch" && !isCloudWatchDimensionName(normalized)) {
|
|
275
|
+
throw protocolError(sink, "attribute key");
|
|
276
|
+
}
|
|
277
|
+
return normalized;
|
|
278
|
+
}
|
|
279
|
+
function normalizeAsciiIdentifier(value, allowColon) {
|
|
280
|
+
let normalized = "";
|
|
281
|
+
for (const character of value) {
|
|
282
|
+
normalized += /[A-Za-z0-9_]/.test(character) || (allowColon && character === ":")
|
|
283
|
+
? character
|
|
284
|
+
: "_";
|
|
285
|
+
}
|
|
286
|
+
if (!/^[A-Za-z_]/.test(normalized) && !(allowColon && normalized.startsWith(":"))) {
|
|
287
|
+
normalized = `_${normalized}`;
|
|
288
|
+
}
|
|
289
|
+
return normalized;
|
|
290
|
+
}
|
|
291
|
+
function validateAuthoritativeValue(sink, field, value) {
|
|
292
|
+
if (typeof value !== "string" || !isWellFormedUnicode(value) ||
|
|
293
|
+
value.trim().length === 0 || utf8Length(value) > maximumAttributeValueBytes(sink) ||
|
|
294
|
+
(sink === "dynatrace" && /[\r\n]/.test(value)) ||
|
|
295
|
+
(sink === "cloudwatch" && !isCloudWatchDimensionValue(value))) {
|
|
296
|
+
throw protocolError(sink, field);
|
|
297
|
+
}
|
|
298
|
+
return value;
|
|
299
|
+
}
|
|
300
|
+
function normalizeCloudWatchNamespace(value) {
|
|
301
|
+
if (typeof value !== "string" || !isWellFormedUnicode(value)) {
|
|
302
|
+
throw protocolError("cloudwatch", "namespace");
|
|
303
|
+
}
|
|
304
|
+
const normalized = value.trim();
|
|
305
|
+
if (utf8Length(normalized) < 1 || utf8Length(normalized) > 100 || normalized.startsWith(":")) {
|
|
306
|
+
throw protocolError("cloudwatch", "namespace");
|
|
307
|
+
}
|
|
308
|
+
return normalized;
|
|
309
|
+
}
|
|
310
|
+
function cloudWatchUnit(metricKind, unit) {
|
|
311
|
+
if (metricKind === "count")
|
|
312
|
+
return "Count";
|
|
313
|
+
switch (unit?.trim().toLowerCase()) {
|
|
314
|
+
case "count":
|
|
315
|
+
case "counts": return "Count";
|
|
316
|
+
case "ms":
|
|
317
|
+
case "millisecond":
|
|
318
|
+
case "milliseconds": return "Milliseconds";
|
|
319
|
+
case "byte":
|
|
320
|
+
case "bytes": return "Bytes";
|
|
321
|
+
case "s":
|
|
322
|
+
case "sec":
|
|
323
|
+
case "seconds":
|
|
324
|
+
case "second": return "Seconds";
|
|
325
|
+
default: return "None";
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function escapeDynatraceDimensionValue(value) {
|
|
329
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
330
|
+
}
|
|
331
|
+
function formatFiniteNumber(value) {
|
|
332
|
+
return Object.is(value, -0) ? "0" : String(value);
|
|
333
|
+
}
|
|
334
|
+
function utf8Length(value) {
|
|
335
|
+
return new TextEncoder().encode(value).byteLength;
|
|
336
|
+
}
|
|
337
|
+
function isWellFormedUnicode(value) {
|
|
338
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
339
|
+
const codeUnit = value.charCodeAt(index);
|
|
340
|
+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
|
341
|
+
if (index + 1 >= value.length)
|
|
342
|
+
return false;
|
|
343
|
+
const next = value.charCodeAt(index + 1);
|
|
344
|
+
if (next < 0xdc00 || next > 0xdfff)
|
|
345
|
+
return false;
|
|
346
|
+
index += 1;
|
|
347
|
+
}
|
|
348
|
+
else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
function isPlainRecord(value) {
|
|
355
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
356
|
+
return false;
|
|
357
|
+
const prototype = Object.getPrototypeOf(value);
|
|
358
|
+
return prototype === Object.prototype || prototype === null;
|
|
359
|
+
}
|
|
360
|
+
function normalizeNewRelicAttributeKey(value) {
|
|
361
|
+
let normalized = "";
|
|
362
|
+
for (const character of value) {
|
|
363
|
+
normalized += /[A-Za-z0-9:._]/.test(character) ? character : "_";
|
|
364
|
+
}
|
|
365
|
+
return normalized;
|
|
366
|
+
}
|
|
367
|
+
function maximumAttributeValueBytes(sink) {
|
|
368
|
+
return sink === "dynatrace" ? MAXIMUM_DYNATRACE_ATTRIBUTE_VALUE_BYTES : MAXIMUM_ATTRIBUTE_VALUE_BYTES;
|
|
369
|
+
}
|
|
370
|
+
function isCloudWatchDimensionName(value) {
|
|
371
|
+
return !value.startsWith(":") && isPrintableASCII(value) && value.trim().length > 0;
|
|
372
|
+
}
|
|
373
|
+
function isCloudWatchDimensionValue(value) {
|
|
374
|
+
return isPrintableASCII(value) && value.trim().length > 0;
|
|
375
|
+
}
|
|
376
|
+
function isPrintableASCII(value) {
|
|
377
|
+
for (const character of value) {
|
|
378
|
+
const code = character.charCodeAt(0);
|
|
379
|
+
if (code < 0x20 || code > 0x7e)
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
function protocolError(sink, field) {
|
|
385
|
+
const displayName = sink === "newrelic" ? "New Relic" : sink === "cloudwatch" ? "CloudWatch" : sink;
|
|
386
|
+
return new Error(`${displayName} reporting metric has an invalid ${field}.`);
|
|
387
|
+
}
|
|
388
|
+
function compareOrdinal(left, right) {
|
|
389
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
390
|
+
}
|
|
@@ -156,7 +156,7 @@ class ProcessIterationObservationBuffer {
|
|
|
156
156
|
exports.ProcessIterationObservationBuffer = ProcessIterationObservationBuffer;
|
|
157
157
|
const processIterationObservationBuffer = new ProcessIterationObservationBuffer();
|
|
158
158
|
class SinkDispatcher {
|
|
159
|
-
constructor(target, depth, parallelism, retryCount, retryBackoffMs, logger, runId, resultOwnerId, onDropped) {
|
|
159
|
+
constructor(target, depth, parallelism, retryCount, retryBackoffMs, logger, runId, resultOwnerId, retryableErrorClassifier, onDropped) {
|
|
160
160
|
this.target = target;
|
|
161
161
|
this.depth = depth;
|
|
162
162
|
this.parallelism = parallelism;
|
|
@@ -165,6 +165,7 @@ class SinkDispatcher {
|
|
|
165
165
|
this.logger = logger;
|
|
166
166
|
this.runId = runId;
|
|
167
167
|
this.resultOwnerId = resultOwnerId;
|
|
168
|
+
this.retryableErrorClassifier = retryableErrorClassifier;
|
|
168
169
|
this.onDropped = onDropped;
|
|
169
170
|
this.queue = [];
|
|
170
171
|
this.active = new Set();
|
|
@@ -277,12 +278,14 @@ class SinkDispatcher {
|
|
|
277
278
|
return "success";
|
|
278
279
|
}
|
|
279
280
|
catch (error) {
|
|
280
|
-
const
|
|
281
|
+
const retryable = this.isRetryableError(error);
|
|
282
|
+
const exhausted = attempt >= maximumAttempts || !retryable;
|
|
283
|
+
const reportedMaximumAttempts = retryable ? maximumAttempts : attempt;
|
|
281
284
|
const nextDelayMs = exhausted
|
|
282
285
|
? 0
|
|
283
286
|
: (0, sink_retry_policy_js_1.sinkRetryDelayMs)(this.retryBackoffMs, attempt);
|
|
284
287
|
(0, iteration_observation_diagnostics_js_1.logIterationObservationFailure)(this.logger, exhausted ? "error" : "warn", {
|
|
285
|
-
...this.diagnosticContext(item, attempt,
|
|
288
|
+
...this.diagnosticContext(item, attempt, reportedMaximumAttempts),
|
|
286
289
|
nextDelayMs
|
|
287
290
|
}, error);
|
|
288
291
|
if (exhausted) {
|
|
@@ -296,6 +299,17 @@ class SinkDispatcher {
|
|
|
296
299
|
}
|
|
297
300
|
return "exhausted";
|
|
298
301
|
}
|
|
302
|
+
isRetryableError(error) {
|
|
303
|
+
if (!this.retryableErrorClassifier) {
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
return this.retryableErrorClassifier(error) !== false;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
299
313
|
diagnosticContext(item, attempt, maximumAttempts) {
|
|
300
314
|
return {
|
|
301
315
|
sinkName: this.target.name,
|
|
@@ -420,7 +434,7 @@ class IterationObservationReporter {
|
|
|
420
434
|
this.recordWarning("sink_iteration_batches_unsupported", sink.name, "", -1, 1n, "The reporting sink does not support raw iteration batches.");
|
|
421
435
|
return false;
|
|
422
436
|
})
|
|
423
|
-
.map((target) => new SinkDispatcher(target, this.settings.sinkQueueDepth, this.settings.sinkParallelism, (0, sink_retry_policy_js_1.normalizeSinkRetryCount)(options.sinkRetryCount), (0, sink_retry_policy_js_1.normalizeSinkRetryBackoffMs)(options.sinkRetryBackoffMs), options.logger, options.runId, options.resultOwnerId, (code, sinkName, observations) => this.recordSinkDrop(code, sinkName, observations)));
|
|
437
|
+
.map((target) => new SinkDispatcher(target, this.settings.sinkQueueDepth, this.settings.sinkParallelism, (0, sink_retry_policy_js_1.normalizeSinkRetryCount)(options.sinkRetryCount), (0, sink_retry_policy_js_1.normalizeSinkRetryBackoffMs)(options.sinkRetryBackoffMs), options.logger, options.runId, options.resultOwnerId, target.retryableErrorClassifier, (code, sinkName, observations) => this.recordSinkDrop(code, sinkName, observations)));
|
|
424
438
|
if (this.dispatchers.length > 0) {
|
|
425
439
|
this.buffer.register(this.streamKey, this.settings.maxBufferBytes);
|
|
426
440
|
}
|
|
@@ -498,7 +512,7 @@ class IterationObservationReporter {
|
|
|
498
512
|
&& dispatcher.droppedCount === 0n
|
|
499
513
|
&& dispatcher.deliveredCount === this.captured;
|
|
500
514
|
const completion = this.createCompletion(dispatcher, sinkReportingComplete);
|
|
501
|
-
const outcome = await this.completeWithRetries(complete, completion, deadline, dispatcher.target.name);
|
|
515
|
+
const outcome = await this.completeWithRetries(dispatcher, complete, completion, deadline, dispatcher.target.name);
|
|
502
516
|
if (outcome === "success") {
|
|
503
517
|
return true;
|
|
504
518
|
}
|
|
@@ -534,7 +548,7 @@ class IterationObservationReporter {
|
|
|
534
548
|
};
|
|
535
549
|
return { ...this.finalResult };
|
|
536
550
|
}
|
|
537
|
-
async completeWithRetries(complete, completion, deadline, sinkName) {
|
|
551
|
+
async completeWithRetries(dispatcher, complete, completion, deadline, sinkName) {
|
|
538
552
|
const retryCount = (0, sink_retry_policy_js_1.normalizeSinkRetryCount)(this.options.sinkRetryCount);
|
|
539
553
|
const backoffMs = (0, sink_retry_policy_js_1.normalizeSinkRetryBackoffMs)(this.options.sinkRetryBackoffMs);
|
|
540
554
|
const maximumAttempts = retryCount + 1;
|
|
@@ -573,7 +587,9 @@ class IterationObservationReporter {
|
|
|
573
587
|
if (outcome.kind === "timeout") {
|
|
574
588
|
return "timeout";
|
|
575
589
|
}
|
|
576
|
-
const
|
|
590
|
+
const retryable = dispatcher.isRetryableError(outcome.error);
|
|
591
|
+
const exhausted = attempt >= maximumAttempts || !retryable;
|
|
592
|
+
const reportedMaximumAttempts = retryable ? maximumAttempts : attempt;
|
|
577
593
|
const nextDelayMs = exhausted ? 0 : (0, sink_retry_policy_js_1.sinkRetryDelayMs)(backoffMs, attempt);
|
|
578
594
|
(0, iteration_observation_diagnostics_js_1.logIterationObservationFailure)(this.options.logger, exhausted ? "error" : "warn", {
|
|
579
595
|
sinkName,
|
|
@@ -582,7 +598,7 @@ class IterationObservationReporter {
|
|
|
582
598
|
resultOwnerId: this.options.resultOwnerId,
|
|
583
599
|
observationCount: 0,
|
|
584
600
|
attempt,
|
|
585
|
-
maximumAttempts,
|
|
601
|
+
maximumAttempts: reportedMaximumAttempts,
|
|
586
602
|
nextDelayMs
|
|
587
603
|
}, outcome.error);
|
|
588
604
|
if (exhausted) {
|