cloudbroker-sdk 0.7.0 → 0.8.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 +88 -13
- package/README.md +40 -0
- package/package.json +6 -2
- package/src/client.js +2 -2
- package/src/cloud-wrapper.js +37 -2
- package/src/executions.js +152 -0
- package/src/index.js +1 -0
- package/src/test-executions.js +190 -0
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
name: Publish to npm
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
|
+
# Testar também em PR: até aqui a suíte só rodava no caminho de publish, e
|
|
5
|
+
# publish só dispara em push no main que toque package.json. Ou seja, um PR
|
|
6
|
+
# que quebrasse o SDK não era pego por nada — o erro só apareceria na hora
|
|
7
|
+
# de lançar a versão. Mesmo formato adotado no backend (job `test` rodando
|
|
8
|
+
# em PR e push, com o job de entrega dependendo dele).
|
|
9
|
+
pull_request:
|
|
4
10
|
push:
|
|
5
11
|
branches: [main]
|
|
6
12
|
paths:
|
|
@@ -16,31 +22,100 @@ on:
|
|
|
16
22
|
workflow_dispatch:
|
|
17
23
|
|
|
18
24
|
jobs:
|
|
25
|
+
test:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
strategy:
|
|
28
|
+
# Não falha as demais versões quando uma quebra — saber que 24 passa e
|
|
29
|
+
# 20 não é a informação útil; abortar em cima da primeira esconde isso.
|
|
30
|
+
fail-fast: false
|
|
31
|
+
matrix:
|
|
32
|
+
# Este pacote roda DENTRO da aplicação de produção do cliente, então o
|
|
33
|
+
# que importa não é "o Node que a gente gosta", é a faixa que o cliente
|
|
34
|
+
# pode estar rodando.
|
|
35
|
+
#
|
|
36
|
+
# 20 é o piso: era a única versão testada até aqui, e sair dela sem
|
|
37
|
+
# aviso quebraria quem ainda está lá. Está EOL desde março/2026, e é
|
|
38
|
+
# exatamente por isso que ele não pode ser a ÚNICA — testar só nele
|
|
39
|
+
# significava nunca exercitar o SDK no Node que os clientes usam hoje.
|
|
40
|
+
node: [20, 22, 24]
|
|
41
|
+
name: test (node ${{ matrix.node }})
|
|
42
|
+
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v5
|
|
45
|
+
|
|
46
|
+
- name: Setup Node
|
|
47
|
+
uses: actions/setup-node@v5
|
|
48
|
+
with:
|
|
49
|
+
node-version: ${{ matrix.node }}
|
|
50
|
+
|
|
51
|
+
- name: Instalar dependências
|
|
52
|
+
run: npm ci || npm install
|
|
53
|
+
|
|
54
|
+
- name: Rodar testes
|
|
55
|
+
run: npm test
|
|
56
|
+
|
|
19
57
|
publish:
|
|
58
|
+
needs: test
|
|
59
|
+
# Só publica no caminho de release. Sem esta guarda, o gatilho de
|
|
60
|
+
# pull_request acima tentaria publicar a cada PR.
|
|
61
|
+
if: github.event_name != 'pull_request'
|
|
20
62
|
runs-on: ubuntu-latest
|
|
21
63
|
permissions:
|
|
22
64
|
contents: read
|
|
23
65
|
id-token: write
|
|
24
66
|
|
|
25
67
|
steps:
|
|
26
|
-
- uses: actions/checkout@
|
|
68
|
+
- uses: actions/checkout@v5
|
|
27
69
|
|
|
28
70
|
- name: Setup Node
|
|
29
|
-
uses: actions/setup-node@
|
|
71
|
+
uses: actions/setup-node@v5
|
|
30
72
|
with:
|
|
31
|
-
|
|
73
|
+
# LTS atual para o artefato publicado. A compatibilidade com as
|
|
74
|
+
# outras versões é garantida pela matriz do job `test` acima, que
|
|
75
|
+
# este job exige via `needs`.
|
|
76
|
+
node-version: 22
|
|
32
77
|
registry-url: 'https://registry.npmjs.org'
|
|
33
78
|
|
|
34
|
-
|
|
35
|
-
# de produção do cliente; publicar sem rodar a suíte já era um risco
|
|
36
|
-
# aberto (nenhum CI de teste existia neste repo).
|
|
37
|
-
- name: Instalar dependências de teste
|
|
79
|
+
- name: Instalar dependências
|
|
38
80
|
run: npm ci || npm install
|
|
39
81
|
|
|
40
|
-
|
|
41
|
-
|
|
82
|
+
# Trusted Publishing (OIDC): o npm confia neste workflow pelo token de
|
|
83
|
+
# identidade do GitHub (permissions.id-token: write acima) — sem NPM_TOKEN
|
|
84
|
+
# de longa duração. O publish por token falhava com EOTP desde 27/07
|
|
85
|
+
# (token que exige 2FA), e as versões saíam à mão, pulando a suíte.
|
|
86
|
+
# Exige npm >= 11.5.1; o Node 22 traz 10.x, e com npm antigo o publish
|
|
87
|
+
# falha sem dizer por quê. Versão fixa, a mesma do cloudbroker-mcp.
|
|
88
|
+
# Configuração do lado do npm (uma vez, pelo dono do pacote):
|
|
89
|
+
# npm trust github cloudbroker-sdk --file publish.yml \
|
|
90
|
+
# --repo cloudbroker-app/cloudbroker-sdk --allow-stage-publish
|
|
91
|
+
# O gatilho dispara em QUALQUER mudança no package.json, não só em troca
|
|
92
|
+
# de versão — o merge do PR #17 (só o campo `repository`) tentou
|
|
93
|
+
# republicar a 0.7.0 e deixou o main vermelho. Se a versão já está no
|
|
94
|
+
# registro, encerra com aviso. Só pula com confirmação positiva (o npm
|
|
95
|
+
# devolveu exatamente esta versão): 404 ou falha de rede seguem para o
|
|
96
|
+
# publish, que falha alto se houver problema de verdade.
|
|
97
|
+
- name: Versão já publicada?
|
|
98
|
+
id: published
|
|
99
|
+
run: |
|
|
100
|
+
NAME=$(node -p "require('./package.json').name")
|
|
101
|
+
VERSION=$(node -p "require('./package.json').version")
|
|
102
|
+
FOUND=$(npm view "$NAME@$VERSION" version 2>/dev/null || true)
|
|
103
|
+
if [ "$FOUND" = "$VERSION" ]; then
|
|
104
|
+
echo "::notice::$NAME@$VERSION já está no npm — nada a publicar (troque a versão para lançar)."
|
|
105
|
+
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
106
|
+
else
|
|
107
|
+
echo "skip=false" >> "$GITHUB_OUTPUT"
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
- name: Atualizar npm (OIDC exige >= 11.5.1; npm stage vem no 11.19)
|
|
111
|
+
if: steps.published.outputs.skip != 'true'
|
|
112
|
+
run: npm install -g npm@11.19.1 && npm --version
|
|
42
113
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
114
|
+
# Staged publishing: o CI só deixa a versão em STAGING (fora do ar); quem
|
|
115
|
+
# publica de fato é uma pessoa, com 2FA:
|
|
116
|
+
# npm stage list cloudbroker-sdk
|
|
117
|
+
# npm stage approve <id-do-staging>
|
|
118
|
+
# Sem NODE_AUTH_TOKEN: a autenticação vem do OIDC.
|
|
119
|
+
- name: Stage no npm (aprovação manual com 2FA)
|
|
120
|
+
if: steps.published.outputs.skip != 'true'
|
|
121
|
+
run: npm stage publish
|
package/README.md
CHANGED
|
@@ -309,6 +309,46 @@ app.use(CloudBrokerCloud.expressMiddleware({
|
|
|
309
309
|
Prioridade de cada campo: explícito no `.send()` (`cbLabel`/`cbProduct`/`cbTeam`) >
|
|
310
310
|
contexto do middleware > valor estático do `wrapAWS` > `null`.
|
|
311
311
|
|
|
312
|
+
### Custo por execução e versão do código (0.8.0)
|
|
313
|
+
|
|
314
|
+
Cada evento do `wrapAWS`/`wrapAzure` é uma **chamada de API**, não uma
|
|
315
|
+
**execução** do trecho. Se o custo de um trecho sobe, só com chamadas não dá
|
|
316
|
+
para saber se o código passou a chamar mais ou se veio mais tráfego. O
|
|
317
|
+
`ExecutionCounter` conta as execuções e fecha essa conta: **custo por
|
|
318
|
+
execução** e **chamadas por execução**.
|
|
319
|
+
|
|
320
|
+
```js
|
|
321
|
+
import { CloudBrokerCloud, ExecutionCounter } from "cloudbroker-sdk";
|
|
322
|
+
|
|
323
|
+
const execucoes = new ExecutionCounter({ apiKey: process.env.CLOUDBROKER_API_KEY });
|
|
324
|
+
|
|
325
|
+
app.use(CloudBrokerCloud.expressMiddleware({
|
|
326
|
+
product: "checkout",
|
|
327
|
+
label: (req) => req.path.split("/").slice(0, 3).join("/"), // prefixo da rota ("/api/pedidos"), sem ids
|
|
328
|
+
executions: execucoes, // 1 execução por requisição
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
// Fora do Express (jobs, filas, workers):
|
|
332
|
+
await CloudBrokerCloud.trackExecution("job:fechamento-mensal", () => fecharMes(), {
|
|
333
|
+
product: "financeiro", executions: execucoes,
|
|
334
|
+
});
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
- A contagem fica em memória, agregada por trecho, app, versão e hora, e sai
|
|
338
|
+
em lote a cada 60 s (`flushIntervalMs`). Nada de rede no caminho da
|
|
339
|
+
requisição; o timer não segura o processo.
|
|
340
|
+
- **Use rótulo de baixa cardinalidade.** Um rótulo com id (`/api/pedidos/123`)
|
|
341
|
+
cria uma combinação por pedido; acima de 200 combinações pendentes o
|
|
342
|
+
excedente é descartado e somado em `execucoes.dropped`. O rótulo padrão do
|
|
343
|
+
middleware é o caminho cru — com `executions`, passe sempre um `label`.
|
|
344
|
+
(Middleware de `app.use` roda antes do roteamento: `req.route` ainda não
|
|
345
|
+
existe nele.)
|
|
346
|
+
- **Versão do código:** defina `CLOUDBROKER_VERSION` (ex.: o `GIT_SHA` do
|
|
347
|
+
build) ou passe `version` ao `ExecutionCounter` e ao `wrapAWS`/`wrapAzure`.
|
|
348
|
+
Ela vai nas execuções e nas chamadas, para comparar o trecho antes e depois
|
|
349
|
+
de um deploy pela versão que de fato rodou.
|
|
350
|
+
- No shutdown: `await execucoes.flush(); execucoes.stop();`.
|
|
351
|
+
|
|
312
352
|
## Garantias
|
|
313
353
|
|
|
314
354
|
- **Fire-and-forget**: se o CloudBroker estiver fora, sua aplicação não é afetada
|
package/package.json
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloudbroker-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "CloudBroker SDK — Token Intelligence & Operational Financial Intelligence",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/cloudbroker-app/cloudbroker-sdk.git"
|
|
8
|
+
},
|
|
5
9
|
"main": "src/index.js",
|
|
6
10
|
"type": "module",
|
|
7
11
|
"exports": {
|
|
8
12
|
".": "./src/index.js"
|
|
9
13
|
},
|
|
10
14
|
"scripts": {
|
|
11
|
-
"test": "node src/test.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-azure-real.js && node src/test-gemini-real.js"
|
|
12
16
|
},
|
|
13
17
|
"keywords": [
|
|
14
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.8.0",
|
|
52
52
|
sentAt: new Date().toISOString(),
|
|
53
53
|
};
|
|
54
54
|
if (this.#cloudQueue.length < MAX_QUEUE_SIZE) {
|
|
@@ -104,7 +104,7 @@ export class CloudBrokerClient {
|
|
|
104
104
|
try {
|
|
105
105
|
const payload = {
|
|
106
106
|
...event,
|
|
107
|
-
sdkVersion: "0.
|
|
107
|
+
sdkVersion: "0.8.0",
|
|
108
108
|
sentAt: new Date().toISOString(),
|
|
109
109
|
};
|
|
110
110
|
|
package/src/cloud-wrapper.js
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
AZURE_COSMOS_ITEMS_ITERATOR_OPS,
|
|
37
37
|
} from "./pricing-cloud.js";
|
|
38
38
|
import { cbStore } from "./context.js";
|
|
39
|
+
import { resolveVersion } from "./executions.js";
|
|
39
40
|
|
|
40
41
|
// Nomes de construtor do @azure/* → tipo do nó na cadeia de sub-clients.
|
|
41
42
|
const AZURE_BLOB_KIND = {
|
|
@@ -149,10 +150,12 @@ export class CloudBrokerCloud {
|
|
|
149
150
|
* @param {string} [options.team] — nome do squad (ex: "mobile")
|
|
150
151
|
* @param {string} [options.region="us-east-2"]
|
|
151
152
|
* @param {string} [options.endpoint] — endpoint de eventos de nuvem (opcional)
|
|
153
|
+
* @param {string} [options.version] — versão do código (padrão: env CLOUDBROKER_VERSION, ex.: o GIT_SHA do build)
|
|
152
154
|
* @param {boolean} [options.debug=false]
|
|
153
155
|
*/
|
|
154
156
|
static wrapAWS(client, options = {}) {
|
|
155
157
|
const { apiKey, product, team, region = "us-east-2", endpoint, debug = false } = options;
|
|
158
|
+
const version = resolveVersion(options.version);
|
|
156
159
|
if (!apiKey) throw new Error("[CloudBroker] apiKey é obrigatório.");
|
|
157
160
|
|
|
158
161
|
if (!client || typeof client.send !== "function") {
|
|
@@ -200,6 +203,7 @@ export class CloudBrokerCloud {
|
|
|
200
203
|
billingUnit,
|
|
201
204
|
latencyMs: Date.now() - start,
|
|
202
205
|
region: clientRegion,
|
|
206
|
+
version,
|
|
203
207
|
});
|
|
204
208
|
} catch (err) {
|
|
205
209
|
if (debug) console.warn("[CloudBroker] trackCloud error:", err.message);
|
|
@@ -221,6 +225,9 @@ export class CloudBrokerCloud {
|
|
|
221
225
|
* @param {string|((req)=>string)} [options.label] — trecho (default: "MÉTODO /caminho")
|
|
222
226
|
* @param {string|((req)=>string)} [options.product] — App (sobrescreve o product do wrapAWS)
|
|
223
227
|
* @param {string|((req)=>string)} [options.team] — Grupo (sobrescreve o team do wrapAWS)
|
|
228
|
+
* @param {import("./executions.js").ExecutionCounter} [options.executions] — se informado,
|
|
229
|
+
* conta UMA execução do trecho por requisição (custo por execução, Passo 2).
|
|
230
|
+
* Use rótulo de baixa cardinalidade (módulo/rota com padrão), nunca o caminho cru com ids.
|
|
224
231
|
* @returns {(req,res,next)=>void}
|
|
225
232
|
*/
|
|
226
233
|
static expressMiddleware(options = {}) {
|
|
@@ -232,12 +239,38 @@ export class CloudBrokerCloud {
|
|
|
232
239
|
const resolveProduct = mk(options.product, () => undefined);
|
|
233
240
|
const resolveTeam = mk(options.team, () => undefined);
|
|
234
241
|
|
|
242
|
+
const executions = options.executions;
|
|
243
|
+
|
|
235
244
|
return (req, res, next) => {
|
|
236
245
|
const safe = (fn) => { try { return fn(req); } catch { return undefined; } };
|
|
237
|
-
|
|
246
|
+
const ctx = { label: safe(resolveLabel), product: safe(resolveProduct), team: safe(resolveTeam) };
|
|
247
|
+
try {
|
|
248
|
+
if (executions && typeof executions.count === "function") executions.count(ctx);
|
|
249
|
+
} catch { /* contador nunca derruba a requisição */ }
|
|
250
|
+
cbStore.run(ctx, () => next());
|
|
238
251
|
};
|
|
239
252
|
}
|
|
240
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Roda `fn` como UMA execução do trecho `label`, para código fora do
|
|
256
|
+
* Express (jobs, filas, workers): as chamadas de nuvem/IA feitas dentro de
|
|
257
|
+
* `fn` herdam o trecho (mesmo contexto do expressMiddleware) e, com
|
|
258
|
+
* `options.executions`, a execução é contada. Devolve o que `fn` devolver
|
|
259
|
+
* (inclusive a Promise) e propaga o erro de `fn` — a instrumentação em si
|
|
260
|
+
* nunca lança.
|
|
261
|
+
*
|
|
262
|
+
* @param {string} label — trecho (baixa cardinalidade, ex.: "job:fechamento")
|
|
263
|
+
* @param {Function} fn
|
|
264
|
+
* @param {{product?: string, team?: string, executions?: import("./executions.js").ExecutionCounter}} [options]
|
|
265
|
+
*/
|
|
266
|
+
static trackExecution(label, fn, options = {}) {
|
|
267
|
+
const ctx = { label, product: options.product, team: options.team };
|
|
268
|
+
try {
|
|
269
|
+
if (options.executions && typeof options.executions.count === "function") options.executions.count(ctx);
|
|
270
|
+
} catch { /* contador nunca derruba a execução */ }
|
|
271
|
+
return cbStore.run(ctx, fn);
|
|
272
|
+
}
|
|
273
|
+
|
|
241
274
|
/**
|
|
242
275
|
* Envolve um client do Azure SDK (Blob Storage / Cosmos DB) e rastreia cada
|
|
243
276
|
* chamada de data-plane como evento de custo — mesmo contrato do wrapAWS.
|
|
@@ -272,6 +305,7 @@ export class CloudBrokerCloud {
|
|
|
272
305
|
*/
|
|
273
306
|
static wrapAzure(client, options = {}) {
|
|
274
307
|
const { apiKey, product, team, region = "eastus2", endpoint, debug = false } = options;
|
|
308
|
+
const version = resolveVersion(options.version);
|
|
275
309
|
if (!apiKey) throw new Error("[CloudBroker] apiKey é obrigatório.");
|
|
276
310
|
|
|
277
311
|
const name = client?.constructor?.name || "";
|
|
@@ -295,7 +329,7 @@ export class CloudBrokerCloud {
|
|
|
295
329
|
}
|
|
296
330
|
|
|
297
331
|
const cb = new CloudBrokerClient({ apiKey, cloudEndpoint: endpoint, debug });
|
|
298
|
-
const env = { cb, product, team, region, debug };
|
|
332
|
+
const env = { cb, product, team, region, debug, version };
|
|
299
333
|
return blobKind
|
|
300
334
|
? CloudBrokerCloud.#wrapBlobNode(client, env)
|
|
301
335
|
: CloudBrokerCloud.#wrapCosmosNode(client, env, cosmosKind);
|
|
@@ -348,6 +382,7 @@ export class CloudBrokerCloud {
|
|
|
348
382
|
billingUnit,
|
|
349
383
|
latencyMs,
|
|
350
384
|
region: env.region,
|
|
385
|
+
version: env.version,
|
|
351
386
|
});
|
|
352
387
|
} catch (err) {
|
|
353
388
|
if (env.debug) console.warn("[CloudBroker] trackCloud error:", err.message);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExecutionCounter — contador de EXECUÇÕES de trecho de código (0.8.0).
|
|
3
|
+
*
|
|
4
|
+
* Por que existe: o wrapAWS/wrapAzure grava um evento por CHAMADA de API, não
|
|
5
|
+
* por execução do trecho. Sem o número de execuções, "o código passou a chamar
|
|
6
|
+
* mais" e "veio mais tráfego" são indistinguíveis — o custo por chamada é
|
|
7
|
+
* quase constante por construção. Este contador é o denominador que separa os
|
|
8
|
+
* dois (custo por execução, chamadas por execução).
|
|
9
|
+
*
|
|
10
|
+
* Como: contagem EM MEMÓRIA, agregada por (trecho, app, versão, hora UTC), e
|
|
11
|
+
* enviada em lote a cada `flushIntervalMs` para POST /api/cloud/executions.
|
|
12
|
+
* Uma requisição = uma soma num Map; nada de rede no caminho da aplicação.
|
|
13
|
+
*
|
|
14
|
+
* Garantias (iguais às do resto do SDK):
|
|
15
|
+
* • nunca lança para a aplicação do cliente;
|
|
16
|
+
* • timer com `.unref()` — não segura o processo (Lambda, cron, CLI);
|
|
17
|
+
* • memória limitada: no máximo `maxKeys` combinações pendentes; o excedente
|
|
18
|
+
* é descartado e contado em `dropped` (rótulo de alta cardinalidade, como
|
|
19
|
+
* uma rota com id no caminho, não pode crescer sem limite);
|
|
20
|
+
* • contagem com mais de 7 dias sem conseguir enviar é descartada (o backend
|
|
21
|
+
* recusa, e reenviar para sempre seria inútil).
|
|
22
|
+
*
|
|
23
|
+
* Uso:
|
|
24
|
+
* const execucoes = new ExecutionCounter({ apiKey: "cb-key-xxx" }); // version ← CLOUDBROKER_VERSION
|
|
25
|
+
* app.use(CloudBrokerCloud.expressMiddleware({ label: ..., executions: execucoes }));
|
|
26
|
+
* await CloudBrokerCloud.trackExecution("job:fechamento", () => fecharMes(), { executions: execucoes });
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const DEFAULT_EXECUTIONS_ENDPOINT = "https://api.cloudbroker.app.br/api/cloud/executions";
|
|
30
|
+
const HOUR_MS = 3_600_000;
|
|
31
|
+
const MAX_AGE_MS = 7 * 24 * HOUR_MS;
|
|
32
|
+
|
|
33
|
+
/** Versão do código do cliente: explícita > CLOUDBROKER_VERSION > null. */
|
|
34
|
+
export function resolveVersion(explicit) {
|
|
35
|
+
const v = typeof explicit === "string" && explicit.trim() ? explicit
|
|
36
|
+
: (typeof process !== "undefined" ? process.env?.CLOUDBROKER_VERSION : undefined);
|
|
37
|
+
return typeof v === "string" && v.trim() ? v.trim().slice(0, 64) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hourStartIso(ms) {
|
|
41
|
+
return new Date(Math.floor(ms / HOUR_MS) * HOUR_MS).toISOString();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class ExecutionCounter {
|
|
45
|
+
#apiKey;
|
|
46
|
+
#endpoint;
|
|
47
|
+
#version;
|
|
48
|
+
#debug;
|
|
49
|
+
#maxKeys;
|
|
50
|
+
#now;
|
|
51
|
+
#pending = new Map();
|
|
52
|
+
#flushing = false;
|
|
53
|
+
#timer = null;
|
|
54
|
+
dropped = 0;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} options
|
|
58
|
+
* @param {string} options.apiKey — API key CloudBroker do tipo SDK
|
|
59
|
+
* @param {string} [options.version] — versão do código (padrão: CLOUDBROKER_VERSION)
|
|
60
|
+
* @param {string} [options.endpoint]
|
|
61
|
+
* @param {number} [options.flushIntervalMs=60000]
|
|
62
|
+
* @param {number} [options.maxKeys=200] — o backend recusa lote com mais de 200
|
|
63
|
+
* @param {boolean} [options.debug=false]
|
|
64
|
+
*/
|
|
65
|
+
constructor({ apiKey, version, endpoint = DEFAULT_EXECUTIONS_ENDPOINT, flushIntervalMs = 60_000, maxKeys = 200, debug = false, now } = {}) {
|
|
66
|
+
if (!apiKey) throw new Error("[CloudBroker] apiKey é obrigatório.");
|
|
67
|
+
this.#apiKey = apiKey;
|
|
68
|
+
this.#endpoint = endpoint;
|
|
69
|
+
this.#version = resolveVersion(version);
|
|
70
|
+
this.#debug = debug;
|
|
71
|
+
this.#maxKeys = Math.max(1, Math.min(200, Math.floor(maxKeys) || 200));
|
|
72
|
+
this.#now = typeof now === "function" ? now : Date.now;
|
|
73
|
+
if (flushIntervalMs > 0) {
|
|
74
|
+
this.#timer = setInterval(() => { this.flush(); }, flushIntervalMs);
|
|
75
|
+
if (typeof this.#timer?.unref === "function") this.#timer.unref();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Versão que este contador anexa às execuções. */
|
|
80
|
+
get version() { return this.#version; }
|
|
81
|
+
|
|
82
|
+
/** Combinações pendentes de envio (para diagnóstico/teste). */
|
|
83
|
+
get pendingKeys() { return this.#pending.size; }
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Soma uma execução do trecho. Nunca lança.
|
|
87
|
+
* @param {{label?: string, product?: string}} ctx
|
|
88
|
+
*/
|
|
89
|
+
count(ctx) {
|
|
90
|
+
try {
|
|
91
|
+
const label = ctx?.label, product = ctx?.product;
|
|
92
|
+
if (typeof label !== "string" || !label.trim()) return;
|
|
93
|
+
const hour = hourStartIso(this.#now());
|
|
94
|
+
const prod = typeof product === "string" && product.trim() ? product : null;
|
|
95
|
+
const key = `${hour}\u0000${label}\u0000${prod || ""}`;
|
|
96
|
+
const cur = this.#pending.get(key);
|
|
97
|
+
if (cur) { cur.count++; return; }
|
|
98
|
+
if (this.#pending.size >= this.#maxKeys) { this.dropped++; return; }
|
|
99
|
+
this.#pending.set(key, { label, product: prod, version: this.#version, hour, count: 1 });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (this.#debug) console.warn("[CloudBroker] executions.count error:", err.message);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Envia o pendente. Nunca lança; em falha transitória, a contagem volta para a fila. */
|
|
106
|
+
async flush() {
|
|
107
|
+
if (this.#flushing || this.#pending.size === 0) return;
|
|
108
|
+
this.#flushing = true;
|
|
109
|
+
const batch = [...this.#pending.values()];
|
|
110
|
+
this.#pending = new Map();
|
|
111
|
+
let devolver = false;
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch(this.#endpoint, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "Content-Type": "application/json", "x-cloudbroker-key": this.#apiKey },
|
|
116
|
+
body: JSON.stringify({ executions: batch }),
|
|
117
|
+
signal: AbortSignal.timeout(5000),
|
|
118
|
+
});
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
// 4xx (exceto 429) é recusa definitiva — reenviar o mesmo lote não muda nada.
|
|
121
|
+
devolver = res.status === 429 || res.status >= 500;
|
|
122
|
+
if (this.#debug) console.warn(`[CloudBroker] executions flush failed: ${res.status}${devolver ? " (vai tentar de novo)" : " (descartado)"}`);
|
|
123
|
+
} else if (this.#debug) {
|
|
124
|
+
console.log(`[CloudBroker] flushed ${batch.length} execution buckets`);
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
devolver = true;
|
|
128
|
+
if (this.#debug) console.warn("[CloudBroker] executions flush error:", err.message);
|
|
129
|
+
} finally {
|
|
130
|
+
if (devolver) this.#requeue(batch);
|
|
131
|
+
this.#flushing = false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#requeue(batch) {
|
|
136
|
+
const limite = this.#now() - MAX_AGE_MS;
|
|
137
|
+
for (const it of batch) {
|
|
138
|
+
if (Date.parse(it.hour) < limite) { this.dropped += it.count; continue; }
|
|
139
|
+
const key = `${it.hour}\u0000${it.label}\u0000${it.product || ""}`;
|
|
140
|
+
const cur = this.#pending.get(key);
|
|
141
|
+
if (cur) cur.count += it.count;
|
|
142
|
+
else if (this.#pending.size < this.#maxKeys) this.#pending.set(key, it);
|
|
143
|
+
else this.dropped += it.count;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Para o timer (ex.: no shutdown, depois de um `await flush()`). */
|
|
148
|
+
stop() {
|
|
149
|
+
if (this.#timer) clearInterval(this.#timer);
|
|
150
|
+
this.#timer = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/index.js
CHANGED
|
@@ -10,4 +10,5 @@
|
|
|
10
10
|
export { CloudBrokerAI } from "./wrapper.js";
|
|
11
11
|
export { CloudBrokerCloud } from "./cloud-wrapper.js";
|
|
12
12
|
export { CloudBrokerClient } from "./client.js";
|
|
13
|
+
export { ExecutionCounter } from "./executions.js";
|
|
13
14
|
export { estimateCloudCost, CLOUD_PRICING } from "./pricing-cloud.js";
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contador de execuções (0.8.0). Execute: node src/test-executions.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 { ExecutionCounter, resolveVersion } from "./executions.js";
|
|
8
|
+
import { CloudBrokerCloud } from "./cloud-wrapper.js";
|
|
9
|
+
import { cbStore } from "./context.js";
|
|
10
|
+
|
|
11
|
+
let pass = 0;
|
|
12
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log("✅", msg); };
|
|
13
|
+
|
|
14
|
+
const realFetch = globalThis.fetch;
|
|
15
|
+
const sent = [];
|
|
16
|
+
let respond = () => ({ ok: true, status: 200 });
|
|
17
|
+
globalThis.fetch = async (url, opts) => {
|
|
18
|
+
sent.push({ url, headers: opts.headers, body: JSON.parse(opts.body) });
|
|
19
|
+
const r = respond();
|
|
20
|
+
if (r instanceof Error) throw r;
|
|
21
|
+
return r;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const T0 = Date.parse("2026-10-12T15:42:10.000Z");
|
|
25
|
+
let clock = T0;
|
|
26
|
+
const mk = (o = {}) => new ExecutionCounter({ apiKey: "cb-test", flushIntervalMs: 0, now: () => clock, ...o });
|
|
27
|
+
|
|
28
|
+
// ─── versão ──────────────────────────────────────────────────────────────────
|
|
29
|
+
delete process.env.CLOUDBROKER_VERSION;
|
|
30
|
+
ok(resolveVersion() === null, "sem versão e sem env → null (nunca inventa versão)");
|
|
31
|
+
process.env.CLOUDBROKER_VERSION = " abc1234 ";
|
|
32
|
+
ok(resolveVersion() === "abc1234", "versão vem de CLOUDBROKER_VERSION, sem espaços");
|
|
33
|
+
ok(resolveVersion("v2") === "v2", "versão explícita vence o env");
|
|
34
|
+
ok(resolveVersion("x".repeat(100)).length === 64, "versão com teto de 64");
|
|
35
|
+
|
|
36
|
+
// ─── agregação ───────────────────────────────────────────────────────────────
|
|
37
|
+
{
|
|
38
|
+
const c = mk();
|
|
39
|
+
c.count({ label: "checkout", product: "Loja" });
|
|
40
|
+
c.count({ label: "checkout", product: "Loja" });
|
|
41
|
+
c.count({ label: "checkout", product: "Loja" });
|
|
42
|
+
c.count({ label: "checkout", product: "Outro" });
|
|
43
|
+
ok(c.pendingKeys === 2, "mesmo trecho+app+hora vira 1 combinação; app diferente, outra");
|
|
44
|
+
c.count({ label: "" }); c.count({}); c.count(null); c.count({ label: 42 });
|
|
45
|
+
ok(c.pendingKeys === 2, "sem rótulo não conta e não lança");
|
|
46
|
+
await c.flush();
|
|
47
|
+
const body = sent.at(-1).body.executions;
|
|
48
|
+
const loja = body.find((e) => e.product === "Loja");
|
|
49
|
+
ok(loja.count === 3 && loja.hour === "2026-10-12T15:00:00.000Z" && loja.version === "abc1234", "lote enviado: contagem somada, início da hora em UTC, versão anexada");
|
|
50
|
+
ok(sent.at(-1).url.endsWith("/api/cloud/executions") && sent.at(-1).headers["x-cloudbroker-key"] === "cb-test", "endpoint e cabeçalho da key");
|
|
51
|
+
ok(c.pendingKeys === 0, "enviado com sucesso → fila vazia");
|
|
52
|
+
|
|
53
|
+
clock = T0 + 3_600_000;
|
|
54
|
+
c.count({ label: "checkout", product: "Loja" });
|
|
55
|
+
clock = T0;
|
|
56
|
+
c.count({ label: "checkout", product: "Loja" });
|
|
57
|
+
ok(c.pendingKeys === 2, "horas diferentes são combinações diferentes");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── teto de memória ─────────────────────────────────────────────────────────
|
|
61
|
+
{
|
|
62
|
+
const c = mk({ maxKeys: 3 });
|
|
63
|
+
for (let i = 0; i < 5; i++) c.count({ label: `/api/apps/${i}` });
|
|
64
|
+
c.count({ label: "/api/apps/0" });
|
|
65
|
+
ok(c.pendingKeys === 3 && c.dropped === 2, "acima do teto de combinações descarta e conta em dropped (rótulo com id não cresce sem limite)");
|
|
66
|
+
const big = mk({ maxKeys: 5000 });
|
|
67
|
+
for (let i = 0; i < 250; i++) big.count({ label: `t${i}` });
|
|
68
|
+
ok(big.pendingKeys === 200 && big.dropped === 50, "maxKeys efetivo nunca passa de 200");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── falha de envio ──────────────────────────────────────────────────────────
|
|
72
|
+
{
|
|
73
|
+
const c = mk();
|
|
74
|
+
c.count({ label: "a" }); c.count({ label: "a" });
|
|
75
|
+
respond = () => new Error("ECONNRESET");
|
|
76
|
+
await c.flush();
|
|
77
|
+
ok(c.pendingKeys === 1, "erro de rede → a contagem volta para a fila");
|
|
78
|
+
c.count({ label: "a" });
|
|
79
|
+
respond = () => ({ ok: false, status: 503 });
|
|
80
|
+
await c.flush();
|
|
81
|
+
respond = () => ({ ok: true, status: 200 });
|
|
82
|
+
await c.flush();
|
|
83
|
+
ok(sent.at(-1).body.executions[0].count === 3, "503 também volta para a fila, e a contagem não se perde nem duplica");
|
|
84
|
+
|
|
85
|
+
c.count({ label: "b" });
|
|
86
|
+
respond = () => ({ ok: false, status: 400 });
|
|
87
|
+
await c.flush();
|
|
88
|
+
ok(c.pendingKeys === 0, "4xx é recusa definitiva → descartado, sem reenviar para sempre");
|
|
89
|
+
respond = () => ({ ok: false, status: 429 });
|
|
90
|
+
c.count({ label: "b" });
|
|
91
|
+
await c.flush();
|
|
92
|
+
ok(c.pendingKeys === 1, "429 volta para a fila");
|
|
93
|
+
respond = () => ({ ok: true, status: 200 });
|
|
94
|
+
|
|
95
|
+
const velho = mk();
|
|
96
|
+
velho.count({ label: "v" });
|
|
97
|
+
respond = () => new Error("offline");
|
|
98
|
+
clock = T0 + 8 * 24 * 3_600_000; // o flush falha 8 dias depois
|
|
99
|
+
await velho.flush();
|
|
100
|
+
clock = T0;
|
|
101
|
+
respond = () => ({ ok: true, status: 200 });
|
|
102
|
+
ok(velho.pendingKeys === 0 && velho.dropped === 1, "contagem com mais de 7 dias não volta para a fila (o backend recusaria)");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── nunca lança, e não segura o processo ────────────────────────────────────
|
|
106
|
+
{
|
|
107
|
+
// Timer padrão (60s) SEM stop(): um processo filho precisa terminar sozinho.
|
|
108
|
+
// Sem o .unref(), o timer seguraria o event loop e o filho não sairia.
|
|
109
|
+
const { spawnSync } = await import("node:child_process");
|
|
110
|
+
const filho = spawnSync(process.execPath, ["--input-type=module", "-e",
|
|
111
|
+
`import { ExecutionCounter } from ${JSON.stringify(new URL("./executions.js", import.meta.url).href)};
|
|
112
|
+
const c = new ExecutionCounter({ apiKey: "cb-test" }); c.count({ label: "x" });`], { timeout: 10_000 });
|
|
113
|
+
ok(filho.status === 0 && !filho.error, "o timer do contador não segura o processo (Lambda, cron, CLI terminam sozinhos)");
|
|
114
|
+
|
|
115
|
+
const c = mk();
|
|
116
|
+
respond = () => { throw new TypeError("fetch quebrado"); };
|
|
117
|
+
c.count({ label: "x" });
|
|
118
|
+
await c.flush();
|
|
119
|
+
respond = () => ({ ok: true, status: 200 });
|
|
120
|
+
ok(true, "fetch que lança não escapa do flush");
|
|
121
|
+
c.stop();
|
|
122
|
+
assert.throws(() => new ExecutionCounter({}), /apiKey/);
|
|
123
|
+
ok(true, "sem apiKey lança na construção (erro de configuração, não de execução)");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── expressMiddleware ───────────────────────────────────────────────────────
|
|
127
|
+
{
|
|
128
|
+
const c = mk();
|
|
129
|
+
const mw = CloudBrokerCloud.expressMiddleware({ label: (req) => `mod:${req.path}`, product: "Loja", executions: c });
|
|
130
|
+
let vistoNoContexto;
|
|
131
|
+
await new Promise((r) => mw({ method: "GET", path: "/x" }, {}, () => { vistoNoContexto = cbStore.getStore(); r(); }));
|
|
132
|
+
await new Promise((r) => mw({ method: "GET", path: "/x" }, {}, r));
|
|
133
|
+
await c.flush();
|
|
134
|
+
const ex = sent.at(-1).body.executions;
|
|
135
|
+
ok(ex.length === 1 && ex[0].label === "mod:/x" && ex[0].product === "Loja" && ex[0].count === 2, "uma execução por requisição, com o mesmo trecho/app do contexto");
|
|
136
|
+
ok(vistoNoContexto?.label === "mod:/x", "o contexto continua chegando ao handler");
|
|
137
|
+
|
|
138
|
+
const quebrado = { count() { throw new Error("boom"); } };
|
|
139
|
+
const mw2 = CloudBrokerCloud.expressMiddleware({ label: "a", executions: quebrado });
|
|
140
|
+
let passou = false;
|
|
141
|
+
mw2({ method: "GET", path: "/" }, {}, () => { passou = true; });
|
|
142
|
+
ok(passou, "contador que lança não derruba a requisição (next é chamado)");
|
|
143
|
+
const mw3 = CloudBrokerCloud.expressMiddleware({ label: "a" });
|
|
144
|
+
let chamou = false;
|
|
145
|
+
mw3({ method: "GET", path: "/" }, {}, () => { chamou = true; });
|
|
146
|
+
ok(chamou, "sem `executions`, o middleware se comporta como antes");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── trackExecution ──────────────────────────────────────────────────────────
|
|
150
|
+
{
|
|
151
|
+
const c = mk();
|
|
152
|
+
const r1 = CloudBrokerCloud.trackExecution("job:fechamento", () => cbStore.getStore().label, { executions: c, product: "Financeiro" });
|
|
153
|
+
ok(r1 === "job:fechamento", "fn roda dentro do contexto do trecho e o retorno é devolvido");
|
|
154
|
+
const r2 = await CloudBrokerCloud.trackExecution("job:fechamento", async () => 7, { executions: c, product: "Financeiro" });
|
|
155
|
+
ok(r2 === 7, "fn assíncrona: devolve a Promise");
|
|
156
|
+
let erro;
|
|
157
|
+
try { await CloudBrokerCloud.trackExecution("job:fechamento", async () => { throw new Error("falha do job"); }, { executions: c, product: "Financeiro" }); } catch (e) { erro = e; }
|
|
158
|
+
ok(erro?.message === "falha do job", "erro de fn propaga intacto (a instrumentação não o engole)");
|
|
159
|
+
await c.flush();
|
|
160
|
+
ok(sent.at(-1).body.executions[0].count === 3, "cada chamada conta uma execução (inclusive a que falhou)");
|
|
161
|
+
ok(CloudBrokerCloud.trackExecution("x", () => 1, { executions: { count() { throw new Error("x"); } } }) === 1, "contador que lança não derruba a execução");
|
|
162
|
+
ok(CloudBrokerCloud.trackExecution("x", () => 2) === 2, "sem contador funciona só como contexto");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── versão nos eventos de chamada ───────────────────────────────────────────
|
|
166
|
+
{
|
|
167
|
+
process.env.CLOUDBROKER_VERSION = "abc1234";
|
|
168
|
+
class S3Client { constructor() { this.config = { region: async () => "us-east-2" }; } async send() { return {}; } }
|
|
169
|
+
class GetObjectCommand { constructor(input) { this.input = input; } }
|
|
170
|
+
const s3 = CloudBrokerCloud.wrapAWS(new S3Client(), { apiKey: "cb-test" });
|
|
171
|
+
await s3.send(new GetObjectCommand({ Bucket: "b", Key: "k" }));
|
|
172
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
173
|
+
const ev = sent.filter((s) => s.url.endsWith("/api/cloud/events")).at(-1)?.body.events[0];
|
|
174
|
+
ok(ev?.version === "abc1234", `evento de chamada leva a versão do código (veio ${ev?.version})`);
|
|
175
|
+
const s3b = CloudBrokerCloud.wrapAWS(new S3Client(), { apiKey: "cb-test", version: "v9" });
|
|
176
|
+
await s3b.send(new GetObjectCommand({ Bucket: "b", Key: "k" }));
|
|
177
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
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
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── versão do pacote ────────────────────────────────────────────────────────
|
|
182
|
+
{
|
|
183
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
184
|
+
const client = readFileSync(new URL("./client.js", import.meta.url), "utf8");
|
|
185
|
+
const declaradas = [...client.matchAll(/sdkVersion:\s*"([^"]+)"/g)].map((m) => m[1]);
|
|
186
|
+
ok(declaradas.length > 0 && declaradas.every((v) => v === pkg.version), `sdkVersion dos eventos (${declaradas.join(", ")}) = versão do package.json (${pkg.version})`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
globalThis.fetch = realFetch;
|
|
190
|
+
console.log(`\n🎉 ${pass} asserções passaram.`);
|