hobots 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -113,7 +113,7 @@ try {
113
113
  await processarNota(nota);
114
114
  } catch (error) {
115
115
  Hobots.captureException(error, {
116
- level: 'error',
116
+ type: 'error',
117
117
  tags: { cliente: 'acme' },
118
118
  fingerprint: ['sefaz-timeout'], // agrupa manualmente; sem isso o servidor agrupa pelo stack
119
119
  });
@@ -123,9 +123,9 @@ try {
123
123
  Hobots.captureMessage('Lote processado com sucesso', 'success');
124
124
  ```
125
125
 
126
- Ambas retornam o `event_id` (ou `undefined` se não inicializado). O hint aceita `level`, `tags`, `extra` e `fingerprint`. Erros **não tratados** são capturados e flushados automaticamente antes do processo sair — desligue com `captureUnhandled: false`. Os eventos aparecem agrupados em issues no módulo **Issues** do app.
126
+ Ambas retornam o `event_id` (ou `undefined` se não inicializado). O hint aceita `type`, `tags`, `extra` e `fingerprint`. Erros **não tratados** são capturados e flushados automaticamente antes do processo sair — desligue com `captureUnhandled: false`. Os eventos aparecem agrupados em issues no módulo **Issues** do app.
127
127
 
128
- `Severity = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'success'`. Use `'success'` para reportar itens concluídos com êxito — contam como eventos, mas **não** viram issue nem disparam alertas.
128
+ `EventType = 'fatal' | 'error' | 'warning' | 'success'`. Não há `'info'`/`'debug'`: narração não abre issue — para isso existem o log da execução e as breadcrumbs. Use `'success'` para reportar itens concluídos com êxito — contam como eventos, mas **não** viram issue nem disparam alertas.
129
129
 
130
130
  ### Breadcrumbs
131
131
 
@@ -261,6 +261,7 @@ interface RunContext {
261
261
  task: string;
262
262
  log: RunLogger; // ctx.log.debug/info/warn/error/success(message)
263
263
  items: RunItems; // ctx.items.succeeded/failed/occurrence — a entrega do bot
264
+ progress: RunProgressReporter; // ctx.progress.total/advance/set — barra + tempo restante no app
264
265
  tx: Transaction; // transaction desta run — correlacionada e finalizada pelo SDK
265
266
  signal: AbortSignal;
266
267
  throwIfCanceled(): void; // lança RunCanceled se o signal já disparou
@@ -271,6 +272,7 @@ interface RunContext {
271
272
  - **`params`** — os dados do formulário configurado no app (JSON validado). Tipável: `Hobots.register<{ lote: number }>(...)`.
272
273
  - **`ctx.log.<level>(message)`** — níveis `debug`, `info`, `warn`, `error` e `success`; logs enviados **incrementalmente** ao app (PATCHes com throttle), visíveis na solicitação quase em tempo real; também impressos no terminal local, coloridos por nível.
273
274
  - **`ctx.items`** — registra os **itens que a execução processou/gerou** (a entrega do bot: notas fiscais baixadas, boletos emitidos…), cada um com status `succeeded`, `failed` (falha técnica) ou `occurrence` (ocorrência de negócio, ex.: "nota nº 10 não encontrada") e um `payload` de atributos livres. Aparecem na aba **itens** da solicitação, com totalizadores; as colunas exibidas são configuradas por formulário no app. Guia: [Itens de execução](./docs/tasks.md#itens-de-execução-ctxitems).
275
+ - **`ctx.progress`** — **opcional**: declare quantos itens a run vai processar (`ctx.progress.total(n)`) e avance a cada item (`ctx.progress.advance()`); o app mostra barra, percentual e tempo restante estimado na solicitação. Independente de `ctx.items`. Guia: [Progresso](./docs/tasks.md#progresso-da-execução-ctxprogress).
274
276
  - **`ctx.tx`** — a transaction de performance desta run, já correlacionada e finalizada pelo SDK. Use `ctx.tx.startChild(op, description)` para medir as etapas — elas viram o waterfall da execução no app. `startTransaction()` chamado dentro do handler retorna essa mesma transaction. Conceito: [ctx.tx](./docs/concepts.md#transaction-de-run-ctxtx).
275
277
  - **`ctx.signal`** — `AbortSignal` que dispara quando a run é **cancelada no app**, em **timeout** ou no **shutdown**.
276
278
  - **`ctx.throwIfCanceled()`** — o ponto de parada de uma linha; lança `RunCanceled` se o `signal` já disparou.
@@ -299,6 +301,23 @@ Hobots.register('baixar-notas', async (params, ctx) => {
299
301
 
300
302
  Chamadas síncronas e bufferizadas (lotes de até 500; dreno antes do reporte final). O `id` opcional é a identidade de negócio do item na run — repetido, é ignorado pelo servidor (registro único, retry idempotente). Limites: 10.000 itens/run, payload 16 KB, mensagem 2.000 caracteres. Detalhes em [Itens de execução](./docs/tasks.md#itens-de-execução-ctxitems).
301
303
 
304
+ ### Progresso (`ctx.progress`)
305
+
306
+ Quando a run percorre uma lista conhecida, declare o total e avance a cada item — a solicitação no app ganha barra de progresso, percentual (`10/100 · 10%`) e **tempo restante estimado**:
307
+
308
+ ```js
309
+ Hobots.register('baixar-notas', async (params, ctx) => {
310
+ const notas = await listarNotas(params);
311
+ ctx.progress.total(notas.length);
312
+ for (const nota of notas) {
313
+ await baixar(nota);
314
+ ctx.progress.advance();
315
+ }
316
+ });
317
+ ```
318
+
319
+ Opcional e independente de `ctx.items` (use um, outro ou ambos). Sem `total()` nada é enviado. Chamadas síncronas e baratas: o snapshot pega carona nos PATCHes de log e, no pior caso, sai 1 vez por segundo; o PATCH terminal sempre leva o valor final. `ctx.progress.set(current, total?)` define um valor absoluto; `ctx.progress.current` e `ctx.progress.snapshot` leem o estado. Detalhes em [Progresso da execução](./docs/tasks.md#progresso-da-execução-ctxprogress).
320
+
302
321
  ### Anexos (screenshots)
303
322
 
304
323
  Texto de log conta o que o robô fez; um print conta **como estava a tela**. Os métodos de captura (`Hobots.captureException` e `Hobots.captureMessage`) aceitam um `attachment` no hint: dentro de uma execução de task, a imagem vira anexo da linha de log da run que espelha a captura — no app ela aparece como miniatura logo abaixo dessa linha, clicável para ampliar.
package/dist/index.cjs CHANGED
@@ -161,7 +161,7 @@ function scrubSecrets(text) {
161
161
 
162
162
  // src/utils.ts
163
163
  var SDK_NAME = "hobots";
164
- var SDK_VERSION = true ? "2.0.0" : "0.0.0-dev";
164
+ var SDK_VERSION = true ? "2.2.0" : "0.0.0-dev";
165
165
  function isNode() {
166
166
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
167
167
  }
@@ -291,34 +291,34 @@ function safeString(value) {
291
291
  }
292
292
  return String(value);
293
293
  }
294
- function eventFromException(error, level = "error") {
295
- let type;
294
+ function eventFromException(error, type = "error") {
295
+ let exceptionType;
296
296
  let value;
297
297
  let frames = [];
298
298
  if (error instanceof Error) {
299
- type = error.name || "Error";
299
+ exceptionType = error.name || "Error";
300
300
  value = scrubSecrets(error.message);
301
301
  frames = parseStacktrace(error.stack);
302
302
  } else {
303
- type = "NonError";
303
+ exceptionType = "NonError";
304
304
  value = scrubSecrets(safeString(error));
305
305
  }
306
306
  return {
307
307
  event_id: uuid4(),
308
308
  timestamp: nowISO(),
309
- level,
309
+ type,
310
310
  exception: {
311
- type,
311
+ type: exceptionType,
312
312
  value,
313
313
  ...frames.length > 0 ? { stacktrace: { frames } } : {}
314
314
  }
315
315
  };
316
316
  }
317
- function eventFromMessage(message, level = "info") {
317
+ function eventFromMessage(message, type = "warning") {
318
318
  return {
319
319
  event_id: uuid4(),
320
320
  timestamp: nowISO(),
321
- level,
321
+ type,
322
322
  message: scrubSecrets(message)
323
323
  };
324
324
  }
@@ -481,10 +481,10 @@ var Client = class {
481
481
  return `Client { baseUrl: '${this.#config.baseUrl}' }`;
482
482
  }
483
483
  captureException(error, hint, scope2) {
484
- return this.captureEvent(eventFromException(error, hint?.level ?? "error"), hint, scope2);
484
+ return this.captureEvent(eventFromException(error, hint?.type ?? "error"), hint, scope2);
485
485
  }
486
- captureMessage(message, level, hint, scope2) {
487
- return this.captureEvent(eventFromMessage(message, level), hint, scope2);
486
+ captureMessage(message, type, hint, scope2) {
487
+ return this.captureEvent(eventFromMessage(message, type), hint, scope2);
488
488
  }
489
489
  captureEvent(event, hint, scope2) {
490
490
  const sampleRate = this.options.sampleRate ?? 1;
@@ -506,7 +506,7 @@ var Client = class {
506
506
  const extra = { ...scope2.extra, ...event.extra, ...hint?.extra };
507
507
  return {
508
508
  ...event,
509
- level: hint?.level ?? event.level ?? scope2.level ?? "error",
509
+ type: hint?.type ?? event.type ?? scope2.type ?? "error",
510
510
  release: event.release ?? this.options.release,
511
511
  environment: event.environment ?? this.options.environment ?? "production",
512
512
  tags: Object.keys(tags).length > 0 ? tags : void 0,
@@ -624,7 +624,7 @@ function installGlobalHandlers(client2, getScope) {
624
624
  process.on(
625
625
  "uncaughtException",
626
626
  markInternal((error) => {
627
- client2.captureException(error, { level: "fatal", tags: { mechanism: "uncaughtException" } }, getScope());
627
+ client2.captureException(error, { type: "fatal", tags: { mechanism: "uncaughtException" } }, getScope());
628
628
  void client2.flush(2e3).then(async () => {
629
629
  if (externalListenerCount("uncaughtException") === 0) {
630
630
  await awaitEmergencyTasks(EMERGENCY_REPORT_CAP_MS);
@@ -637,7 +637,7 @@ function installGlobalHandlers(client2, getScope) {
637
637
  process.on(
638
638
  "unhandledRejection",
639
639
  markInternal((reason) => {
640
- client2.captureException(reason, { level: "error", tags: { mechanism: "unhandledRejection" } }, getScope());
640
+ client2.captureException(reason, { type: "error", tags: { mechanism: "unhandledRejection" } }, getScope());
641
641
  void client2.flush(2e3);
642
642
  })
643
643
  );
@@ -668,7 +668,7 @@ var Scope = class _Scope {
668
668
  user;
669
669
  contexts = {};
670
670
  extra = {};
671
- level;
671
+ type;
672
672
  fingerprint;
673
673
  breadcrumbs = [];
674
674
  setTag(key, value) {
@@ -699,8 +699,8 @@ var Scope = class _Scope {
699
699
  Object.assign(this.extra, extras);
700
700
  return this;
701
701
  }
702
- setLevel(level) {
703
- this.level = level;
702
+ setType(type) {
703
+ this.type = type;
704
704
  return this;
705
705
  }
706
706
  setFingerprint(fingerprint) {
@@ -724,7 +724,7 @@ var Scope = class _Scope {
724
724
  next.user = this.user ? { ...this.user } : void 0;
725
725
  next.contexts = Object.fromEntries(Object.entries(this.contexts).map(([k, v]) => [k, { ...v }]));
726
726
  next.extra = { ...this.extra };
727
- next.level = this.level;
727
+ next.type = this.type;
728
728
  next.fingerprint = this.fingerprint ? [...this.fingerprint] : void 0;
729
729
  next.breadcrumbs = [...this.breadcrumbs];
730
730
  return next;
@@ -1201,6 +1201,7 @@ var MAX_ITEM_MESSAGE_CHARS = 2e3;
1201
1201
  var MAX_ITEM_PAYLOAD_BYTES = 16384;
1202
1202
  var ITEMS_FLUSH_AT_BUFFERED = 100;
1203
1203
  var ITEMS_DRAIN_MS = 15e3;
1204
+ var PROGRESS_FLUSH_MS = 1e3;
1204
1205
  function scrubPayloadValue(value, depth) {
1205
1206
  if (typeof value === "string") return scrubSecrets(value);
1206
1207
  if (depth >= 4 || value === null || typeof value !== "object") return value;
@@ -1235,12 +1236,28 @@ var RunReporter = class {
1235
1236
  failed: (message, payload, options) => this.addItem("failed", message, payload, options?.id),
1236
1237
  occurrence: (message, payload, options) => this.addItem("occurrence", message, payload, options?.id)
1237
1238
  };
1239
+ const progress = Object.defineProperties(
1240
+ {
1241
+ total: (total) => this.setProgress(this.progressCurrent, total),
1242
+ advance: (count = 1) => this.setProgress(this.progressCurrent + count, this.progressTotal),
1243
+ set: (current, total) => this.setProgress(current, total ?? this.progressTotal)
1244
+ },
1245
+ {
1246
+ current: { enumerable: true, get: () => this.progressCurrent },
1247
+ snapshot: {
1248
+ enumerable: true,
1249
+ get: () => this.progressTotal == null ? null : { current: this.progressCurrent, total: this.progressTotal }
1250
+ }
1251
+ }
1252
+ // defineProperties não tipa os getters adicionados — a interface é a fonte de verdade
1253
+ );
1238
1254
  const signal = this.abortController.signal;
1239
1255
  this.context = {
1240
1256
  runId,
1241
1257
  task,
1242
1258
  log,
1243
1259
  items,
1260
+ progress,
1244
1261
  tx,
1245
1262
  signal,
1246
1263
  throwIfCanceled: () => {
@@ -1284,6 +1301,14 @@ var RunReporter = class {
1284
1301
  uploadQueue = [];
1285
1302
  /** Uma vez que a instalação recusa anexos, para de tentar (e de avisar) nesta run. */
1286
1303
  attachmentsUnsupported = false;
1304
+ // Progresso (ctx.progress): snapshot absoluto, enviado com throttle via piggyback
1305
+ // em `enqueue()` — nunca gera tráfego próprio enquanto logs/heartbeat já fluem.
1306
+ progressCurrent = 0;
1307
+ progressTotal = null;
1308
+ /** Snapshot ainda não entregue ao servidor. */
1309
+ progressDirty = false;
1310
+ progressTimer;
1311
+ progressWarned = false;
1287
1312
  get isDead() {
1288
1313
  return this.dead;
1289
1314
  }
@@ -1301,6 +1326,7 @@ var RunReporter = class {
1301
1326
  this.abort();
1302
1327
  this.dead = true;
1303
1328
  this.clearFlushTimer();
1329
+ this.clearProgressTimer();
1304
1330
  this.buffer = [];
1305
1331
  this.itemsClosed = true;
1306
1332
  this.clearItemsTimer();
@@ -1380,13 +1406,63 @@ var RunReporter = class {
1380
1406
  reportSuccess() {
1381
1407
  this.stopCancelWatch();
1382
1408
  this.clearFlushTimer();
1409
+ this.forceFinalProgress();
1383
1410
  this.enqueue({ status: "succeeded", logs: this.takeLogs() });
1384
1411
  }
1385
1412
  reportFailure(error) {
1386
1413
  this.stopCancelWatch();
1387
1414
  this.clearFlushTimer();
1415
+ this.forceFinalProgress();
1388
1416
  this.enqueue({ status: "failed", error, logs: this.takeLogs() });
1389
1417
  }
1418
+ // ── Progresso ────────────────────────────────────────────────────────────────
1419
+ /** Valida, clampa e marca o snapshot como pendente; agenda flush só se nada mais vai sair. */
1420
+ setProgress(current, total) {
1421
+ if (this.dead) return;
1422
+ if (total != null && (!Number.isInteger(total) || total < 1)) {
1423
+ if (!this.progressWarned) {
1424
+ this.progressWarned = true;
1425
+ this.logger.warn(`ctx.progress: total inv\xE1lido (${String(total)}) \u2014 precisa ser inteiro >= 1; ignorado`);
1426
+ }
1427
+ return;
1428
+ }
1429
+ const safeCurrent = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : this.progressCurrent;
1430
+ const next = total == null ? safeCurrent : Math.min(safeCurrent, total);
1431
+ if (next === this.progressCurrent && total === this.progressTotal) return;
1432
+ this.progressCurrent = next;
1433
+ this.progressTotal = total;
1434
+ if (total == null) return;
1435
+ this.progressDirty = true;
1436
+ if (this.progressTimer) return;
1437
+ this.progressTimer = setTimeout(() => {
1438
+ this.progressTimer = void 0;
1439
+ this.flushProgress();
1440
+ }, PROGRESS_FLUSH_MS);
1441
+ unrefTimer(this.progressTimer);
1442
+ }
1443
+ flushProgress() {
1444
+ this.clearProgressTimer();
1445
+ if (this.dead || !this.progressDirty) return;
1446
+ if (this.buffer.length > 0) this.flushLogs();
1447
+ else this.enqueue({ status: "running" });
1448
+ }
1449
+ clearProgressTimer() {
1450
+ if (this.progressTimer) {
1451
+ clearTimeout(this.progressTimer);
1452
+ this.progressTimer = void 0;
1453
+ }
1454
+ }
1455
+ /** O PATCH terminal sempre leva o snapshot final (o app mostra "87/100" numa run que falhou). */
1456
+ forceFinalProgress() {
1457
+ this.clearProgressTimer();
1458
+ this.progressDirty = this.progressTotal != null;
1459
+ }
1460
+ /** Consome o snapshot pendente para pegar carona no próximo PATCH. */
1461
+ takeProgress() {
1462
+ if (!this.progressDirty || this.progressTotal == null) return void 0;
1463
+ this.progressDirty = false;
1464
+ return { current: this.progressCurrent, total: this.progressTotal };
1465
+ }
1390
1466
  /**
1391
1467
  * Heartbeat de cancelamento: enquanto o handler roda, um PATCH 'running' vazio
1392
1468
  * periódico faz o servidor responder 409 se a run foi cancelada no app — o que
@@ -1649,11 +1725,14 @@ var RunReporter = class {
1649
1725
  new Promise((resolve) => unrefTimer(setTimeout(resolve, timeoutMs)))
1650
1726
  ]);
1651
1727
  }
1652
- enqueue(update) {
1728
+ enqueue(updateIn) {
1653
1729
  if (this.dead) {
1654
- this.salvageLogs(update.logs);
1730
+ this.salvageLogs(updateIn.logs);
1655
1731
  return;
1656
1732
  }
1733
+ this.clearProgressTimer();
1734
+ const progress = this.takeProgress();
1735
+ const update = progress ? { ...updateIn, progress } : updateIn;
1657
1736
  this.pendingUpdates++;
1658
1737
  this.chain = this.chain.then(async () => {
1659
1738
  if (this.dead) {
@@ -1679,6 +1758,7 @@ var RunReporter = class {
1679
1758
  this.buffer.unshift(...update.logs);
1680
1759
  this.scheduleFlush();
1681
1760
  }
1761
+ if (update.progress && this.progressTotal != null) this.progressDirty = true;
1682
1762
  this.logger.error(`update da run ${this.runId} perdido:`, error);
1683
1763
  }
1684
1764
  }).finally(() => {
@@ -2084,10 +2164,10 @@ function init(options) {
2084
2164
  function getClient() {
2085
2165
  return client;
2086
2166
  }
2087
- function toRunLogLevel(level) {
2088
- return level === "fatal" ? "error" : level;
2167
+ function toRunLogLevel(type) {
2168
+ return type === "fatal" ? "error" : type;
2089
2169
  }
2090
- function forwardAttachment(attachment, message, level) {
2170
+ function forwardAttachment(attachment, message, type) {
2091
2171
  const attach = currentRunAttach();
2092
2172
  if (!attach) {
2093
2173
  client?.logger.warn("attachment descartado \u2014 s\xF3 \xE9 suportado dentro de uma execu\xE7\xE3o de task (register/start)");
@@ -2096,7 +2176,7 @@ function forwardAttachment(attachment, message, level) {
2096
2176
  const withMeta = typeof attachment === "object" && "image" in attachment ? attachment : { image: attachment };
2097
2177
  void attach(withMeta.image, {
2098
2178
  message,
2099
- level: toRunLogLevel(level),
2179
+ level: toRunLogLevel(type),
2100
2180
  caption: withMeta.caption,
2101
2181
  contentType: withMeta.contentType
2102
2182
  });
@@ -2104,15 +2184,15 @@ function forwardAttachment(attachment, message, level) {
2104
2184
  function captureException(error, hint) {
2105
2185
  if (hint?.attachment) {
2106
2186
  const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
2107
- forwardAttachment(hint.attachment, message, hint.level ?? "error");
2187
+ forwardAttachment(hint.attachment, message, hint.type ?? "error");
2108
2188
  }
2109
2189
  return client?.captureException(error, hint, scope);
2110
2190
  }
2111
- function captureMessage(message, level = "info", hint) {
2191
+ function captureMessage(message, type = "warning", hint) {
2112
2192
  if (hint?.attachment) {
2113
- forwardAttachment(hint.attachment, message, hint.level ?? level);
2193
+ forwardAttachment(hint.attachment, message, hint.type ?? type);
2114
2194
  }
2115
- return client?.captureMessage(message, level, hint, scope);
2195
+ return client?.captureMessage(message, type, hint, scope);
2116
2196
  }
2117
2197
  function addBreadcrumb(crumb) {
2118
2198
  scope.addBreadcrumb(crumb, client?.options.maxBreadcrumbs ?? DEFAULT_MAX_BREADCRUMBS);