@loadstrike/loadstrike-sdk 1.0.23001 → 1.0.23201

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/dist/cjs/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.InfluxDbReportingSink = exports.GrafanaLokiReportingSinkOptions = exports.GrafanaLokiReportingSink = exports.DatadogReportingSinkOptions = exports.DatadogReportingSink = exports.HttpAuthOptions = exports.HttpOAuth2ClientCredentialsOptions = exports.PushDiffusionEndpointDefinition = exports.DelegateStreamEndpointDefinition = exports.SqsEndpointDefinition = exports.AzureEventHubsEndpointDefinition = exports.RedisStreamsEndpointDefinition = exports.NatsEndpointDefinition = exports.RabbitMqEndpointDefinition = exports.KafkaSaslOptions = exports.KafkaEndpointDefinition = exports.HttpEndpointDefinition = exports.TrafficEndpointDefinition = exports.LOADSTRIKE_TRACE_ID_TRACKING_FIELD = exports.LOADSTRIKE_TRACE_ID_HEADER = exports.TrackingFieldSelector = exports.TrackingPayloadBuilder = exports.RedisCorrelationStore = exports.RedisCorrelationStoreOptions = exports.InMemoryCorrelationStore = exports.CrossPlatformTrackingRuntime = exports.CorrelationStoreConfiguration = exports.LoadStrikeThreshold = exports.LoadStrikeStep = exports.LoadStrikeGauge = exports.LoadStrikeCounter = exports.LoadStrikeMetric = exports.CrossPlatformTrackingConfiguration = exports.LoadStrikeSimulation = exports.LoadStrikeScenario = exports.LoadStrikeRunner = exports.LoadStrikeOperationType = exports.LoadStrikeScenarioOperation = exports.LoadStrikeLogLevel = exports.LoadStrikeResponse = exports.LoadStrikeReportFormat = exports.LoadStrikeNodeType = exports.LoadStrikePluginDataTable = exports.LoadStrikePluginData = exports.LoadStrikeContext = exports.ScenarioTrackingExtensions = exports.CrossPlatformScenarioConfigurator = exports.LoadStrikeAutopilotReadiness = exports.LoadStrikeAutopilotResult = exports.LoadStrikeAutopilot = void 0;
4
- exports.TimescaleDbReportingSinkOptions = exports.TimescaleDbReportingSink = exports.SplunkReportingSinkOptions = exports.SplunkReportingSink = exports.OtelCollectorReportingSinkOptions = exports.OtelCollectorReportingSink = exports.InfluxDbReportingSinkOptions = void 0;
4
+ exports.TimescaleDbReportingSinkOptions = exports.TimescaleDbReportingSink = exports.SplunkReportingSinkOptions = exports.SplunkReportingSink = exports.PortalReportingSink = exports.OtelCollectorReportingSinkOptions = exports.OtelCollectorReportingSink = exports.InfluxDbReportingSinkOptions = void 0;
5
5
  var autopilot_js_1 = require("./autopilot.js");
6
6
  Object.defineProperty(exports, "LoadStrikeAutopilot", { enumerable: true, get: function () { return autopilot_js_1.LoadStrikeAutopilot; } });
7
7
  Object.defineProperty(exports, "LoadStrikeAutopilotResult", { enumerable: true, get: function () { return autopilot_js_1.LoadStrikeAutopilotResult; } });
