hobots 1.8.1 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -278,6 +278,41 @@ interface RunContext {
278
278
 
279
279
  > **Dados sensíveis:** `ctx.log` e mensagens/stack de erros **são enviados ao servidor** e ficam visíveis no app. Não inclua senhas, tokens, dados pessoais ou qualquer informação sensível em logs e mensagens de erro.
280
280
 
281
+ ### Anexos (screenshots)
282
+
283
+ 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.
284
+
285
+ ```js
286
+ Hobots.register('processar-nfe', async (params, ctx) => {
287
+ await entrar();
288
+ Hobots.captureMessage('portal após o login', 'info', {
289
+ attachment: await page.screenshot(),
290
+ });
291
+
292
+ try {
293
+ await emitir(params);
294
+ } catch (erro) {
295
+ Hobots.captureException(erro, {
296
+ attachment: { image: await page.screenshot(), caption: 'tela no momento do erro' },
297
+ });
298
+ throw erro;
299
+ }
300
+ });
301
+ ```
302
+
303
+ **O SDK não tira o print** — ele recebe a imagem pronta, de onde você quiser (Playwright, Puppeteer, uma lib de captura de desktop, um arquivo em disco). O `attachment` aceita bytes (`Uint8Array`/`ArrayBuffer`/`Buffer`), um caminho de arquivo (só em Node) ou um objeto `{ image, caption?, contentType? }`.
304
+
305
+ | Limite | Valor |
306
+ | --- | --- |
307
+ | formatos | PNG, JPEG, WEBP |
308
+ | tamanho por imagem | 5 MB |
309
+ | anexos por execução | 20 |
310
+ | anexos por linha de log | 4 |
311
+
312
+ A linha de log é registrada **na hora**; o upload roda em segundo plano e vai direto para o S3 por URL presignada, sem passar pela API — a captura **nunca bloqueia nem lança** por causa do anexo (falhas viram um aviso no log da run). Fora de uma execução de task não há onde pendurar a imagem: o `attachment` é descartado com um aviso. Detalhes em [Anexos](./docs/tasks.md#anexos-screenshots).
313
+
314
+ > **Dados sensíveis:** o print é enviado **exatamente como está** — o SDK não borra nem recorta nada, e telas de portal costumam conter CPF, nome e valores. Capture só o necessário. Solicitantes só veem os anexos quando o projeto está com "mostrar logs para solicitantes" ligado, o mesmo gate dos logs.
315
+
281
316
  ### Cancelamento cooperativo
282
317
 
283
318
  Uma run cancelada no app **não mata** o handler — o SDK apenas dispara `ctx.signal`. Chame **`ctx.throwIfCanceled()`** em loops longos e pontos de parada (e troque esperas longas por `ctx.sleep()`):
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 ? "1.8.0" : "0.0.0-dev";
164
+ var SDK_VERSION = true ? "1.9.0" : "0.0.0-dev";
165
165
  function isNode() {
166
166
  return typeof process !== "undefined" && typeof process.versions?.node === "string";
167
167
  }
@@ -328,6 +328,7 @@ var REQUEST_TIMEOUT_MS = 1e4;
328
328
  var DEFAULT_FLUSH_INTERVAL_MS = 2e3;
329
329
  var DEFAULT_MAX_BATCH_SIZE = 10;
330
330
  var DEFAULT_MAX_QUEUE_SIZE = 200;
331
+ var MAX_ITEMS_PER_ENVELOPE = 50;
331
332
  var Transport = class {
332
333
  endpoint;
333
334
  key;
@@ -377,7 +378,15 @@ var Transport = class {
377
378
  this.timer = void 0;
378
379
  }
379
380
  if (this.buffer.length === 0) return Promise.resolve();
380
- const items = this.buffer.splice(0, this.buffer.length);
381
+ const taken = this.buffer.splice(0, this.buffer.length);
382
+ const sends = [];
383
+ for (let offset = 0; offset < taken.length; offset += MAX_ITEMS_PER_ENVELOPE) {
384
+ sends.push(this.send(taken.slice(offset, offset + MAX_ITEMS_PER_ENVELOPE)));
385
+ }
386
+ return Promise.all(sends).then(() => void 0);
387
+ }
388
+ /** Registra o envio em `this.pending` para que `flush()` aguarde todos os envelopes. */
389
+ send(items) {
381
390
  const envelope = {
382
391
  sdk: { name: SDK_NAME, version: SDK_VERSION },
383
392
  sent_at: nowISO(),
@@ -409,7 +418,7 @@ var Transport = class {
409
418
  this.logger.debug(`envelope enviado (${envelope.items.length} itens)`);
410
419
  return;
411
420
  }
412
- this.logger.error(`envelope rejeitado (HTTP ${response.status}), descartado`);
421
+ this.logger.error(`envelope rejeitado (HTTP ${response.status}), descartado (${envelope.items.length} itens)`);
413
422
  }
414
423
  /** Envia o que estiver na fila e aguarda envios em andamento (até timeoutMs). */
415
424
  async flush(timeoutMs = 5e3) {
@@ -882,6 +891,8 @@ var Transaction = class {
882
891
  // src/tasks/api.ts
883
892
  var POLL_TIMEOUT_MS = 3e4;
884
893
  var UPDATE_TIMEOUT_MS = 1e4;
894
+ var UPLOAD_TIMEOUT_MS = 3e4;
895
+ var UPLOAD_RETRY_DELAYS_MS = [1e3, 4e3];
885
896
  var TasksApiError = class extends Error {
886
897
  constructor(message, status) {
887
898
  super(message);
@@ -976,8 +987,123 @@ var TasksApi = class {
976
987
  signal: AbortSignal.timeout(timeoutMs)
977
988
  });
978
989
  }
990
+ /**
991
+ * Registra o anexo e obtém a URL presignada de upload. 404/501/503 significam
992
+ * "esta instalação não tem anexos" — o RunReporter degrada em silêncio a partir daí.
993
+ */
994
+ async createAttachment(runId, request, signal) {
995
+ const { signal: timeoutSignal, cleanup } = withTimeoutSignal(UPDATE_TIMEOUT_MS, signal);
996
+ let response;
997
+ try {
998
+ response = await fetchWithRetry(
999
+ () => fetch(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/attachments`, {
1000
+ method: "POST",
1001
+ headers: this.headers(),
1002
+ body: JSON.stringify(request),
1003
+ signal: timeoutSignal
1004
+ }),
1005
+ { logger: this.logger, label: `anexo da run ${runId}` }
1006
+ );
1007
+ } finally {
1008
+ cleanup();
1009
+ }
1010
+ if (!response.ok) {
1011
+ throw new TasksApiError(`cria\xE7\xE3o de anexo rejeitada (HTTP ${response.status})`, response.status);
1012
+ }
1013
+ const body = await response.json();
1014
+ if (typeof body?.upload_url !== "string") {
1015
+ throw new TasksApiError("resposta inv\xE1lida do anexo (upload_url ausente)", 0);
1016
+ }
1017
+ return {
1018
+ id: body.id ?? request.id,
1019
+ upload_url: body.upload_url,
1020
+ headers: body.headers ?? { "content-type": request.content_type },
1021
+ expires_in: body.expires_in ?? 900
1022
+ };
1023
+ }
1024
+ /**
1025
+ * PUT do binário direto no S3. Deliberadamente sem `authorization`: a autorização
1026
+ * está na assinatura da URL, e mandar o client secret para fora da API seria vazamento.
1027
+ */
1028
+ async uploadAttachment(url, headers, body, signal) {
1029
+ const { signal: timeoutSignal, cleanup } = withTimeoutSignal(UPLOAD_TIMEOUT_MS, signal);
1030
+ let response;
1031
+ try {
1032
+ response = await fetchWithRetry(
1033
+ () => fetch(url, {
1034
+ method: "PUT",
1035
+ headers,
1036
+ // BodyInit não aceita Uint8Array<ArrayBufferLike> em todos os lib.dom; o buffer é o mesmo
1037
+ body,
1038
+ signal: timeoutSignal
1039
+ }),
1040
+ { delays: UPLOAD_RETRY_DELAYS_MS, logger: this.logger, label: "upload de anexo" }
1041
+ );
1042
+ } finally {
1043
+ cleanup();
1044
+ }
1045
+ if (!response.ok) {
1046
+ throw new TasksApiError(`upload de anexo rejeitado (HTTP ${response.status})`, response.status);
1047
+ }
1048
+ }
1049
+ /** Marca o anexo como enviado (ou falho). Best-effort — a leitura não depende disso. */
1050
+ async confirmAttachment(runId, attachmentId, request) {
1051
+ const response = await fetch(
1052
+ `${this.baseUrl}/runs/${encodeURIComponent(runId)}/attachments/${encodeURIComponent(attachmentId)}`,
1053
+ {
1054
+ method: "PATCH",
1055
+ headers: this.headers(),
1056
+ body: JSON.stringify(request),
1057
+ signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS)
1058
+ }
1059
+ );
1060
+ if (!response.ok) {
1061
+ throw new TasksApiError(`confirma\xE7\xE3o de anexo rejeitada (HTTP ${response.status})`, response.status);
1062
+ }
1063
+ }
979
1064
  };
980
1065
 
1066
+ // src/tasks/attachments.ts
1067
+ var MAX_ATTACHMENT_BYTES = 5242880;
1068
+ var MAX_ATTACHMENTS_PER_RUN = 20;
1069
+ var MAX_CONCURRENT_UPLOADS = 3;
1070
+ var MAX_CAPTION_CHARS = 200;
1071
+ var ATTACHMENT_DRAIN_MS = 15e3;
1072
+ async function readImageFile(path) {
1073
+ if (!isNode()) {
1074
+ throw new Error("caminho de arquivo s\xF3 \xE9 suportado em Node");
1075
+ }
1076
+ const { readFile } = await import("fs/promises");
1077
+ return new Uint8Array(await readFile(path));
1078
+ }
1079
+ function startsWith(bytes, signature, offset = 0) {
1080
+ if (bytes.length < offset + signature.length) return false;
1081
+ return signature.every((byte, index) => bytes[offset + index] === byte);
1082
+ }
1083
+ var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
1084
+ var JPEG_MAGIC = [255, 216, 255];
1085
+ var RIFF_MAGIC = [82, 73, 70, 70];
1086
+ var WEBP_MAGIC = [87, 69, 66, 80];
1087
+ function sniffContentType(bytes) {
1088
+ if (startsWith(bytes, PNG_MAGIC)) return "image/png";
1089
+ if (startsWith(bytes, JPEG_MAGIC)) return "image/jpeg";
1090
+ if (startsWith(bytes, RIFF_MAGIC) && startsWith(bytes, WEBP_MAGIC, 8)) return "image/webp";
1091
+ return void 0;
1092
+ }
1093
+ function readPngDimensions(bytes) {
1094
+ if (!startsWith(bytes, PNG_MAGIC) || bytes.length < 24) return void 0;
1095
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1096
+ const width = view.getUint32(16);
1097
+ const height = view.getUint32(20);
1098
+ if (width === 0 || height === 0) return void 0;
1099
+ return { width, height };
1100
+ }
1101
+ async function toBytes(input) {
1102
+ if (typeof input === "string") return readImageFile(input);
1103
+ if (input instanceof Uint8Array) return input;
1104
+ return new Uint8Array(input);
1105
+ }
1106
+
981
1107
  // src/tasks/context.ts
982
1108
  var LOG_LEVEL_COLOR = {
983
1109
  debug: "gray",
@@ -1059,6 +1185,14 @@ var RunReporter = class {
1059
1185
  abortController = new AbortController();
1060
1186
  flushIntervalMs;
1061
1187
  cancelCheckIntervalMs;
1188
+ // Uploads de anexo vivem FORA de `this.chain`: a cadeia é um FIFO puro de
1189
+ // status/logs e não pode ficar bloqueada esperando um binário subir.
1190
+ pendingAttachments = /* @__PURE__ */ new Set();
1191
+ attachmentCount = 0;
1192
+ uploadSlots = MAX_CONCURRENT_UPLOADS;
1193
+ uploadQueue = [];
1194
+ /** Uma vez que a instalação recusa anexos, para de tentar (e de avisar) nesta run. */
1195
+ attachmentsUnsupported = false;
1062
1196
  get isDead() {
1063
1197
  return this.dead;
1064
1198
  }
@@ -1077,6 +1211,8 @@ var RunReporter = class {
1077
1211
  this.dead = true;
1078
1212
  this.clearFlushTimer();
1079
1213
  this.buffer = [];
1214
+ const waiting = this.uploadQueue.splice(0, this.uploadQueue.length);
1215
+ for (const resume of waiting) resume();
1080
1216
  }
1081
1217
  /**
1082
1218
  * Drena o buffer pendente para o PATCH único do exit path (chame ANTES de kill,
@@ -1094,15 +1230,22 @@ var RunReporter = class {
1094
1230
  this.push(level, scrubbed, true);
1095
1231
  }
1096
1232
  /** Bufferiza uma entrada de log sem eco no terminal (cap, truncate, flush). */
1097
- push(level, message, alreadyScrubbed = false) {
1098
- if (this.dead) return;
1233
+ push(level, message, alreadyScrubbed = false, attachments) {
1234
+ const ts = nowISO();
1235
+ if (this.dead) return ts;
1099
1236
  const scrubbed = alreadyScrubbed ? message : scrubSecrets(message);
1100
- this.buffer.push({ ts: nowISO(), level, message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS) });
1237
+ this.buffer.push({
1238
+ ts,
1239
+ level,
1240
+ message: truncate(scrubbed, MAX_LOG_MESSAGE_CHARS),
1241
+ ...attachments && attachments.length > 0 ? { attachments } : {}
1242
+ });
1101
1243
  if (this.buffer.length >= FLUSH_AT_BUFFERED) {
1102
1244
  this.flushLogs();
1103
1245
  } else {
1104
1246
  this.scheduleFlush();
1105
1247
  }
1248
+ return ts;
1106
1249
  }
1107
1250
  scheduleFlush() {
1108
1251
  if (this.timer) return;
@@ -1170,6 +1313,131 @@ var RunReporter = class {
1170
1313
  finalize() {
1171
1314
  return this.chain;
1172
1315
  }
1316
+ // ── Anexos ───────────────────────────────────────────────────────────────────
1317
+ /**
1318
+ * Registra a linha de log com o id do anexo (síncrono, na ordem certa) e dispara
1319
+ * o upload em segundo plano. Nunca lança: todo caminho de erro vira `{ ok: false }`
1320
+ * com um aviso no log da run. Chega ao handler pelos capture* do SDK — o hint
1321
+ * `attachment` de Hobots.captureException/captureMessage cai aqui via run-context-store.
1322
+ */
1323
+ async attach(image, options = {}) {
1324
+ const id = uuid4();
1325
+ const level = options.level ?? "info";
1326
+ const caption = options.caption ? truncate(scrubSecrets(options.caption), MAX_CAPTION_CHARS) : void 0;
1327
+ const message = options.message ?? options.caption ?? "captura de tela";
1328
+ if (this.dead) return { id, ok: false, reason: "canceled" };
1329
+ if (this.attachmentsUnsupported) {
1330
+ this.log(level, message);
1331
+ return { id, ok: false, reason: "unsupported" };
1332
+ }
1333
+ if (this.attachmentCount >= MAX_ATTACHMENTS_PER_RUN) {
1334
+ this.log(level, message);
1335
+ this.log("warning", `anexo descartado: limite de ${MAX_ATTACHMENTS_PER_RUN} por execu\xE7\xE3o atingido`);
1336
+ return { id, ok: false, reason: "quota" };
1337
+ }
1338
+ this.attachmentCount++;
1339
+ const scrubbed = scrubSecrets(message);
1340
+ printRunLog(this.context.task, level, `${scrubbed} [imagem]`);
1341
+ const logTs = this.push(level, scrubbed, true, [id]);
1342
+ let settle;
1343
+ const result = new Promise((resolve) => {
1344
+ settle = resolve;
1345
+ });
1346
+ const work = this.uploadAttachment(id, image, { caption, logTs, contentType: options.contentType }).then((reason) => settle(reason ? { id, ok: false, reason } : { id, ok: true })).catch(() => settle({ id, ok: false, reason: "upload-failed" }));
1347
+ this.pendingAttachments.add(work);
1348
+ void work.finally(() => this.pendingAttachments.delete(work));
1349
+ return result;
1350
+ }
1351
+ /** Retorna o motivo da falha, ou undefined em sucesso. */
1352
+ async uploadAttachment(id, image, meta) {
1353
+ let bytes;
1354
+ try {
1355
+ bytes = await toBytes(image);
1356
+ } catch (error) {
1357
+ this.log("warning", `anexo n\xE3o p\xF4de ser lido: ${String(error)}`);
1358
+ return "read-failed";
1359
+ }
1360
+ const contentType = sniffContentType(bytes) ?? meta.contentType;
1361
+ if (!contentType) {
1362
+ this.log("warning", "anexo descartado: formato n\xE3o reconhecido (aceitos: PNG, JPEG, WEBP)");
1363
+ return "unsupported-format";
1364
+ }
1365
+ if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
1366
+ this.log(
1367
+ "warning",
1368
+ `anexo descartado: ${Math.round(bytes.byteLength / 1024)} KB excede o limite de ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
1369
+ );
1370
+ return "too-large";
1371
+ }
1372
+ const dimensions = readPngDimensions(bytes);
1373
+ const release = await this.acquireUploadSlot();
1374
+ try {
1375
+ if (this.abortController.signal.aborted || this.dead) return "canceled";
1376
+ const created = await this.api.createAttachment(
1377
+ this.runId,
1378
+ {
1379
+ id,
1380
+ content_type: contentType,
1381
+ size_bytes: bytes.byteLength,
1382
+ log_ts: meta.logTs,
1383
+ ...meta.caption ? { caption: meta.caption } : {},
1384
+ ...dimensions ?? {}
1385
+ },
1386
+ this.abortController.signal
1387
+ );
1388
+ await this.api.uploadAttachment(created.upload_url, created.headers, bytes, this.abortController.signal);
1389
+ await this.api.confirmAttachment(this.runId, id, { status: "uploaded", size_bytes: bytes.byteLength, ...dimensions ?? {} }).catch(() => {
1390
+ });
1391
+ return void 0;
1392
+ } catch (error) {
1393
+ if (this.isUnsupported(error)) {
1394
+ this.attachmentsUnsupported = true;
1395
+ this.log("warning", "anexos n\xE3o est\xE3o habilitados nesta instala\xE7\xE3o do Hobots \u2014 imagem n\xE3o enviada");
1396
+ return "unsupported";
1397
+ }
1398
+ if (error instanceof TasksApiError && error.status === 429) {
1399
+ this.log("warning", "anexo descartado: limite de anexos por execu\xE7\xE3o atingido no servidor");
1400
+ return "quota";
1401
+ }
1402
+ if (this.abortController.signal.aborted) return "canceled";
1403
+ this.log("warning", `falha ao enviar o anexo: ${String(error)}`);
1404
+ void this.api.confirmAttachment(this.runId, id, { status: "failed" }).catch(() => {
1405
+ });
1406
+ return "upload-failed";
1407
+ } finally {
1408
+ release();
1409
+ }
1410
+ }
1411
+ /** 404 = API anterior ao recurso; 501/503 = instalação sem S3 configurado. */
1412
+ isUnsupported(error) {
1413
+ return error instanceof TasksApiError && (error.status === 404 || error.status === 501 || error.status === 503);
1414
+ }
1415
+ /** Semáforo simples: no máximo MAX_CONCURRENT_UPLOADS binários em voo por run. */
1416
+ acquireUploadSlot() {
1417
+ const release = () => {
1418
+ const next = this.uploadQueue.shift();
1419
+ if (next) next();
1420
+ else this.uploadSlots++;
1421
+ };
1422
+ if (this.uploadSlots > 0) {
1423
+ this.uploadSlots--;
1424
+ return Promise.resolve(release);
1425
+ }
1426
+ return new Promise((resolve) => {
1427
+ this.uploadQueue.push(() => resolve(release));
1428
+ });
1429
+ }
1430
+ /**
1431
+ * Espera os uploads pendentes antes do PATCH terminal, para a run não terminar
1432
+ * exibindo miniaturas em "enviando…". Capado: um upload lento não segura a run.
1433
+ */
1434
+ async finalizeAttachments(timeoutMs = ATTACHMENT_DRAIN_MS) {
1435
+ if (this.pendingAttachments.size === 0) return;
1436
+ await Promise.race([
1437
+ Promise.allSettled([...this.pendingAttachments]),
1438
+ new Promise((resolve) => unrefTimer(setTimeout(resolve, timeoutMs)))
1439
+ ]);
1440
+ }
1173
1441
  enqueue(update) {
1174
1442
  if (this.dead) {
1175
1443
  this.salvageLogs(update.logs);
@@ -1237,6 +1505,9 @@ async function runWithRunContext(store, fn) {
1237
1505
  function currentRunTransaction() {
1238
1506
  return storage?.getStore()?.tx;
1239
1507
  }
1508
+ function currentRunAttach() {
1509
+ return storage?.getStore()?.attach;
1510
+ }
1240
1511
 
1241
1512
  // src/tasks/shutdown.ts
1242
1513
  var WATCHED_SIGNALS = ["SIGINT", "SIGTERM"];
@@ -1478,12 +1749,17 @@ var TaskAgent = class {
1478
1749
  }
1479
1750
  this.logger.debug(`executando run ${run.id} (${run.task})`);
1480
1751
  try {
1481
- await runWithRunContext({ runId: run.id, tx }, () => this.invokeWithTimeout(handler, run, reporter));
1752
+ await runWithRunContext(
1753
+ { runId: run.id, tx, attach: (image, options) => reporter.attach(image, options) },
1754
+ () => this.invokeWithTimeout(handler, run, reporter)
1755
+ );
1756
+ await reporter.finalizeAttachments();
1482
1757
  tx.setStatus("ok");
1483
1758
  tx.finish();
1484
1759
  reporter.reportSuccess();
1485
1760
  this.logger.debug(`run ${run.id} conclu\xEDda`);
1486
1761
  } catch (error) {
1762
+ await reporter.finalizeAttachments();
1487
1763
  tx.setStatus("error");
1488
1764
  tx.finish();
1489
1765
  reporter.reportFailure(toRunError(error));
@@ -1566,10 +1842,34 @@ function init(options) {
1566
1842
  function getClient() {
1567
1843
  return client;
1568
1844
  }
1845
+ function toRunLogLevel(level) {
1846
+ return level === "fatal" ? "error" : level;
1847
+ }
1848
+ function forwardAttachment(attachment, message, level) {
1849
+ const attach = currentRunAttach();
1850
+ if (!attach) {
1851
+ client?.logger.warn("attachment descartado \u2014 s\xF3 \xE9 suportado dentro de uma execu\xE7\xE3o de task (register/start)");
1852
+ return;
1853
+ }
1854
+ const withMeta = typeof attachment === "object" && "image" in attachment ? attachment : { image: attachment };
1855
+ void attach(withMeta.image, {
1856
+ message,
1857
+ level: toRunLogLevel(level),
1858
+ caption: withMeta.caption,
1859
+ contentType: withMeta.contentType
1860
+ });
1861
+ }
1569
1862
  function captureException(error, hint) {
1863
+ if (hint?.attachment) {
1864
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
1865
+ forwardAttachment(hint.attachment, message, hint.level ?? "error");
1866
+ }
1570
1867
  return client?.captureException(error, hint, scope);
1571
1868
  }
1572
1869
  function captureMessage(message, level = "info", hint) {
1870
+ if (hint?.attachment) {
1871
+ forwardAttachment(hint.attachment, message, hint.level ?? level);
1872
+ }
1573
1873
  return client?.captureMessage(message, level, hint, scope);
1574
1874
  }
1575
1875
  function addBreadcrumb(crumb) {