cloudbroker-sdk 0.4.0 → 0.6.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/.github/workflows/publish.yml +18 -0
- package/README.md +83 -5
- package/docs/COST_PER_FEATURE.md +296 -0
- package/example-checkout-app/.env.example +13 -0
- package/example-checkout-app/README.md +225 -0
- package/example-checkout-app/package.json +28 -0
- package/example-checkout-app/server.js +209 -0
- package/example-checkout-app/test/e2e.js +97 -0
- package/package.json +13 -4
- package/src/client.js +17 -6
- package/src/cloud-wrapper.js +531 -5
- package/src/pricing-cloud.js +165 -10
- package/src/test-azure-real.js +169 -0
- package/src/test.js +316 -0
- package/src/wrapper.js +24 -4
package/src/cloud-wrapper.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CloudBrokerCloud — atribuição de custo de nuvem a nível de código
|
|
2
|
+
* CloudBrokerCloud — atribuição de custo de nuvem a nível de código.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* estima o custo de requests + transferência
|
|
6
|
-
*
|
|
4
|
+
* wrapAWS: envolve um client do AWS SDK v3 (S3 / DynamoDB) e, a cada chamada
|
|
5
|
+
* `.send()`, estima o custo de requests + transferência.
|
|
6
|
+
* wrapAzure: envolve um client do Azure SDK (Blob Storage / Cosmos DB) e
|
|
7
|
+
* intercepta os métodos de data-plane na cadeia de sub-clients — Cosmos usa o
|
|
8
|
+
* requestCharge (RUs) real de cada resposta.
|
|
9
|
+
* Ambos enviam eventos fire-and-forget ao backend, sem alterar o comportamento
|
|
10
|
+
* do client original.
|
|
7
11
|
*
|
|
8
12
|
* Uso:
|
|
9
13
|
* import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
@@ -23,9 +27,119 @@
|
|
|
23
27
|
*/
|
|
24
28
|
|
|
25
29
|
import { CloudBrokerClient } from "./client.js";
|
|
26
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
estimateCloudCost,
|
|
32
|
+
AZURE_BLOB_OPS,
|
|
33
|
+
AZURE_BLOB_ITERATOR_OPS,
|
|
34
|
+
AZURE_COSMOS_NODE_OPS,
|
|
35
|
+
AZURE_COSMOS_ITEMS_OPS,
|
|
36
|
+
AZURE_COSMOS_ITEMS_ITERATOR_OPS,
|
|
37
|
+
} from "./pricing-cloud.js";
|
|
27
38
|
import { cbStore } from "./context.js";
|
|
28
39
|
|
|
40
|
+
// Nomes de construtor do @azure/* → tipo do nó na cadeia de sub-clients.
|
|
41
|
+
const AZURE_BLOB_KIND = {
|
|
42
|
+
BlobServiceClient: "service",
|
|
43
|
+
ContainerClient: "container",
|
|
44
|
+
BlobClient: "blob",
|
|
45
|
+
BlockBlobClient: "blob",
|
|
46
|
+
AppendBlobClient: "blob",
|
|
47
|
+
PageBlobClient: "blob",
|
|
48
|
+
};
|
|
49
|
+
const AZURE_COSMOS_KIND = {
|
|
50
|
+
CosmosClient: "client",
|
|
51
|
+
Database: "database",
|
|
52
|
+
Container: "container",
|
|
53
|
+
Item: "item",
|
|
54
|
+
};
|
|
55
|
+
const AZURE_BLOB_FACTORIES = [
|
|
56
|
+
"getContainerClient", "getBlobClient", "getBlockBlobClient",
|
|
57
|
+
"getAppendBlobClient", "getPageBlobClient",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Objeto literal — exclui Buffer/Uint8Array/Array/instâncias de classe do SDK. */
|
|
61
|
+
function isPlainObject(v) {
|
|
62
|
+
if (v === null || typeof v !== "object") return false;
|
|
63
|
+
if (Buffer.isBuffer(v) || ArrayBuffer.isView(v) || Array.isArray(v)) return false;
|
|
64
|
+
const proto = Object.getPrototypeOf(v);
|
|
65
|
+
return proto === Object.prototype || proto === null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* RUs realmente cobradas pelo Cosmos. Respostas de `bulk`/`batch` não trazem
|
|
70
|
+
* requestCharge no topo — vêm por operação, e somar é o número certo (senão
|
|
71
|
+
* um bulk de 1000 escritas seria contabilizado como 1 leitura).
|
|
72
|
+
*/
|
|
73
|
+
function readRequestCharge(result) {
|
|
74
|
+
if (!result) return undefined;
|
|
75
|
+
if (typeof result.requestCharge === "number") return result.requestCharge;
|
|
76
|
+
const rows = Array.isArray(result) ? result
|
|
77
|
+
: Array.isArray(result?.result) ? result.result
|
|
78
|
+
: null;
|
|
79
|
+
if (rows) {
|
|
80
|
+
let sum = 0, saw = false;
|
|
81
|
+
for (const r of rows) {
|
|
82
|
+
if (typeof r?.requestCharge === "number") { sum += r.requestCharge; saw = true; }
|
|
83
|
+
}
|
|
84
|
+
if (saw) return sum;
|
|
85
|
+
}
|
|
86
|
+
const header = result.headers?.["x-ms-request-charge"];
|
|
87
|
+
if (header != null) {
|
|
88
|
+
const v = parseFloat(header);
|
|
89
|
+
if (Number.isFinite(v)) return v;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Instrumenta um async iterable para emitir só quando uma página é DE FATO
|
|
96
|
+
* buscada (o iterator do Azure é lazy — nenhum HTTP acontece na criação).
|
|
97
|
+
* `onceOnly` = emite 1 evento na primeira busca (for-await direto, onde o SDK
|
|
98
|
+
* pagina internamente e não dá pra observar as páginas seguintes).
|
|
99
|
+
*/
|
|
100
|
+
function wrapAsyncIterable(iterable, emit, onceOnly) {
|
|
101
|
+
let emitted = false;
|
|
102
|
+
const maybeEmit = (start) => {
|
|
103
|
+
if (onceOnly && emitted) return;
|
|
104
|
+
emitted = true;
|
|
105
|
+
emit(start);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// PagedAsyncIterableIterator: [Symbol.asyncIterator]() devolve `this`, então
|
|
109
|
+
// embrulhar `next` já cobre o for-await.
|
|
110
|
+
if (typeof iterable.next === "function") {
|
|
111
|
+
const orig = iterable.next.bind(iterable);
|
|
112
|
+
iterable.next = async (...a) => {
|
|
113
|
+
const start = Date.now();
|
|
114
|
+
const r = await orig(...a);
|
|
115
|
+
if (!r?.done) maybeEmit(start);
|
|
116
|
+
return r;
|
|
117
|
+
};
|
|
118
|
+
return iterable;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof iterable[Symbol.asyncIterator] === "function") {
|
|
122
|
+
const origAsync = iterable[Symbol.asyncIterator].bind(iterable);
|
|
123
|
+
iterable[Symbol.asyncIterator] = () => {
|
|
124
|
+
const it = origAsync();
|
|
125
|
+
const n = it.next.bind(it);
|
|
126
|
+
const wrapped = {
|
|
127
|
+
next: async (...a) => {
|
|
128
|
+
const start = Date.now();
|
|
129
|
+
const r = await n(...a);
|
|
130
|
+
if (!r?.done) maybeEmit(start);
|
|
131
|
+
return r;
|
|
132
|
+
},
|
|
133
|
+
[Symbol.asyncIterator]() { return wrapped; },
|
|
134
|
+
};
|
|
135
|
+
if (typeof it.return === "function") wrapped.return = it.return.bind(it);
|
|
136
|
+
if (typeof it.throw === "function") wrapped.throw = it.throw.bind(it);
|
|
137
|
+
return wrapped;
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return iterable;
|
|
141
|
+
}
|
|
142
|
+
|
|
29
143
|
export class CloudBrokerCloud {
|
|
30
144
|
/**
|
|
31
145
|
* @param {object} client — instância do AWS SDK v3 (ex: new S3Client())
|
|
@@ -124,6 +238,418 @@ export class CloudBrokerCloud {
|
|
|
124
238
|
};
|
|
125
239
|
}
|
|
126
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Envolve um client do Azure SDK (Blob Storage / Cosmos DB) e rastreia cada
|
|
243
|
+
* chamada de data-plane como evento de custo — mesmo contrato do wrapAWS.
|
|
244
|
+
*
|
|
245
|
+
* Azure não tem um `.send()` único como o AWS SDK v3: cada sub-client tem
|
|
246
|
+
* métodos próprios, e as fábricas (getContainerClient → getBlockBlobClient,
|
|
247
|
+
* database → container → items) criam sub-clients novos a cada chamada.
|
|
248
|
+
* Por isso a interceptação aqui é em cadeia: métodos de fábrica retornam
|
|
249
|
+
* sub-clients já embrulhados, e só os métodos de data-plane conhecidos são
|
|
250
|
+
* rastreados — qualquer método desconhecido passa direto, intocado.
|
|
251
|
+
*
|
|
252
|
+
* Garantia (igual ao wrapAWS): nada além da própria chamada Azure pode
|
|
253
|
+
* lançar. Se a instrumentação falhar, a aplicação do cliente não percebe.
|
|
254
|
+
*
|
|
255
|
+
* Cosmos: usa o requestCharge (RUs) REAL que o servidor retorna em cada
|
|
256
|
+
* resposta. O PREÇO por RU assumido é o de **serverless** — em throughput
|
|
257
|
+
* provisionado a Azure cobra RU/s reservada por hora, modelo que o SDK não
|
|
258
|
+
* modela ainda (ver README).
|
|
259
|
+
*
|
|
260
|
+
* Uso:
|
|
261
|
+
* const blobSvc = CloudBrokerCloud.wrapAzure(
|
|
262
|
+
* BlobServiceClient.fromConnectionString(conn),
|
|
263
|
+
* { apiKey: "cb-key-xxx", product: "checkout", team: "mobile" },
|
|
264
|
+
* );
|
|
265
|
+
* // label opcional dentro do objeto de options nativo do método:
|
|
266
|
+
* await blobSvc.getContainerClient("docs").getBlockBlobClient("nf.pdf")
|
|
267
|
+
* .uploadData(buf, { cbLabel: "upload-nota-fiscal" });
|
|
268
|
+
*
|
|
269
|
+
* @param {object} client — BlobServiceClient | ContainerClient | Blob*Client
|
|
270
|
+
* | CosmosClient | Database | Container
|
|
271
|
+
* @param {object} options — mesmos campos do wrapAWS (region default: "eastus2")
|
|
272
|
+
*/
|
|
273
|
+
static wrapAzure(client, options = {}) {
|
|
274
|
+
const { apiKey, product, team, region = "eastus2", endpoint, debug = false } = options;
|
|
275
|
+
if (!apiKey) throw new Error("[CloudBroker] apiKey é obrigatório.");
|
|
276
|
+
|
|
277
|
+
const name = client?.constructor?.name || "";
|
|
278
|
+
const blobKind = AZURE_BLOB_KIND[name];
|
|
279
|
+
const cosmosKind = AZURE_COSMOS_KIND[name];
|
|
280
|
+
if (!blobKind && !cosmosKind) {
|
|
281
|
+
// Aviso incondicional (não só em debug): sob bundler com mangling de nome
|
|
282
|
+
// de classe isto vira no-op, e um SDK que silenciosamente não coleta nada
|
|
283
|
+
// é pior que um que erra o rótulo. O wrapAWS degrada pra service:"unknown"
|
|
284
|
+
// justamente porque o gate dele é estrutural, não por nome.
|
|
285
|
+
console.warn(
|
|
286
|
+
`[CloudBroker] wrapAzure: client não reconhecido (${name || "sem construtor"}) — ` +
|
|
287
|
+
`nenhum custo será rastreado. Passe o client original do @azure/storage-blob ou @azure/cosmos.`,
|
|
288
|
+
);
|
|
289
|
+
return client;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (client.__cbWrapped) {
|
|
293
|
+
console.warn("[CloudBroker] wrapAzure: este client já foi embrulhado — ignorando a segunda chamada.");
|
|
294
|
+
return client;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const cb = new CloudBrokerClient({ apiKey, cloudEndpoint: endpoint, debug });
|
|
298
|
+
const env = { cb, product, team, region, debug };
|
|
299
|
+
return blobKind
|
|
300
|
+
? CloudBrokerCloud.#wrapBlobNode(client, env)
|
|
301
|
+
: CloudBrokerCloud.#wrapCosmosNode(client, env, cosmosKind);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Azure: helpers compartilhados ─────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Extrai cbLabel/cbProduct/cbTeam do objeto de options NATIVO do método.
|
|
308
|
+
*
|
|
309
|
+
* Só olha a partir de `optIdx` — a posição onde o options bag pode
|
|
310
|
+
* legalmente começar naquele método. Isso é o que impede tocar num
|
|
311
|
+
* argumento de DADO do cliente: em `items.create(doc, options)` o `doc`
|
|
312
|
+
* está no índice 0 e nunca é inspecionado, então um documento que por
|
|
313
|
+
* acaso tenha um campo `cbLabel` é gravado intacto no Cosmos.
|
|
314
|
+
* Também nunca remove o argumento — devolve o objeto sem as chaves cb*,
|
|
315
|
+
* porque `setMetadata()` sem argumento apaga os metadados do blob.
|
|
316
|
+
*/
|
|
317
|
+
static #extractCbArgs(args, optIdx) {
|
|
318
|
+
for (let i = Math.max(0, optIdx); i < args.length; i++) {
|
|
319
|
+
const a = args[i];
|
|
320
|
+
if (!isPlainObject(a)) continue;
|
|
321
|
+
if (a.cbLabel === undefined && a.cbProduct === undefined && a.cbTeam === undefined) continue;
|
|
322
|
+
const { cbLabel, cbProduct, cbTeam, ...rest } = a;
|
|
323
|
+
const cleaned = args.slice();
|
|
324
|
+
cleaned[i] = rest;
|
|
325
|
+
return { cbLabel, cbProduct, cbTeam, args: cleaned };
|
|
326
|
+
}
|
|
327
|
+
return { args };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Resolve label/product/team (explícito > contexto ALS > estático) e envia o evento. */
|
|
331
|
+
static #trackAzure(env, { service, operation, cbOpts = {}, requests = 1, bytesIn = 0, bytesOut = 0, requestCharge, latencyMs = 0 }) {
|
|
332
|
+
try {
|
|
333
|
+
const ctx = cbStore.getStore() || {};
|
|
334
|
+
const { costUSD, billingUnit } = estimateCloudCost({
|
|
335
|
+
provider: "azure", service, operation, requests, bytesOut, requestCharge, region: env.region,
|
|
336
|
+
});
|
|
337
|
+
env.cb.trackCloud({
|
|
338
|
+
provider: "azure",
|
|
339
|
+
service,
|
|
340
|
+
operation,
|
|
341
|
+
product: cbOpts.cbProduct || ctx.product || env.product || null,
|
|
342
|
+
team: cbOpts.cbTeam || ctx.team || env.team || null,
|
|
343
|
+
label: cbOpts.cbLabel || ctx.label || null,
|
|
344
|
+
requests,
|
|
345
|
+
bytesIn,
|
|
346
|
+
bytesOut,
|
|
347
|
+
costUSD,
|
|
348
|
+
billingUnit,
|
|
349
|
+
latencyMs,
|
|
350
|
+
region: env.region,
|
|
351
|
+
});
|
|
352
|
+
} catch (err) {
|
|
353
|
+
if (env.debug) console.warn("[CloudBroker] trackCloud error:", err.message);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Bytes de ENTRADA de um upload: só conta o argumento de corpo declarado
|
|
359
|
+
* (`bodyIdx`), nunca "a primeira string que aparecer" — em
|
|
360
|
+
* `uploadBlockBlob(nome, corpo, len)` a primeira string é o NOME do blob.
|
|
361
|
+
*/
|
|
362
|
+
static #azureInputBytes(args, bodyIdx) {
|
|
363
|
+
if (bodyIdx == null) return 0;
|
|
364
|
+
const b = args[bodyIdx];
|
|
365
|
+
if (b == null) return 0;
|
|
366
|
+
if (typeof b === "string") return Buffer.byteLength(b);
|
|
367
|
+
if (Buffer.isBuffer(b)) return b.length;
|
|
368
|
+
if (b instanceof Uint8Array) return b.byteLength;
|
|
369
|
+
// Alguns métodos declaram o tamanho num argumento próprio (upload/stageBlock).
|
|
370
|
+
const len = args[bodyIdx + 1];
|
|
371
|
+
if (typeof len === "number" && Number.isFinite(len)) return len;
|
|
372
|
+
return 0; // streams/factories não são medidos
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Bytes de SAÍDA — só para operações que de fato transferem conteúdo.
|
|
377
|
+
* `getProperties`/`exists` também devolvem `contentLength` (o tamanho do
|
|
378
|
+
* blob), mas não trafegam o blob: contá-lo cobrava egress de um GET de
|
|
379
|
+
* metadado, inflando o custo em ordens de grandeza.
|
|
380
|
+
*/
|
|
381
|
+
static #azureOutputBytes(result) {
|
|
382
|
+
if (!result) return 0;
|
|
383
|
+
if (Buffer.isBuffer(result)) return result.length; // downloadToBuffer
|
|
384
|
+
if (typeof result.contentLength === "number") return result.contentLength;
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Azure Blob Storage ────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
static #wrapBlobNode(node, env) {
|
|
391
|
+
if (!node || typeof node !== "object" || node.__cbWrapped) return node;
|
|
392
|
+
if (!CloudBrokerCloud.#isMutable(node, env)) return node;
|
|
393
|
+
|
|
394
|
+
// Fábricas retornam sub-clients novos — embrulhados recursivamente.
|
|
395
|
+
for (const f of AZURE_BLOB_FACTORIES) {
|
|
396
|
+
if (typeof node[f] !== "function") continue;
|
|
397
|
+
const orig = node[f].bind(node);
|
|
398
|
+
CloudBrokerCloud.#safeAssign(node, f, (...args) => {
|
|
399
|
+
const sub = orig(...args);
|
|
400
|
+
try { return CloudBrokerCloud.#wrapBlobNode(sub, env); }
|
|
401
|
+
catch (err) {
|
|
402
|
+
if (env.debug) console.warn("[CloudBroker] wrap sub-client falhou:", err.message);
|
|
403
|
+
return sub; // instrumentação nunca pode custar o sub-client do cliente
|
|
404
|
+
}
|
|
405
|
+
}, env);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
for (const [op, spec] of Object.entries(AZURE_BLOB_OPS)) {
|
|
409
|
+
if (typeof node[op] !== "function") continue;
|
|
410
|
+
const orig = node[op].bind(node);
|
|
411
|
+
CloudBrokerCloud.#safeAssign(node, op, async (...rawArgs) => {
|
|
412
|
+
// Fase 1 (pré-chamada): nada aqui pode impedir a chamada real.
|
|
413
|
+
let call = { args: rawArgs }, bytesIn = 0, start = Date.now();
|
|
414
|
+
try {
|
|
415
|
+
call = CloudBrokerCloud.#extractCbArgs(rawArgs, spec.optIdx);
|
|
416
|
+
bytesIn = CloudBrokerCloud.#azureInputBytes(call.args, spec.bodyIdx);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
if (env.debug) console.warn("[CloudBroker] pré-tracking falhou:", err.message);
|
|
419
|
+
call = { args: rawArgs };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Fase 2: a ÚNICA coisa que pode lançar para o cliente.
|
|
423
|
+
const result = await orig(...call.args);
|
|
424
|
+
|
|
425
|
+
// Fase 3 (pós-chamada): a operação já aconteceu no Azure — daqui pra
|
|
426
|
+
// frente uma exceção nossa faria o cliente tratar como falha e
|
|
427
|
+
// retentar, gerando escrita duplicada.
|
|
428
|
+
try {
|
|
429
|
+
CloudBrokerCloud.#trackAzure(env, {
|
|
430
|
+
service: "blob", operation: op,
|
|
431
|
+
cbOpts: { cbLabel: call.cbLabel, cbProduct: call.cbProduct, cbTeam: call.cbTeam },
|
|
432
|
+
bytesIn,
|
|
433
|
+
bytesOut: spec.transfers ? CloudBrokerCloud.#azureOutputBytes(result) : 0,
|
|
434
|
+
latencyMs: Date.now() - start,
|
|
435
|
+
});
|
|
436
|
+
} catch (err) {
|
|
437
|
+
if (env.debug) console.warn("[CloudBroker] pós-tracking falhou:", err.message);
|
|
438
|
+
}
|
|
439
|
+
return result;
|
|
440
|
+
}, env);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// list* devolvem PagedAsyncIterableIterator LAZY — nenhum HTTP acontece na
|
|
444
|
+
// chamada. Rastrear aqui emitia custo de uma operação que podia nunca
|
|
445
|
+
// existir; o evento sai quando a página é de fato buscada.
|
|
446
|
+
for (const op of AZURE_BLOB_ITERATOR_OPS) {
|
|
447
|
+
if (typeof node[op] !== "function") continue;
|
|
448
|
+
const orig = node[op].bind(node);
|
|
449
|
+
CloudBrokerCloud.#safeAssign(node, op, (...rawArgs) => {
|
|
450
|
+
let call = { args: rawArgs };
|
|
451
|
+
try { call = CloudBrokerCloud.#extractCbArgs(rawArgs, 0); }
|
|
452
|
+
catch { call = { args: rawArgs }; }
|
|
453
|
+
const iterator = orig(...call.args);
|
|
454
|
+
try {
|
|
455
|
+
return CloudBrokerCloud.#wrapPagedIterator(iterator, {
|
|
456
|
+
service: "blob", operation: op, env,
|
|
457
|
+
cbOpts: { cbLabel: call.cbLabel, cbProduct: call.cbProduct, cbTeam: call.cbTeam },
|
|
458
|
+
});
|
|
459
|
+
} catch (err) {
|
|
460
|
+
if (env.debug) console.warn("[CloudBroker] wrap iterator falhou:", err.message);
|
|
461
|
+
return iterator;
|
|
462
|
+
}
|
|
463
|
+
}, env);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
CloudBrokerCloud.#markWrapped(node);
|
|
467
|
+
return node;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── Azure Cosmos DB ───────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* @param {"client"|"database"|"container"|"item"} kind — o tipo do nó.
|
|
474
|
+
* Necessário porque `Database` e `Container` do @azure/cosmos TAMBÉM
|
|
475
|
+
* expõem read/replace/delete (control-plane, sobre a própria definição do
|
|
476
|
+
* banco/container). Sem discriminar, um `database("x").read()` saía
|
|
477
|
+
* rotulado como `item.read` — operação errada numa tela cujo propósito é
|
|
478
|
+
* dizer qual trecho de código gasta.
|
|
479
|
+
*/
|
|
480
|
+
static #wrapCosmosNode(node, env, kind) {
|
|
481
|
+
if (!node || typeof node !== "object" || node.__cbWrapped) return node;
|
|
482
|
+
if (!CloudBrokerCloud.#isMutable(node, env)) return node;
|
|
483
|
+
|
|
484
|
+
const childKind = { client: "database", database: "container", container: "item" };
|
|
485
|
+
for (const [f, next] of [["database", "database"], ["container", "container"], ["item", "item"]]) {
|
|
486
|
+
if (typeof node[f] !== "function") continue;
|
|
487
|
+
if (childKind[kind] !== next) continue; // só a fábrica que faz sentido pro nó atual
|
|
488
|
+
const orig = node[f].bind(node);
|
|
489
|
+
CloudBrokerCloud.#safeAssign(node, f, (...args) => {
|
|
490
|
+
const sub = orig(...args);
|
|
491
|
+
try { return CloudBrokerCloud.#wrapCosmosNode(sub, env, next); }
|
|
492
|
+
catch (err) {
|
|
493
|
+
if (env.debug) console.warn("[CloudBroker] wrap sub-client falhou:", err.message);
|
|
494
|
+
return sub;
|
|
495
|
+
}
|
|
496
|
+
}, env);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// read/replace/delete/patch existem em item, container E database — o
|
|
500
|
+
// prefixo da operação vem do tipo do nó, não do nome do método.
|
|
501
|
+
for (const [op, spec] of Object.entries(AZURE_COSMOS_NODE_OPS)) {
|
|
502
|
+
if (typeof node[op] !== "function") continue;
|
|
503
|
+
const orig = node[op].bind(node);
|
|
504
|
+
CloudBrokerCloud.#safeAssign(node, op, CloudBrokerCloud.#cosmosAsyncOp(orig, `${kind}.${op}`, env, spec.optIdx), env);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Só Container tem .items
|
|
508
|
+
const items = node.items;
|
|
509
|
+
if (kind === "container" && items && typeof items === "object" && !items.__cbWrapped) {
|
|
510
|
+
for (const [op, spec] of Object.entries(AZURE_COSMOS_ITEMS_OPS)) {
|
|
511
|
+
if (typeof items[op] !== "function") continue;
|
|
512
|
+
const orig = items[op].bind(items);
|
|
513
|
+
CloudBrokerCloud.#safeAssign(items, op, CloudBrokerCloud.#cosmosAsyncOp(orig, `items.${op}`, env, spec.optIdx), env);
|
|
514
|
+
}
|
|
515
|
+
for (const [op, spec] of Object.entries(AZURE_COSMOS_ITEMS_ITERATOR_OPS)) {
|
|
516
|
+
if (typeof items[op] !== "function") continue;
|
|
517
|
+
const orig = items[op].bind(items);
|
|
518
|
+
CloudBrokerCloud.#safeAssign(items, op, (...rawArgs) => {
|
|
519
|
+
let call = { args: rawArgs };
|
|
520
|
+
try { call = CloudBrokerCloud.#extractCbArgs(rawArgs, spec.optIdx); }
|
|
521
|
+
catch { call = { args: rawArgs }; }
|
|
522
|
+
const iterator = orig(...call.args);
|
|
523
|
+
try {
|
|
524
|
+
return CloudBrokerCloud.#wrapCosmosIterator(iterator, `items.${op}`, {
|
|
525
|
+
cbLabel: call.cbLabel, cbProduct: call.cbProduct, cbTeam: call.cbTeam,
|
|
526
|
+
}, env);
|
|
527
|
+
} catch (err) {
|
|
528
|
+
if (env.debug) console.warn("[CloudBroker] wrap iterator falhou:", err.message);
|
|
529
|
+
return iterator;
|
|
530
|
+
}
|
|
531
|
+
}, env);
|
|
532
|
+
}
|
|
533
|
+
CloudBrokerCloud.#markWrapped(items);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
CloudBrokerCloud.#markWrapped(node);
|
|
537
|
+
return node;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Embrulha uma operação async do Cosmos — lê o requestCharge (RUs) real da resposta. */
|
|
541
|
+
static #cosmosAsyncOp(orig, operation, env, optIdx) {
|
|
542
|
+
return async (...rawArgs) => {
|
|
543
|
+
let call = { args: rawArgs }, start = Date.now();
|
|
544
|
+
try { call = CloudBrokerCloud.#extractCbArgs(rawArgs, optIdx); }
|
|
545
|
+
catch (err) {
|
|
546
|
+
if (env.debug) console.warn("[CloudBroker] pré-tracking falhou:", err.message);
|
|
547
|
+
call = { args: rawArgs };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const result = await orig(...call.args);
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
CloudBrokerCloud.#trackAzure(env, {
|
|
554
|
+
service: "cosmosdb", operation,
|
|
555
|
+
cbOpts: { cbLabel: call.cbLabel, cbProduct: call.cbProduct, cbTeam: call.cbTeam },
|
|
556
|
+
requestCharge: readRequestCharge(result),
|
|
557
|
+
latencyMs: Date.now() - start,
|
|
558
|
+
});
|
|
559
|
+
} catch (err) {
|
|
560
|
+
if (env.debug) console.warn("[CloudBroker] pós-tracking falhou:", err.message);
|
|
561
|
+
}
|
|
562
|
+
return result;
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Embrulha o QueryIterator do Cosmos — RUs reais por fetchAll/fetchNext. */
|
|
567
|
+
static #wrapCosmosIterator(iterator, operation, cbOpts, env) {
|
|
568
|
+
if (!iterator || typeof iterator !== "object" || iterator.__cbWrapped) return iterator;
|
|
569
|
+
for (const m of ["fetchAll", "fetchNext"]) {
|
|
570
|
+
if (typeof iterator[m] !== "function") continue;
|
|
571
|
+
const orig = iterator[m].bind(iterator);
|
|
572
|
+
iterator[m] = async (...args) => {
|
|
573
|
+
const start = Date.now();
|
|
574
|
+
const result = await orig(...args);
|
|
575
|
+
try {
|
|
576
|
+
CloudBrokerCloud.#trackAzure(env, {
|
|
577
|
+
service: "cosmosdb", operation: `${operation}.${m}`, cbOpts,
|
|
578
|
+
requestCharge: readRequestCharge(result),
|
|
579
|
+
latencyMs: Date.now() - start,
|
|
580
|
+
});
|
|
581
|
+
} catch (err) {
|
|
582
|
+
if (env.debug) console.warn("[CloudBroker] pós-tracking falhou:", err.message);
|
|
583
|
+
}
|
|
584
|
+
return result;
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
// for-await direto (getAsyncIterator) não é instrumentado — fetchAll/
|
|
588
|
+
// fetchNext cobrem o uso comum do @azure/cosmos. Documentado no README.
|
|
589
|
+
CloudBrokerCloud.#markWrapped(iterator);
|
|
590
|
+
return iterator;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Embrulha um PagedAsyncIterableIterator (list* do Blob): emite 1 evento por
|
|
595
|
+
* página REALMENTE buscada via byPage(); no for-await direto o SDK pagina
|
|
596
|
+
* internamente e só dá pra observar a primeira busca — nesse caso emite 1
|
|
597
|
+
* evento (aproximação declarada no README).
|
|
598
|
+
*/
|
|
599
|
+
static #wrapPagedIterator(iterator, { service, operation, env, cbOpts }) {
|
|
600
|
+
if (!iterator || typeof iterator !== "object" || iterator.__cbWrapped) return iterator;
|
|
601
|
+
|
|
602
|
+
const emit = (start) => {
|
|
603
|
+
try {
|
|
604
|
+
CloudBrokerCloud.#trackAzure(env, { service, operation, cbOpts, latencyMs: Date.now() - start });
|
|
605
|
+
} catch (err) {
|
|
606
|
+
if (env.debug) console.warn("[CloudBroker] pós-tracking falhou:", err.message);
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
if (typeof iterator.byPage === "function") {
|
|
611
|
+
const origByPage = iterator.byPage.bind(iterator);
|
|
612
|
+
iterator.byPage = (...args) => {
|
|
613
|
+
const pages = origByPage(...args);
|
|
614
|
+
try { return wrapAsyncIterable(pages, emit, false); } // 1 evento POR página
|
|
615
|
+
catch { return pages; }
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// for-await direto / .next(): 1 evento na primeira busca de fato.
|
|
620
|
+
try { return wrapAsyncIterable(iterator, emit, true); }
|
|
621
|
+
catch { return iterator; }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** defineProperty pode lançar em objeto congelado — nunca deve derrubar o cliente. */
|
|
625
|
+
static #markWrapped(node) {
|
|
626
|
+
try { Object.defineProperty(node, "__cbWrapped", { value: true, enumerable: false, configurable: true }); }
|
|
627
|
+
catch { /* objeto não extensível: segue sem marca (pior caso, re-wrap) */ }
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* O wrap funciona mutando o client in-place. Se o cliente congelou (ou selou)
|
|
632
|
+
* a instância, a atribuição lança — em strict mode, que é o caso de um módulo
|
|
633
|
+
* ES. Instrumentação nunca pode derrubar a aplicação dele: avisa e devolve o
|
|
634
|
+
* client original, sem rastrear.
|
|
635
|
+
*/
|
|
636
|
+
static #isMutable(node, env) {
|
|
637
|
+
if (Object.isExtensible(node)) return true;
|
|
638
|
+
console.warn(
|
|
639
|
+
"[CloudBroker] wrapAzure: client congelado/não extensível — nenhum custo será rastreado " +
|
|
640
|
+
"neste client. Embrulhe a instância antes de congelá-la.",
|
|
641
|
+
);
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Atribuição defensiva: propriedade não gravável não pode virar exceção. */
|
|
646
|
+
static #safeAssign(node, key, value, env) {
|
|
647
|
+
try { node[key] = value; return true; }
|
|
648
|
+
catch (err) {
|
|
649
|
+
if (env?.debug) console.warn(`[CloudBroker] não foi possível instrumentar ${key}:`, err.message);
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
127
653
|
/** Detecta o serviço AWS pelo nome do construtor do client. */
|
|
128
654
|
static #detectService(client) {
|
|
129
655
|
const name = client?.constructor?.name || "";
|