cloudbroker-sdk 0.1.0 → 0.3.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/README.md CHANGED
@@ -106,6 +106,73 @@ CloudBrokerAI.trackDeploy("cb-key-xxxx", {
106
106
  });
107
107
  ```
108
108
 
109
+ ## Custo de nuvem a nível de código (MVP)
110
+
111
+ Atribua custo de **AWS** a um trecho de código / produto. O SDK envolve um client
112
+ do AWS SDK v3 e, a cada `.send()`, estima o custo (requests + transferência) e
113
+ registra um evento — mesmo padrão fire-and-forget do wrapper de IA.
114
+
115
+ > MVP: AWS apenas, serviços **S3** e **DynamoDB**, região `us-east-2`. Armazenamento
116
+ > GB-mês, Azure e atribuição automática (stack-trace) estão fora do MVP.
117
+
118
+ ```js
119
+ import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
120
+ import { CloudBrokerCloud } from "cloudbroker-sdk";
121
+
122
+ const s3 = CloudBrokerCloud.wrapAWS(new S3Client({ region: "us-east-2" }), {
123
+ apiKey: "cb-key-xxxx",
124
+ product: "checkout",
125
+ team: "mobile",
126
+ });
127
+
128
+ // label opcional do trecho, no 2º argumento do .send()
129
+ await s3.send(new PutObjectCommand({ Bucket: "b", Key: "nf.pdf", Body: buf }), {
130
+ cbLabel: "upload-nota-fiscal",
131
+ });
132
+ ```
133
+
134
+ Funciona igual com DynamoDB:
135
+
136
+ ```js
137
+ import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
138
+
139
+ const ddb = CloudBrokerCloud.wrapAWS(new DynamoDBClient({ region: "us-east-2" }), {
140
+ apiKey: "cb-key-xxxx", product: "checkout",
141
+ });
142
+ await ddb.send(new PutItemCommand({ ... }), { cbLabel: "gravar-pedido" });
143
+ ```
144
+
145
+ Cada chamada gera um evento `POST /api/cloud/events` com `service`, `operation`,
146
+ `product`, `team`, `label`, `requests`, `bytesIn`/`bytesOut`, `costUSD` estimado e
147
+ `latencyMs`. O `cbLabel` nunca é repassado ao AWS SDK.
148
+
149
+ ### Atribuição automática por requisição (Express)
150
+
151
+ Em vez de passar `cbLabel` em cada `.send()`, use o middleware: todas as chamadas
152
+ de nuvem feitas durante a requisição herdam um label de negócio automaticamente
153
+ (via `AsyncLocalStorage`). Ótimo pra agrupar custo por **módulo** ou **rota**.
154
+
155
+ ```js
156
+ import express from "express";
157
+ import { CloudBrokerCloud } from "cloudbroker-sdk";
158
+
159
+ const app = express();
160
+
161
+ // label fixo, "MÉTODO /caminho" (default), ou uma função req → label:
162
+ app.use(CloudBrokerCloud.expressMiddleware({
163
+ label: (req) => {
164
+ if (req.path.startsWith("/api/auth")) return "Autenticação";
165
+ if (req.path.startsWith("/api/billing")) return "Billing";
166
+ return "Outros";
167
+ },
168
+ }));
169
+
170
+ // daqui pra frente, qualquer s3.send(...)/ddb.send(...) dentro da request
171
+ // é atribuído ao módulo retornado — sem cbLabel manual.
172
+ ```
173
+
174
+ Prioridade do label: `cbLabel` explícito no `.send()` > contexto do middleware > `null`.
175
+
109
176
  ## Garantias
110
177
 
111
178
  - **Fire-and-forget**: se o CloudBroker estiver fora, sua aplicação não é afetada
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudbroker-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "CloudBroker SDK — Token Intelligence & Operational Financial Intelligence",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/client.js CHANGED
@@ -3,25 +3,86 @@
3
3
  * Fire-and-forget: nunca bloqueia a aplicação do cliente
4
4
  */
5
5
 
6
- const DEFAULT_AI_ENDPOINT = "https://api.cloudbroker.app.br/api/ai/events";
7
- const DEFAULT_API_BASE = "https://api.cloudbroker.app.br/api";
8
- const RETRY_DELAY_MS = 30_000; // 30s entre retries
9
- const MAX_QUEUE_SIZE = 500; // máximo de eventos em memória
6
+ const DEFAULT_AI_ENDPOINT = "https://api.cloudbroker.app.br/api/ai/events";
7
+ const DEFAULT_CLOUD_ENDPOINT = "https://api.cloudbroker.app.br/api/cloud/events";
8
+ const DEFAULT_API_BASE = "https://api.cloudbroker.app.br/api";
9
+ const RETRY_DELAY_MS = 30_000; // 30s entre retries
10
+ const MAX_QUEUE_SIZE = 500; // máximo de eventos em memória
10
11
 
11
12
  export class CloudBrokerClient {
12
13
  #apiKey;
13
14
  #aiEndpoint;
15
+ #cloudEndpoint;
14
16
  #apiBase;
15
17
  #queue = [];
16
18
  #flushing = false;
19
+ #cloudQueue = [];
20
+ #cloudFlushing = false;
17
21
  #debug;
18
22
 
19
- constructor({ apiKey, endpoint = DEFAULT_AI_ENDPOINT, apiBase = DEFAULT_API_BASE, debug = false } = {}) {
23
+ constructor({ apiKey, endpoint = DEFAULT_AI_ENDPOINT, cloudEndpoint = DEFAULT_CLOUD_ENDPOINT, apiBase = DEFAULT_API_BASE, debug = false } = {}) {
20
24
  if (!apiKey) throw new Error("[CloudBroker] apiKey é obrigatório.");
21
- this.#apiKey = apiKey;
22
- this.#aiEndpoint = endpoint;
23
- this.#apiBase = apiBase;
24
- this.#debug = debug;
25
+ this.#apiKey = apiKey;
26
+ this.#aiEndpoint = endpoint;
27
+ this.#cloudEndpoint = cloudEndpoint;
28
+ this.#apiBase = apiBase;
29
+ this.#debug = debug;
30
+ }
31
+
32
+ /**
33
+ * Envia um evento de custo de nuvem (atribuição a nível de código)
34
+ * Fire-and-forget — nunca lança erro para o caller
35
+ */
36
+ trackCloud(event) {
37
+ try {
38
+ const payload = {
39
+ ...event,
40
+ sdkVersion: "0.3.0",
41
+ sentAt: new Date().toISOString(),
42
+ };
43
+ if (this.#cloudQueue.length < MAX_QUEUE_SIZE) {
44
+ this.#cloudQueue.push(payload);
45
+ }
46
+ this.#flushCloud();
47
+ } catch (err) {
48
+ if (this.#debug) console.warn("[CloudBroker] trackCloud error:", err.message);
49
+ }
50
+ }
51
+
52
+ async #flushCloud() {
53
+ if (this.#cloudFlushing || this.#cloudQueue.length === 0) return;
54
+ this.#cloudFlushing = true;
55
+
56
+ const batch = this.#cloudQueue.splice(0, 50);
57
+
58
+ try {
59
+ const res = await fetch(this.#cloudEndpoint, {
60
+ method: "POST",
61
+ headers: {
62
+ "Content-Type": "application/json",
63
+ "x-cloudbroker-key": this.#apiKey,
64
+ },
65
+ body: JSON.stringify({ events: batch }),
66
+ signal: AbortSignal.timeout(5000),
67
+ });
68
+
69
+ if (!res.ok) {
70
+ if (this.#debug) console.warn("[CloudBroker] cloud flush failed:", res.status);
71
+ this.#cloudQueue.unshift(...batch);
72
+ setTimeout(() => { this.#cloudFlushing = false; this.#flushCloud(); }, RETRY_DELAY_MS);
73
+ return;
74
+ }
75
+
76
+ if (this.#debug) console.log(`[CloudBroker] flushed ${batch.length} cloud events`);
77
+ } catch (err) {
78
+ this.#cloudQueue.unshift(...batch);
79
+ if (this.#debug) console.warn("[CloudBroker] cloud flush error:", err.message);
80
+ setTimeout(() => { this.#cloudFlushing = false; this.#flushCloud(); }, RETRY_DELAY_MS);
81
+ return;
82
+ }
83
+
84
+ this.#cloudFlushing = false;
85
+ if (this.#cloudQueue.length > 0) this.#flushCloud();
25
86
  }
26
87
 
27
88
  /**
@@ -32,7 +93,7 @@ export class CloudBrokerClient {
32
93
  try {
33
94
  const payload = {
34
95
  ...event,
35
- sdkVersion: "0.1.0",
96
+ sdkVersion: "0.3.0",
36
97
  sentAt: new Date().toISOString(),
37
98
  };
38
99
 
@@ -144,11 +205,15 @@ export class CloudBrokerClient {
144
205
  if (this.#queue.length > 0) this.#flush();
145
206
  }
146
207
 
147
- /** Força flush imediato — útil em shutdown */
208
+ /** Força flush imediato (IA + nuvem) — útil em shutdown */
148
209
  async flushSync() {
149
210
  while (this.#queue.length > 0) {
150
211
  await this.#flush();
151
212
  await new Promise(r => setTimeout(r, 100));
152
213
  }
214
+ while (this.#cloudQueue.length > 0) {
215
+ await this.#flushCloud();
216
+ await new Promise(r => setTimeout(r, 100));
217
+ }
153
218
  }
154
219
  }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * CloudBrokerCloud — atribuição de custo de nuvem a nível de código (MVP).
3
+ *
4
+ * Envolve um client do AWS SDK v3 (S3 / DynamoDB) e, a cada chamada `.send()`,
5
+ * estima o custo de requests + transferência e envia um evento fire-and-forget
6
+ * ao backend. Não altera o comportamento do client original.
7
+ *
8
+ * Uso:
9
+ * import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
10
+ * import { CloudBrokerCloud } from "cloudbroker-sdk";
11
+ *
12
+ * const s3 = CloudBrokerCloud.wrapAWS(new S3Client({ region: "us-east-2" }), {
13
+ * apiKey: "cb-key-xxx", product: "checkout", team: "mobile",
14
+ * });
15
+ *
16
+ * // label opcional do trecho, no 2º argumento do .send()
17
+ * await s3.send(new PutObjectCommand({ ... }), { cbLabel: "upload-nota-fiscal" });
18
+ *
19
+ * Nota de implementação: interceptamos via override de `client.send` (em vez do
20
+ * middlewareStack) porque o `cbLabel` passado no 2º argumento do `.send()` não é
21
+ * lido de forma confiável pelo context do middleware. O override captura tudo —
22
+ * nome da operação, bytes in/out, latência e label — de forma limpa e testável.
23
+ */
24
+
25
+ import { CloudBrokerClient } from "./client.js";
26
+ import { estimateCloudCost } from "./pricing-cloud.js";
27
+ import { cbStore, currentLabel } from "./context.js";
28
+
29
+ export class CloudBrokerCloud {
30
+ /**
31
+ * @param {object} client — instância do AWS SDK v3 (ex: new S3Client())
32
+ * @param {object} options
33
+ * @param {string} options.apiKey — CloudBroker API key
34
+ * @param {string} [options.product] — nome do produto (ex: "checkout")
35
+ * @param {string} [options.team] — nome do squad (ex: "mobile")
36
+ * @param {string} [options.region="us-east-2"]
37
+ * @param {string} [options.endpoint] — endpoint de eventos de nuvem (opcional)
38
+ * @param {boolean} [options.debug=false]
39
+ */
40
+ static wrapAWS(client, options = {}) {
41
+ const { apiKey, product, team, region = "us-east-2", endpoint, debug = false } = options;
42
+ if (!apiKey) throw new Error("[CloudBroker] apiKey é obrigatório.");
43
+
44
+ if (!client || typeof client.send !== "function") {
45
+ if (debug) console.warn("[CloudBroker] cliente AWS sem .send() — retornando original.");
46
+ return client;
47
+ }
48
+
49
+ const cb = new CloudBrokerClient({ apiKey, cloudEndpoint: endpoint, debug });
50
+ const service = CloudBrokerCloud.#detectService(client);
51
+ const clientRegion = CloudBrokerCloud.#resolveRegion(client) || region;
52
+ const originalSend = client.send.bind(client);
53
+
54
+ client.send = async (command, sendOptions) => {
55
+ const start = Date.now();
56
+ const operation = (command?.constructor?.name || "").replace(/Command$/, "");
57
+ // Label: explícito no .send() tem prioridade; senão usa o do contexto da
58
+ // requisição (expressMiddleware), senão null.
59
+ const cbLabel = sendOptions?.cbLabel || currentLabel() || null;
60
+ const bytesIn = CloudBrokerCloud.#inputBytes(command?.input);
61
+
62
+ // Passa adiante apenas as options nativas — nunca vaza cbLabel pro AWS SDK.
63
+ const { cbLabel: _omit, ...awsOptions } = sendOptions || {};
64
+ const result = await originalSend(command, Object.keys(awsOptions).length ? awsOptions : undefined);
65
+
66
+ try {
67
+ const bytesOut = CloudBrokerCloud.#outputBytes(result);
68
+ const { costUSD, billingUnit } = estimateCloudCost({
69
+ service, operation, requests: 1, bytesOut, region: clientRegion,
70
+ });
71
+
72
+ cb.trackCloud({
73
+ provider: "aws",
74
+ service,
75
+ operation,
76
+ product: product || null,
77
+ team: team || null,
78
+ label: cbLabel,
79
+ requests: 1,
80
+ bytesIn,
81
+ bytesOut,
82
+ costUSD,
83
+ billingUnit,
84
+ latencyMs: Date.now() - start,
85
+ region: clientRegion,
86
+ });
87
+ } catch (err) {
88
+ if (debug) console.warn("[CloudBroker] trackCloud error:", err.message);
89
+ }
90
+
91
+ return result;
92
+ };
93
+
94
+ return client;
95
+ }
96
+
97
+ /**
98
+ * Middleware Express que atribui um label de negócio a todas as chamadas de
99
+ * nuvem feitas durante a requisição (via AsyncLocalStorage). Assim o custo por
100
+ * código agrupa por módulo/rota sem precisar de cbLabel manual em cada send.
101
+ *
102
+ * @param {object} [options]
103
+ * @param {string|((req)=>string)} [options.label] — label fixo, ou função que
104
+ * recebe o req e devolve o label (ex: mapear rota → módulo de negócio).
105
+ * Default: "MÉTODO /caminho" da requisição.
106
+ * @returns {(req,res,next)=>void}
107
+ */
108
+ static expressMiddleware(options = {}) {
109
+ const resolve =
110
+ typeof options.label === "function" ? options.label :
111
+ typeof options.label === "string" ? () => options.label :
112
+ (req) => `${req.method || ""} ${req.baseUrl || ""}${req.route?.path || req.path || ""}`.trim();
113
+
114
+ return (req, res, next) => {
115
+ let label = null;
116
+ try { label = resolve(req); } catch { label = null; }
117
+ cbStore.run({ label }, () => next());
118
+ };
119
+ }
120
+
121
+ /** Detecta o serviço AWS pelo nome do construtor do client. */
122
+ static #detectService(client) {
123
+ const name = client?.constructor?.name || "";
124
+ if (name.startsWith("S3")) return "s3";
125
+ if (name.startsWith("DynamoDB")) return "dynamodb"; // cobre DynamoDBClient e DynamoDBDocumentClient
126
+ return "unknown";
127
+ }
128
+
129
+ /** Tenta resolver a região configurada no client (pode ser função async ou string). */
130
+ static #resolveRegion(client) {
131
+ const r = client?.config?.region;
132
+ if (typeof r === "string") return r;
133
+ return null; // região via provider async não é resolvida no MVP — usa o fallback de options
134
+ }
135
+
136
+ /** Estima bytes de entrada a partir do input do comando (Body do S3, etc). */
137
+ static #inputBytes(input) {
138
+ if (!input) return 0;
139
+ const body = input.Body;
140
+ if (body == null) return 0;
141
+ if (typeof body === "string") return Buffer.byteLength(body);
142
+ if (Buffer.isBuffer(body)) return body.length;
143
+ if (body instanceof Uint8Array) return body.byteLength;
144
+ if (typeof input.ContentLength === "number") return input.ContentLength;
145
+ return 0; // streams não são medidos no MVP
146
+ }
147
+
148
+ /** Estima bytes de saída a partir do resultado (ContentLength de um GetObject, etc). */
149
+ static #outputBytes(result) {
150
+ if (!result) return 0;
151
+ if (typeof result.ContentLength === "number") return result.ContentLength;
152
+ return 0;
153
+ }
154
+ }
package/src/context.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Contexto de requisição via AsyncLocalStorage.
3
+ * Permite atribuir um label de negócio (módulo / rota) a todas as chamadas de
4
+ * nuvem disparadas dentro de uma requisição, sem precisar passar cbLabel manual
5
+ * em cada `.send()`. Server-side apenas (Node).
6
+ */
7
+ import { AsyncLocalStorage } from "node:async_hooks";
8
+
9
+ export const cbStore = new AsyncLocalStorage();
10
+
11
+ /** Label de negócio do contexto atual, se houver. */
12
+ export function currentLabel() {
13
+ return cbStore.getStore()?.label || null;
14
+ }
package/src/index.js CHANGED
@@ -8,4 +8,6 @@
8
8
  */
9
9
 
10
10
  export { CloudBrokerAI } from "./wrapper.js";
11
+ export { CloudBrokerCloud } from "./cloud-wrapper.js";
11
12
  export { CloudBrokerClient } from "./client.js";
13
+ export { estimateCloudCost, CLOUD_PRICING } from "./pricing-cloud.js";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tabela de preços de nuvem (curada, MVP) — atribuição de custo a nível de código.
3
+ * Escopo MVP: AWS, região us-east-2, serviços S3 e DynamoDB.
4
+ * Componentes: requests + transferência de dados (armazenamento GB-mês fora do MVP).
5
+ * Preços em USD. Atualizado: Jun/2026.
6
+ */
7
+ export const CLOUD_PRICING = {
8
+ aws: {
9
+ "us-east-2": {
10
+ s3: {
11
+ // por request (preço por unidade — já dividido por 1.000)
12
+ put: 0.005 / 1000, // PUT, COPY, POST, LIST
13
+ get: 0.0004 / 1000, // GET, SELECT e demais
14
+ // transferência de dados de saída para a internet — 1ª faixa (simplificado), por GB
15
+ transferOutPerGB: 0.09,
16
+ },
17
+ dynamodb: {
18
+ // on-demand
19
+ wru: 1.25 / 1_000_000, // write request unit
20
+ rru: 0.25 / 1_000_000, // read request unit
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ const GB = 1024 * 1024 * 1024;
27
+
28
+ // Operações S3 cobradas como PUT-class (mais caras). O resto cai em GET-class.
29
+ const S3_PUT_CLASS = new Set([
30
+ "PutObject", "CopyObject", "PostObject", "ListObjects", "ListObjectsV2",
31
+ "ListBuckets", "CreateMultipartUpload", "UploadPart", "CompleteMultipartUpload",
32
+ ]);
33
+
34
+ // Operações DynamoDB cobradas como escrita (WRU). O resto que gera custo cai em leitura (RRU).
35
+ const DDB_WRITE_OPS = new Set([
36
+ "PutItem", "UpdateItem", "DeleteItem", "BatchWriteItem", "TransactWriteItems",
37
+ ]);
38
+ const DDB_READ_OPS = new Set([
39
+ "GetItem", "Query", "Scan", "BatchGetItem", "TransactGetItems",
40
+ ]);
41
+
42
+ /**
43
+ * Classifica a operação e estima o custo USD de uma única chamada.
44
+ * MVP: aproxima 1 unidade de capacidade por request (não conhece o tamanho do item
45
+ * com precisão); a reconciliação contra o Cost Explorer corrige o desvio agregado.
46
+ *
47
+ * @param {object} p
48
+ * @param {string} p.service — "s3" | "dynamodb"
49
+ * @param {string} p.operation — nome da operação sem o sufixo "Command" (ex: "PutObject")
50
+ * @param {number} [p.requests=1]
51
+ * @param {number} [p.bytesOut=0] — bytes transferidos de saída (ex: ContentLength de um GetObject)
52
+ * @param {string} [p.region="us-east-2"]
53
+ * @returns {{ costUSD:number, billingUnit:string }}
54
+ */
55
+ export function estimateCloudCost({ service, operation, requests = 1, bytesOut = 0, region = "us-east-2" }) {
56
+ const regionTable = CLOUD_PRICING.aws[region] || CLOUD_PRICING.aws["us-east-2"];
57
+ const svc = (service || "").toLowerCase();
58
+ let costUSD = 0;
59
+ let billingUnit = "unknown";
60
+
61
+ if (svc === "s3") {
62
+ const t = regionTable.s3;
63
+ const isPut = S3_PUT_CLASS.has(operation);
64
+ costUSD += requests * (isPut ? t.put : t.get);
65
+ billingUnit = isPut ? "s3-put" : "s3-get";
66
+ if (bytesOut > 0) {
67
+ costUSD += (bytesOut / GB) * t.transferOutPerGB;
68
+ }
69
+ } else if (svc === "dynamodb") {
70
+ const t = regionTable.dynamodb;
71
+ if (DDB_WRITE_OPS.has(operation)) {
72
+ costUSD += requests * t.wru;
73
+ billingUnit = "ddb-wru";
74
+ } else if (DDB_READ_OPS.has(operation)) {
75
+ costUSD += requests * t.rru;
76
+ billingUnit = "ddb-rru";
77
+ } else {
78
+ billingUnit = "ddb-none"; // control-plane (ex: DescribeTable) — sem custo de request
79
+ }
80
+ }
81
+
82
+ return { costUSD: parseFloat(costUSD.toFixed(8)), billingUnit };
83
+ }
package/src/test.js CHANGED
@@ -74,3 +74,98 @@ wrapped.trackDeploy({
74
74
  linesRemoved: 30,
75
75
  });
76
76
  console.log("✅ Deploy trackado (fire-and-forget)");
77
+
78
+ // ─── CLOUD COST ATTRIBUTION (MVP) ───────────────────────────────────────────────
79
+ import assert from "node:assert";
80
+ import { CloudBrokerCloud } from "./cloud-wrapper.js";
81
+ import { estimateCloudCost } from "./pricing-cloud.js";
82
+
83
+ console.log("\n☁️ Testando atribuição de custo de nuvem...");
84
+
85
+ // 1) Tabela de preços / estimativa
86
+ const s3put = estimateCloudCost({ service: "s3", operation: "PutObject", requests: 1 });
87
+ assert.strictEqual(s3put.billingUnit, "s3-put");
88
+ assert.ok(Math.abs(s3put.costUSD - 0.000005) < 1e-9, `s3 put esperado 0.000005, veio ${s3put.costUSD}`);
89
+
90
+ const s3get = estimateCloudCost({ service: "s3", operation: "GetObject", requests: 1, bytesOut: 1024 * 1024 * 1024 });
91
+ assert.strictEqual(s3get.billingUnit, "s3-get");
92
+ // 0.0000004 (request) + 0.09 (1 GB de transferência)
93
+ assert.ok(Math.abs(s3get.costUSD - 0.0900004) < 1e-6, `s3 get+transfer veio ${s3get.costUSD}`);
94
+
95
+ const ddbW = estimateCloudCost({ service: "dynamodb", operation: "PutItem", requests: 1 });
96
+ assert.strictEqual(ddbW.billingUnit, "ddb-wru");
97
+ assert.ok(Math.abs(ddbW.costUSD - 0.00000125) < 1e-12, `ddb write veio ${ddbW.costUSD}`);
98
+
99
+ const ddbR = estimateCloudCost({ service: "dynamodb", operation: "GetItem", requests: 1 });
100
+ assert.strictEqual(ddbR.billingUnit, "ddb-rru");
101
+ assert.ok(Math.abs(ddbR.costUSD - 0.00000025) < 1e-12, `ddb read veio ${ddbR.costUSD}`);
102
+
103
+ const ddbCtl = estimateCloudCost({ service: "dynamodb", operation: "DescribeTable", requests: 1 });
104
+ assert.strictEqual(ddbCtl.billingUnit, "ddb-none");
105
+ assert.strictEqual(ddbCtl.costUSD, 0);
106
+ console.log("✅ estimateCloudCost: S3 put/get+transfer e DynamoDB wru/rru/control-plane");
107
+
108
+ // 2) wrapAWS — captura evento, não vaza cbLabel pro AWS, preserva resultado
109
+ const captured = [];
110
+ const realFetch = globalThis.fetch;
111
+ globalThis.fetch = async (url, opts) => {
112
+ if (String(url).includes("/api/cloud/events")) {
113
+ captured.push(JSON.parse(opts.body));
114
+ return { ok: true, status: 200, json: async () => ({}) };
115
+ }
116
+ return { ok: true, status: 200, json: async () => ({}) };
117
+ };
118
+
119
+ let sawAwsOptions; // o que o "AWS SDK" recebeu de options
120
+ class S3Client {
121
+ constructor(cfg) { this.config = cfg; }
122
+ async send(command, options) {
123
+ sawAwsOptions = options;
124
+ return { ContentLength: 2048, $metadata: { httpStatusCode: 200 } };
125
+ }
126
+ }
127
+ class PutObjectCommand {
128
+ constructor(input) { this.input = input; }
129
+ }
130
+
131
+ const s3 = CloudBrokerCloud.wrapAWS(new S3Client({ region: "us-east-2" }), {
132
+ apiKey: "cb-test-key", product: "checkout", team: "mobile", debug: true,
133
+ });
134
+
135
+ const putRes = await s3.send(new PutObjectCommand({ Bucket: "b", Key: "k", Body: "hello world" }), { cbLabel: "upload-nf" });
136
+ assert.strictEqual(putRes.ContentLength, 2048, "resultado original deve ser preservado");
137
+ assert.strictEqual(sawAwsOptions, undefined, "cbLabel não pode vazar pro AWS SDK (options ficou só com cbLabel → vira undefined)");
138
+
139
+ await s3.flushSync?.(); // garante envio
140
+ // pequena espera pro fire-and-forget
141
+ await new Promise(r => setTimeout(r, 50));
142
+
143
+ assert.strictEqual(captured.length, 1, `esperado 1 evento de nuvem, veio ${captured.length}`);
144
+ const ev = captured[0].events[0];
145
+ assert.strictEqual(ev.provider, "aws");
146
+ assert.strictEqual(ev.service, "s3");
147
+ assert.strictEqual(ev.operation, "PutObject");
148
+ assert.strictEqual(ev.product, "checkout");
149
+ assert.strictEqual(ev.team, "mobile");
150
+ assert.strictEqual(ev.label, "upload-nf");
151
+ assert.strictEqual(ev.bytesIn, Buffer.byteLength("hello world"));
152
+ assert.strictEqual(ev.billingUnit, "s3-put");
153
+ assert.ok(ev.costUSD > 0, "deve ter custo estimado");
154
+ console.log("✅ wrapAWS: evento capturado, cbLabel não vazou, resultado preservado");
155
+
156
+ // 3) expressMiddleware — label do contexto (ALS) entra sem cbLabel explícito
157
+ const mw = CloudBrokerCloud.expressMiddleware({ label: (req) => `modulo:${req.path}` });
158
+ const fakeReq = { method: "GET", path: "/api/billing" };
159
+ await new Promise((resolve) => {
160
+ mw(fakeReq, {}, async () => {
161
+ await s3.send(new PutObjectCommand({ Bucket: "b", Key: "k", Body: "x" })); // sem cbLabel — herda do contexto
162
+ resolve();
163
+ });
164
+ });
165
+ await new Promise((r) => setTimeout(r, 50));
166
+ const ctxEvent = captured[captured.length - 1].events[0];
167
+ assert.strictEqual(ctxEvent.label, "modulo:/api/billing", `label do contexto esperado, veio ${ctxEvent.label}`);
168
+ console.log("✅ expressMiddleware: label do contexto aplicado sem cbLabel explícito");
169
+
170
+ globalThis.fetch = realFetch;
171
+ console.log("\n🎉 Todos os testes passaram.");