@@ -61,6 +61,7 @@ Object.defineProperty(exports, "InfluxDbReportingSink", { enumerable: true, get:
61
61
  Object.defineProperty(exports, "InfluxDbReportingSinkOptions", { enumerable: true, get: function () { return sinks_js_1.InfluxDbReportingSinkOptions; } });
62
62
  Object.defineProperty(exports, "OtelCollectorReportingSink", { enumerable: true, get: function () { return sinks_js_1.OtelCollectorReportingSink; } });
63
63
  Object.defineProperty(exports, "OtelCollectorReportingSinkOptions", { enumerable: true, get: function () { return sinks_js_1.OtelCollectorReportingSinkOptions; } });
64
+ Object.defineProperty(exports, "PortalReportingSink", { enumerable: true, get: function () { return sinks_js_1.PortalReportingSink; } });
64
65
  Object.defineProperty(exports, "SplunkReportingSink", { enumerable: true, get: function () { return sinks_js_1.SplunkReportingSink; } });
65
66
  Object.defineProperty(exports, "SplunkReportingSinkOptions", { enumerable: true, get: function () { return sinks_js_1.SplunkReportingSinkOptions; } });
66
67
  Object.defineProperty(exports, "TimescaleDbReportingSink", { enumerable: true, get: function () { return sinks_js_1.TimescaleDbReportingSink; } });
package/dist/cjs/local.js CHANGED
@@ -55,7 +55,8 @@ const SINK_FEATURE_BY_KIND = {
55
55
  grafanaloki: "extensions.reporting_sinks.grafana_loki",
56
56
  datadog: "extensions.reporting_sinks.datadog",
57
57
  splunk: "extensions.reporting_sinks.splunk",
58
- otelcollector: "extensions.reporting_sinks.otel_collector"
58
+ otelcollector: "extensions.reporting_sinks.otel_collector",
59
+ portal: "extensions.reporting_sinks.portal"
59
60
  };
60
61
  const TRACKING_FEATURE_BY_KIND = {
61
62
  http: "endpoint.http",
@@ -96,6 +97,9 @@ class LoadStrikeLocalClient {
96
97
  this.licensingApiBaseUrl = resolveLicensingApiBaseUrl();
97
98
  this.licenseValidationTimeoutMs = normalizeTimeoutMs(options.licenseValidationTimeoutMs);
98
99
  }
100
+ portalReportingIngestUrl() {
101
+ return `${this.licensingApiBaseUrl.replace(/\/+$/, "")}/api/v1/reporting/ingest`;
102
+ }
99
103
  async run(request) {
100
104
  const sanitized = sanitizeRequest(request);
101
105
  const licenseSession = await this.acquireLicenseLease(sanitized);
@@ -13,6 +13,7 @@ const cluster_js_1 = require("./cluster.js");
13
13
  const correlation_js_1 = require("./correlation.js");
14
14
  const transports_js_1 = require("./transports.js");
15
15
  const reporting_js_1 = require("./reporting.js");
16
+ const sinks_js_1 = require("./sinks.js");
16
17
  exports.LoadStrikeNodeType = {
17
18
  SingleNode: "SingleNode",
18
19
  Coordinator: "Coordinator",
@@ -1395,6 +1396,16 @@ class LoadStrikeContext {
1395
1396
  validateNamedReportingSinks(sinks);
1396
1397
  return this.mergeValues({ ReportingSinks: sinks });
1397
1398
  }
1399
+ withPortalReporting() {
1400
+ return this.WithPortalReporting();
1401
+ }
1402
+ WithPortalReporting() {
1403
+ const sinks = [...(this.values.ReportingSinks ?? [])];
1404
+ if (!sinks.some((sink, index) => resolveSinkName(sink, index).trim().toLowerCase() === "portal")) {
1405
+ sinks.push(new sinks_js_1.PortalReportingSink());
1406
+ }
1407
+ return this.mergeValues({ ReportingSinks: sinks });
1408
+ }
1398
1409
  /**
1399
1410
  * Registers runtime policies for the run.
1400
1411
  * Use this when scenario selection or step execution should obey policy callbacks.
@@ -2137,6 +2148,9 @@ class LoadStrikeRunner {
2137
2148
  static WithReportingSinks(context, ...sinks) {
2138
2149
  return context.WithReportingSinks(...sinks);
2139
2150
  }
2151
+ static WithPortalReporting(context) {
2152
+ return context.WithPortalReporting();
2153
+ }
2140
2154
  /**
2141
2155
  * Registers runtime policies for the run.
2142
2156
  * Use this when scenario selection or step execution should obey policy callbacks.
@@ -2365,6 +2379,29 @@ class LoadStrikeRunner {
2365
2379
  WithReportingInterval(intervalSeconds) {
2366
2380
  return this.withReportingInterval(intervalSeconds);
2367
2381
  }
2382
+ withReportingSinks(...sinks) {
2383
+ if (!sinks.length) {
2384
+ throw new Error("At least one reporting sink should be provided.");
2385
+ }
2386
+ if (sinks.some((sink) => sink == null)) {
2387
+ throw new Error("Reporting sink collection cannot contain null values.");
2388
+ }
2389
+ validateNamedReportingSinks(sinks);
2390
+ return this.configure({ reportingSinks: sinks });
2391
+ }
2392
+ WithReportingSinks(...sinks) {
2393
+ return this.withReportingSinks(...sinks);
2394
+ }
2395
+ withPortalReporting() {
2396
+ const sinks = [...(this.options.reportingSinks ?? [])];
2397
+ if (!sinks.some((sink, index) => resolveSinkName(sink, index).trim().toLowerCase() === "portal")) {
2398
+ sinks.push(new sinks_js_1.PortalReportingSink());
2399
+ }
2400
+ return this.configure({ reportingSinks: sinks });
2401
+ }
2402
+ WithPortalReporting() {
2403
+ return this.withPortalReporting();
2404
+ }
2368
2405
  /**
2369
2406
  * Sets the timeout for cluster command round-trips.
2370
2407
  * Use this when distributed control messages need a tighter or looser deadline.
@@ -2538,6 +2575,7 @@ class LoadStrikeRunner {
2538
2575
  testInfo,
2539
2576
  getNodeInfo: () => attachNodeInfoAliases({ ...nodeInfo })
2540
2577
  };
2578
+ attachPortalReportingSession(sinkSession, sessionInfo, licenseClient, licenseSession);
2541
2579
  attachSessionStartInfoAliases(sessionInfo);
2542
2580
  nodeInfo.currentOperation = "Init";
2543
2581
  for (const plugin of plugins) {
@@ -4072,10 +4110,23 @@ function attachSessionStartInfoAliases(session) {
4072
4110
  attachAliasMap(session, {
4073
4111
  StartedUtc: "startedUtc",
4074
4112
  ScenarioNames: "scenarioNames",
4075
- Scenarios: "scenarios"
4113
+ Scenarios: "scenarios",
4114
+ RunToken: "runToken",
4115
+ PortalReportingIngestUrl: "portalReportingIngestUrl"
4076
4116
  });
4077
4117
  return session;
4078
4118
  }
4119
+ function attachPortalReportingSession(sinkSession, sessionInfo, licenseClient, licenseSession) {
4120
+ const runToken = stringValueOrDefault(licenseSession?.runToken, "").trim();
4121
+ if (!runToken || !licenseClient) {
4122
+ return;
4123
+ }
4124
+ const ingestUrl = licenseClient.portalReportingIngestUrl();
4125
+ sinkSession.runToken = runToken;
4126
+ sinkSession.portalReportingIngestUrl = ingestUrl;
4127
+ sessionInfo.runToken = runToken;
4128
+ sessionInfo.portalReportingIngestUrl = ingestUrl;
4129
+ }
4079
4130
  function attachScenarioInitContextAliases(context) {
4080
4131
  context.nodeInfo = attachNodeInfoAliases(context.nodeInfo);
4081
4132
  context.testInfo = attachTestInfoAliases(context.testInfo);
package/dist/cjs/sinks.js CHANGED
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.__loadstrikeTestExports = exports.OtelCollectorReportingSink = exports.SplunkReportingSink = exports.DatadogReportingSink = exports.TimescaleDbReportingSink = exports.GrafanaLokiReportingSink = exports.InfluxDbReportingSink = exports.CompositeReportingSink = exports.ConsoleReportingSink = exports.MemoryReportingSink = exports.OtelCollectorReportingSinkOptions = exports.SplunkReportingSinkOptions = exports.DatadogReportingSinkOptions = exports.TimescaleDbReportingSinkOptions = exports.GrafanaLokiReportingSinkOptions = exports.InfluxDbReportingSinkOptions = void 0;
3
+ exports.__loadstrikeTestExports = exports.OtelCollectorReportingSink = exports.SplunkReportingSink = exports.DatadogReportingSink = exports.TimescaleDbReportingSink = exports.GrafanaLokiReportingSink = exports.InfluxDbReportingSink = exports.PortalReportingSink = exports.CompositeReportingSink = exports.ConsoleReportingSink = exports.MemoryReportingSink = exports.OtelCollectorReportingSinkOptions = exports.SplunkReportingSinkOptions = exports.DatadogReportingSinkOptions = exports.TimescaleDbReportingSinkOptions = exports.GrafanaLokiReportingSinkOptions = exports.InfluxDbReportingSinkOptions = void 0;
4
4
  const runtime_js_1 = require("./runtime.js");
5
+ const node_crypto_1 = require("node:crypto");
5
6
  const pg_1 = require("pg");
6
7
  const DEFAULT_INFLUX_CONFIGURATION_SECTION_PATH = "LoadStrike:ReportingSinks:InfluxDb";
7
8
  const DEFAULT_GRAFANA_LOKI_CONFIGURATION_SECTION_PATH = "LoadStrike:ReportingSinks:GrafanaLoki";
@@ -384,6 +385,92 @@ class CompositeReportingSink {
384
385
  }
385
386
  }
386
387
  exports.CompositeReportingSink = CompositeReportingSink;
388
+ class PortalReportingSink {
389
+ constructor(options = {}) {
390
+ this.sinkName = "portal";
391
+ this.SinkName = "portal";
392
+ this.licenseFeature = "extensions.reporting_sinks.portal";
393
+ this.LicenseFeature = "extensions.reporting_sinks.portal";
394
+ this.baseContext = null;
395
+ this.session = null;
396
+ this.runToken = "";
397
+ this.ingestUrl = "";
398
+ const source = asRecord(options);
399
+ this.timeoutMs = resolveTimeoutMs(optionNumber(source, "timeoutSeconds", "TimeoutSeconds"), optionNumber(source, "timeoutMs", "TimeoutMs"));
400
+ this.fetchImpl = pickRecordValue(source, "fetchImpl", "FetchImpl") ?? fetch;
401
+ }
402
+ init(context, _infraConfig) {
403
+ this.baseContext = cloneBaseContext(context);
404
+ }
405
+ Init(context, infraConfig) {
406
+ this.init(context, infraConfig);
407
+ }
408
+ start(session) {
409
+ this.runToken = String(session.runToken ?? session.RunToken ?? "").trim();
410
+ this.ingestUrl = String(session.portalReportingIngestUrl ?? session.PortalReportingIngestUrl ?? "").trim();
411
+ if (!this.runToken || !this.ingestUrl) {
412
+ throw new Error("PortalReportingSink requires a managed portal reporting session.");
413
+ }
414
+ this.session = sinkSessionMetadataFromContext(this.getBaseContext(), session);
415
+ }
416
+ Start(session) {
417
+ this.start(session);
418
+ }
419
+ async saveRealtimeStats(scenarioStats) {
420
+ await this.persistEvents(createRealtimeStatsEvents(this.getSession(), scenarioStats));
421
+ }
422
+ async SaveRealtimeStats(scenarioStats) {
423
+ await this.saveRealtimeStats(scenarioStats);
424
+ }
425
+ async saveRealtimeMetrics(metrics) {
426
+ await this.persistEvents(createRealtimeMetricEvents(this.getSession(), metrics));
427
+ }
428
+ async SaveRealtimeMetrics(metrics) {
429
+ await this.saveRealtimeMetrics(metrics);
430
+ }
431
+ async saveRunResult(result) {
432
+ await this.persistEvents(createRunResultEvents(this.getSession(), result));
433
+ }
434
+ async SaveRunResult(result) {
435
+ await this.saveRunResult(result);
436
+ }
437
+ stop() {
438
+ this.session = null;
439
+ }
440
+ Stop() {
441
+ this.stop();
442
+ }
443
+ Dispose() {
444
+ this.baseContext = null;
445
+ this.stop();
446
+ }
447
+ getBaseContext() {
448
+ if (!this.baseContext) {
449
+ throw new Error(`${this.sinkName} has not been initialized.`);
450
+ }
451
+ return this.baseContext;
452
+ }
453
+ getSession() {
454
+ if (!this.session) {
455
+ throw new Error(`${this.sinkName} has not been started.`);
456
+ }
457
+ return this.session;
458
+ }
459
+ async persistEvents(events) {
460
+ if (!events.length) {
461
+ return;
462
+ }
463
+ await postWithTimeout(this.fetchImpl, this.ingestUrl, {
464
+ method: "POST",
465
+ headers: { "Content-Type": "application/json" },
466
+ body: JSON.stringify({
467
+ runToken: this.runToken,
468
+ events: events.map((event, index) => portalEventPayload(event, index))
469
+ })
470
+ }, this.timeoutMs, "PortalReportingSink");
471
+ }
472
+ }
473
+ exports.PortalReportingSink = PortalReportingSink;
387
474
  class InfluxDbReportingSink {
388
475
  constructor(options = {}) {
389
476
  this.sinkName = "influxdb";
@@ -1546,6 +1633,37 @@ function createReportingEvent(session, occurredUtc, eventType, scenarioName, ste
1546
1633
  fields: eventFields
1547
1634
  };
1548
1635
  }
1636
+ function portalEventPayload(event, index) {
1637
+ return {
1638
+ eventId: portalEventId(event, index),
1639
+ runId: event.sessionId,
1640
+ eventType: event.eventType,
1641
+ occurredUtc: event.occurredUtc.toISOString(),
1642
+ sessionId: event.sessionId,
1643
+ testSuite: event.testSuite,
1644
+ testName: event.testName,
1645
+ clusterId: event.clusterId,
1646
+ nodeType: event.nodeType,
1647
+ machineName: event.machineName,
1648
+ scenarioName: event.scenarioName,
1649
+ stepName: event.stepName,
1650
+ tags: { ...event.tags },
1651
+ fields: deepCloneRecord(event.fields)
1652
+ };
1653
+ }
1654
+ function portalEventId(event, index) {
1655
+ const material = JSON.stringify({
1656
+ sessionId: event.sessionId,
1657
+ eventType: event.eventType,
1658
+ occurredUtc: event.occurredUtc.toISOString(),
1659
+ scenarioName: event.scenarioName ?? "",
1660
+ stepName: event.stepName ?? "",
1661
+ index,
1662
+ tags: event.tags,
1663
+ fields: event.fields
1664
+ });
1665
+ return `lsr_${(0, node_crypto_1.createHash)("sha256").update(material).digest("hex").slice(0, 40)}`;
1666
+ }
1549
1667
  function addMeasurementFields(fields, prefix, measurement) {
1550
1668
  const request = measurement?.request ?? { count: 0, percent: 0, rps: 0 };
1551
1669
  const latency = measurement?.latency ?? {
@@ -2571,7 +2689,9 @@ function cloneSessionStartInfo(session) {
2571
2689
  ...cloneBaseContext(session),
2572
2690
  startedUtc: session.startedUtc,
2573
2691
  scenarioNames: [...session.scenarioNames],
2574
- scenarios: session.scenarios.map((value) => ({ ...value }))
2692
+ scenarios: session.scenarios.map((value) => ({ ...value })),
2693
+ runToken: session.runToken,
2694
+ portalReportingIngestUrl: session.portalReportingIngestUrl
2575
2695
  };
2576
2696
  }
2577
2697
  function cloneNodeInfo(nodeInfo) {
@@ -2673,23 +2793,7 @@ function runResultToNodeStats(result) {
2673
2793
  disabledSinks: [...(result.disabledSinks ?? [])],
2674
2794
  sinkErrors: (result.sinkErrors ?? []).map((value) => ({ ...value })),
2675
2795
  reportFiles: [...(result.reportFiles ?? [])],
2676
- logFiles: [...(result.logFiles ?? [])],
2677
- findScenarioStats: (scenarioName) => result.scenarioStats.find((value) => value.scenarioName === scenarioName),
2678
- getScenarioStats: (scenarioName) => {
2679
- const scenario = result.scenarioStats.find((value) => value.scenarioName === scenarioName);
2680
- if (!scenario) {
2681
- throw new Error(`Scenario stats not found: ${scenarioName}`);
2682
- }
2683
- return scenario;
2684
- },
2685
- FindScenarioStats: (scenarioName) => result.scenarioStats.find((value) => value.scenarioName === scenarioName),
2686
- GetScenarioStats: (scenarioName) => {
2687
- const scenario = result.scenarioStats.find((value) => value.scenarioName === scenarioName);
2688
- if (!scenario) {
2689
- throw new Error(`Scenario stats not found: ${scenarioName}`);
2690
- }
2691
- return scenario;
2692
- }
2796
+ logFiles: [...(result.logFiles ?? [])]
2693
2797
  };
2694
2798
  }
2695
2799
  function deepCloneRecord(value) {
package/dist/esm/index.js CHANGED
@@ -3,4 +3,4 @@ export { LoadStrikeAutopilotReadiness } from "./autopilot-contracts.js";
3
3
  export { CrossPlatformScenarioConfigurator, ScenarioTrackingExtensions, LoadStrikeContext, LoadStrikePluginData, LoadStrikePluginDataTable, LoadStrikeNodeType, LoadStrikeReportFormat, LoadStrikeResponse, LoadStrikeLogLevel, LoadStrikeScenarioOperation, LoadStrikeOperationType, LoadStrikeRunner, LoadStrikeScenario, LoadStrikeSimulation, CrossPlatformTrackingConfiguration, LoadStrikeMetric, LoadStrikeCounter, LoadStrikeGauge, LoadStrikeStep, LoadStrikeThreshold } from "./runtime.js";
4
4
  export { CorrelationStoreConfiguration, CrossPlatformTrackingRuntime, InMemoryCorrelationStore, RedisCorrelationStoreOptions, RedisCorrelationStore, TrackingPayloadBuilder, TrackingFieldSelector } from "./correlation.js";
5
5
  export { LOADSTRIKE_TRACE_ID_HEADER, LOADSTRIKE_TRACE_ID_TRACKING_FIELD, TrafficEndpointDefinition, HttpEndpointDefinition, KafkaEndpointDefinition, KafkaSaslOptions, RabbitMqEndpointDefinition, NatsEndpointDefinition, RedisStreamsEndpointDefinition, AzureEventHubsEndpointDefinition, SqsEndpointDefinition, DelegateStreamEndpointDefinition, PushDiffusionEndpointDefinition, HttpOAuth2ClientCredentialsOptions, HttpAuthOptions } from "./transports.js";
6
- export { DatadogReportingSink, DatadogReportingSinkOptions, GrafanaLokiReportingSink, GrafanaLokiReportingSinkOptions, InfluxDbReportingSink, InfluxDbReportingSinkOptions, OtelCollectorReportingSink, OtelCollectorReportingSinkOptions, SplunkReportingSink, SplunkReportingSinkOptions, TimescaleDbReportingSink, TimescaleDbReportingSinkOptions } from "./sinks.js";
6
+ export { DatadogReportingSink, DatadogReportingSinkOptions, GrafanaLokiReportingSink, GrafanaLokiReportingSinkOptions, InfluxDbReportingSink, InfluxDbReportingSinkOptions, OtelCollectorReportingSink, OtelCollectorReportingSinkOptions, PortalReportingSink, SplunkReportingSink, SplunkReportingSinkOptions, TimescaleDbReportingSink, TimescaleDbReportingSinkOptions } from "./sinks.js";
package/dist/esm/local.js CHANGED
@@ -16,7 +16,8 @@ const SINK_FEATURE_BY_KIND = {
16
16
  grafanaloki: "extensions.reporting_sinks.grafana_loki",
17
17
  datadog: "extensions.reporting_sinks.datadog",
18
18
  splunk: "extensions.reporting_sinks.splunk",
19
- otelcollector: "extensions.reporting_sinks.otel_collector"
19
+ otelcollector: "extensions.reporting_sinks.otel_collector",
20
+ portal: "extensions.reporting_sinks.portal"
20
21
  };
21
22
  const TRACKING_FEATURE_BY_KIND = {
22
23
  http: "endpoint.http",
@@ -57,6 +58,9 @@ export class LoadStrikeLocalClient {
57
58
  this.licensingApiBaseUrl = resolveLicensingApiBaseUrl();
58
59
  this.licenseValidationTimeoutMs = normalizeTimeoutMs(options.licenseValidationTimeoutMs);
59
60
  }
61
+ portalReportingIngestUrl() {
62
+ return `${this.licensingApiBaseUrl.replace(/\/+$/, "")}/api/v1/reporting/ingest`;
63
+ }
60
64
  async run(request) {
61
65
  const sanitized = sanitizeRequest(request);
62
66
  const licenseSession = await this.acquireLicenseLease(sanitized);
@@ -7,6 +7,7 @@ import { DistributedClusterAgent, DistributedClusterCoordinator } from "./cluste
7
7
  import { CorrelationStoreConfiguration, CrossPlatformTrackingRuntime, RedisCorrelationStore, RedisCorrelationStoreOptions, TrackingFieldSelector } from "./correlation.js";
8
8
  import { EndpointAdapterFactory, LOADSTRIKE_TRACE_ID_TRACKING_FIELD } from "./transports.js";
9
9
  import { buildDotnetCsvReport, buildDotnetHtmlReport, buildDotnetMarkdownReport, buildDotnetTxtReport } from "./reporting.js";
10
+ import { PortalReportingSink } from "./sinks.js";
10
11
  export const LoadStrikeNodeType = {
11
12
  SingleNode: "SingleNode",
12
13
  Coordinator: "Coordinator",
@@ -1378,6 +1379,16 @@ export class LoadStrikeContext {
1378
1379
  validateNamedReportingSinks(sinks);
1379
1380
  return this.mergeValues({ ReportingSinks: sinks });
1380
1381
  }
1382
+ withPortalReporting() {
1383
+ return this.WithPortalReporting();
1384
+ }
1385
+ WithPortalReporting() {
1386
+ const sinks = [...(this.values.ReportingSinks ?? [])];
1387
+ if (!sinks.some((sink, index) => resolveSinkName(sink, index).trim().toLowerCase() === "portal")) {
1388
+ sinks.push(new PortalReportingSink());
1389
+ }
1390
+ return this.mergeValues({ ReportingSinks: sinks });
1391
+ }
1381
1392
  /**
1382
1393
  * Registers runtime policies for the run.
1383
1394
  * Use this when scenario selection or step execution should obey policy callbacks.
@@ -2118,6 +2129,9 @@ export class LoadStrikeRunner {
2118
2129
  static WithReportingSinks(context, ...sinks) {
2119
2130
  return context.WithReportingSinks(...sinks);
2120
2131
  }
2132
+ static WithPortalReporting(context) {
2133
+ return context.WithPortalReporting();
2134
+ }
2121
2135
  /**
2122
2136
  * Registers runtime policies for the run.
2123
2137
  * Use this when scenario selection or step execution should obey policy callbacks.
@@ -2346,6 +2360,29 @@ export class LoadStrikeRunner {
2346
2360
  WithReportingInterval(intervalSeconds) {
2347
2361
  return this.withReportingInterval(intervalSeconds);
2348
2362
  }
2363
+ withReportingSinks(...sinks) {
2364
+ if (!sinks.length) {
2365
+ throw new Error("At least one reporting sink should be provided.");
2366
+ }
2367
+ if (sinks.some((sink) => sink == null)) {
2368
+ throw new Error("Reporting sink collection cannot contain null values.");
2369
+ }
2370
+ validateNamedReportingSinks(sinks);
2371
+ return this.configure({ reportingSinks: sinks });
2372
+ }
2373
+ WithReportingSinks(...sinks) {
2374
+ return this.withReportingSinks(...sinks);
2375
+ }
2376
+ withPortalReporting() {
2377
+ const sinks = [...(this.options.reportingSinks ?? [])];
2378
+ if (!sinks.some((sink, index) => resolveSinkName(sink, index).trim().toLowerCase() === "portal")) {
2379
+ sinks.push(new PortalReportingSink());
2380
+ }
2381
+ return this.configure({ reportingSinks: sinks });
2382
+ }
2383
+ WithPortalReporting() {
2384
+ return this.withPortalReporting();
2385
+ }
2349
2386
  /**
2350
2387
  * Sets the timeout for cluster command round-trips.
2351
2388
  * Use this when distributed control messages need a tighter or looser deadline.
@@ -2519,6 +2556,7 @@ export class LoadStrikeRunner {
2519
2556
  testInfo,
2520
2557
  getNodeInfo: () => attachNodeInfoAliases({ ...nodeInfo })
2521
2558
  };
2559
+ attachPortalReportingSession(sinkSession, sessionInfo, licenseClient, licenseSession);
2522
2560
  attachSessionStartInfoAliases(sessionInfo);
2523
2561
  nodeInfo.currentOperation = "Init";
2524
2562
  for (const plugin of plugins) {
@@ -4052,10 +4090,23 @@ function attachSessionStartInfoAliases(session) {
4052
4090
  attachAliasMap(session, {
4053
4091
  StartedUtc: "startedUtc",
4054
4092
  ScenarioNames: "scenarioNames",
4055
- Scenarios: "scenarios"
4093
+ Scenarios: "scenarios",
4094
+ RunToken: "runToken",
4095
+ PortalReportingIngestUrl: "portalReportingIngestUrl"
4056
4096
  });
4057
4097
  return session;
4058
4098
  }
4099
+ function attachPortalReportingSession(sinkSession, sessionInfo, licenseClient, licenseSession) {
4100
+ const runToken = stringValueOrDefault(licenseSession?.runToken, "").trim();
4101
+ if (!runToken || !licenseClient) {
4102
+ return;
4103
+ }
4104
+ const ingestUrl = licenseClient.portalReportingIngestUrl();
4105
+ sinkSession.runToken = runToken;
4106
+ sinkSession.portalReportingIngestUrl = ingestUrl;
4107
+ sessionInfo.runToken = runToken;
4108
+ sessionInfo.portalReportingIngestUrl = ingestUrl;
4109
+ }
4059
4110
  function attachScenarioInitContextAliases(context) {
4060
4111
  context.nodeInfo = attachNodeInfoAliases(context.nodeInfo);
4061
4112
  context.testInfo = attachTestInfoAliases(context.testInfo);
package/dist/esm/sinks.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { LoadStrikePluginData as LoadStrikePluginDataModel, LoadStrikePluginDataTable as LoadStrikePluginDataTableModel } from "./runtime.js";
2
+ import { createHash } from "node:crypto";
2
3
  import { Pool } from "pg";
3
4
  const DEFAULT_INFLUX_CONFIGURATION_SECTION_PATH = "LoadStrike:ReportingSinks:InfluxDb";
4
5
  const DEFAULT_GRAFANA_LOKI_CONFIGURATION_SECTION_PATH = "LoadStrike:ReportingSinks:GrafanaLoki";
@@ -372,6 +373,91 @@ export class CompositeReportingSink {
372
373
  await this.stop();
373
374
  }
374
375
  }
376
+ export class PortalReportingSink {
377
+ constructor(options = {}) {
378
+ this.sinkName = "portal";
379
+ this.SinkName = "portal";
380
+ this.licenseFeature = "extensions.reporting_sinks.portal";
381
+ this.LicenseFeature = "extensions.reporting_sinks.portal";
382
+ this.baseContext = null;
383
+ this.session = null;
384
+ this.runToken = "";
385
+ this.ingestUrl = "";
386
+ const source = asRecord(options);
387
+ this.timeoutMs = resolveTimeoutMs(optionNumber(source, "timeoutSeconds", "TimeoutSeconds"), optionNumber(source, "timeoutMs", "TimeoutMs"));
388
+ this.fetchImpl = pickRecordValue(source, "fetchImpl", "FetchImpl") ?? fetch;
389
+ }
390
+ init(context, _infraConfig) {
391
+ this.baseContext = cloneBaseContext(context);
392
+ }
393
+ Init(context, infraConfig) {
394
+ this.init(context, infraConfig);
395
+ }
396
+ start(session) {
397
+ this.runToken = String(session.runToken ?? session.RunToken ?? "").trim();
398
+ this.ingestUrl = String(session.portalReportingIngestUrl ?? session.PortalReportingIngestUrl ?? "").trim();
399
+ if (!this.runToken || !this.ingestUrl) {
400
+ throw new Error("PortalReportingSink requires a managed portal reporting session.");
401
+ }
402
+ this.session = sinkSessionMetadataFromContext(this.getBaseContext(), session);
403
+ }
404
+ Start(session) {
405
+ this.start(session);
406
+ }
407
+ async saveRealtimeStats(scenarioStats) {
408
+ await this.persistEvents(createRealtimeStatsEvents(this.getSession(), scenarioStats));
409
+ }
410
+ async SaveRealtimeStats(scenarioStats) {
411
+ await this.saveRealtimeStats(scenarioStats);
412
+ }
413
+ async saveRealtimeMetrics(metrics) {
414
+ await this.persistEvents(createRealtimeMetricEvents(this.getSession(), metrics));
415
+ }
416
+ async SaveRealtimeMetrics(metrics) {
417
+ await this.saveRealtimeMetrics(metrics);
418
+ }
419
+ async saveRunResult(result) {
420
+ await this.persistEvents(createRunResultEvents(this.getSession(), result));
421
+ }
422
+ async SaveRunResult(result) {
423
+ await this.saveRunResult(result);
424
+ }
425
+ stop() {
426
+ this.session = null;
427
+ }
428
+ Stop() {
429
+ this.stop();
430
+ }
431
+ Dispose() {
432
+ this.baseContext = null;
433
+ this.stop();
434
+ }
435
+ getBaseContext() {
436
+ if (!this.baseContext) {
437
+ throw new Error(`${this.sinkName} has not been initialized.`);
438
+ }
439
+ return this.baseContext;
440
+ }
441
+ getSession() {
442
+ if (!this.session) {
443
+ throw new Error(`${this.sinkName} has not been started.`);
444
+ }
445
+ return this.session;
446
+ }
447
+ async persistEvents(events) {
448
+ if (!events.length) {
449
+ return;
450
+ }
451
+ await postWithTimeout(this.fetchImpl, this.ingestUrl, {
452
+ method: "POST",
453
+ headers: { "Content-Type": "application/json" },
454
+ body: JSON.stringify({
455
+ runToken: this.runToken,
456
+ events: events.map((event, index) => portalEventPayload(event, index))
457
+ })
458
+ }, this.timeoutMs, "PortalReportingSink");
459
+ }
460
+ }
375
461
  export class InfluxDbReportingSink {
376
462
  constructor(options = {}) {
377
463
  this.sinkName = "influxdb";
@@ -1528,6 +1614,37 @@ function createReportingEvent(session, occurredUtc, eventType, scenarioName, ste
1528
1614
  fields: eventFields
1529
1615
  };
1530
1616
  }
1617
+ function portalEventPayload(event, index) {
1618
+ return {
1619
+ eventId: portalEventId(event, index),
1620
+ runId: event.sessionId,
1621
+ eventType: event.eventType,
1622
+ occurredUtc: event.occurredUtc.toISOString(),
1623
+ sessionId: event.sessionId,
1624
+ testSuite: event.testSuite,
1625
+ testName: event.testName,
1626
+ clusterId: event.clusterId,
1627
+ nodeType: event.nodeType,
1628
+ machineName: event.machineName,
1629
+ scenarioName: event.scenarioName,
1630
+ stepName: event.stepName,
1631
+ tags: { ...event.tags },
1632
+ fields: deepCloneRecord(event.fields)
1633
+ };
1634
+ }
1635
+ function portalEventId(event, index) {
1636
+ const material = JSON.stringify({
1637
+ sessionId: event.sessionId,
1638
+ eventType: event.eventType,
1639
+ occurredUtc: event.occurredUtc.toISOString(),
1640
+ scenarioName: event.scenarioName ?? "",
1641
+ stepName: event.stepName ?? "",
1642
+ index,
1643
+ tags: event.tags,
1644
+ fields: event.fields
1645
+ });
1646
+ return `lsr_${createHash("sha256").update(material).digest("hex").slice(0, 40)}`;
1647
+ }
1531
1648
  function addMeasurementFields(fields, prefix, measurement) {
1532
1649
  const request = measurement?.request ?? { count: 0, percent: 0, rps: 0 };
1533
1650
  const latency = measurement?.latency ?? {
@@ -2553,7 +2670,9 @@ function cloneSessionStartInfo(session) {
2553
2670
  ...cloneBaseContext(session),
2554
2671
  startedUtc: session.startedUtc,
2555
2672
  scenarioNames: [...session.scenarioNames],
2556
- scenarios: session.scenarios.map((value) => ({ ...value }))
2673
+ scenarios: session.scenarios.map((value) => ({ ...value })),
2674
+ runToken: session.runToken,
2675
+ portalReportingIngestUrl: session.portalReportingIngestUrl
2557
2676
  };
2558
2677
  }
2559
2678
  function cloneNodeInfo(nodeInfo) {
@@ -2655,23 +2774,7 @@ function runResultToNodeStats(result) {
2655
2774
  disabledSinks: [...(result.disabledSinks ?? [])],
2656
2775
  sinkErrors: (result.sinkErrors ?? []).map((value) => ({ ...value })),
2657
2776
  reportFiles: [...(result.reportFiles ?? [])],
2658
- logFiles: [...(result.logFiles ?? [])],
2659
- findScenarioStats: (scenarioName) => result.scenarioStats.find((value) => value.scenarioName === scenarioName),
2660
- getScenarioStats: (scenarioName) => {
2661
- const scenario = result.scenarioStats.find((value) => value.scenarioName === scenarioName);
2662
- if (!scenario) {
2663
- throw new Error(`Scenario stats not found: ${scenarioName}`);
2664
- }
2665
- return scenario;
2666
- },
2667
- FindScenarioStats: (scenarioName) => result.scenarioStats.find((value) => value.scenarioName === scenarioName),
2668
- GetScenarioStats: (scenarioName) => {
2669
- const scenario = result.scenarioStats.find((value) => value.scenarioName === scenarioName);
2670
- if (!scenario) {
2671
- throw new Error(`Scenario stats not found: ${scenarioName}`);
2672
- }
2673
- return scenario;
2674
- }
2777
+ logFiles: [...(result.logFiles ?? [])]
2675
2778
  };
2676
2779
  }
2677
2780
  function deepCloneRecord(value) {
@@ -8,5 +8,5 @@ export { CorrelationStoreConfiguration, CrossPlatformTrackingRuntime, InMemoryCo
8
8
  export type { CorrelationEntry, DestinationConsumeResult, GatheredRow, CorrelationStoreKind, CorrelationRuntimeOptions, CorrelationRuntimePlugin, CorrelationRuntimeStats, CorrelationStore, SourceProduceResult, TrackingFieldLocation, TrackingPayload } from "./correlation.js";
9
9
  export { LOADSTRIKE_TRACE_ID_HEADER, LOADSTRIKE_TRACE_ID_TRACKING_FIELD, TrafficEndpointDefinition, HttpEndpointDefinition, KafkaEndpointDefinition, KafkaSaslOptions, RabbitMqEndpointDefinition, NatsEndpointDefinition, RedisStreamsEndpointDefinition, AzureEventHubsEndpointDefinition, SqsEndpointDefinition, DelegateStreamEndpointDefinition, PushDiffusionEndpointDefinition, HttpOAuth2ClientCredentialsOptions, HttpAuthOptions } from "./transports.js";
10
10
  export type { EndpointAdapter, EndpointDefinition, EndpointDefinitionInput, EndpointKind, EndpointMode, DotNetDelegateEndpointOptions, DotNetEndpointDefinition, DotNetHttpAuthOptions, DotNetHttpEndpointOptions, DotNetHttpOAuth2ClientCredentialsOptions, HttpAuthMode, HttpAuthType, HttpRequestBodyType, HttpResponseSource, HttpTrackingPayloadSource, KafkaSaslMechanismType, KafkaSecurityProtocolType, HttpEndpointOptions, KafkaEndpointOptions, RabbitMqEndpointOptions, NatsEndpointOptions, RedisStreamsEndpointOptions, AzureEventHubsEndpointOptions, SqsEndpointOptions, PushDiffusionEndpointOptions, TrackingFieldSelectorInput, TrackingRunMode, TrafficEndpointKind, TrafficEndpointMode, ProducedMessageRequest, ProducedMessageResult, ConsumedMessage, DelegateConsumeAsync, DelegateConsumeStreamHandler, DelegateEndpointOptions } from "./transports.js";
11
- export { DatadogReportingSink, DatadogReportingSinkOptions, GrafanaLokiReportingSink, GrafanaLokiReportingSinkOptions, InfluxDbReportingSink, InfluxDbReportingSinkOptions, OtelCollectorReportingSink, OtelCollectorReportingSinkOptions, SplunkReportingSink, SplunkReportingSinkOptions, TimescaleDbReportingSink, TimescaleDbReportingSinkOptions } from "./sinks.js";
12
- export type { DatadogSinkOptions, GrafanaLokiSinkOptions, InfluxDbSinkOptions, OtelCollectorSinkOptions, SplunkSinkOptions, TimescaleDbSinkOptions } from "./sinks.js";
11
+ export { DatadogReportingSink, DatadogReportingSinkOptions, GrafanaLokiReportingSink, GrafanaLokiReportingSinkOptions, InfluxDbReportingSink, InfluxDbReportingSinkOptions, OtelCollectorReportingSink, OtelCollectorReportingSinkOptions, PortalReportingSink, SplunkReportingSink, SplunkReportingSinkOptions, TimescaleDbReportingSink, TimescaleDbReportingSinkOptions } from "./sinks.js";
12
+ export type { DatadogSinkOptions, GrafanaLokiSinkOptions, InfluxDbSinkOptions, OtelCollectorSinkOptions, PortalReportingSinkOptionsInput, SplunkSinkOptions, TimescaleDbSinkOptions } from "./sinks.js";
@@ -29,6 +29,7 @@ export declare class LoadStrikeLocalClient {
29
29
  * Use this when the surrounding wrapper type makes this operation the clearest way to express your intent.
30
30
  */
31
31
  constructor(options?: LoadStrikeLocalClientOptions);
32
+ portalReportingIngestUrl(): string;
32
33
  run(request: LoadStrikeRunRequest): Promise<LoadStrikeRunResponse>;
33
34
  acquireLicenseLease(request: LoadStrikeRunRequest): Promise<LicenseValidationSession>;
34
35
  releaseLicenseLease(session: LicenseValidationSession, request: LoadStrikeRunRequest): Promise<void>;
@@ -558,6 +558,8 @@ export interface LoadStrikeSinkSession {
558
558
  configPath?: string;
559
559
  infraConfigPath?: string;
560
560
  infraConfig?: Record<string, unknown>;
561
+ runToken?: string;
562
+ portalReportingIngestUrl?: string;
561
563
  }
562
564
  export interface LoadStrikeBaseContext {
563
565
  logger: LoadStrikeLogger;
@@ -573,9 +575,13 @@ export interface LoadStrikeSessionStartInfo extends LoadStrikeBaseContext {
573
575
  startedUtc: string;
574
576
  scenarioNames: string[];
575
577
  scenarios: LoadStrikeScenarioStartInfo[];
578
+ runToken?: string;
579
+ portalReportingIngestUrl?: string;
576
580
  readonly StartedUtc?: string;
577
581
  readonly ScenarioNames?: string[];
578
582
  readonly Scenarios?: LoadStrikeScenarioStartInfo[];
583
+ readonly RunToken?: string;
584
+ readonly PortalReportingIngestUrl?: string;
579
585
  }
580
586
  type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Omit<T, Keys> & {
581
587
  [Key in Keys]-?: Required<Pick<T, Key>> & Partial<Pick<T, Exclude<Keys, Key>>>;
@@ -1339,6 +1345,8 @@ export declare class LoadStrikeContext {
1339
1345
  * Use this when run results must be pushed to external observability or storage systems.
1340
1346
  */
1341
1347
  WithReportingSinks(...sinks: ILoadStrikeReportingSink[]): LoadStrikeContext;
1348
+ withPortalReporting(): LoadStrikeContext;
1349
+ WithPortalReporting(): LoadStrikeContext;
1342
1350
  /**
1343
1351
  * Registers runtime policies for the run.
1344
1352
  * Use this when scenario selection or step execution should obey policy callbacks.
@@ -1790,6 +1798,7 @@ export declare class LoadStrikeRunner {
1790
1798
  * Use this when run results must be pushed to external observability or storage systems.
1791
1799
  */
1792
1800
  static WithReportingSinks(context: LoadStrikeContext, ...sinks: ILoadStrikeReportingSink[]): LoadStrikeContext;
1801
+ static WithPortalReporting(context: LoadStrikeContext): LoadStrikeContext;
1793
1802
  /**
1794
1803
  * Registers runtime policies for the run.
1795
1804
  * Use this when scenario selection or step execution should obey policy callbacks.
@@ -1924,6 +1933,10 @@ export declare class LoadStrikeRunner {
1924
1933
  * Use this when sinks or dashboards should receive updates at a controlled interval.
1925
1934
  */
1926
1935
  WithReportingInterval(intervalSeconds: number): LoadStrikeRunner;
1936
+ withReportingSinks(...sinks: ILoadStrikeReportingSink[]): LoadStrikeRunner;
1937
+ WithReportingSinks(...sinks: ILoadStrikeReportingSink[]): LoadStrikeRunner;
1938
+ withPortalReporting(): LoadStrikeRunner;
1939
+ WithPortalReporting(): LoadStrikeRunner;
1927
1940
  /**
1928
1941
  * Sets the timeout for cluster command round-trips.
1929
1942
  * Use this when distributed control messages need a tighter or looser deadline.
@@ -21,10 +21,6 @@ interface LoadStrikeNodeStats {
21
21
  sinkErrors: LoadStrikeSinkError[];
22
22
  reportFiles: string[];
23
23
  logFiles: string[];
24
- findScenarioStats: (scenarioName: string) => LoadStrikeScenarioStats | undefined;
25
- getScenarioStats: (scenarioName: string) => LoadStrikeScenarioStats;
26
- FindScenarioStats: (scenarioName: string) => LoadStrikeScenarioStats | undefined;
27
- GetScenarioStats: (scenarioName: string) => LoadStrikeScenarioStats;
28
24
  }
29
25
  type SinkFetch = (input: string, init?: RequestInit) => Promise<Response>;
30
26
  interface ReportingSinkEvent {
@@ -282,6 +278,14 @@ export interface OtelCollectorSinkOptionsInput {
282
278
  FetchImpl?: SinkFetch;
283
279
  fetchImpl?: SinkFetch;
284
280
  }
281
+ export interface PortalReportingSinkOptionsInput {
282
+ TimeoutSeconds?: number;
283
+ timeoutSeconds?: number;
284
+ TimeoutMs?: number;
285
+ timeoutMs?: number;
286
+ FetchImpl?: SinkFetch;
287
+ fetchImpl?: SinkFetch;
288
+ }
285
289
  export declare class InfluxDbReportingSinkOptions {
286
290
  ConfigurationSectionPath: string;
287
291
  BaseUrl: string;
@@ -439,6 +443,35 @@ export declare class CompositeReportingSink implements LoadStrikeReportingSink {
439
443
  stop(): Promise<void>;
440
444
  Stop(): Promise<void>;
441
445
  }
446
+ export declare class PortalReportingSink implements LoadStrikeReportingSink {
447
+ readonly sinkName = "portal";
448
+ readonly SinkName = "portal";
449
+ readonly licenseFeature = "extensions.reporting_sinks.portal";
450
+ readonly LicenseFeature = "extensions.reporting_sinks.portal";
451
+ private readonly fetchImpl;
452
+ private readonly timeoutMs;
453
+ private baseContext;
454
+ private session;
455
+ private runToken;
456
+ private ingestUrl;
457
+ constructor(options?: PortalReportingSinkOptionsInput);
458
+ init(context: LoadStrikeBaseContext, _infraConfig: Record<string, unknown>): void;
459
+ Init(context: LoadStrikeBaseContext, infraConfig: Record<string, unknown>): void;
460
+ start(session: LoadStrikeSessionStartInfo): void;
461
+ Start(session: LoadStrikeSessionStartInfo): void;
462
+ saveRealtimeStats(scenarioStats: LoadStrikeScenarioStats[]): Promise<void>;
463
+ SaveRealtimeStats(scenarioStats: LoadStrikeScenarioStats[]): Promise<void>;
464
+ saveRealtimeMetrics(metrics: LoadStrikeMetricStats): Promise<void>;
465
+ SaveRealtimeMetrics(metrics: LoadStrikeMetricStats): Promise<void>;
466
+ saveRunResult(result: LoadStrikeRunResult): Promise<void>;
467
+ SaveRunResult(result: LoadStrikeRunResult): Promise<void>;
468
+ stop(): void;
469
+ Stop(): void;
470
+ Dispose(): void;
471
+ private getBaseContext;
472
+ private getSession;
473
+ private persistEvents;
474
+ }
442
475
  export declare class InfluxDbReportingSink implements LoadStrikeReportingSink {
443
476
  readonly sinkName = "influxdb";
444
477
  readonly SinkName = "influxdb";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadstrike/loadstrike-sdk",
3
- "version": "1.0.23001",
3
+ "version": "1.0.23201",
4
4
  "description": "TypeScript and JavaScript SDK for in-process load execution, traffic correlation, and reporting.",
5
5
  "keywords": [
6
6
  "load-testing",