@loadmap/providers 0.0.0-stage → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jaime Rojas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # @loadmap/providers
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Reads measured data from the metrics stores a team already runs into a **snapshot**: the file a loadmap model reads
4
+ (`fromSnapshot` in `@loadmap/core/measure`). Nothing in a definition or a shared link fetches anything; a pull is an
5
+ explicit action, and the address and the credentials come from the environment, never from the text.
6
+
7
+ `loadmap pull x.loadmap` (in `@loadmap/cli`) is the usual way in. In code:
8
+
9
+ ```ts
10
+ import { compile } from '@loadmap/dsl'
11
+ import { providersFromEnv, pull } from '@loadmap/providers'
12
+
13
+ const { measures } = compile(source)
14
+ const snapshot = await pull(measures, {
15
+ from: '2026-11-01T00:00:00Z',
16
+ to: '2026-11-08T00:00:00Z',
17
+ step: 300,
18
+ fetched: new Date().toISOString(),
19
+ providers: providersFromEnv(process.env), // LOADMAP_PROMETHEUS_URL, LOADMAP_ELASTICSEARCH_URL, …
20
+ })
21
+ ```
22
+
23
+ A provider is one adapter, `{ id, read({ query, from, to, step }) }`, that answers with the points or with why not:
24
+ an error is data, so one failing query leaves that measurement missing and the model on the definition's own value.
25
+ `prometheus({ url, token })` reads `/api/v1/query_range`; a query must give one series. `elasticsearch({ url, apiKey
26
+ })` reads an index as a date histogram at the step (documents per second or per step, or the avg, sum, min or max of
27
+ a field), and answers OpenSearch's clusters too. A user and password written in an address go in the header, and no
28
+ error repeats the address or a credential. MIT.
@@ -0,0 +1,96 @@
1
+ import { Snapshot, Stat, TimeSeries } from "@loadmap/core";
2
+ /** What a provider is asked: a query over a window, at a step. Instants are ISO 8601, the step in seconds. */
3
+ interface ProviderQuery {
4
+ query: string;
5
+ from: string;
6
+ to: string;
7
+ step: number;
8
+ }
9
+ /** A provider answers with the points, or says why not: an error is data, never a throw. */
10
+ type ProviderResult = {
11
+ points: TimeSeries;
12
+ } | {
13
+ error: string;
14
+ };
15
+ /** One metrics store: a query in, normalized points out. */
16
+ interface Provider {
17
+ /** "prometheus": the name a metric's block gives it. */
18
+ id: string;
19
+ read(q: ProviderQuery): Promise<ProviderResult>;
20
+ }
21
+ /** A measurement to read: a definition's `measure` with its query filled in (`compile(…).measures`). */
22
+ interface PullRequest {
23
+ id: string;
24
+ metric?: string;
25
+ label?: string;
26
+ provider: string;
27
+ query: string;
28
+ stat?: Stat;
29
+ min?: number;
30
+ unit: string;
31
+ }
32
+ interface PullOptions {
33
+ /** The window, ISO 8601: every measurement reads the same one. */
34
+ from: string;
35
+ to: string;
36
+ /** Seconds between points. */
37
+ step: number;
38
+ /** When the pull ran, ISO 8601: the snapshot's date. */
39
+ fetched: string;
40
+ providers: Readonly<Partial<Record<string, Provider>>>;
41
+ }
42
+ /**
43
+ * Reads every measurement and writes the snapshot: the value over the window by its stat (mean by default), the
44
+ * points, and where they came from. What fails is missing, with why; a window with fewer samples than its `min`
45
+ * counts as missing too, and the model then uses the definition's own value.
46
+ */
47
+ export declare function pull(requests: readonly PullRequest[], o: PullOptions): Promise<Snapshot>;
48
+ interface ElasticsearchOptions {
49
+ /** The cluster's address: "https://search.example:9200", or a path behind a proxy. */
50
+ url: string;
51
+ /** Sent as `ApiKey …`. */
52
+ apiKey?: string;
53
+ /** Basic auth, when there is no API key. */
54
+ user?: string;
55
+ password?: string;
56
+ /** For tests and proxies; the global `fetch` by default. */
57
+ fetch?: typeof fetch;
58
+ }
59
+ /**
60
+ * Reads an index over a range as a date histogram at the step: documents per second (`rate`), per step (`count`),
61
+ * or the avg, sum, min or max of a field. OpenSearch answers the same request. A window where no document matches is
62
+ * an error, not a series of zeros: a wrong query must not read as no traffic. Errors never carry the address or the
63
+ * credentials.
64
+ */
65
+ export declare function elasticsearch(o: ElasticsearchOptions): Provider;
66
+ interface PrometheusOptions {
67
+ /** The server's address: "https://prometheus.example:9090", or a path behind a proxy. */
68
+ url: string;
69
+ /** Sent as a bearer token. */
70
+ token?: string;
71
+ /** Basic auth, when there is no token. */
72
+ user?: string;
73
+ password?: string;
74
+ /** For tests and proxies; the global `fetch` by default. */
75
+ fetch?: typeof fetch;
76
+ }
77
+ /**
78
+ * Reads a PromQL query over a range (`/api/v1/query_range`). The query must give one series: several are an error
79
+ * that says to sum them. Points Prometheus writes as NaN or ±Inf are left out. Errors never carry the address or the
80
+ * credentials, so a snapshot or a log can be shared.
81
+ */
82
+ export declare function prometheus(o: PrometheusOptions): Provider;
83
+ /**
84
+ * The providers the environment configures: `LOADMAP_PROMETHEUS_URL`, with `LOADMAP_PROMETHEUS_TOKEN` or a user and
85
+ * password; `LOADMAP_ELASTICSEARCH_URL`, with `LOADMAP_ELASTICSEARCH_API_KEY` or a user and password. A definition
86
+ * never names an address or a credential.
87
+ */
88
+ export declare function providersFromEnv(env: Readonly<Record<string, string | undefined>>,
89
+ /** `only`: the providers to build, so an address set for another one cannot fail this pull. */
90
+ o?: {
91
+ fetch?: typeof fetch;
92
+ only?: readonly string[];
93
+ }): Record<string, Provider>;
94
+ /** What a provider needs to be configured, as a line to print. */
95
+ export declare const PROVIDER_ENV: Readonly<Record<string, string>>;
96
+ export type { ElasticsearchOptions, PrometheusOptions, Provider, ProviderQuery, ProviderResult, PullOptions, PullRequest };
package/dist/index.js ADDED
@@ -0,0 +1,292 @@
1
+ import { parseSnapshot, summarize } from "@loadmap/core/measure";
2
+ // src/http.ts
3
+ /**
4
+ * A provider's address, ready to ask: a user and password written in it move into the header (fetch refuses them in a
5
+ * URL, and repeats the URL saying so). The text is never repeated in an error: it may hold a password.
6
+ */
7
+ function endpoint(name, url, auth) {
8
+ let base;
9
+ try {
10
+ base = new URL(url.endsWith("/") ? url : `${url}/`);
11
+ } catch {
12
+ throw new Error(`loadmap: the ${name} address is not a URL`);
13
+ }
14
+ const user = auth.user ?? (base.username ? decodeURIComponent(base.username) : void 0);
15
+ const password = auth.password ?? decodeURIComponent(base.password);
16
+ base.username = "";
17
+ base.password = "";
18
+ const headers = { accept: "application/json" };
19
+ if (auth.header) headers.authorization = auth.header;
20
+ else if (user != null) headers.authorization = `Basic ${btoa(`${user}:${password}`)}`;
21
+ return {
22
+ base,
23
+ headers
24
+ };
25
+ }
26
+ /** Why a request did not reach the server: the network error's code, never its message, which may name the host. */
27
+ function unreachable(name, err) {
28
+ const code = err?.cause?.code;
29
+ return { error: `${name} cannot be reached${typeof code === "string" ? ` (${code})` : ""}` };
30
+ }
31
+ // src/elasticsearch.ts
32
+ /** What a metric's `elasticsearch` block asks, as `compile` writes it into the query. */
33
+ /** What it counts or aggregates at each step: the values a metric's `agg` takes. */
34
+ const AGGS = [
35
+ "rate",
36
+ "count",
37
+ "avg",
38
+ "sum",
39
+ "min",
40
+ "max"
41
+ ];
42
+ /**
43
+ * Reads an index over a range as a date histogram at the step: documents per second (`rate`), per step (`count`),
44
+ * or the avg, sum, min or max of a field. OpenSearch answers the same request. A window where no document matches is
45
+ * an error, not a series of zeros: a wrong query must not read as no traffic. Errors never carry the address or the
46
+ * credentials.
47
+ */
48
+ function elasticsearch(o) {
49
+ const get = o.fetch ?? fetch;
50
+ const { base, headers } = endpoint("Elasticsearch", o.url, {
51
+ ...o.user != null && { user: o.user },
52
+ ...o.password != null && { password: o.password },
53
+ ...o.apiKey && { header: `ApiKey ${o.apiKey}` }
54
+ });
55
+ headers["content-type"] = "application/json";
56
+ return {
57
+ id: "elasticsearch",
58
+ async read(q) {
59
+ const spec = specOf(q.query);
60
+ if (!spec) return { error: "the query is not an Elasticsearch request loadmap wrote" };
61
+ if (spec.index.split(",").some((p) => !p || p === "." || p === ".." || /[\s/?#]/.test(p))) return { error: "the index is not a name or a pattern" };
62
+ const from = Date.parse(q.from);
63
+ const to = Date.parse(q.to);
64
+ if (!(to - from >= q.step * 1e3)) return { error: "the window is shorter than a step" };
65
+ const body = {
66
+ size: 0,
67
+ track_total_hits: false,
68
+ query: { bool: { filter: [{ range: { [spec.time]: {
69
+ gte: from,
70
+ lt: to,
71
+ format: "epoch_millis"
72
+ } } }, ...spec.query ? [spec.query] : []] } },
73
+ aggs: { t: {
74
+ date_histogram: {
75
+ field: spec.time,
76
+ fixed_interval: `${q.step}s`,
77
+ min_doc_count: 0,
78
+ offset: `${from / 1e3 % q.step}s`,
79
+ extended_bounds: {
80
+ min: from,
81
+ max: to - q.step * 1e3
82
+ }
83
+ },
84
+ ...spec.field && { aggs: { v: { [spec.agg]: { field: spec.field } } } }
85
+ } }
86
+ };
87
+ const url = new URL(base);
88
+ url.pathname = `${base.pathname}${encodeURIComponent(spec.index).replace(/%2C/g, ",").replace(/%3A/g, ":")}/_search`;
89
+ let res;
90
+ try {
91
+ res = await get(url, {
92
+ method: "POST",
93
+ headers,
94
+ body: JSON.stringify(body)
95
+ });
96
+ } catch (err) {
97
+ return unreachable("Elasticsearch", err);
98
+ }
99
+ let r;
100
+ try {
101
+ r = await res.json();
102
+ } catch {
103
+ r = void 0;
104
+ }
105
+ if (!res.ok || !r || r.error) {
106
+ const e = r?.error;
107
+ const why = typeof e === "string" ? e : [e?.type, e?.reason].filter(Boolean).join(": ");
108
+ return { error: `Elasticsearch answered ${res.status}${why ? `: ${why}` : ""}` };
109
+ }
110
+ const failed = r._shards?.failed ?? 0;
111
+ if (failed) return { error: `${failed} of ${r._shards?.total ?? "?"} shards failed` };
112
+ if (r.timed_out) return { error: "the search timed out" };
113
+ const buckets = r.aggregations?.t?.buckets ?? [];
114
+ if (!buckets.some((b) => (b.doc_count ?? 0) > 0)) return { error: "no document matches the query in the window" };
115
+ const points = [];
116
+ for (const b of buckets) {
117
+ const n = b.doc_count ?? 0;
118
+ const v = spec.agg === "count" ? n : spec.agg === "rate" ? n / q.step : b.v?.value;
119
+ if (typeof b.key === "number" && typeof v === "number" && Number.isFinite(v)) points.push([Math.round(b.key / 1e3), v]);
120
+ }
121
+ return { points };
122
+ }
123
+ };
124
+ }
125
+ function specOf(text) {
126
+ let s;
127
+ try {
128
+ s = JSON.parse(text);
129
+ } catch {
130
+ return;
131
+ }
132
+ if (typeof s !== "object" || s === null) return void 0;
133
+ const { index, time, agg, field, query } = s;
134
+ if (typeof index !== "string" || typeof time !== "string" || typeof agg !== "string" || !AGGS.includes(agg)) return void 0;
135
+ if (field !== void 0 && typeof field !== "string") return void 0;
136
+ if (query !== void 0 && (typeof query !== "object" || query === null || Array.isArray(query))) return void 0;
137
+ return s;
138
+ }
139
+ // src/prometheus.ts
140
+ /**
141
+ * Reads a PromQL query over a range (`/api/v1/query_range`). The query must give one series: several are an error
142
+ * that says to sum them. Points Prometheus writes as NaN or ±Inf are left out. Errors never carry the address or the
143
+ * credentials, so a snapshot or a log can be shared.
144
+ */
145
+ function prometheus(o) {
146
+ const get = o.fetch ?? fetch;
147
+ const { base, headers } = endpoint("Prometheus", o.url, {
148
+ ...o.user != null && { user: o.user },
149
+ ...o.password != null && { password: o.password },
150
+ ...o.token && { header: `Bearer ${o.token}` }
151
+ });
152
+ return {
153
+ id: "prometheus",
154
+ async read(q) {
155
+ const url = new URL("api/v1/query_range", base);
156
+ url.search = new URLSearchParams({
157
+ query: q.query,
158
+ start: q.from,
159
+ end: q.to,
160
+ step: String(q.step)
161
+ }).toString();
162
+ let res;
163
+ try {
164
+ res = await get(url, { headers });
165
+ } catch (err) {
166
+ return unreachable("Prometheus", err);
167
+ }
168
+ let body;
169
+ try {
170
+ body = await res.json();
171
+ } catch {
172
+ body = void 0;
173
+ }
174
+ if (!res.ok || body?.status !== "success") {
175
+ const why = body?.error ? `: ${body.error}` : "";
176
+ return { error: `Prometheus answered ${res.status}${why}` };
177
+ }
178
+ const result = body.data?.result ?? [];
179
+ if (body.data?.resultType !== "matrix") return { error: `the query gave a ${body.data?.resultType}, not a range` };
180
+ if (!result.length) return { error: "the query returned no series" };
181
+ if (result.length > 1) return { error: `the query returned ${result.length} series: sum them into one` };
182
+ const points = [];
183
+ for (const [t, v] of result[0].values ?? []) {
184
+ const n = Number(v);
185
+ if (Number.isFinite(t) && Number.isFinite(n)) points.push([t, n]);
186
+ }
187
+ return { points };
188
+ }
189
+ };
190
+ }
191
+ // src/pull.ts
192
+ /**
193
+ * Reads every measurement and writes the snapshot: the value over the window by its stat (mean by default), the
194
+ * points, and where they came from. What fails is missing, with why; a window with fewer samples than its `min`
195
+ * counts as missing too, and the model then uses the definition's own value.
196
+ */
197
+ async function pull(requests, o) {
198
+ const read = await Promise.all(requests.map(async (r) => {
199
+ const provider = Object.hasOwn(o.providers, r.provider) ? o.providers[r.provider] : void 0;
200
+ const result = provider ? await provider.read({
201
+ query: r.query,
202
+ from: o.from,
203
+ to: o.to,
204
+ step: o.step
205
+ }).catch((err) => ({ error: `${r.provider} failed (${err?.name ?? "error"})` })) : { error: `no ${r.provider} is configured` };
206
+ const points = "points" in result ? result.points : [];
207
+ const origin = {
208
+ provider: r.provider,
209
+ query: r.query,
210
+ ...r.metric != null && { metric: r.metric },
211
+ from: o.from,
212
+ to: o.to,
213
+ step: o.step,
214
+ fetched: o.fetched,
215
+ samples: points.length,
216
+ ...r.min != null && { min: r.min },
217
+ ...r.stat != null && { stat: r.stat }
218
+ };
219
+ const label = r.label != null ? { label: r.label } : {};
220
+ if ("error" in result) return [r.id, {
221
+ ...label,
222
+ unit: r.unit,
223
+ missing: result.error,
224
+ origin
225
+ }];
226
+ if (!points.length) return [r.id, {
227
+ ...label,
228
+ unit: r.unit,
229
+ missing: "no samples in the window",
230
+ origin
231
+ }];
232
+ const value = summarize(points, r.stat ?? "mean");
233
+ return [r.id, {
234
+ ...label,
235
+ unit: r.unit,
236
+ value,
237
+ series: points,
238
+ origin
239
+ }];
240
+ }));
241
+ return parseSnapshot({
242
+ format: "loadmap-snapshot",
243
+ version: 1,
244
+ fetched: o.fetched,
245
+ from: o.from,
246
+ to: o.to,
247
+ measurements: Object.fromEntries(read)
248
+ });
249
+ }
250
+ // src/index.ts
251
+ /**
252
+ * The providers the environment configures: `LOADMAP_PROMETHEUS_URL`, with `LOADMAP_PROMETHEUS_TOKEN` or a user and
253
+ * password; `LOADMAP_ELASTICSEARCH_URL`, with `LOADMAP_ELASTICSEARCH_API_KEY` or a user and password. A definition
254
+ * never names an address or a credential.
255
+ */
256
+ function providersFromEnv(env, o = {}) {
257
+ const out = {};
258
+ const wanted = (id) => !o.only || o.only.includes(id);
259
+ const url = env.LOADMAP_PROMETHEUS_URL;
260
+ if (url && wanted("prometheus")) {
261
+ const p = {
262
+ url,
263
+ ...o.fetch && { fetch: o.fetch }
264
+ };
265
+ if (env.LOADMAP_PROMETHEUS_TOKEN) p.token = env.LOADMAP_PROMETHEUS_TOKEN;
266
+ else if (env.LOADMAP_PROMETHEUS_USER) {
267
+ p.user = env.LOADMAP_PROMETHEUS_USER;
268
+ p.password = env.LOADMAP_PROMETHEUS_PASSWORD ?? "";
269
+ }
270
+ out.prometheus = prometheus(p);
271
+ }
272
+ const es = env.LOADMAP_ELASTICSEARCH_URL;
273
+ if (es && wanted("elasticsearch")) {
274
+ const e = {
275
+ url: es,
276
+ ...o.fetch && { fetch: o.fetch }
277
+ };
278
+ if (env.LOADMAP_ELASTICSEARCH_API_KEY) e.apiKey = env.LOADMAP_ELASTICSEARCH_API_KEY;
279
+ else if (env.LOADMAP_ELASTICSEARCH_USER) {
280
+ e.user = env.LOADMAP_ELASTICSEARCH_USER;
281
+ e.password = env.LOADMAP_ELASTICSEARCH_PASSWORD ?? "";
282
+ }
283
+ out.elasticsearch = elasticsearch(e);
284
+ }
285
+ return out;
286
+ }
287
+ /** What a provider needs to be configured, as a line to print. */
288
+ const PROVIDER_ENV = {
289
+ prometheus: "LOADMAP_PROMETHEUS_URL (and LOADMAP_PROMETHEUS_TOKEN, or LOADMAP_PROMETHEUS_USER and _PASSWORD)",
290
+ elasticsearch: "LOADMAP_ELASTICSEARCH_URL (and LOADMAP_ELASTICSEARCH_API_KEY, or LOADMAP_ELASTICSEARCH_USER and _PASSWORD)"
291
+ };
292
+ export { PROVIDER_ENV, elasticsearch, prometheus, providersFromEnv, pull };
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@loadmap/providers",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.32.0",
4
+ "description": "Data providers for loadmap: read measured data from Prometheus and Elasticsearch into a snapshot a model reads, on an explicit pull, with the address and the credentials from the environment.",
5
+ "keywords": [
6
+ "loadmap",
7
+ "prometheus",
8
+ "elasticsearch",
9
+ "opensearch",
10
+ "metrics",
11
+ "capacity-planning"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Jaime Rojas",
15
+ "homepage": "https://jaim3r0jas.github.io/loadmap/",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/jaim3r0jas/loadmap.git",
19
+ "directory": "packages/providers"
20
+ },
21
+ "bugs": "https://github.com/jaim3r0jas/loadmap/issues",
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "dependencies": {
34
+ "@loadmap/core": "0.32.0"
35
+ },
36
+ "peerDependencies": {},
37
+ "scripts": {
38
+ "build": "tsdown"
39
+ }
6
40
  }