cloudbroker-sdk 0.8.1 → 0.9.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
@@ -349,6 +349,29 @@ 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
+ ### Custo por operação (0.9.0)
353
+
354
+ 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`).
355
+
356
+ - `expressMiddleware` abre uma operação por requisição, e `trackExecution`, uma por execução. Quem já usa um dos dois não precisa mudar nada.
357
+ - Para jobs, filas e operações de negócio:
358
+
359
+ ```js
360
+ import { CloudBroker } from "cloudbroker-sdk";
361
+
362
+ await CloudBroker.operation("process-order", { product: "commerce", team: "checkout" }, async () => {
363
+ await s3.send(new PutObjectCommand({ ... })); // herda o operationId
364
+ await openai.chat.completions.create({ ... }); // idem
365
+ });
366
+ ```
367
+
368
+ - **Aninhamento:** uma operação aberta dentro de outra recebe id próprio e `parentOperationId`. Cada evento conta só para a operação mais interna, então a soma não conta nada duas vezes.
369
+ - **Nome:** use nome de baixa cardinalidade (`"GET /users/:id"`, nunca `"/users/123"`). O nome da operação vira o trecho (`label`).
370
+ - **Escrita:** nenhum evento de início ou fim de operação é enviado. O custo da operação é a soma dos eventos que o SDK já manda.
371
+ - **Fora desta versão:** `tenantId` (o tenant é sempre o da API key), `customerId` e `metadata` (risco de dado pessoal).
372
+ - `CloudBroker.currentOperation()` devolve os ids da operação corrente, para correlacionar com o log da aplicação.
373
+ - A tela que agrega por operação chega numa versão futura do backend (RFC-0026). Até lá, o campo é enviado e ignorado sem erro.
374
+
352
375
  ## Garantias
353
376
 
354
377
  - **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.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "CloudBroker SDK — Token Intelligence & Operational Financial Intelligence",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,7 +12,7 @@
12
12
  ".": "./src/index.js"
13
13
  },
14
14
  "scripts": {
15
- "test": "node src/test.js && node src/test-executions.js && node src/test-azure-real.js && node src/test-gemini-real.js"
15
+ "test": "node src/test.js && node src/test-executions.js && node src/test-operations.js && node src/test-azure-real.js && node src/test-gemini-real.js"
16
16
  },
17
17
  "keywords": [
18
18
  "cloudbroker",
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.8.1",
51
+ sdkVersion: "0.9.0",
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.8.1",
107
+ sdkVersion: "0.9.0",
108
108
  sentAt: new Date().toISOString(),
109
109
  };
110
110
 
@@ -35,7 +35,7 @@ import {
35
35
  AZURE_COSMOS_ITEMS_OPS,
36
36
  AZURE_COSMOS_ITEMS_ITERATOR_OPS,
37
37
  } from "./pricing-cloud.js";
38
- import { cbStore } from "./context.js";
38
+ import { cbStore, childOperationIds, operationFields } from "./context.js";
39
39
  import { resolveVersion } from "./executions.js";
40
40
 
41
41
  // Nomes de construtor do @azure/* → tipo do nó na cadeia de sub-clients.
@@ -177,6 +177,7 @@ export class CloudBrokerCloud {
177
177
  const cbLabel = sendOptions?.cbLabel || ctx.label || null;
178
178
  const evProduct = sendOptions?.cbProduct || ctx.product || product || null;
179
179
  const evTeam = sendOptions?.cbTeam || ctx.team || team || null;
180
+ const op = operationFields();
180
181
  const bytesIn = CloudBrokerCloud.#inputBytes(command?.input);
181
182
 
182
183
  // Passa adiante apenas as options nativas — nunca vaza campos cb* pro AWS SDK.
@@ -204,6 +205,7 @@ export class CloudBrokerCloud {
204
205
  latencyMs: Date.now() - start,
205
206
  region: clientRegion,
206
207
  version,
208
+ ...op,
207
209
  });
208
210
  } catch (err) {
209
211
  if (debug) console.warn("[CloudBroker] trackCloud error:", err.message);
@@ -243,7 +245,9 @@ export class CloudBrokerCloud {
243
245
 
244
246
  return (req, res, next) => {
245
247
  const safe = (fn) => { try { return fn(req); } catch { return undefined; } };
246
- const ctx = { label: safe(resolveLabel), product: safe(resolveProduct), team: safe(resolveTeam) };
248
+ // Uma operação por requisição (RFC-0026): os eventos de nuvem e IA feitos
249
+ // durante a requisição levam o mesmo operationId.
250
+ const ctx = { label: safe(resolveLabel), product: safe(resolveProduct), team: safe(resolveTeam), ...childOperationIds() };
247
251
  try {
248
252
  if (executions && typeof executions.count === "function") executions.count(ctx);
249
253
  } catch { /* contador nunca derruba a requisição */ }
@@ -264,7 +268,8 @@ export class CloudBrokerCloud {
264
268
  * @param {{product?: string, team?: string, executions?: import("./executions.js").ExecutionCounter}} [options]
265
269
  */
266
270
  static trackExecution(label, fn, options = {}) {
267
- const ctx = { label, product: options.product, team: options.team };
271
+ // Uma operação por execução (RFC-0026); dentro de outra operação, vira filha.
272
+ const ctx = { label, product: options.product, team: options.team, ...childOperationIds() };
268
273
  try {
269
274
  if (options.executions && typeof options.executions.count === "function") options.executions.count(ctx);
270
275
  } catch { /* contador nunca derruba a execução */ }
@@ -383,6 +388,7 @@ export class CloudBrokerCloud {
383
388
  latencyMs,
384
389
  region: env.region,
385
390
  version: env.version,
391
+ ...operationFields(),
386
392
  });
387
393
  } catch (err) {
388
394
  if (env.debug) console.warn("[CloudBroker] trackCloud error:", err.message);
package/src/context.js CHANGED
@@ -3,8 +3,14 @@
3
3
  * Permite atribuir um label de negócio (módulo / rota) a todas as chamadas de
4
4
  * nuvem disparadas dentro de uma requisição, sem precisar passar cbLabel manual
5
5
  * em cada `.send()`. Server-side apenas (Node).
6
+ *
7
+ * Desde a 0.9.0 (RFC-0026, custo por operação) o mesmo contexto carrega o
8
+ * `operationId` da execução corrente — não existe um segundo mecanismo de
9
+ * contexto. Os wrappers anexam esse id aos eventos, e o custo de UMA execução
10
+ * passa a ser um agrupamento na leitura (nenhuma escrita a mais).
6
11
  */
7
12
  import { AsyncLocalStorage } from "node:async_hooks";
13
+ import { randomBytes } from "node:crypto";
8
14
 
9
15
  export const cbStore = new AsyncLocalStorage();
10
16
 
@@ -12,3 +18,53 @@ export const cbStore = new AsyncLocalStorage();
12
18
  export function currentLabel() {
13
19
  return cbStore.getStore()?.label || null;
14
20
  }
21
+
22
+ /** Formato aceito para operationId: "op_" + UUID v7 em minúsculas. */
23
+ export const OPERATION_ID_RE = /^op_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
24
+
25
+ /**
26
+ * Gera um operationId local ("op_" + UUID v7), sem ida ao backend.
27
+ * UUID v7 começa pelo timestamp em ms, então ids ordenam por tempo — útil para
28
+ * a leitura por janela no armazenamento analítico. Nunca contém dado do cliente.
29
+ */
30
+ export function newOperationId(now = Date.now()) {
31
+ const b = randomBytes(16);
32
+ const ms = BigInt(Math.max(0, Math.floor(now)));
33
+ for (let i = 0; i < 6; i++) b[i] = Number((ms >> BigInt(8 * (5 - i))) & 0xffn);
34
+ b[6] = (b[6] & 0x0f) | 0x70; // versão 7
35
+ b[8] = (b[8] & 0x3f) | 0x80; // variante RFC 4122
36
+ const h = b.toString("hex");
37
+ return `op_${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
38
+ }
39
+
40
+ /**
41
+ * Ids de uma operação nova aberta DENTRO do contexto atual: o pai é a
42
+ * operação corrente, se houver. Nunca lança — sem id, a execução segue sem
43
+ * correlação, igual a antes da 0.9.0.
44
+ */
45
+ export function childOperationIds() {
46
+ try {
47
+ const parent = cbStore.getStore()?.operationId;
48
+ return { operationId: newOperationId(), ...(parent ? { parentOperationId: parent } : {}) };
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Campos de operação para anexar a um evento. Vazio fora de uma operação, para
56
+ * que o evento saia idêntico ao formato anterior (compatibilidade V1).
57
+ * Chamar no INÍCIO da chamada instrumentada (como o label), não na emissão:
58
+ * o fim de um stream pode rodar fora da operação que o abriu.
59
+ */
60
+ export function operationFields() {
61
+ try {
62
+ const s = cbStore.getStore();
63
+ if (!s?.operationId) return {};
64
+ return s.parentOperationId
65
+ ? { operationId: s.operationId, parentOperationId: s.parentOperationId }
66
+ : { operationId: s.operationId };
67
+ } catch {
68
+ return {};
69
+ }
70
+ }
package/src/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * const openai = CloudBrokerAI.wrap(new OpenAI(), { apiKey: "cb-key-xxx", product: "checkout", team: "mobile" });
8
8
  */
9
9
 
10
+ export { CloudBroker } from "./operation.js";
10
11
  export { CloudBrokerAI } from "./wrapper.js";
11
12
  export { CloudBrokerCloud } from "./cloud-wrapper.js";
12
13
  export { CloudBrokerClient } from "./client.js";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * CloudBroker.operation — custo por operação (0.9.0, RFC-0026).
3
+ *
4
+ * Abre uma operação lógica (job, fila, operação de negócio) no MESMO contexto
5
+ * AsyncLocalStorage do expressMiddleware/trackExecution: as chamadas de
6
+ * nuvem e IA feitas dentro do callback levam o mesmo `operationId`. Não há
7
+ * evento de início/fim de operação — o custo da operação é a soma, na leitura,
8
+ * dos eventos que já são enviados (nenhuma escrita a mais por execução).
9
+ *
10
+ * Uso:
11
+ * import { CloudBroker } from "cloudbroker-sdk";
12
+ * await CloudBroker.operation("process-order", { product: "commerce", team: "checkout" }, async () => {
13
+ * await s3.send(...); // wrapAWS herda o operationId
14
+ * await openai.chat.completions.create(...); // CloudBrokerAI.wrap também
15
+ * });
16
+ *
17
+ * Aninhada: a operação interna ganha id próprio e `parentOperationId` = o da
18
+ * externa; cada evento pertence só à operação mais interna (soma plana sem
19
+ * contagem dupla).
20
+ *
21
+ * De propósito FORA desta versão: tenantId (o tenant é sempre o da API key, no
22
+ * servidor), customerId e metadata (risco de dado pessoal — RFC-0026 §3).
23
+ */
24
+ import { cbStore, childOperationIds, operationFields } from "./context.js";
25
+
26
+ const OPERATION_TYPES = new Set(["http", "business", "job", "queue", "graphql", "database", "ai", "custom"]);
27
+
28
+ /** Texto curto e sem caractere de controle; qualquer outra coisa vira null. */
29
+ function text(v, max = 128) {
30
+ if (typeof v !== "string") return null;
31
+ const t = v.replace(/[\u0000-\u001F\u007F]/g, "").trim();
32
+ return t ? t.slice(0, max) : null;
33
+ }
34
+
35
+ export class CloudBroker {
36
+ /**
37
+ * @template T
38
+ * @param {string} name — nome da operação, de BAIXA cardinalidade
39
+ * ("process-order", "GET /users/:id"), nunca com id ou dado do usuário.
40
+ * Vira o label (trecho) das chamadas feitas dentro dela.
41
+ * @param {{label?: string, product?: string, team?: string, operationType?: string,
42
+ * executions?: import("./executions.js").ExecutionCounter}} [options]
43
+ * product/team ausentes herdam da operação externa.
44
+ * @param {() => T} callback — síncrono ou assíncrono
45
+ * @returns {T} o que o callback devolver (inclusive a Promise); o erro do
46
+ * callback propaga intacto. A instrumentação em si nunca lança.
47
+ */
48
+ static operation(name, options, callback) {
49
+ if (typeof options === "function" && callback === undefined) { callback = options; options = {}; }
50
+ if (typeof callback !== "function") {
51
+ throw new TypeError("[CloudBroker] operation(name, options, callback): callback deve ser uma função.");
52
+ }
53
+
54
+ let ctx = null;
55
+ try {
56
+ const parent = cbStore.getStore() || {};
57
+ const o = options && typeof options === "object" ? options : {};
58
+ const opName = text(name);
59
+ ctx = {
60
+ label: text(o.label) || opName || parent.label || null,
61
+ product: text(o.product) ?? parent.product ?? null,
62
+ team: text(o.team) ?? parent.team ?? null,
63
+ operationName: opName,
64
+ operationType: OPERATION_TYPES.has(o.operationType) ? o.operationType : "business",
65
+ ...childOperationIds(),
66
+ };
67
+ try {
68
+ if (o.executions && typeof o.executions.count === "function") o.executions.count(ctx);
69
+ } catch { /* contador nunca derruba a operação */ }
70
+ } catch {
71
+ ctx = null;
72
+ }
73
+ return ctx ? cbStore.run(ctx, callback) : callback();
74
+ }
75
+
76
+ /**
77
+ * Ids da operação corrente ({operationId, parentOperationId?}) ou `{}` fora
78
+ * de uma operação — ex.: para correlacionar com o log da própria aplicação.
79
+ */
80
+ static currentOperation() {
81
+ return operationFields();
82
+ }
83
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Custo por operação (0.9.0, RFC-0026). Execute: node src/test-operations.js
3
+ * Sem rede: fetch é substituído por um falso que registra o que seria enviado.
4
+ */
5
+ import assert from "node:assert";
6
+ import { readFileSync } from "node:fs";
7
+ import { CloudBroker, CloudBrokerCloud, CloudBrokerAI } from "./index.js";
8
+ import { cbStore, newOperationId, OPERATION_ID_RE, operationFields } from "./context.js";
9
+
10
+ let pass = 0;
11
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log("✅", msg); };
12
+ const tick = () => new Promise((r) => setTimeout(r, 20));
13
+
14
+ const cloudSent = [];
15
+ const aiSent = [];
16
+ globalThis.fetch = async (url, opts) => {
17
+ const body = JSON.parse(opts.body);
18
+ if (String(url).includes("/api/cloud/events")) cloudSent.push(...body.events);
19
+ if (String(url).includes("/api/ai/events")) aiSent.push(...body.events);
20
+ return { ok: true, status: 200, json: async () => ({}) };
21
+ };
22
+
23
+ class S3Client {
24
+ constructor(cfg) { this.config = cfg; }
25
+ async send() { return { ContentLength: 10, $metadata: {} }; }
26
+ }
27
+ class PutObjectCommand { constructor(input) { this.input = input; } }
28
+ class OpenAI {
29
+ constructor() {
30
+ this.chat = { completions: { create: async (p) => ({ model: p.model, usage: { prompt_tokens: 100, completion_tokens: 50 } }) } };
31
+ }
32
+ }
33
+
34
+ const s3 = CloudBrokerCloud.wrapAWS(new S3Client({ region: "us-east-2" }), { apiKey: "cb-test", product: "loja" });
35
+ const openai = CloudBrokerAI.wrap(new OpenAI(), { apiKey: "cb-test", product: "loja" });
36
+ const put = () => s3.send(new PutObjectCommand({ Bucket: "b", Key: "k", Body: "x" }));
37
+ const ask = () => openai.chat.completions.create({ model: "gpt-4o-mini", messages: [] });
38
+
39
+ // ─── id ──────────────────────────────────────────────────────────────────────
40
+ {
41
+ const a = newOperationId(Date.parse("2026-10-17T12:00:00Z"));
42
+ const b = newOperationId(Date.parse("2026-10-17T12:00:01Z"));
43
+ ok(OPERATION_ID_RE.test(a) && OPERATION_ID_RE.test(b), "operationId = op_ + UUID v7 (versão 7, variante RFC 4122)");
44
+ ok(a < b, "ids ordenam por tempo (UUID v7 começa pelo timestamp)");
45
+ const many = new Set(Array.from({ length: 2000 }, () => newOperationId()));
46
+ ok(many.size === 2000, "2000 ids no mesmo ms não colidem");
47
+ }
48
+
49
+ // ─── critério de saída da Fase 1: S3 + IA no mesmo operationId ───────────────
50
+ {
51
+ cloudSent.length = 0; aiSent.length = 0;
52
+ let inside;
53
+ const r = await CloudBroker.operation("process-order", { team: "checkout" }, async () => {
54
+ inside = CloudBroker.currentOperation().operationId;
55
+ await put();
56
+ await ask();
57
+ return 42;
58
+ });
59
+ await tick();
60
+ ok(r === 42, "devolve o que o callback devolver");
61
+ ok(OPERATION_ID_RE.test(inside), "currentOperation() expõe o id dentro da operação");
62
+ ok(cloudSent.length === 1 && aiSent.length === 1, "1 evento de nuvem + 1 de IA (nenhum evento de ciclo de vida)");
63
+ ok(cloudSent[0].operationId === inside && aiSent[0].operationId === inside, "S3 e IA saem com o MESMO operationId");
64
+ ok(cloudSent[0].label === "process-order" && aiSent[0].label === "process-order", "nome da operação vira o label (trecho)");
65
+ ok(cloudSent[0].team === "checkout" && cloudSent[0].product === "loja", "team da operação; product estático do wrapAWS preservado");
66
+ ok(!("parentOperationId" in cloudSent[0]), "operação raiz não tem pai");
67
+ }
68
+
69
+ // ─── fora de operação: evento idêntico ao V1 ─────────────────────────────────
70
+ {
71
+ cloudSent.length = 0; aiSent.length = 0;
72
+ await put(); await ask(); await tick();
73
+ ok(!("operationId" in cloudSent[0]) && !("operationId" in aiSent[0]), "sem operação ativa o evento não ganha campo novo (V1)");
74
+ ok(Object.keys(operationFields()).length === 0 && Object.keys(CloudBroker.currentOperation()).length === 0, "fora de operação: {}");
75
+ }
76
+
77
+ // ─── aninhada: id próprio, pai, sem contagem dupla ───────────────────────────
78
+ {
79
+ cloudSent.length = 0;
80
+ let outerId, innerId;
81
+ await CloudBroker.operation("checkout", { product: "commerce" }, async () => {
82
+ outerId = CloudBroker.currentOperation().operationId;
83
+ await put();
84
+ await CloudBroker.operation("calculate-shipping", {}, async () => {
85
+ innerId = CloudBroker.currentOperation().operationId;
86
+ await put();
87
+ await put();
88
+ });
89
+ ok(CloudBroker.currentOperation().operationId === outerId, "ao sair da filha, volta à operação externa");
90
+ });
91
+ await tick();
92
+ const outer = cloudSent.filter((e) => e.operationId === outerId);
93
+ const inner = cloudSent.filter((e) => e.operationId === innerId);
94
+ ok(innerId !== outerId, "filha recebe operationId próprio");
95
+ ok(outer.length === 1 && inner.length === 2, "cada evento pertence só à operação mais interna (soma plana sem contagem dupla)");
96
+ ok(inner.every((e) => e.parentOperationId === outerId), "eventos da filha levam parentOperationId = externa");
97
+ ok(inner[0].product === "commerce" && inner[0].label === "calculate-shipping", "filha herda product da externa; label é o próprio nome");
98
+ }
99
+
100
+ // ─── middleware e trackExecution criam operação ──────────────────────────────
101
+ {
102
+ const mw = CloudBrokerCloud.expressMiddleware({ label: "POST /orders" });
103
+ const ids = [];
104
+ for (let i = 0; i < 2; i++) {
105
+ await new Promise((r) => mw({ method: "POST", path: "/orders" }, {}, () => { ids.push(cbStore.getStore().operationId); r(); }));
106
+ }
107
+ ok(ids.every((id) => OPERATION_ID_RE.test(id)) && ids[0] !== ids[1], "expressMiddleware: uma operação nova por requisição");
108
+
109
+ let jobId, jobParent;
110
+ await CloudBroker.operation("batch", {}, async () => {
111
+ const outer = CloudBroker.currentOperation().operationId;
112
+ await CloudBrokerCloud.trackExecution("job:item", async () => {
113
+ jobId = cbStore.getStore().operationId; jobParent = cbStore.getStore().parentOperationId;
114
+ });
115
+ ok(jobParent === outer && jobId !== outer, "trackExecution dentro de operação vira filha");
116
+ });
117
+ ok(OPERATION_ID_RE.test(jobId), "trackExecution gera operationId");
118
+ }
119
+
120
+ // ─── síncrono, erro, uso indevido ────────────────────────────────────────────
121
+ {
122
+ ok(CloudBroker.operation("sync", {}, () => 7) === 7, "callback síncrono devolve valor direto (sem Promise)");
123
+ ok(CloudBroker.operation("sem-options", () => "ok") === "ok", "options é opcional");
124
+ class MeuErro extends Error {}
125
+ let e1; try { CloudBroker.operation("x", {}, () => { throw new MeuErro("sync"); }); } catch (e) { e1 = e; }
126
+ ok(e1 instanceof MeuErro && e1.message === "sync", "erro síncrono do callback propaga com o mesmo tipo");
127
+ let e2; try { await CloudBroker.operation("x", {}, async () => { throw new MeuErro("async"); }); } catch (e) { e2 = e; }
128
+ ok(e2 instanceof MeuErro && e2.message === "async", "rejeição do callback propaga com o mesmo tipo");
129
+ let e3; try { CloudBroker.operation("x", {}); } catch (e) { e3 = e; }
130
+ ok(e3 instanceof TypeError, "sem callback: TypeError claro (erro de uso, não de instrumentação)");
131
+ ok(CloudBroker.operation({ evil: 1 }, null, () => "segue") === "segue", "nome/options inválidos não derrubam a execução");
132
+ const contadorQuebrado = { count() { throw new Error("x"); } };
133
+ ok(CloudBroker.operation("x", { executions: contadorQuebrado }, () => 1) === 1, "contador que lança não derruba a operação");
134
+ }
135
+
136
+ // ─── fronteira de privacidade (RFC-0026 §3) ──────────────────────────────────
137
+ {
138
+ cloudSent.length = 0;
139
+ await CloudBroker.operation("x\n\u0007forjado", { tenantId: "outro-tenant", customerId: "a@b.com", metadata: { cpf: "1" } }, async () => { await put(); });
140
+ await tick();
141
+ const ev = cloudSent[0];
142
+ ok(!("tenantId" in ev) && !("customerId" in ev) && !("metadata" in ev), "tenantId/customerId/metadata NÃO saem do SDK nesta fase");
143
+ ok(ev.label === "xforjado", "nome da operação sem caractere de controle (não forja linha de log)");
144
+ }
145
+
146
+ // ─── Azure e Anthropic também propagam ───────────────────────────────────────
147
+ {
148
+ class BlockBlobClient { async uploadData() { return { etag: "1" }; } }
149
+ class ContainerClient { getBlockBlobClient() { return new BlockBlobClient(); } }
150
+ class BlobServiceClient { getContainerClient() { return new ContainerClient(); } }
151
+ class Anthropic {
152
+ constructor() { this.messages = { create: async (p) => ({ model: p.model, usage: { input_tokens: 10, output_tokens: 5 } }) }; }
153
+ }
154
+ const blob = CloudBrokerCloud.wrapAzure(new BlobServiceClient(), { apiKey: "cb-test" });
155
+ const claude = CloudBrokerAI.wrap(new Anthropic(), { apiKey: "cb-test" });
156
+ cloudSent.length = 0; aiSent.length = 0;
157
+ let id;
158
+ await CloudBroker.operation("azure-op", {}, async () => {
159
+ id = CloudBroker.currentOperation().operationId;
160
+ await blob.getContainerClient("c").getBlockBlobClient("b").uploadData(Buffer.from("x"));
161
+ await claude.messages.create({ model: "claude-haiku-4-5", max_tokens: 10, messages: [] });
162
+ });
163
+ await tick();
164
+ ok(cloudSent[0]?.provider === "azure" && cloudSent[0].operationId === id, "wrapAzure propaga o operationId");
165
+ ok(aiSent[0]?.provider === "anthropic" && aiSent[0].operationId === id, "wrap Anthropic propaga o operationId");
166
+ }
167
+
168
+ // ─── versão anunciada = package.json ─────────────────────────────────────────
169
+ {
170
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
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");
173
+ }
174
+
175
+ console.log(`\n${pass} asserções ok — custo por operação`);
package/src/wrapper.js CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { CloudBrokerClient } from "./client.js";
7
7
  import { PRICING, priceGemini, priceAnthropic } from "./pricing.js";
8
- import { cbStore } from "./context.js";
8
+ import { cbStore, operationFields } from "./context.js";
9
9
 
10
10
  /**
11
11
  * Evento de uma chamada da Anthropic. O preço vem de `priceAnthropic`: inclui
@@ -122,6 +122,7 @@ export class CloudBrokerAI {
122
122
  // Mesma precedência de cloud-wrapper.js: explícito na chamada > contexto
123
123
  // cbStore (expressMiddleware) > nenhum. Nunca vaza cbLabel pro SDK do provider.
124
124
  const cbLabel = options?.cbLabel || cbStore.getStore()?.label || null;
125
+ const op = operationFields(); // capturado no início, como o label (RFC-0026)
125
126
  const { cbLabel: _l, ...providerOptions } = options || {};
126
127
  const fwdOptions = Object.keys(providerOptions).length ? providerOptions : undefined;
127
128
 
@@ -159,6 +160,7 @@ export class CloudBrokerAI {
159
160
  latencyMs: Date.now() - start,
160
161
  endpoint: "chat.completions",
161
162
  streaming: true,
163
+ ...op,
162
164
  });
163
165
  } catch (err) {
164
166
  if (debug) console.warn("[CloudBroker] track error:", err.message);
@@ -192,6 +194,7 @@ export class CloudBrokerAI {
192
194
  costUSD: parseFloat(costUSD.toFixed(6)),
193
195
  latencyMs: ms,
194
196
  endpoint: "chat.completions",
197
+ ...op,
195
198
  });
196
199
  } catch (err) {
197
200
  if (debug) console.warn("[CloudBroker] track error:", err.message);
@@ -209,6 +212,7 @@ export class CloudBrokerAI {
209
212
  client.messages.create = async (params, options) => {
210
213
  const start = Date.now();
211
214
  const cbLabel = options?.cbLabel || cbStore.getStore()?.label || null;
215
+ const op = operationFields(); // capturado no início, como o label (RFC-0026)
212
216
  const { cbLabel: _l, ...providerOptions } = options || {};
213
217
  const fwdOptions = Object.keys(providerOptions).length ? providerOptions : undefined;
214
218
 
@@ -230,7 +234,7 @@ export class CloudBrokerAI {
230
234
  };
231
235
  const onDone = () => {
232
236
  try {
233
- cb.track(anthropicEvent(model, usage, { product, team, label: cbLabel, start, streaming: true }));
237
+ cb.track({ ...anthropicEvent(model, usage, { product, team, label: cbLabel, start, streaming: true }), ...op });
234
238
  } catch (err) {
235
239
  if (debug) console.warn("[CloudBroker] track error:", err.message);
236
240
  }
@@ -245,7 +249,7 @@ export class CloudBrokerAI {
245
249
 
246
250
  try {
247
251
  const model = res.model || params.model || "unknown";
248
- cb.track(anthropicEvent(model, res.usage, { product, team, label: cbLabel, start, latencyMs: ms }));
252
+ cb.track({ ...anthropicEvent(model, res.usage, { product, team, label: cbLabel, start, latencyMs: ms }), ...op });
249
253
  } catch (err) {
250
254
  if (debug) console.warn("[CloudBroker] track error:", err.message);
251
255
  }
@@ -324,14 +328,15 @@ export class CloudBrokerAI {
324
328
  models.generateContent = async (params, options) => {
325
329
  const start = Date.now();
326
330
  const { label, fwd } = splitLabel(params, options);
331
+ const op = operationFields();
327
332
 
328
333
  const res = await originalGenerate(fwd);
329
334
 
330
335
  try {
331
336
  const model = res?.modelVersion || params?.model || "unknown";
332
- cb.track(buildEvent(model, res?.usageMetadata, {
337
+ cb.track({ ...buildEvent(model, res?.usageMetadata, {
333
338
  label, start, streaming: false, endpoint: "models.generateContent",
334
- }));
339
+ }), ...op });
335
340
  } catch (err) {
336
341
  if (debug) console.warn("[CloudBroker] track error:", err.message);
337
342
  }
@@ -345,6 +350,7 @@ export class CloudBrokerAI {
345
350
  models.generateContentStream = async (params, options) => {
346
351
  const start = Date.now();
347
352
  const { label, fwd } = splitLabel(params, options);
353
+ const op = operationFields();
348
354
 
349
355
  const stream = await originalStream(fwd);
350
356
 
@@ -358,9 +364,9 @@ export class CloudBrokerAI {
358
364
  };
359
365
  const onDone = () => {
360
366
  try {
361
- cb.track(buildEvent(model, usage, {
367
+ cb.track({ ...buildEvent(model, usage, {
362
368
  label, start, streaming: true, endpoint: "models.generateContentStream",
363
- }));
369
+ }), ...op });
364
370
  } catch (err) {
365
371
  if (debug) console.warn("[CloudBroker] track error:", err.message);
366
372
  }