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.
@@ -1,9 +1,13 @@
1
1
  /**
2
- * CloudBrokerCloud — atribuição de custo de nuvem a nível de código (MVP).
2
+ * CloudBrokerCloud — atribuição de custo de nuvem a nível de código.
3
3
  *
4
- * Envolve um client do AWS SDK v3 (S3 / DynamoDB) e, a cada chamada `.send()`,
5
- * estima o custo de requests + transferência e envia um evento fire-and-forget
6
- * ao backend. Não altera o comportamento do client original.
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 { estimateCloudCost } from "./pricing-cloud.js";
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 || "";