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 +23 -0
- package/package.json +2 -2
- package/src/client.js +2 -2
- package/src/cloud-wrapper.js +9 -3
- package/src/context.js +56 -0
- package/src/index.js +1 -0
- package/src/operation.js +83 -0
- package/src/test-operations.js +175 -0
- package/src/wrapper.js +13 -7
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.
|
|
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.
|
|
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.
|
|
107
|
+
sdkVersion: "0.9.0",
|
|
108
108
|
sentAt: new Date().toISOString(),
|
|
109
109
|
};
|
|
110
110
|
|
package/src/cloud-wrapper.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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";
|
package/src/operation.js
ADDED
|
@@ -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
|
}
|