@diegotsi/flint-core 1.9.0 → 1.10.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/dist/index.cjs +141 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +31 -3
- package/dist/index.d.ts +31 -3
- package/dist/index.js +141 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,11 @@ interface NetworkCollector {
|
|
|
3
3
|
stop(): void;
|
|
4
4
|
getEntries(): NetworkEntry[];
|
|
5
5
|
}
|
|
6
|
-
|
|
6
|
+
interface NetworkCollectorOptions {
|
|
7
|
+
maxEntries?: number;
|
|
8
|
+
maxResponseBody?: number;
|
|
9
|
+
}
|
|
10
|
+
declare function createNetworkCollector(extraBlockedHosts?: string[], options?: NetworkCollectorOptions): NetworkCollector;
|
|
7
11
|
|
|
8
12
|
interface ConsoleCollector {
|
|
9
13
|
start(): void;
|
|
@@ -62,6 +66,13 @@ interface NetworkEntry {
|
|
|
62
66
|
responseSize?: number;
|
|
63
67
|
requestContentType?: string;
|
|
64
68
|
responseContentType?: string;
|
|
69
|
+
statusText?: string;
|
|
70
|
+
/** How the entry was captured. "perf" entries have no body/headers. */
|
|
71
|
+
source?: "fetch" | "xhr" | "perf";
|
|
72
|
+
/** Network-level failure (offline, CORS, DNS, timeout) — distinct from a 4xx/5xx response. */
|
|
73
|
+
failed?: boolean;
|
|
74
|
+
/** Present when `failed` is true. */
|
|
75
|
+
errorMessage?: string;
|
|
65
76
|
}
|
|
66
77
|
interface FormErrorField {
|
|
67
78
|
name: string;
|
|
@@ -177,7 +188,7 @@ interface FlintConfig {
|
|
|
177
188
|
environment?: () => EnvironmentInfo;
|
|
178
189
|
};
|
|
179
190
|
/** @internal Injected by framework packages to start replay without rrweb in core */
|
|
180
|
-
_replayRecorder?: (onEmit: (event: unknown) => void) => Promise<(() => void) | undefined>;
|
|
191
|
+
_replayRecorder?: (onEmit: (event: unknown, isCheckout?: boolean) => void) => Promise<(() => void) | undefined>;
|
|
181
192
|
}
|
|
182
193
|
interface CollectedMeta {
|
|
183
194
|
environment: EnvironmentInfo;
|
|
@@ -303,6 +314,23 @@ declare function trackDatadogBugReported(meta: {
|
|
|
303
314
|
title?: string;
|
|
304
315
|
}): void;
|
|
305
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Rolling buffer for rrweb replay events.
|
|
319
|
+
*
|
|
320
|
+
* rrweb playback requires the event stream to begin with a Meta + FullSnapshot
|
|
321
|
+
* ("checkout"). A naive time-window trim can strip that opening snapshot and
|
|
322
|
+
* leave the player with orphaned incremental mutations it can't render.
|
|
323
|
+
*
|
|
324
|
+
* When the recorder signals checkouts (via `isCheckout`), this buffer trims by
|
|
325
|
+
* *segment* instead of by time: it always retains the current checkout segment
|
|
326
|
+
* plus the previous one, so the buffer never starts mid-stream. Recorders that
|
|
327
|
+
* never signal a checkout fall back to the legacy time-window trim.
|
|
328
|
+
*/
|
|
329
|
+
interface ReplayBuffer {
|
|
330
|
+
push(event: unknown, isCheckout?: boolean): void;
|
|
331
|
+
getEvents(): unknown[];
|
|
332
|
+
}
|
|
333
|
+
|
|
306
334
|
interface FlintInstance {
|
|
307
335
|
config: FlintConfig;
|
|
308
336
|
console: ConsoleCollector | null;
|
|
@@ -310,7 +338,7 @@ interface FlintInstance {
|
|
|
310
338
|
formErrors: FormErrorCollector | null;
|
|
311
339
|
frustration: FrustrationCollector | null;
|
|
312
340
|
errorCapture: ErrorCaptureCollector | null;
|
|
313
|
-
|
|
341
|
+
replayBuffer: ReplayBuffer;
|
|
314
342
|
stopReplay: (() => void) | null;
|
|
315
343
|
}
|
|
316
344
|
declare function init(config: FlintConfig): void;
|
package/dist/index.js
CHANGED
|
@@ -903,7 +903,8 @@ function captureHeaders(raw, max) {
|
|
|
903
903
|
count++;
|
|
904
904
|
}
|
|
905
905
|
} else {
|
|
906
|
-
const
|
|
906
|
+
const headersLike = raw;
|
|
907
|
+
const entries = typeof headersLike.entries === "function" && typeof headersLike.forEach === "function" ? headersLike.entries() : Array.isArray(raw) ? raw : Object.entries(raw);
|
|
907
908
|
for (const [k, v] of entries) {
|
|
908
909
|
total++;
|
|
909
910
|
if (count >= max) continue;
|
|
@@ -921,7 +922,7 @@ function captureHeaders(raw, max) {
|
|
|
921
922
|
function extractContentType(headers) {
|
|
922
923
|
if (!headers) return void 0;
|
|
923
924
|
try {
|
|
924
|
-
if (typeof
|
|
925
|
+
if (typeof headers.get === "function") return headers.get("content-type") ?? void 0;
|
|
925
926
|
if (Array.isArray(headers)) {
|
|
926
927
|
const found = headers.find(([k]) => k.toLowerCase() === "content-type");
|
|
927
928
|
return found?.[1];
|
|
@@ -934,17 +935,22 @@ function extractContentType(headers) {
|
|
|
934
935
|
}
|
|
935
936
|
return void 0;
|
|
936
937
|
}
|
|
937
|
-
function createNetworkCollector(extraBlockedHosts = []) {
|
|
938
|
+
function createNetworkCollector(extraBlockedHosts = [], options) {
|
|
939
|
+
const maxEntries = options?.maxEntries ?? MAX_ENTRIES3;
|
|
940
|
+
const maxResponseBody = options?.maxResponseBody ?? MAX_RESPONSE_BODY;
|
|
938
941
|
const entries = [];
|
|
939
942
|
const blocked = /* @__PURE__ */ new Set([...DEFAULT_BLOCKED_HOSTS, ...extraBlockedHosts]);
|
|
940
943
|
let origFetch = null;
|
|
941
944
|
let origXHROpen = null;
|
|
942
945
|
let origXHRSend = null;
|
|
943
946
|
let origXHRSetHeader = null;
|
|
947
|
+
let perfObserver = null;
|
|
944
948
|
let active = false;
|
|
949
|
+
const seenUrls = /* @__PURE__ */ new Set();
|
|
945
950
|
function push(entry) {
|
|
951
|
+
seenUrls.add(entry.fullUrl ?? entry.url);
|
|
946
952
|
entries.push(entry);
|
|
947
|
-
if (entries.length >
|
|
953
|
+
if (entries.length > maxEntries) entries.shift();
|
|
948
954
|
}
|
|
949
955
|
return {
|
|
950
956
|
start() {
|
|
@@ -952,19 +958,50 @@ function createNetworkCollector(extraBlockedHosts = []) {
|
|
|
952
958
|
active = true;
|
|
953
959
|
origFetch = window.fetch;
|
|
954
960
|
window.fetch = async (input, init2) => {
|
|
955
|
-
const
|
|
956
|
-
const
|
|
961
|
+
const reqObj = typeof Request !== "undefined" && input instanceof Request ? input : null;
|
|
962
|
+
const method = (init2?.method ?? reqObj?.method ?? "GET").toUpperCase();
|
|
963
|
+
const url = reqObj ? reqObj.url : typeof input === "string" ? input : input.href;
|
|
957
964
|
const startTime = Date.now();
|
|
958
|
-
const
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
965
|
+
const reqHeaders = captureHeaders(init2?.headers ?? reqObj?.headers, MAX_HEADERS);
|
|
966
|
+
const reqContentType = extractContentType(init2?.headers ?? reqObj?.headers);
|
|
967
|
+
let reqBody = safeBodyPreview(init2?.body, MAX_REQUEST_BODY);
|
|
968
|
+
if (reqBody === void 0 && reqObj && !shouldIgnore(url, blocked)) {
|
|
969
|
+
try {
|
|
970
|
+
const t = await reqObj.clone().text();
|
|
971
|
+
if (t) reqBody = t.length > MAX_REQUEST_BODY ? t.slice(0, MAX_REQUEST_BODY) + "\u2026" : t;
|
|
972
|
+
} catch {
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
let res;
|
|
976
|
+
try {
|
|
977
|
+
res = await origFetch.call(window, input, init2);
|
|
978
|
+
} catch (err) {
|
|
979
|
+
if (!shouldIgnore(url, blocked)) {
|
|
980
|
+
push({
|
|
981
|
+
method,
|
|
982
|
+
url: truncateUrl(url),
|
|
983
|
+
fullUrl: captureFullUrl(url),
|
|
984
|
+
status: 0,
|
|
985
|
+
duration: Date.now() - startTime,
|
|
986
|
+
timestamp: startTime,
|
|
987
|
+
source: "fetch",
|
|
988
|
+
failed: true,
|
|
989
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
990
|
+
requestHeaders: reqHeaders,
|
|
991
|
+
requestBody: reqBody,
|
|
992
|
+
requestContentType: reqContentType
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
throw err;
|
|
996
|
+
}
|
|
962
997
|
if (!shouldIgnore(url, blocked)) {
|
|
963
998
|
const entry = {
|
|
964
999
|
method,
|
|
965
1000
|
url: truncateUrl(url),
|
|
966
1001
|
fullUrl: captureFullUrl(url),
|
|
967
1002
|
status: res.status,
|
|
1003
|
+
statusText: res.statusText || void 0,
|
|
1004
|
+
source: "fetch",
|
|
968
1005
|
duration: Date.now() - startTime,
|
|
969
1006
|
timestamp: startTime,
|
|
970
1007
|
requestHeaders: reqHeaders,
|
|
@@ -977,7 +1014,7 @@ function createNetworkCollector(extraBlockedHosts = []) {
|
|
|
977
1014
|
try {
|
|
978
1015
|
const clone = res.clone();
|
|
979
1016
|
clone.text().then((text) => {
|
|
980
|
-
entry.responseBody = text.length >
|
|
1017
|
+
entry.responseBody = text.length > maxResponseBody ? text.slice(0, maxResponseBody) + "\u2026" : text;
|
|
981
1018
|
if (!entry.responseSize && text.length) entry.responseSize = text.length;
|
|
982
1019
|
}).catch(() => {
|
|
983
1020
|
});
|
|
@@ -1014,13 +1051,16 @@ function createNetworkCollector(extraBlockedHosts = []) {
|
|
|
1014
1051
|
XMLHttpRequest.prototype.open = function(method, url, async, username, password) {
|
|
1015
1052
|
const startTime = Date.now();
|
|
1016
1053
|
const urlStr = typeof url === "string" ? url : url.href;
|
|
1054
|
+
let captured = false;
|
|
1017
1055
|
this.addEventListener("load", () => {
|
|
1056
|
+
if (captured) return;
|
|
1057
|
+
captured = true;
|
|
1018
1058
|
if (!shouldIgnore(urlStr, blocked)) {
|
|
1019
1059
|
const meta = xhrMeta.get(this);
|
|
1020
1060
|
let resBody;
|
|
1021
1061
|
try {
|
|
1022
1062
|
const text = this.responseText;
|
|
1023
|
-
resBody = text && text.length >
|
|
1063
|
+
resBody = text && text.length > maxResponseBody ? text.slice(0, maxResponseBody) + "\u2026" : text || void 0;
|
|
1024
1064
|
} catch {
|
|
1025
1065
|
}
|
|
1026
1066
|
push({
|
|
@@ -1028,6 +1068,8 @@ function createNetworkCollector(extraBlockedHosts = []) {
|
|
|
1028
1068
|
url: truncateUrl(urlStr),
|
|
1029
1069
|
fullUrl: captureFullUrl(urlStr),
|
|
1030
1070
|
status: this.status,
|
|
1071
|
+
statusText: this.statusText || void 0,
|
|
1072
|
+
source: "xhr",
|
|
1031
1073
|
duration: Date.now() - startTime,
|
|
1032
1074
|
timestamp: startTime,
|
|
1033
1075
|
requestHeaders: meta?.reqHeaders && Object.keys(meta.reqHeaders).length > 0 ? meta.reqHeaders : void 0,
|
|
@@ -1040,8 +1082,58 @@ function createNetworkCollector(extraBlockedHosts = []) {
|
|
|
1040
1082
|
});
|
|
1041
1083
|
}
|
|
1042
1084
|
});
|
|
1085
|
+
const pushFailure = (errorMessage) => {
|
|
1086
|
+
if (captured) return;
|
|
1087
|
+
captured = true;
|
|
1088
|
+
if (shouldIgnore(urlStr, blocked)) return;
|
|
1089
|
+
const meta = xhrMeta.get(this);
|
|
1090
|
+
push({
|
|
1091
|
+
method: method.toUpperCase(),
|
|
1092
|
+
url: truncateUrl(urlStr),
|
|
1093
|
+
fullUrl: captureFullUrl(urlStr),
|
|
1094
|
+
status: 0,
|
|
1095
|
+
source: "xhr",
|
|
1096
|
+
failed: true,
|
|
1097
|
+
errorMessage,
|
|
1098
|
+
duration: Date.now() - startTime,
|
|
1099
|
+
timestamp: startTime,
|
|
1100
|
+
requestHeaders: meta?.reqHeaders && Object.keys(meta.reqHeaders).length > 0 ? meta.reqHeaders : void 0,
|
|
1101
|
+
requestBody: meta?.reqBody,
|
|
1102
|
+
requestContentType: meta?.reqContentType
|
|
1103
|
+
});
|
|
1104
|
+
};
|
|
1105
|
+
this.addEventListener("error", () => pushFailure("Network error"));
|
|
1106
|
+
this.addEventListener("timeout", () => pushFailure("Timeout"));
|
|
1107
|
+
this.addEventListener("abort", () => pushFailure("Aborted"));
|
|
1043
1108
|
return origXHROpen.apply(this, [method, url, async ?? true, username, password]);
|
|
1044
1109
|
};
|
|
1110
|
+
if (typeof PerformanceObserver !== "undefined") {
|
|
1111
|
+
try {
|
|
1112
|
+
perfObserver = new PerformanceObserver((list) => {
|
|
1113
|
+
for (const entry of list.getEntries()) {
|
|
1114
|
+
const res = entry;
|
|
1115
|
+
if (res.initiatorType !== "fetch" && res.initiatorType !== "xmlhttprequest") continue;
|
|
1116
|
+
const url = res.name;
|
|
1117
|
+
if (shouldIgnore(url, blocked)) continue;
|
|
1118
|
+
if (seenUrls.has(captureFullUrl(url)) || seenUrls.has(truncateUrl(url))) continue;
|
|
1119
|
+
push({
|
|
1120
|
+
// Resource timing does not expose the HTTP method; default to GET.
|
|
1121
|
+
method: "GET",
|
|
1122
|
+
url: truncateUrl(url),
|
|
1123
|
+
fullUrl: captureFullUrl(url),
|
|
1124
|
+
status: res.responseStatus ?? 0,
|
|
1125
|
+
source: "perf",
|
|
1126
|
+
duration: Math.round(res.duration),
|
|
1127
|
+
timestamp: Math.round(performance.timeOrigin + res.startTime),
|
|
1128
|
+
responseSize: res.transferSize || res.encodedBodySize || void 0
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
perfObserver.observe({ type: "resource", buffered: true });
|
|
1133
|
+
} catch {
|
|
1134
|
+
perfObserver = null;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1045
1137
|
},
|
|
1046
1138
|
stop() {
|
|
1047
1139
|
if (!active) return;
|
|
@@ -1050,6 +1142,10 @@ function createNetworkCollector(extraBlockedHosts = []) {
|
|
|
1050
1142
|
if (origXHROpen) XMLHttpRequest.prototype.open = origXHROpen;
|
|
1051
1143
|
if (origXHRSend) XMLHttpRequest.prototype.send = origXHRSend;
|
|
1052
1144
|
if (origXHRSetHeader) XMLHttpRequest.prototype.setRequestHeader = origXHRSetHeader;
|
|
1145
|
+
if (perfObserver) {
|
|
1146
|
+
perfObserver.disconnect();
|
|
1147
|
+
perfObserver = null;
|
|
1148
|
+
}
|
|
1053
1149
|
},
|
|
1054
1150
|
getEntries() {
|
|
1055
1151
|
return [...entries];
|
|
@@ -1057,6 +1153,34 @@ function createNetworkCollector(extraBlockedHosts = []) {
|
|
|
1057
1153
|
};
|
|
1058
1154
|
}
|
|
1059
1155
|
|
|
1156
|
+
// src/replayBuffer.ts
|
|
1157
|
+
function createReplayBuffer(replayBufferMs, now = Date.now) {
|
|
1158
|
+
const events = [];
|
|
1159
|
+
let checkoutSeen = false;
|
|
1160
|
+
let recentCheckoutIdx = 0;
|
|
1161
|
+
return {
|
|
1162
|
+
push(event, isCheckout) {
|
|
1163
|
+
if (isCheckout) {
|
|
1164
|
+
checkoutSeen = true;
|
|
1165
|
+
if (events.length > 0) {
|
|
1166
|
+
events.splice(0, recentCheckoutIdx);
|
|
1167
|
+
recentCheckoutIdx = events.length;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
events.push(event);
|
|
1171
|
+
if (!checkoutSeen) {
|
|
1172
|
+
const cutoff = now() - replayBufferMs;
|
|
1173
|
+
while (events.length > 0 && events[0].timestamp < cutoff) {
|
|
1174
|
+
events.shift();
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
},
|
|
1178
|
+
getEvents() {
|
|
1179
|
+
return [...events];
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1060
1184
|
// src/store.ts
|
|
1061
1185
|
var formErrorCollectorRef = null;
|
|
1062
1186
|
function _setFormErrorCollector(collector) {
|
|
@@ -1172,7 +1296,7 @@ function init(config) {
|
|
|
1172
1296
|
if (config.user) {
|
|
1173
1297
|
flint.setUser(config.user);
|
|
1174
1298
|
}
|
|
1175
|
-
const
|
|
1299
|
+
const replayBuffer = createReplayBuffer(replayBufferMs);
|
|
1176
1300
|
let stopReplay = null;
|
|
1177
1301
|
debugLog(config, "Collectors started", {
|
|
1178
1302
|
console: !!consoleCol,
|
|
@@ -1188,16 +1312,12 @@ function init(config) {
|
|
|
1188
1312
|
formErrors: formErrorsCol,
|
|
1189
1313
|
frustration: frustrationCol,
|
|
1190
1314
|
errorCapture: errorCaptureCol,
|
|
1191
|
-
|
|
1315
|
+
replayBuffer,
|
|
1192
1316
|
stopReplay: null
|
|
1193
1317
|
};
|
|
1194
1318
|
if (enableReplay && _replayRecorder) {
|
|
1195
|
-
_replayRecorder((event) => {
|
|
1196
|
-
|
|
1197
|
-
const cutoff = Date.now() - replayBufferMs;
|
|
1198
|
-
while (replayEvents.length > 0 && replayEvents[0].timestamp < cutoff) {
|
|
1199
|
-
replayEvents.shift();
|
|
1200
|
-
}
|
|
1319
|
+
_replayRecorder((event, isCheckout) => {
|
|
1320
|
+
replayBuffer.push(event, isCheckout);
|
|
1201
1321
|
}).then((stop) => {
|
|
1202
1322
|
stopReplay = stop ?? null;
|
|
1203
1323
|
if (instance) instance.stopReplay = stopReplay;
|
|
@@ -1289,7 +1409,7 @@ function getMeta(extraMeta) {
|
|
|
1289
1409
|
};
|
|
1290
1410
|
}
|
|
1291
1411
|
function getReplayEvents() {
|
|
1292
|
-
return instance ?
|
|
1412
|
+
return instance ? instance.replayBuffer.getEvents() : [];
|
|
1293
1413
|
}
|
|
1294
1414
|
function getConfig() {
|
|
1295
1415
|
return instance?.config ?? null;
|