cloudbroker-sdk 0.9.0 → 0.9.1

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 CHANGED
@@ -349,6 +349,23 @@ await CloudBrokerCloud.trackExecution("job:fechamento-mensal", () => fecharMes()
349
349
  de um deploy pela versão que de fato rodou.
350
350
  - No shutdown: `await execucoes.flush(); execucoes.stop();`.
351
351
 
352
+ #### Falhas e duração (0.9.1)
353
+
354
+ Sem mudar nada no seu código, `expressMiddleware`, `trackExecution` e
355
+ `CloudBroker.operation` passam a informar, junto com a contagem, quantas
356
+ execuções **terminaram**, quantas **falharam** e a **duração** somada:
357
+
358
+ - requisição: termina quando a resposta é enviada; status 5xx = falha. Uma
359
+ requisição abortada antes de responder conta como execução, mas não como
360
+ terminada;
361
+ - `trackExecution`/`operation`: falha = o callback lançou ou a Promise rejeitou.
362
+ O retorno e o erro chegam intactos ao seu código.
363
+
364
+ Por isso a taxa de falha e a duração média são calculadas sobre as execuções
365
+ **terminadas**, não sobre o total. Duração por execução tem teto de 24 h.
366
+
367
+ Para contar à mão: `const fim = execucoes.count({ label }); … fim({ failed })`.
368
+
352
369
  ### Custo por operação (0.9.0)
353
370
 
354
371
  Cada execução ganha um `operationId` (`op_` + UUID v7, gerado localmente). As chamadas de nuvem e de IA feitas durante a execução levam esse id, e o CloudBroker passa a somar o custo de **uma** execução (ex.: S3 + DynamoDB + OpenAI de um `POST /checkout`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudbroker-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "CloudBroker SDK — Token Intelligence & Operational Financial Intelligence",
5
5
  "repository": {
6
6
  "type": "git",
package/src/client.js CHANGED
@@ -48,7 +48,7 @@ export class CloudBrokerClient {
48
48
  try {
49
49
  const payload = {
50
50
  ...event,
51
- sdkVersion: "0.9.0",
51
+ sdkVersion: "0.9.1",
52
52
  sentAt: new Date().toISOString(),
53
53
  };
54
54
  if (this.#cloudQueue.length < MAX_QUEUE_SIZE) {
@@ -104,7 +104,7 @@ export class CloudBrokerClient {
104
104
  try {
105
105
  const payload = {
106
106
  ...event,
107
- sdkVersion: "0.9.0",
107
+ sdkVersion: "0.9.1",
108
108
  sentAt: new Date().toISOString(),
109
109
  };
110
110
 
@@ -35,6 +35,7 @@ import {
35
35
  AZURE_COSMOS_ITEMS_OPS,
36
36
  AZURE_COSMOS_ITEMS_ITERATOR_OPS,
37
37
  } from "./pricing-cloud.js";
38
+ import { runObserved } from "./observe.js";
38
39
  import { cbStore, childOperationIds, operationFields } from "./context.js";
39
40
  import { resolveVersion } from "./executions.js";
40
41
 
@@ -249,7 +250,14 @@ export class CloudBrokerCloud {
249
250
  // durante a requisição levam o mesmo operationId.
250
251
  const ctx = { label: safe(resolveLabel), product: safe(resolveProduct), team: safe(resolveTeam), ...childOperationIds() };
251
252
  try {
252
- if (executions && typeof executions.count === "function") executions.count(ctx);
253
+ if (executions && typeof executions.count === "function") {
254
+ const fim = executions.count(ctx);
255
+ // Fim da requisição: resposta enviada. 5xx = falha. Abortada antes
256
+ // de responder não chega aqui — conta só em executions, não em completed.
257
+ if (typeof fim === "function" && typeof res?.once === "function") {
258
+ res.once("finish", () => { try { fim({ failed: res.statusCode >= 500 }); } catch { /* nunca lança */ } });
259
+ }
260
+ }
253
261
  } catch { /* contador nunca derruba a requisição */ }
254
262
  cbStore.run(ctx, () => next());
255
263
  };
@@ -270,10 +278,11 @@ export class CloudBrokerCloud {
270
278
  static trackExecution(label, fn, options = {}) {
271
279
  // Uma operação por execução (RFC-0026); dentro de outra operação, vira filha.
272
280
  const ctx = { label, product: options.product, team: options.team, ...childOperationIds() };
281
+ let fim = null;
273
282
  try {
274
- if (options.executions && typeof options.executions.count === "function") options.executions.count(ctx);
283
+ if (options.executions && typeof options.executions.count === "function") fim = options.executions.count(ctx);
275
284
  } catch { /* contador nunca derruba a execução */ }
276
- return cbStore.run(ctx, fn);
285
+ return runObserved(ctx, fn, fim);
277
286
  }
278
287
 
279
288
  /**
package/src/executions.js CHANGED
@@ -20,6 +20,14 @@
20
20
  * • contagem com mais de 7 dias sem conseguir enviar é descartada (o backend
21
21
  * recusa, e reenviar para sempre seria inútil).
22
22
  *
23
+ * Falhas e duração (0.9.1, RFC-0026): `count()` devolve uma função de
24
+ * término. Chamada no fim (`fim({ failed })`), ela soma na MESMA combinação
25
+ * da hora de início: `completed`, `failures` e `durationMsSum` (relógio
26
+ * monotônico). Execução que nunca termina (requisição abortada, processo
27
+ * morto) conta em `count` e não em `completed` — por isso a taxa de falha e a
28
+ * duração média se calculam sobre `completed`. O expressMiddleware e o
29
+ * trackExecution chamam o término sozinhos.
30
+ *
23
31
  * Uso:
24
32
  * const execucoes = new ExecutionCounter({ apiKey: "cb-key-xxx" }); // version ← CLOUDBROKER_VERSION
25
33
  * app.use(CloudBrokerCloud.expressMiddleware({ label: ..., executions: execucoes }));
@@ -29,6 +37,8 @@
29
37
  const DEFAULT_EXECUTIONS_ENDPOINT = "https://api.cloudbroker.app.br/api/cloud/executions";
30
38
  const HOUR_MS = 3_600_000;
31
39
  const MAX_AGE_MS = 7 * 24 * HOUR_MS;
40
+ const MAX_DURATION_MS = 24 * HOUR_MS; // o backend recusa acima disso
41
+ const NOOP = () => {};
32
42
 
33
43
  /** Versão do código do cliente: explícita > CLOUDBROKER_VERSION > null. */
34
44
  export function resolveVersion(explicit) {
@@ -48,10 +58,13 @@ export class ExecutionCounter {
48
58
  #debug;
49
59
  #maxKeys;
50
60
  #now;
61
+ #mono;
51
62
  #pending = new Map();
52
63
  #flushing = false;
53
64
  #timer = null;
54
65
  dropped = 0;
66
+ /** Términos que não couberam no teto de combinações (sem a execução perdida). */
67
+ droppedCompletions = 0;
55
68
 
56
69
  /**
57
70
  * @param {object} options
@@ -62,7 +75,7 @@ export class ExecutionCounter {
62
75
  * @param {number} [options.maxKeys=200] — o backend recusa lote com mais de 200
63
76
  * @param {boolean} [options.debug=false]
64
77
  */
65
- constructor({ apiKey, version, endpoint = DEFAULT_EXECUTIONS_ENDPOINT, flushIntervalMs = 60_000, maxKeys = 200, debug = false, now } = {}) {
78
+ constructor({ apiKey, version, endpoint = DEFAULT_EXECUTIONS_ENDPOINT, flushIntervalMs = 60_000, maxKeys = 200, debug = false, now, monotonicNow } = {}) {
66
79
  if (!apiKey) throw new Error("[CloudBroker] apiKey é obrigatório.");
67
80
  this.#apiKey = apiKey;
68
81
  this.#endpoint = endpoint;
@@ -70,6 +83,7 @@ export class ExecutionCounter {
70
83
  this.#debug = debug;
71
84
  this.#maxKeys = Math.max(1, Math.min(200, Math.floor(maxKeys) || 200));
72
85
  this.#now = typeof now === "function" ? now : Date.now;
86
+ this.#mono = typeof monotonicNow === "function" ? monotonicNow : () => performance.now();
73
87
  if (flushIntervalMs > 0) {
74
88
  this.#timer = setInterval(() => { this.flush(); }, flushIntervalMs);
75
89
  if (typeof this.#timer?.unref === "function") this.#timer.unref();
@@ -85,21 +99,48 @@ export class ExecutionCounter {
85
99
  /**
86
100
  * Soma uma execução do trecho. Nunca lança.
87
101
  * @param {{label?: string, product?: string}} ctx
102
+ * @returns {(result?: {failed?: boolean}) => void} término da execução —
103
+ * soma completed/failures/durationMsSum; só a 1ª chamada conta. Sem rótulo
104
+ * (execução não contada), é uma função vazia.
88
105
  */
89
106
  count(ctx) {
90
107
  try {
91
108
  const label = ctx?.label, product = ctx?.product;
92
- if (typeof label !== "string" || !label.trim()) return;
109
+ if (typeof label !== "string" || !label.trim()) return NOOP;
93
110
  const hour = hourStartIso(this.#now());
94
111
  const prod = typeof product === "string" && product.trim() ? product : null;
95
112
  const key = `${hour}\u0000${label}\u0000${prod || ""}`;
96
113
  const cur = this.#pending.get(key);
97
- if (cur) { cur.count++; return; }
98
- if (this.#pending.size >= this.#maxKeys) { this.dropped++; return; }
99
- this.#pending.set(key, { label, product: prod, version: this.#version, hour, count: 1 });
114
+ if (cur) cur.count++;
115
+ else if (this.#pending.size >= this.#maxKeys) { this.dropped++; return NOOP; }
116
+ else this.#pending.set(key, { label, product: prod, version: this.#version, hour, count: 1 });
117
+ const start = this.#mono();
118
+ let done = false;
119
+ return (result) => {
120
+ if (done) return;
121
+ done = true;
122
+ try { this.#complete(key, { label, product: prod, hour }, start, result?.failed === true); }
123
+ catch (err) { if (this.#debug) console.warn("[CloudBroker] executions finish error:", err.message); }
124
+ };
100
125
  } catch (err) {
101
126
  if (this.#debug) console.warn("[CloudBroker] executions.count error:", err.message);
127
+ return NOOP;
128
+ }
129
+ }
130
+
131
+ #complete(key, base, start, failed) {
132
+ const d = this.#mono() - start;
133
+ const ms = Number.isFinite(d) && d > 0 ? Math.min(Math.round(d), MAX_DURATION_MS) : 0;
134
+ let it = this.#pending.get(key);
135
+ if (!it) {
136
+ // A combinação já foi enviada: o término vai num item só com fim (count 0).
137
+ if (this.#pending.size >= this.#maxKeys) { this.droppedCompletions++; return; }
138
+ it = { ...base, version: this.#version, count: 0 };
139
+ this.#pending.set(key, it);
102
140
  }
141
+ it.completed = (it.completed || 0) + 1;
142
+ it.failures = (it.failures || 0) + (failed ? 1 : 0);
143
+ it.durationMsSum = (it.durationMsSum || 0) + ms;
103
144
  }
104
145
 
105
146
  /** Envia o pendente. Nunca lança; em falha transitória, a contagem volta para a fila. */
@@ -138,8 +179,14 @@ export class ExecutionCounter {
138
179
  if (Date.parse(it.hour) < limite) { this.dropped += it.count; continue; }
139
180
  const key = `${it.hour}\u0000${it.label}\u0000${it.product || ""}`;
140
181
  const cur = this.#pending.get(key);
141
- if (cur) cur.count += it.count;
142
- else if (this.#pending.size < this.#maxKeys) this.#pending.set(key, it);
182
+ if (cur) {
183
+ cur.count += it.count;
184
+ if (it.completed) {
185
+ cur.completed = (cur.completed || 0) + it.completed;
186
+ cur.failures = (cur.failures || 0) + it.failures;
187
+ cur.durationMsSum = (cur.durationMsSum || 0) + it.durationMsSum;
188
+ }
189
+ } else if (this.#pending.size < this.#maxKeys) this.#pending.set(key, it);
143
190
  else this.dropped += it.count;
144
191
  }
145
192
  }
package/src/observe.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Roda `fn` no contexto `ctx` e avisa o término da execução (0.9.1, RFC-0026).
3
+ *
4
+ * `fim` é a função devolvida por ExecutionCounter.count(); null = sem contador.
5
+ * Garantias: o retorno de `fn` (inclusive a MESMA Promise) e o erro de `fn`
6
+ * chegam intactos ao chamador; a observação nunca lança nem cria rejeição
7
+ * não tratada nova (o ramo `.then` trata as duas saídas).
8
+ */
9
+ import { cbStore } from "./context.js";
10
+
11
+ export function runObserved(ctx, fn, fim) {
12
+ if (typeof fim !== "function") return cbStore.run(ctx, fn);
13
+ const end = (failed) => { try { fim({ failed }); } catch { /* nunca lança */ } };
14
+ let result;
15
+ try {
16
+ result = cbStore.run(ctx, fn);
17
+ } catch (err) {
18
+ end(true);
19
+ throw err;
20
+ }
21
+ let then = null;
22
+ try { then = result !== null && (typeof result === "object" || typeof result === "function") ? result.then : null; } catch { then = null; }
23
+ if (typeof then === "function") {
24
+ try { then.call(result, () => end(false), () => end(true)); } catch { end(false); }
25
+ } else {
26
+ end(false);
27
+ }
28
+ return result;
29
+ }
package/src/operation.js CHANGED
@@ -22,6 +22,7 @@
22
22
  * servidor), customerId e metadata (risco de dado pessoal — RFC-0026 §3).
23
23
  */
24
24
  import { cbStore, childOperationIds, operationFields } from "./context.js";
25
+ import { runObserved } from "./observe.js";
25
26
 
26
27
  const OPERATION_TYPES = new Set(["http", "business", "job", "queue", "graphql", "database", "ai", "custom"]);
27
28
 
@@ -51,7 +52,7 @@ export class CloudBroker {
51
52
  throw new TypeError("[CloudBroker] operation(name, options, callback): callback deve ser uma função.");
52
53
  }
53
54
 
54
- let ctx = null;
55
+ let ctx = null, fim = null;
55
56
  try {
56
57
  const parent = cbStore.getStore() || {};
57
58
  const o = options && typeof options === "object" ? options : {};
@@ -65,12 +66,12 @@ export class CloudBroker {
65
66
  ...childOperationIds(),
66
67
  };
67
68
  try {
68
- if (o.executions && typeof o.executions.count === "function") o.executions.count(ctx);
69
+ if (o.executions && typeof o.executions.count === "function") fim = o.executions.count(ctx);
69
70
  } catch { /* contador nunca derruba a operação */ }
70
71
  } catch {
71
72
  ctx = null;
72
73
  }
73
- return ctx ? cbStore.run(ctx, callback) : callback();
74
+ return ctx ? runObserved(ctx, callback, fim) : callback();
74
75
  }
75
76
 
76
77
  /**
@@ -178,6 +178,99 @@ ok(resolveVersion("x".repeat(100)).length === 64, "versão com teto de 64");
178
178
  ok(sent.filter((s) => s.url.endsWith("/api/cloud/events")).at(-1)?.body.events[0].version === "v9", "versão explícita do wrapAWS vence o env");
179
179
  }
180
180
 
181
+ // ─── falhas e duração (0.9.1, RFC-0026) ──────────────────────────────────────
182
+ {
183
+ const { EventEmitter } = await import("node:events");
184
+ let mono = 1000;
185
+ const c = mk({ monotonicNow: () => mono });
186
+ const fim1 = c.count({ label: "checkout", product: "Loja" });
187
+ const fim2 = c.count({ label: "checkout", product: "Loja" });
188
+ c.count({ label: "checkout", product: "Loja" }); // em andamento: sem fim
189
+ mono += 120; fim1();
190
+ mono += 30; fim2({ failed: true });
191
+ fim1({ failed: true }); // 2ª chamada não conta
192
+ ok(typeof c.count({}) === "function", "count sem rótulo devolve término vazio (chamável)");
193
+ await c.flush();
194
+ let it = sent.at(-1).body.executions[0];
195
+ ok(it.count === 3 && it.completed === 2 && it.failures === 1 && it.durationMsSum === 270, `contagem 3, fim 2, 1 falha, duração somada (veio ${JSON.stringify(it)})`);
196
+
197
+ const fim3 = c.count({ label: "checkout", product: "Loja" });
198
+ await c.flush(); // início já enviado
199
+ mono += 50; fim3();
200
+ await c.flush();
201
+ it = sent.at(-1).body.executions[0];
202
+ ok(it.count === 0 && it.completed === 1 && it.durationMsSum === 50 && it.hour === "2026-10-12T15:00:00.000Z", "término depois do envio do início → item só com fim, na hora do INÍCIO");
203
+
204
+ const c0 = mk({ monotonicNow: () => mono });
205
+ c0.count({ label: "v1" });
206
+ await c0.flush();
207
+ ok(!("completed" in sent.at(-1).body.executions[0]), "sem término observado não manda completed (ausente ≠ 0)");
208
+
209
+ const cNeg = mk({ monotonicNow: () => NaN });
210
+ cNeg.count({ label: "n" })();
211
+ await cNeg.flush();
212
+ ok(sent.at(-1).body.executions[0].durationMsSum === 0, "relógio inválido → duração 0, nunca NaN");
213
+ let longa = 0; const cL = mk({ monotonicNow: () => longa });
214
+ const fimL = cL.count({ label: "longa" }); longa = 48 * 3_600_000; fimL();
215
+ await cL.flush();
216
+ ok(sent.at(-1).body.executions[0].durationMsSum === 24 * 3_600_000, "duração limitada a 24 h (teto do backend)");
217
+
218
+ const cR = mk({ monotonicNow: () => mono });
219
+ respond = () => ({ ok: false, status: 503 });
220
+ cR.count({ label: "r" })({ failed: true });
221
+ await cR.flush();
222
+ respond = () => ({ ok: true, status: 200 });
223
+ cR.count({ label: "r" })();
224
+ await cR.flush();
225
+ it = sent.at(-1).body.executions[0];
226
+ ok(it.count === 2 && it.completed === 2 && it.failures === 1, "falha transitória: fim volta à fila e soma com o seguinte");
227
+
228
+ // middleware: 'finish' com 5xx = falha; abortada (sem finish) não completa
229
+ const cm = mk({ monotonicNow: () => mono });
230
+ const mw = CloudBrokerCloud.expressMiddleware({ label: "mod", executions: cm });
231
+ const r1 = Object.assign(new EventEmitter(), { statusCode: 200 });
232
+ const r2 = Object.assign(new EventEmitter(), { statusCode: 503 });
233
+ const r3 = Object.assign(new EventEmitter(), { statusCode: 200 });
234
+ for (const r of [r1, r2, r3]) await new Promise((ok_) => mw({ method: "GET", path: "/" }, r, ok_));
235
+ r1.emit("finish"); r2.emit("finish"); r3.emit("close");
236
+ await cm.flush();
237
+ it = sent.at(-1).body.executions[0];
238
+ ok(it.count === 3 && it.completed === 2 && it.failures === 1, "middleware: 2xx ok, 5xx falha, abortada só em count");
239
+
240
+ // trackExecution e operation: sync, async, erro; retorno e erro intactos
241
+ const ct = mk({ monotonicNow: () => mono });
242
+ const p = Promise.resolve(9);
243
+ ok(CloudBrokerCloud.trackExecution("t", () => p, { executions: ct }) === p, "trackExecution devolve a MESMA Promise");
244
+ ok(CloudBrokerCloud.trackExecution("t", () => 1, { executions: ct }) === 1, "síncrono devolve o valor");
245
+ class E extends Error {}
246
+ let e1; try { CloudBrokerCloud.trackExecution("t", () => { throw new E("s"); }, { executions: ct }); } catch (e) { e1 = e; }
247
+ let e2; try { await CloudBrokerCloud.trackExecution("t", async () => { throw new E("a"); }, { executions: ct }); } catch (e) { e2 = e; }
248
+ ok(e1 instanceof E && e2 instanceof E, "erro síncrono e assíncrono propagam com o mesmo tipo");
249
+ const { CloudBroker } = await import("./operation.js");
250
+ await CloudBroker.operation("t", { executions: ct }, async () => 1);
251
+ let e3; try { await CloudBroker.operation("t", { executions: ct }, async () => { throw new E("o"); }); } catch (e) { e3 = e; }
252
+ ok(e3 instanceof E, "CloudBroker.operation propaga o erro");
253
+ await new Promise((r) => setTimeout(r, 10));
254
+ await ct.flush();
255
+ it = sent.at(-1).body.executions[0];
256
+ ok(it.count === 6 && it.completed === 6 && it.failures === 3, `trackExecution + operation: 6 execuções, 3 falhas (veio ${it.count}/${it.completed}/${it.failures})`);
257
+
258
+ // nenhuma rejeição não tratada nova
259
+ const unhandled = [];
260
+ const h = (r) => unhandled.push(r);
261
+ process.on("unhandledRejection", h);
262
+ const pr = CloudBrokerCloud.trackExecution("t", async () => { throw new E("x"); }, { executions: ct });
263
+ await pr.catch(() => {});
264
+ await new Promise((r) => setTimeout(r, 20));
265
+ process.off("unhandledRejection", h);
266
+ ok(unhandled.length === 0, "a observação não cria rejeição não tratada");
267
+
268
+ const quebrado = { count() { return () => { throw new Error("x"); }; } };
269
+ ok(CloudBrokerCloud.trackExecution("t", () => 5, { executions: quebrado }) === 5, "término que lança não derruba a execução");
270
+ const thenHostil = { get then() { throw new Error("x"); } };
271
+ ok(CloudBrokerCloud.trackExecution("t", () => thenHostil, { executions: ct }) === thenHostil, "retorno com 'then' hostil volta intacto");
272
+ }
273
+
181
274
  // ─── versão do pacote ────────────────────────────────────────────────────────
182
275
  {
183
276
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -169,7 +169,7 @@ const ask = () => openai.chat.completions.create({ model: "gpt-4o-mini", message
169
169
  {
170
170
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
171
171
  const client = readFileSync(new URL("./client.js", import.meta.url), "utf8");
172
- ok(pkg.version === "0.9.0" && [...client.matchAll(/sdkVersion:\s*"([^"]+)"/g)].every((m) => m[1] === pkg.version), "sdkVersion 0.9.0 nos eventos");
172
+ ok(pkg.version === "0.9.1" && [...client.matchAll(/sdkVersion:\s*"([^"]+)"/g)].every((m) => m[1] === pkg.version), "sdkVersion 0.9.1 nos eventos");
173
173
  }
174
174
 
175
175
  console.log(`\n${pass} asserções ok — custo por operação`);