cloudbroker-sdk 0.8.1 → 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 +40 -0
- package/package.json +2 -2
- package/src/client.js +2 -2
- package/src/cloud-wrapper.js +21 -6
- package/src/context.js +56 -0
- package/src/executions.js +54 -7
- package/src/index.js +1 -0
- package/src/observe.js +29 -0
- package/src/operation.js +84 -0
- package/src/test-executions.js +93 -0
- package/src/test-operations.js +175 -0
- package/src/wrapper.js +13 -7
package/README.md
CHANGED
|
@@ -349,6 +349,46 @@ 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
|
+
|
|
369
|
+
### Custo por operação (0.9.0)
|
|
370
|
+
|
|
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`).
|
|
372
|
+
|
|
373
|
+
- `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.
|
|
374
|
+
- Para jobs, filas e operações de negócio:
|
|
375
|
+
|
|
376
|
+
```js
|
|
377
|
+
import { CloudBroker } from "cloudbroker-sdk";
|
|
378
|
+
|
|
379
|
+
await CloudBroker.operation("process-order", { product: "commerce", team: "checkout" }, async () => {
|
|
380
|
+
await s3.send(new PutObjectCommand({ ... })); // herda o operationId
|
|
381
|
+
await openai.chat.completions.create({ ... }); // idem
|
|
382
|
+
});
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
- **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.
|
|
386
|
+
- **Nome:** use nome de baixa cardinalidade (`"GET /users/:id"`, nunca `"/users/123"`). O nome da operação vira o trecho (`label`).
|
|
387
|
+
- **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.
|
|
388
|
+
- **Fora desta versão:** `tenantId` (o tenant é sempre o da API key), `customerId` e `metadata` (risco de dado pessoal).
|
|
389
|
+
- `CloudBroker.currentOperation()` devolve os ids da operação corrente, para correlacionar com o log da aplicação.
|
|
390
|
+
- 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.
|
|
391
|
+
|
|
352
392
|
## Garantias
|
|
353
393
|
|
|
354
394
|
- **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.1",
|
|
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.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.
|
|
107
|
+
sdkVersion: "0.9.1",
|
|
108
108
|
sentAt: new Date().toISOString(),
|
|
109
109
|
};
|
|
110
110
|
|
package/src/cloud-wrapper.js
CHANGED
|
@@ -35,7 +35,8 @@ import {
|
|
|
35
35
|
AZURE_COSMOS_ITEMS_OPS,
|
|
36
36
|
AZURE_COSMOS_ITEMS_ITERATOR_OPS,
|
|
37
37
|
} from "./pricing-cloud.js";
|
|
38
|
-
import {
|
|
38
|
+
import { runObserved } from "./observe.js";
|
|
39
|
+
import { cbStore, childOperationIds, operationFields } from "./context.js";
|
|
39
40
|
import { resolveVersion } from "./executions.js";
|
|
40
41
|
|
|
41
42
|
// Nomes de construtor do @azure/* → tipo do nó na cadeia de sub-clients.
|
|
@@ -177,6 +178,7 @@ export class CloudBrokerCloud {
|
|
|
177
178
|
const cbLabel = sendOptions?.cbLabel || ctx.label || null;
|
|
178
179
|
const evProduct = sendOptions?.cbProduct || ctx.product || product || null;
|
|
179
180
|
const evTeam = sendOptions?.cbTeam || ctx.team || team || null;
|
|
181
|
+
const op = operationFields();
|
|
180
182
|
const bytesIn = CloudBrokerCloud.#inputBytes(command?.input);
|
|
181
183
|
|
|
182
184
|
// Passa adiante apenas as options nativas — nunca vaza campos cb* pro AWS SDK.
|
|
@@ -204,6 +206,7 @@ export class CloudBrokerCloud {
|
|
|
204
206
|
latencyMs: Date.now() - start,
|
|
205
207
|
region: clientRegion,
|
|
206
208
|
version,
|
|
209
|
+
...op,
|
|
207
210
|
});
|
|
208
211
|
} catch (err) {
|
|
209
212
|
if (debug) console.warn("[CloudBroker] trackCloud error:", err.message);
|
|
@@ -243,9 +246,18 @@ export class CloudBrokerCloud {
|
|
|
243
246
|
|
|
244
247
|
return (req, res, next) => {
|
|
245
248
|
const safe = (fn) => { try { return fn(req); } catch { return undefined; } };
|
|
246
|
-
|
|
249
|
+
// Uma operação por requisição (RFC-0026): os eventos de nuvem e IA feitos
|
|
250
|
+
// durante a requisição levam o mesmo operationId.
|
|
251
|
+
const ctx = { label: safe(resolveLabel), product: safe(resolveProduct), team: safe(resolveTeam), ...childOperationIds() };
|
|
247
252
|
try {
|
|
248
|
-
if (executions && typeof executions.count === "function")
|
|
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
|
+
}
|
|
249
261
|
} catch { /* contador nunca derruba a requisição */ }
|
|
250
262
|
cbStore.run(ctx, () => next());
|
|
251
263
|
};
|
|
@@ -264,11 +276,13 @@ export class CloudBrokerCloud {
|
|
|
264
276
|
* @param {{product?: string, team?: string, executions?: import("./executions.js").ExecutionCounter}} [options]
|
|
265
277
|
*/
|
|
266
278
|
static trackExecution(label, fn, options = {}) {
|
|
267
|
-
|
|
279
|
+
// Uma operação por execução (RFC-0026); dentro de outra operação, vira filha.
|
|
280
|
+
const ctx = { label, product: options.product, team: options.team, ...childOperationIds() };
|
|
281
|
+
let fim = null;
|
|
268
282
|
try {
|
|
269
|
-
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);
|
|
270
284
|
} catch { /* contador nunca derruba a execução */ }
|
|
271
|
-
return
|
|
285
|
+
return runObserved(ctx, fn, fim);
|
|
272
286
|
}
|
|
273
287
|
|
|
274
288
|
/**
|
|
@@ -383,6 +397,7 @@ export class CloudBrokerCloud {
|
|
|
383
397
|
latencyMs,
|
|
384
398
|
region: env.region,
|
|
385
399
|
version: env.version,
|
|
400
|
+
...operationFields(),
|
|
386
401
|
});
|
|
387
402
|
} catch (err) {
|
|
388
403
|
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/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)
|
|
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)
|
|
142
|
-
|
|
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/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/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
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
import { runObserved } from "./observe.js";
|
|
26
|
+
|
|
27
|
+
const OPERATION_TYPES = new Set(["http", "business", "job", "queue", "graphql", "database", "ai", "custom"]);
|
|
28
|
+
|
|
29
|
+
/** Texto curto e sem caractere de controle; qualquer outra coisa vira null. */
|
|
30
|
+
function text(v, max = 128) {
|
|
31
|
+
if (typeof v !== "string") return null;
|
|
32
|
+
const t = v.replace(/[\u0000-\u001F\u007F]/g, "").trim();
|
|
33
|
+
return t ? t.slice(0, max) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class CloudBroker {
|
|
37
|
+
/**
|
|
38
|
+
* @template T
|
|
39
|
+
* @param {string} name — nome da operação, de BAIXA cardinalidade
|
|
40
|
+
* ("process-order", "GET /users/:id"), nunca com id ou dado do usuário.
|
|
41
|
+
* Vira o label (trecho) das chamadas feitas dentro dela.
|
|
42
|
+
* @param {{label?: string, product?: string, team?: string, operationType?: string,
|
|
43
|
+
* executions?: import("./executions.js").ExecutionCounter}} [options]
|
|
44
|
+
* product/team ausentes herdam da operação externa.
|
|
45
|
+
* @param {() => T} callback — síncrono ou assíncrono
|
|
46
|
+
* @returns {T} o que o callback devolver (inclusive a Promise); o erro do
|
|
47
|
+
* callback propaga intacto. A instrumentação em si nunca lança.
|
|
48
|
+
*/
|
|
49
|
+
static operation(name, options, callback) {
|
|
50
|
+
if (typeof options === "function" && callback === undefined) { callback = options; options = {}; }
|
|
51
|
+
if (typeof callback !== "function") {
|
|
52
|
+
throw new TypeError("[CloudBroker] operation(name, options, callback): callback deve ser uma função.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let ctx = null, fim = null;
|
|
56
|
+
try {
|
|
57
|
+
const parent = cbStore.getStore() || {};
|
|
58
|
+
const o = options && typeof options === "object" ? options : {};
|
|
59
|
+
const opName = text(name);
|
|
60
|
+
ctx = {
|
|
61
|
+
label: text(o.label) || opName || parent.label || null,
|
|
62
|
+
product: text(o.product) ?? parent.product ?? null,
|
|
63
|
+
team: text(o.team) ?? parent.team ?? null,
|
|
64
|
+
operationName: opName,
|
|
65
|
+
operationType: OPERATION_TYPES.has(o.operationType) ? o.operationType : "business",
|
|
66
|
+
...childOperationIds(),
|
|
67
|
+
};
|
|
68
|
+
try {
|
|
69
|
+
if (o.executions && typeof o.executions.count === "function") fim = o.executions.count(ctx);
|
|
70
|
+
} catch { /* contador nunca derruba a operação */ }
|
|
71
|
+
} catch {
|
|
72
|
+
ctx = null;
|
|
73
|
+
}
|
|
74
|
+
return ctx ? runObserved(ctx, callback, fim) : callback();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Ids da operação corrente ({operationId, parentOperationId?}) ou `{}` fora
|
|
79
|
+
* de uma operação — ex.: para correlacionar com o log da própria aplicação.
|
|
80
|
+
*/
|
|
81
|
+
static currentOperation() {
|
|
82
|
+
return operationFields();
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/test-executions.js
CHANGED
|
@@ -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"));
|
|
@@ -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.1" && [...client.matchAll(/sdkVersion:\s*"([^"]+)"/g)].every((m) => m[1] === pkg.version), "sdkVersion 0.9.1 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
|
}
|