@oconde/radar 0.2.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Novo
6
+
7
+ - `traceFetch`: cada `fetch` de saída vira um log `http.client` com método, host, caminho (sem a query, com trechos que parecem segredo mascarados), status e duração, ou o erro com a causa (`fetch failed (ECONNREFUSED)`). Para os hosts de `propagateTo`, o SDK manda o `x-request-id` da requisição atual, sem trocar um que o app já tenha posto. Chamadas para o próprio Radar ficam de fora. Desligado por padrão.
8
+
9
+ ### Correções
10
+
11
+ - O nome da função inferido do código original não se confunde mais com chaves e com a palavra `function` dentro de comentários de várias linhas (como os de JSDoc).
12
+
3
13
  ## 0.2.0
4
14
 
5
15
  ### Novo
package/README.md CHANGED
@@ -98,6 +98,14 @@ await radar.cron('nightly-report', () => buildReport());
98
98
 
99
99
  `radar.cron` avisa o Radar no início, no fim e na falha, e o Radar alerta se a tarefa atrasar ou falhar. Em scripts sem o SDK, o check-in é um `POST` (veja o [PROTOCOL.md](PROTOCOL.md)). Para jobs e workers, `radar.withContext(fn)` dá a cada execução o próprio `requestId`. Detalhes em [Tarefas agendadas](https://radar.oconde.dev/docs/sdk/cron/).
100
100
 
101
+ ## Chamadas para outros serviços
102
+
103
+ ```ts
104
+ radar.init({ key: process.env.RADAR_KEY, traceFetch: { propagateTo: ['api.spinlab.dev', '.oconde.dev'] } });
105
+ ```
106
+
107
+ Com `traceFetch`, cada `fetch` que o app faz vira um log `http.client` com método, host, caminho (sem a query, e com trechos que parecem token mascarados), status e duração, ou o erro quando a chamada nem chega a responder. Dentro de uma requisição, o log entra na linha do tempo dela. Para os hosts de `propagateTo` (o nome exato, `.dominio` para os subdomínios ou uma RegExp), o SDK manda o `x-request-id` da requisição atual, e o Radar mostra a mesma requisição nos dois projetos. `traceFetch: true` registra as chamadas sem mandar o id para ninguém. Chamadas para o próprio Radar ficam de fora.
108
+
101
109
  ## Erros com o seu código
102
110
 
103
111
  Ligue no `tsconfig` para os erros mostrarem as linhas do TypeScript:
@@ -35,6 +35,14 @@ type RadarMiddleware = (req: RequestLike, res: ResponseLike, next: (error?: unkn
35
35
 
36
36
  type RedactMode = 'mask' | 'none';
37
37
  type RequestDetail = 'full' | 'route';
38
+ type FetchTraceTarget = string | RegExp;
39
+ type TraceFetchOption = boolean | {
40
+ propagateTo?: FetchTraceTarget[];
41
+ };
42
+ type ResolvedTraceFetch = {
43
+ enabled: boolean;
44
+ propagateTo: FetchTraceTarget[];
45
+ };
38
46
  type RadarOptions = {
39
47
  key?: string;
40
48
  endpoint?: string;
@@ -48,6 +56,7 @@ type RadarOptions = {
48
56
  logRequests?: boolean;
49
57
  ignorePaths?: string[];
50
58
  captureUnhandled?: boolean;
59
+ traceFetch?: TraceFetchOption;
51
60
  debug?: boolean;
52
61
  };
53
62
  type ResolvedOptions = {
@@ -63,6 +72,7 @@ type ResolvedOptions = {
63
72
  logRequests: boolean;
64
73
  ignorePaths: string[];
65
74
  captureUnhandled: boolean;
75
+ traceFetch: ResolvedTraceFetch;
66
76
  debug: boolean;
67
77
  };
68
78
 
@@ -95,6 +105,7 @@ declare class RadarClient {
95
105
  private initialized;
96
106
  private closed;
97
107
  private readonly captured;
108
+ private restoreFetch;
98
109
  get isEnabled(): boolean;
99
110
  get settings(): Readonly<ResolvedOptions>;
100
111
  init(options?: RadarOptions): void;
@@ -35,6 +35,14 @@ type RadarMiddleware = (req: RequestLike, res: ResponseLike, next: (error?: unkn
35
35
 
36
36
  type RedactMode = 'mask' | 'none';
37
37
  type RequestDetail = 'full' | 'route';
38
+ type FetchTraceTarget = string | RegExp;
39
+ type TraceFetchOption = boolean | {
40
+ propagateTo?: FetchTraceTarget[];
41
+ };
42
+ type ResolvedTraceFetch = {
43
+ enabled: boolean;
44
+ propagateTo: FetchTraceTarget[];
45
+ };
38
46
  type RadarOptions = {
39
47
  key?: string;
40
48
  endpoint?: string;
@@ -48,6 +56,7 @@ type RadarOptions = {
48
56
  logRequests?: boolean;
49
57
  ignorePaths?: string[];
50
58
  captureUnhandled?: boolean;
59
+ traceFetch?: TraceFetchOption;
51
60
  debug?: boolean;
52
61
  };
53
62
  type ResolvedOptions = {
@@ -63,6 +72,7 @@ type ResolvedOptions = {
63
72
  logRequests: boolean;
64
73
  ignorePaths: string[];
65
74
  captureUnhandled: boolean;
75
+ traceFetch: ResolvedTraceFetch;
66
76
  debug: boolean;
67
77
  };
68
78
 
@@ -95,6 +105,7 @@ declare class RadarClient {
95
105
  private initialized;
96
106
  private closed;
97
107
  private readonly captured;
108
+ private restoreFetch;
98
109
  get isEnabled(): boolean;
99
110
  get settings(): Readonly<ResolvedOptions>;
100
111
  init(options?: RadarOptions): void;
package/dist/index.cjs CHANGED
@@ -282,7 +282,10 @@ function requestPath(req) {
282
282
  }
283
283
  function requestRoute(req, route) {
284
284
  if (route) return route;
285
- return typeof req.route?.path === "string" ? req.route.path : void 0;
285
+ if (typeof req.route?.path !== "string") return void 0;
286
+ const base = typeof req.baseUrl === "string" ? req.baseUrl.replace(/\/+$/, "") : "";
287
+ if (!base) return req.route.path;
288
+ return req.route.path === "/" ? base : `${base}${req.route.path}`;
286
289
  }
287
290
  var ROUTE_PARAM = /^:([A-Za-z_$][\w$]*)/;
288
291
  function pathSegments(value) {
@@ -416,6 +419,12 @@ function createMiddleware(logger) {
416
419
  // src/options.ts
417
420
  var DEFAULT_ENDPOINT = "https://radar-ingest.oconde.dev";
418
421
  var LEVELS = ["debug", "info", "warn", "error"];
422
+ function resolveTraceFetch(value) {
423
+ if (!value) return { enabled: false, propagateTo: [] };
424
+ if (value === true) return { enabled: true, propagateTo: [] };
425
+ const targets2 = Array.isArray(value.propagateTo) ? value.propagateTo : [];
426
+ return { enabled: true, propagateTo: targets2.filter((target) => typeof target === "string" ? target.trim() !== "" : target instanceof RegExp) };
427
+ }
419
428
  function resolveOptions(options = {}, env = process.env) {
420
429
  const key = options.key?.trim() || void 0;
421
430
  return {
@@ -431,6 +440,7 @@ function resolveOptions(options = {}, env = process.env) {
431
440
  logRequests: options.logRequests ?? true,
432
441
  ignorePaths: options.ignorePaths ?? ["/healthz", "/health"],
433
442
  captureUnhandled: options.captureUnhandled ?? true,
443
+ traceFetch: resolveTraceFetch(options.traceFetch),
434
444
  debug: options.debug ?? false
435
445
  };
436
446
  }
@@ -498,6 +508,7 @@ var IDENTIFIER = "[A-Za-z_$][\\w$]*";
498
508
  var MAX_LINES_UP = 400;
499
509
  var MAX_HEADER_LINES = 6;
500
510
  var MAX_NAME_LENGTH = 100;
511
+ var MAX_COMMENT_LINES = 40;
501
512
  var CONTROL_BLOCK = /^\s*(?:\}\s*)?(?:if|else|for|while|do|switch|try|catch|finally|with)\b/;
502
513
  var CLASS_BODY = new RegExp(`\\bclass\\s+${IDENTIFIER}`);
503
514
  var MARKUP_BOUNDARY = /<\/?(?:script|style|template)\b/i;
@@ -510,8 +521,58 @@ var NAME_PATTERNS = [
510
521
  ];
511
522
  var METHOD_PATTERN = new RegExp(`^\\s*(?:(?:export|default|public|private|protected|static|async|override|readonly|get|set)\\s+)*\\*?\\s*(${IDENTIFIER})\\s*(?:<[^>]*>)?\\s*\\([^]*\\)\\s*(?::\\s*[^{]+)?$`);
512
523
  var ANONYMOUS_FUNCTION = /\bfunction\b|=>/;
513
- function codeOnly(text) {
514
- return text.replace(/(["'`])(?:\\.|(?!\1)[^\\])*\1/g, '""').replace(/\/\*.*?\*\//g, "").replace(/(^|[^:])\/\/.*$/, "$1");
524
+ var STRING_LITERAL = /(["'`])(?:\\.|(?!\1)[^\\])*\1/g;
525
+ function lineCommentAt(text, from) {
526
+ for (let index = text.indexOf("//", from); index >= 0; index = text.indexOf("//", index + 1)) {
527
+ if (index === 0 || text[index - 1] !== ":") return index;
528
+ }
529
+ return -1;
530
+ }
531
+ function blockCommentAt(text, from) {
532
+ for (let index = text.indexOf("/*", from); index >= 0; index = text.indexOf("/*", index + 1)) {
533
+ if (index === 0 || text[index - 1] !== "\\") return index;
534
+ }
535
+ return -1;
536
+ }
537
+ function stripComments(text, insideComment, closesLater) {
538
+ let code = "";
539
+ let position = 0;
540
+ let inside = insideComment;
541
+ while (position < text.length) {
542
+ if (inside) {
543
+ const end = text.indexOf("*/", position);
544
+ if (end < 0) return { code, insideComment: true };
545
+ inside = false;
546
+ position = end + 2;
547
+ continue;
548
+ }
549
+ const block = blockCommentAt(text, position);
550
+ const line = lineCommentAt(text, position);
551
+ if (line >= 0 && (block < 0 || line < block)) return { code: code + text.slice(position, line), insideComment: false };
552
+ if (block < 0) return { code: code + text.slice(position), insideComment: false };
553
+ if (text.indexOf("*/", block + 2) < 0 && !closesLater()) {
554
+ code += text.slice(position, block + 2);
555
+ position = block + 2;
556
+ continue;
557
+ }
558
+ code += text.slice(position, block);
559
+ inside = true;
560
+ position = block + 2;
561
+ }
562
+ return { code, insideComment: inside };
563
+ }
564
+ function codeLines(lines, from, start, column) {
565
+ const plain = lines.slice(from, start + 1).map((line) => line.replace(STRING_LITERAL, '""'));
566
+ const closesWithin = (index) => plain.slice(index + 1, index + 1 + MAX_COMMENT_LINES).some((line) => line.includes("*/"));
567
+ const result = [];
568
+ let insideComment = false;
569
+ for (let index = 0; index < plain.length; index++) {
570
+ const text = index === plain.length - 1 && column ? (lines[start] ?? "").slice(0, Math.max(0, column - 1)).replace(STRING_LITERAL, '""') : plain[index] ?? "";
571
+ const stripped = stripComments(text, insideComment, () => closesWithin(index));
572
+ insideComment = stripped.insideComment;
573
+ result.push(stripped.code);
574
+ }
575
+ return result;
515
576
  }
516
577
  function parenBalance(text) {
517
578
  let balance = 0;
@@ -521,11 +582,11 @@ function parenBalance(text) {
521
582
  }
522
583
  return balance;
523
584
  }
524
- function headerOf(lines, index, prefix) {
585
+ function headerOf(code, index, prefix) {
525
586
  let header = prefix;
526
587
  for (let previous = index - 1; previous >= 0 && previous >= index - MAX_HEADER_LINES; previous--) {
527
588
  if (header.trim() !== "" && parenBalance(header) >= 0) break;
528
- header = `${codeOnly(lines[previous] ?? "")} ${header}`;
589
+ header = `${code[previous] ?? ""} ${header}`;
529
590
  }
530
591
  return header;
531
592
  }
@@ -540,11 +601,12 @@ function nameIn(header) {
540
601
  function enclosingFunctionName(lines, line, column) {
541
602
  const start = line - 1;
542
603
  if (start < 0 || start >= lines.length) return void 0;
604
+ const from = Math.max(0, start - MAX_LINES_UP - MAX_HEADER_LINES);
605
+ const cleaned = codeLines(lines, from, start, column);
543
606
  let depth = 0;
544
607
  for (let index = start; index >= 0 && index >= start - MAX_LINES_UP; index--) {
545
- const raw = lines[index] ?? "";
546
- if (index < start && MARKUP_BOUNDARY.test(raw)) return void 0;
547
- const code = codeOnly(index === start && column ? raw.slice(0, Math.max(0, column - 1)) : raw);
608
+ if (index < start && MARKUP_BOUNDARY.test(lines[index] ?? "")) return void 0;
609
+ const code = cleaned[index - from] ?? "";
548
610
  for (let position = code.length - 1; position >= 0; position--) {
549
611
  const char = code[position];
550
612
  if (char === "}") {
@@ -554,7 +616,7 @@ function enclosingFunctionName(lines, line, column) {
554
616
  depth -= 1;
555
617
  continue;
556
618
  }
557
- const header = headerOf(lines, index, code.slice(0, position));
619
+ const header = headerOf(cleaned, index - from, code.slice(0, position));
558
620
  if (CLASS_BODY.test(header)) return void 0;
559
621
  if (CONTROL_BLOCK.test(header)) continue;
560
622
  const name = nameIn(header);
@@ -796,10 +858,6 @@ function safeStringify(value) {
796
858
  }
797
859
  }
798
860
 
799
- // src/transport.ts
800
- var import_node_util2 = require("util");
801
- var import_node_zlib = require("zlib");
802
-
803
861
  // src/util.ts
804
862
  var import_node_perf_hooks2 = require("perf_hooks");
805
863
  function describeError(error) {
@@ -809,7 +867,101 @@ function elapsedSince(startedAt) {
809
867
  return Math.round(import_node_perf_hooks2.performance.now() - startedAt);
810
868
  }
811
869
 
870
+ // src/trace-fetch.ts
871
+ var WRAPPED = /* @__PURE__ */ Symbol.for("@oconde/radar.fetch");
872
+ var REQUEST_ID_HEADER = "x-request-id";
873
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
874
+ var TELEGRAM_BOT = /^bot\d{5,}:[\w-]{20,}$/;
875
+ var TOKEN_LIKE = /^[\w\-.~+=]{20,}$/;
876
+ function secretSegment(segment) {
877
+ if (UUID.test(segment)) return false;
878
+ if (TELEGRAM_BOT.test(segment) || looksLikeCredential(segment)) return true;
879
+ return TOKEN_LIKE.test(segment) && /[A-Za-z]/.test(segment) && /\d/.test(segment);
880
+ }
881
+ function outboundPath(pathname, mode) {
882
+ if (mode === "none") return pathname;
883
+ return pathname.split("/").map((segment) => {
884
+ let decoded = segment;
885
+ try {
886
+ decoded = decodeURIComponent(segment);
887
+ } catch {
888
+ decoded = segment;
889
+ }
890
+ return secretSegment(decoded) ? maskValue(decoded, false) : segment;
891
+ }).join("/");
892
+ }
893
+ function failureOf(error) {
894
+ const message = describeError(error);
895
+ const cause = error instanceof Error ? error.cause : void 0;
896
+ const detail = typeof cause?.code === "string" ? cause.code : typeof cause?.message === "string" ? cause.message : "";
897
+ return detail && !message.includes(detail) ? `${message} (${detail})` : message;
898
+ }
899
+ function urlOf(input) {
900
+ try {
901
+ if (typeof input === "string") return new URL(input);
902
+ if (input instanceof URL) return input;
903
+ return new URL(input.url);
904
+ } catch {
905
+ return null;
906
+ }
907
+ }
908
+ function originOf(value) {
909
+ try {
910
+ return new URL(value).origin;
911
+ } catch {
912
+ return null;
913
+ }
914
+ }
915
+ function targets(url, target) {
916
+ if (target instanceof RegExp) return target.test(url.host);
917
+ const wanted = target.trim().toLowerCase();
918
+ if (wanted.startsWith(".")) return url.hostname.toLowerCase().endsWith(wanted);
919
+ return url.host.toLowerCase() === wanted || url.hostname.toLowerCase() === wanted;
920
+ }
921
+ function isPlainObject(value) {
922
+ if (value === null || typeof value !== "object") return false;
923
+ const prototype = Object.getPrototypeOf(value);
924
+ return prototype === Object.prototype || prototype === null;
925
+ }
926
+ function withRequestId(input, init, requestId) {
927
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
928
+ if (headers.has(REQUEST_ID_HEADER)) return [input, init];
929
+ headers.set(REQUEST_ID_HEADER, requestId);
930
+ if (init === void 0 || isPlainObject(init)) return [input, { ...init, headers }];
931
+ return [new Request(input, init), { headers }];
932
+ }
933
+ function traceFetch(config) {
934
+ const current = globalThis.fetch;
935
+ if (typeof current !== "function" || current[WRAPPED]) return () => void 0;
936
+ const original = current;
937
+ const radarOrigin = originOf(config.endpoint);
938
+ const traced = async (input, init) => {
939
+ const url = urlOf(input);
940
+ if (!url || url.origin === radarOrigin || url.protocol !== "http:" && url.protocol !== "https:") return original(input, init);
941
+ const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
942
+ const context = currentContext();
943
+ const [nextInput, nextInit] = context && config.propagateTo.some((target) => targets(url, target)) ? withRequestId(input, init, context.requestId) : [input, init];
944
+ const attrs = { method, host: url.host, path: outboundPath(url.pathname, config.redact) };
945
+ const started = performance.now();
946
+ try {
947
+ const response = await original(nextInput, nextInit);
948
+ config.record(response.status >= 500 ? "error" : response.status >= 400 ? "warn" : "info", { ...attrs, status: response.status, durationMs: elapsedSince(started) });
949
+ return response;
950
+ } catch (error) {
951
+ config.record("error", { ...attrs, error: failureOf(error), durationMs: elapsedSince(started) });
952
+ throw error;
953
+ }
954
+ };
955
+ Object.defineProperty(traced, WRAPPED, { value: original });
956
+ globalThis.fetch = traced;
957
+ return () => {
958
+ if (globalThis.fetch === traced) globalThis.fetch = original;
959
+ };
960
+ }
961
+
812
962
  // src/transport.ts
963
+ var import_node_util2 = require("util");
964
+ var import_node_zlib = require("zlib");
813
965
  var gzipAsync = (0, import_node_util2.promisify)(import_node_zlib.gzip);
814
966
  var MAX_BATCH_BYTES = 9e5;
815
967
  var GZIP_THRESHOLD_BYTES = 8192;
@@ -1037,7 +1189,7 @@ function withTimeout(promise, ms) {
1037
1189
 
1038
1190
  // src/version.ts
1039
1191
  var SDK_NAME = "@oconde/radar";
1040
- var SDK_VERSION = true ? "0.2.0" : "0.0.0-dev";
1192
+ var SDK_VERSION = true ? "0.3.0" : "0.0.0-dev";
1041
1193
 
1042
1194
  // src/client.ts
1043
1195
  var MAX_CHECK_IN_DURATION_MS = 7 * 864e5;
@@ -1049,6 +1201,7 @@ var RadarClient = class {
1049
1201
  initialized = false;
1050
1202
  closed = false;
1051
1203
  captured = /* @__PURE__ */ new WeakSet();
1204
+ restoreFetch = null;
1052
1205
  get isEnabled() {
1053
1206
  return this.transport !== null && !this.closed;
1054
1207
  }
@@ -1076,6 +1229,14 @@ var RadarClient = class {
1076
1229
  });
1077
1230
  }
1078
1231
  if (this.options.captureUnhandled && (this.transport || this.options.console)) installProcessHandlers(this);
1232
+ if (this.options.traceFetch.enabled && (this.transport || this.options.console)) {
1233
+ this.restoreFetch = traceFetch({
1234
+ endpoint: this.options.endpoint,
1235
+ propagateTo: this.options.traceFetch.propagateTo,
1236
+ redact: this.options.redact,
1237
+ record: (level, attrs) => this.safely(() => this.emitLog(level, "http.client", attrs))
1238
+ });
1239
+ }
1079
1240
  } catch (error) {
1080
1241
  this.debugWarn(`radar.init falhou: ${describeError(error)}`);
1081
1242
  }
@@ -1212,6 +1373,8 @@ var RadarClient = class {
1212
1373
  async close(timeoutMs = 2e3) {
1213
1374
  if (this.closed) return;
1214
1375
  await this.flush(timeoutMs);
1376
+ this.restoreFetch?.();
1377
+ this.restoreFetch = null;
1215
1378
  this.closed = true;
1216
1379
  this.transport?.stop();
1217
1380
  }