@logbrew/sdk 0.1.31 → 0.1.33
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 +3 -1
- package/browser-breadcrumbs.cjs +194 -0
- package/core.cjs +119 -12
- package/index.d.ts +21 -0
- package/index.js +1 -0
- package/package.json +2 -1
- package/react-native.d.ts +1 -0
- package/react-native.js +1 -0
- package/winston.cjs +51 -0
package/README.md
CHANGED
|
@@ -506,7 +506,7 @@ When either queue limit is full, LogBrew drops the incoming event and keeps earl
|
|
|
506
506
|
|
|
507
507
|
A fatal crash report (an issue with `fatal` or `critical` level) is the exception. When it does not fit, LogBrew removes the oldest queued events that are not fatal until it fits, and counts each removed event as `evicted_for_fatal`. It never removes events in a batch that is being sent or retried, and it removes nothing if the fatal still cannot fit. So a full queue drops a fatal only when everything else in it is fatal or in a batch that is being sent; that fatal is counted as `queue_overflow` or `queue_bytes_overflow`. A fatal larger than one request body is still dropped as `event_too_large`. Drop callbacks are advisory and must not interrupt application logging. The default queue remains memory-only; automatic delivery reduces normal flush work, while the app still calls `shutdown()` at its owned graceful-lifecycle boundary.
|
|
508
508
|
|
|
509
|
-
LogBrew sees these drops too. The next request body carries the per-reason counts since the last accepted report, such as `"client_report":{"dropped_events":{"queue_overflow":3}}`. The
|
|
509
|
+
LogBrew sees these drops too. The next request body carries the per-reason counts since the last accepted report, such as `"client_report":{"report_id":"5f0c6a1e9b2d4c7a8e3f1b6d0a9c2e4f","dropped_events":{"queue_overflow":3}}`. The counts keep one `report_id` until a `2xx` response acknowledges a request that carried them, and LogBrew counts every resend of one report ID once, even when the resend carries different events. Drops recorded while a request is in flight wait for the next report. A report never sends a request of its own, is left out when nothing was dropped, lists at most 16 reasons, caps each count at 1,000,000,000, and fits inside `maxBatchBytes`. Without an `eventStore`, counts live in memory and drops not yet reported are lost when the process restarts. A store that also provides synchronous `loadClientReport()` and `saveClientReport(state)` keeps them: the client saves at once for a new report or reason and at most once a second during a drop burst, and after a restart resends a pending report under its original `report_id`. The Node and React Native persistent queues and browser `persistOffline` do this for you.
|
|
510
510
|
|
|
511
511
|
Server runtimes can supply an explicit synchronous `eventStore` when they need restart recovery. The core loads and revalidates compact records during client creation, persists each accepted event before adding it to memory, persists an accepted prefix before removing it from memory, and closes the store only after successful shutdown. `purgePendingEvents()` clears both layers only while no flush or shutdown is queued or active. Implementations must provide synchronous `load`, `append`, `acknowledge`, `purge`, and `close` methods; use the encrypted `persistentQueue` adapter in `@logbrew/node` instead of writing a filesystem adapter from scratch. A store can also provide a synchronous `remove(indexes)` that deletes the records at those ascending `load()` positions. Fatal eviction needs it: a full queue on a store without `remove` drops the fatal as before.
|
|
512
512
|
|
|
@@ -697,6 +697,8 @@ await logbrewTransport.flush();
|
|
|
697
697
|
|
|
698
698
|
The Winston adapter receives Winston `info` objects, maps `debug`/`silly` to LogBrew `info`, `warn` to `warning`, `error` to `error`, `fatal`/`critical` to `critical`, and other common Winston levels to `info`. It captures primitive info fields as `context.*`, captures nested `err`/`error` objects or formatted error stack name/message, omits stack text unless `includeErrorStack: true` is set, and exposes `onError` for capture failures. It does not mutate Winston globals or replace the app's logger.
|
|
699
699
|
|
|
700
|
+
Each Winston message is also kept as an issue breadcrumb (category `log.winston`, message capped at 512 characters, no info fields), so the next issue shows the log lines that led to it. With `@logbrew/node`, breadcrumbs written during a request stay with that request's issues. Pass `breadcrumbs: false` to turn this off.
|
|
701
|
+
|
|
700
702
|
Use the same `traceProvider: getActiveLogBrewTrace` option with LogBrew Node/framework helpers when you want Winston logs to carry the active request trace. The adapter only copies normalized W3C-shaped IDs and sampled state; it does not patch Winston globally or serialize arbitrary active context.
|
|
701
703
|
|
|
702
704
|
Use a clearly fake placeholder like `LOGBREW_API_KEY` in examples. Call `flush` or `shutdown` to send queued events through a transport, and use `previewJson()` when you want a stable local JSON preview before sending anything.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const MAX_MESSAGE_LENGTH = 512;
|
|
4
|
+
const MAX_URL_LENGTH = 256;
|
|
5
|
+
const CONSOLE_LEVELS = Object.freeze({
|
|
6
|
+
debug: "debug",
|
|
7
|
+
error: "error",
|
|
8
|
+
info: "info",
|
|
9
|
+
log: "info",
|
|
10
|
+
warn: "warning"
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Records console calls, fetch and XHR requests, and route changes as issue breadcrumbs.
|
|
14
|
+
// Browser-safe and shared by @logbrew/browser and @logbrew/next/client. Returns an uninstall function
|
|
15
|
+
// that puts back every patched method it still owns.
|
|
16
|
+
function installBrowserBreadcrumbs(browserWindow, client, {
|
|
17
|
+
console: captureConsole = true,
|
|
18
|
+
ignoredUrls = [],
|
|
19
|
+
navigation = true,
|
|
20
|
+
network = true
|
|
21
|
+
} = {}) {
|
|
22
|
+
const ignored = new Set(ignoredUrls.map((url) => sanitizeUrl(url, browserWindow)).filter(Boolean));
|
|
23
|
+
const add = (breadcrumb) => {
|
|
24
|
+
try {
|
|
25
|
+
client.addBreadcrumb(breadcrumb);
|
|
26
|
+
} catch {
|
|
27
|
+
// Breadcrumbs are advisory and never reach application code.
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const restores = [];
|
|
31
|
+
if (captureConsole) {
|
|
32
|
+
restores.push(...patchConsole(browserWindow.console ?? globalThis.console, add));
|
|
33
|
+
}
|
|
34
|
+
if (network) {
|
|
35
|
+
restores.push(...patchFetch(browserWindow, add, ignored), ...patchXhr(browserWindow, add, ignored));
|
|
36
|
+
}
|
|
37
|
+
if (navigation) {
|
|
38
|
+
restores.push(...patchNavigation(browserWindow, add));
|
|
39
|
+
}
|
|
40
|
+
return () => {
|
|
41
|
+
for (const restore of restores.splice(0).reverse()) {
|
|
42
|
+
restore();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function patchConsole(target, add) {
|
|
48
|
+
if (!target) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
return Object.entries(CONSOLE_LEVELS).flatMap(([method, level]) => replaceMethod(target, method, (original) => (
|
|
52
|
+
function logBrewConsoleBreadcrumb(...args) {
|
|
53
|
+
const message = boundedText(consoleTemplate(args), MAX_MESSAGE_LENGTH);
|
|
54
|
+
if (message) {
|
|
55
|
+
add({ type: "debug", category: "console", level, message });
|
|
56
|
+
}
|
|
57
|
+
return original.apply(this, args);
|
|
58
|
+
}
|
|
59
|
+
)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function patchFetch(browserWindow, add, ignored) {
|
|
63
|
+
return replaceMethod(browserWindow, "fetch", (original) => async function logBrewFetchBreadcrumb(input, init) {
|
|
64
|
+
const method = String(init?.method ?? input?.method ?? "GET").toUpperCase();
|
|
65
|
+
const url = sanitizeUrl(typeof input === "string" || input instanceof URL ? input : input?.url, browserWindow);
|
|
66
|
+
try {
|
|
67
|
+
const response = await original.call(this, input, init);
|
|
68
|
+
if (!ignored.has(url)) {
|
|
69
|
+
add(httpBreadcrumb("fetch", method, url, response?.status));
|
|
70
|
+
}
|
|
71
|
+
return response;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (!ignored.has(url)) {
|
|
74
|
+
add({ ...httpBreadcrumb("fetch", method, url), level: "error" });
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function patchXhr(browserWindow, add, ignored) {
|
|
82
|
+
const prototype = browserWindow.XMLHttpRequest?.prototype;
|
|
83
|
+
if (!prototype) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const state = new WeakMap();
|
|
87
|
+
return [
|
|
88
|
+
...replaceMethod(prototype, "open", (original) => function logBrewXhrOpen(method, url, ...rest) {
|
|
89
|
+
state.set(this, { method: String(method ?? "GET").toUpperCase(), url: sanitizeUrl(url, browserWindow) });
|
|
90
|
+
return original.call(this, method, url, ...rest);
|
|
91
|
+
}),
|
|
92
|
+
...replaceMethod(prototype, "send", (original) => function logBrewXhrSend(...args) {
|
|
93
|
+
const request = state.get(this);
|
|
94
|
+
if (request && !ignored.has(request.url) && typeof this.addEventListener === "function") {
|
|
95
|
+
this.addEventListener("loadend", () => {
|
|
96
|
+
add(httpBreadcrumb("xhr", request.method, request.url, this.status || undefined));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return original.apply(this, args);
|
|
100
|
+
})
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function patchNavigation(browserWindow, add) {
|
|
105
|
+
const history = browserWindow.history;
|
|
106
|
+
if (!history) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
let from = currentPath(browserWindow);
|
|
110
|
+
const record = () => {
|
|
111
|
+
const to = currentPath(browserWindow);
|
|
112
|
+
if (to && to !== from) {
|
|
113
|
+
add({ type: "navigation", category: "navigation", data: { ...(from ? { from } : {}), to } });
|
|
114
|
+
from = to;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const restores = ["pushState", "replaceState"].flatMap((method) => replaceMethod(history, method, (original) => (
|
|
118
|
+
function logBrewNavigationBreadcrumb(...args) {
|
|
119
|
+
const result = original.apply(this, args);
|
|
120
|
+
record();
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
)));
|
|
124
|
+
if (typeof browserWindow.addEventListener === "function") {
|
|
125
|
+
browserWindow.addEventListener("popstate", record);
|
|
126
|
+
restores.push(() => browserWindow.removeEventListener?.("popstate", record));
|
|
127
|
+
}
|
|
128
|
+
return restores;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function replaceMethod(target, name, wrap) {
|
|
132
|
+
const original = target?.[name];
|
|
133
|
+
if (typeof original !== "function") {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
const replacement = wrap(original);
|
|
137
|
+
target[name] = replacement;
|
|
138
|
+
return [() => {
|
|
139
|
+
if (target[name] === replacement) {
|
|
140
|
+
target[name] = original;
|
|
141
|
+
}
|
|
142
|
+
}];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function httpBreadcrumb(category, method, url, status) {
|
|
146
|
+
const statusCode = Number.isInteger(status) && status > 0 ? status : undefined;
|
|
147
|
+
return {
|
|
148
|
+
type: "http",
|
|
149
|
+
category,
|
|
150
|
+
level: statusCode === undefined || statusCode < 400 ? "info" : statusCode >= 500 ? "error" : "warning",
|
|
151
|
+
data: {
|
|
152
|
+
method: method.slice(0, 16) || "GET",
|
|
153
|
+
...(url ? { url } : {}),
|
|
154
|
+
...(statusCode === undefined ? {} : { status_code: statusCode })
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Records the message template, never the interpolated arguments.
|
|
160
|
+
function consoleTemplate(args) {
|
|
161
|
+
const [first] = args;
|
|
162
|
+
if (typeof first === "string") {
|
|
163
|
+
return first;
|
|
164
|
+
}
|
|
165
|
+
return first instanceof Error ? first.name || "Error" : "";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function currentPath(browserWindow) {
|
|
169
|
+
const pathname = browserWindow.location?.pathname;
|
|
170
|
+
return typeof pathname === "string" ? boundedText(pathname, MAX_URL_LENGTH) : undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Keeps scheme, host, and path; drops user info, query, and fragment.
|
|
174
|
+
function sanitizeUrl(value, browserWindow) {
|
|
175
|
+
if (value === undefined || value === null) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const url = new URL(String(value), String(browserWindow.location?.href ?? "http://localhost/"));
|
|
180
|
+
return boundedText(`${url.protocol}//${url.host}${url.pathname}`, MAX_URL_LENGTH) || undefined;
|
|
181
|
+
} catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function boundedText(value, limit) {
|
|
187
|
+
const characters = Array.from(String(value), (character) => {
|
|
188
|
+
const code = character.codePointAt(0);
|
|
189
|
+
return code <= 31 || (code >= 127 && code <= 159) ? " " : character;
|
|
190
|
+
});
|
|
191
|
+
return Array.from(characters.join("").replace(/\s+/gu, " ").trim()).slice(0, limit).join("");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { installBrowserBreadcrumbs };
|
package/core.cjs
CHANGED
|
@@ -96,6 +96,11 @@ const MAX_CLIENT_REPORT_REASONS = 16;
|
|
|
96
96
|
const MAX_CLIENT_REPORT_COUNT = 1_000_000_000;
|
|
97
97
|
const DELIVERY_HEALTH_SCHEMA_VERSION = 1;
|
|
98
98
|
const EVENT_QUEUE_FACTORY = Symbol.for("@logbrew/sdk.eventQueueFactory");
|
|
99
|
+
// Runtimes without an event store (browser storage) persist unreported drop counts through this seam.
|
|
100
|
+
const CLIENT_REPORT_STORE = Symbol.for("@logbrew/sdk.clientReportStore");
|
|
101
|
+
const CLIENT_REPORT_SAVE_INTERVAL_MS = 1_000;
|
|
102
|
+
const CLIENT_REPORT_ID_PATTERN = /^[A-Za-z0-9_-]{16,64}$/u;
|
|
103
|
+
const CLIENT_REPORT_REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/u;
|
|
99
104
|
const CONTEXT_PROVIDER = Symbol.for("@logbrew/sdk.contextProvider");
|
|
100
105
|
// Runtime packages supply request-scoped breadcrumbs through this seam; core merges them into issues.
|
|
101
106
|
const BREADCRUMB_PROVIDER = Symbol.for("@logbrew/sdk.breadcrumbProvider");
|
|
@@ -228,6 +233,7 @@ class LogBrewClient {
|
|
|
228
233
|
onEventDropped,
|
|
229
234
|
eventStore,
|
|
230
235
|
[EVENT_QUEUE_FACTORY]: eventQueueFactory,
|
|
236
|
+
[CLIENT_REPORT_STORE]: clientReportStore,
|
|
231
237
|
transport,
|
|
232
238
|
automaticDelivery = transport !== undefined,
|
|
233
239
|
deliveryIntervalMs = DEFAULT_DELIVERY_INTERVAL_MS,
|
|
@@ -261,6 +267,9 @@ class LogBrewClient {
|
|
|
261
267
|
if (eventStore !== undefined && eventQueueFactory !== undefined) {
|
|
262
268
|
throw new SdkError("validation_error", "eventStore and event queue factory are mutually exclusive");
|
|
263
269
|
}
|
|
270
|
+
if (clientReportStore !== undefined && !isClientReportStore(clientReportStore)) {
|
|
271
|
+
throw new SdkError("validation_error", "client report store must provide loadClientReport and saveClientReport");
|
|
272
|
+
}
|
|
264
273
|
validateTransport(transport);
|
|
265
274
|
if (typeof automaticDelivery !== "boolean") {
|
|
266
275
|
throw new SdkError("validation_error", "automaticDelivery must be a boolean");
|
|
@@ -284,6 +293,7 @@ class LogBrewClient {
|
|
|
284
293
|
deliveryQueueThreshold,
|
|
285
294
|
eventStore,
|
|
286
295
|
eventQueueFactory,
|
|
296
|
+
clientReportStore,
|
|
287
297
|
eventFilter,
|
|
288
298
|
maxBatchBytes,
|
|
289
299
|
maxBatchEvents,
|
|
@@ -318,6 +328,7 @@ class LogBrewClient {
|
|
|
318
328
|
breadcrumbProvider,
|
|
319
329
|
eventStore,
|
|
320
330
|
eventQueueFactory,
|
|
331
|
+
clientReportStore,
|
|
321
332
|
transport,
|
|
322
333
|
automaticDelivery,
|
|
323
334
|
deliveryIntervalMs,
|
|
@@ -382,8 +393,14 @@ class LogBrewClient {
|
|
|
382
393
|
queue_bytes_overflow: 0,
|
|
383
394
|
queue_overflow: 0
|
|
384
395
|
};
|
|
385
|
-
//
|
|
386
|
-
this.
|
|
396
|
+
// A persistent store keeps counts not yet acknowledged by a 2xx across restarts.
|
|
397
|
+
this.clientReportStore = clientReportStore
|
|
398
|
+
?? (isClientReportStore(resolvedEventStore) ? resolvedEventStore : undefined);
|
|
399
|
+
this.clientReportSavedAtMs = 0;
|
|
400
|
+
const restoredReport = loadClientReport(this.clientReportStore);
|
|
401
|
+
this.unreportedDropsByReason = restoredReport.unreported;
|
|
402
|
+
// Drop counts frozen under one report ID until a 2xx acknowledges a body that carried them.
|
|
403
|
+
this.pendingClientReport = restoredReport.pending;
|
|
387
404
|
this.lastDropReason = "none";
|
|
388
405
|
this.storage = resolvedEventStore ? "persistent" : "memory";
|
|
389
406
|
this.hydratedEventCount = recovered.events.length;
|
|
@@ -618,6 +635,7 @@ class LogBrewClient {
|
|
|
618
635
|
if (controlsAutomaticDelivery) {
|
|
619
636
|
this.#recordAutomaticSuccess();
|
|
620
637
|
}
|
|
638
|
+
this.#saveClientReport(true);
|
|
621
639
|
if (this.eventStore) {
|
|
622
640
|
try {
|
|
623
641
|
requireSynchronousStoreResult("close", this.eventStore.close());
|
|
@@ -784,7 +802,9 @@ class LogBrewClient {
|
|
|
784
802
|
#recordDroppedEvent(event, reason) {
|
|
785
803
|
this.droppedEventCount = incrementBounded(this.droppedEventCount);
|
|
786
804
|
this.droppedEventsByReason[reason] = incrementBounded(this.droppedEventsByReason[reason]);
|
|
805
|
+
const firstOfReason = this.unreportedDropsByReason[reason] === undefined;
|
|
787
806
|
this.unreportedDropsByReason[reason] = Math.min(MAX_CLIENT_REPORT_COUNT, (this.unreportedDropsByReason[reason] ?? 0) + 1);
|
|
807
|
+
this.#saveClientReport(firstOfReason);
|
|
788
808
|
this.lastDropReason = reason;
|
|
789
809
|
this.lastDroppedAtUnixMs = nextBoundedTimestamp(this.lastDroppedAtUnixMs);
|
|
790
810
|
if (!this.onEventDropped) {
|
|
@@ -949,9 +969,10 @@ class LogBrewClient {
|
|
|
949
969
|
const batch = this.unacknowledgedBatch;
|
|
950
970
|
const response = await this.#sendBatch(transport, batch.body);
|
|
951
971
|
this.unacknowledgedBatch = undefined;
|
|
952
|
-
//
|
|
953
|
-
|
|
954
|
-
this.
|
|
972
|
+
// Drops recorded while this body was in flight stay pending under a new report ID.
|
|
973
|
+
if (batch.carriesClientReport) {
|
|
974
|
+
this.pendingClientReport = undefined;
|
|
975
|
+
this.#saveClientReport(true);
|
|
955
976
|
}
|
|
956
977
|
this.#acknowledge(batch.eventsCount);
|
|
957
978
|
this.acceptedBatchCount = incrementBounded(this.acceptedBatchCount);
|
|
@@ -979,11 +1000,8 @@ class LogBrewClient {
|
|
|
979
1000
|
}
|
|
980
1001
|
|
|
981
1002
|
#nextBatch(maxEvents) {
|
|
982
|
-
const
|
|
983
|
-
const
|
|
984
|
-
? Object.fromEntries(reported.slice(0, MAX_CLIENT_REPORT_REASONS))
|
|
985
|
-
: undefined;
|
|
986
|
-
const reportSuffix = droppedEvents && `],"client_report":${JSON.stringify({ dropped_events: droppedEvents })}}`;
|
|
1003
|
+
const report = this.#pendingClientReport();
|
|
1004
|
+
const reportSuffix = report && `],"client_report":${JSON.stringify(report)}}`;
|
|
987
1005
|
const reportedEvents = reportSuffix ? this.#batchEventCount(maxEvents, utf8ByteLength(reportSuffix)) : 0;
|
|
988
1006
|
const eventsCount = reportedEvents || this.#batchEventCount(maxEvents, this.batchSuffixBytes);
|
|
989
1007
|
if (eventsCount === 0) {
|
|
@@ -991,11 +1009,45 @@ class LogBrewClient {
|
|
|
991
1009
|
}
|
|
992
1010
|
return {
|
|
993
1011
|
body: `${this.batchPrefix}${this.serializedEvents.slice(0, eventsCount).join(",")}${reportedEvents ? reportSuffix : this.batchSuffix}`,
|
|
994
|
-
|
|
1012
|
+
carriesClientReport: reportedEvents > 0,
|
|
995
1013
|
eventsCount
|
|
996
1014
|
};
|
|
997
1015
|
}
|
|
998
1016
|
|
|
1017
|
+
// Freezes unreported drops under one stable ID, so every resend of them is counted once.
|
|
1018
|
+
#pendingClientReport() {
|
|
1019
|
+
if (this.pendingClientReport === undefined) {
|
|
1020
|
+
const reported = Object.entries(this.unreportedDropsByReason).filter(([, count]) => count > 0);
|
|
1021
|
+
if (reported.length === 0) {
|
|
1022
|
+
return undefined;
|
|
1023
|
+
}
|
|
1024
|
+
const droppedEvents = Object.fromEntries(reported.slice(0, MAX_CLIENT_REPORT_REASONS));
|
|
1025
|
+
for (const reason of Object.keys(droppedEvents)) {
|
|
1026
|
+
delete this.unreportedDropsByReason[reason];
|
|
1027
|
+
}
|
|
1028
|
+
this.pendingClientReport = Object.freeze({ report_id: randomHex(16), dropped_events: droppedEvents });
|
|
1029
|
+
this.#saveClientReport(true);
|
|
1030
|
+
}
|
|
1031
|
+
return this.pendingClientReport;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Saves at once for a new report or reason, and at most once a second during a drop burst.
|
|
1035
|
+
#saveClientReport(immediately) {
|
|
1036
|
+
const now = Date.now();
|
|
1037
|
+
if (!this.clientReportStore || (!immediately && now - this.clientReportSavedAtMs < CLIENT_REPORT_SAVE_INTERVAL_MS)) {
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
this.clientReportSavedAtMs = now;
|
|
1041
|
+
try {
|
|
1042
|
+
requireSynchronousStoreResult("saveClientReport", this.clientReportStore.saveClientReport({
|
|
1043
|
+
pending: this.pendingClientReport ?? null,
|
|
1044
|
+
unreported: { ...this.unreportedDropsByReason }
|
|
1045
|
+
}));
|
|
1046
|
+
} catch {
|
|
1047
|
+
// Drop counts are advisory: a failed save keeps them in memory and never blocks capture.
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
999
1051
|
#batchEventCount(maxEvents, suffixBytes) {
|
|
1000
1052
|
let bodyBytes = this.batchPrefixBytes + suffixBytes;
|
|
1001
1053
|
let eventsCount = 0;
|
|
@@ -1193,6 +1245,50 @@ function isFatalEvent(event) {
|
|
|
1193
1245
|
return event.type === "issue" && event.attributes.level === "critical";
|
|
1194
1246
|
}
|
|
1195
1247
|
|
|
1248
|
+
// Report IDs only need uniqueness, so runtimes without Web Crypto fall back to Math.random.
|
|
1249
|
+
function randomHex(byteLength) {
|
|
1250
|
+
const bytes = new Uint8Array(byteLength);
|
|
1251
|
+
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
|
1252
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
1253
|
+
} else {
|
|
1254
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
1255
|
+
bytes[index] = Math.floor(Math.random() * 256);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function isClientReportStore(store) {
|
|
1262
|
+
return typeof store?.loadClientReport === "function" && typeof store.saveClientReport === "function";
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Restores saved drop counts; anything unreadable or out of bounds is ignored, never fatal.
|
|
1266
|
+
function loadClientReport(store) {
|
|
1267
|
+
let state;
|
|
1268
|
+
try {
|
|
1269
|
+
state = store ? requireSynchronousStoreResult("loadClientReport", store.loadClientReport()) : undefined;
|
|
1270
|
+
} catch {
|
|
1271
|
+
state = undefined;
|
|
1272
|
+
}
|
|
1273
|
+
const pending = state?.pending;
|
|
1274
|
+
const pendingCounts = validClientReportCounts(pending?.dropped_events);
|
|
1275
|
+
return {
|
|
1276
|
+
pending: CLIENT_REPORT_ID_PATTERN.test(pending?.report_id ?? "") && Object.keys(pendingCounts).length > 0
|
|
1277
|
+
? Object.freeze({ report_id: pending.report_id, dropped_events: pendingCounts })
|
|
1278
|
+
: undefined,
|
|
1279
|
+
unreported: validClientReportCounts(state?.unreported)
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function validClientReportCounts(counts) {
|
|
1284
|
+
if (!counts || typeof counts !== "object" || Array.isArray(counts)) {
|
|
1285
|
+
return {};
|
|
1286
|
+
}
|
|
1287
|
+
return Object.fromEntries(Object.entries(counts).filter(([reason, count]) => (
|
|
1288
|
+
CLIENT_REPORT_REASON_PATTERN.test(reason) && Number.isSafeInteger(count) && count >= 1 && count <= MAX_CLIENT_REPORT_COUNT
|
|
1289
|
+
)).slice(0, MAX_CLIENT_REPORT_REASONS));
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1196
1292
|
function incrementBounded(value) {
|
|
1197
1293
|
return value >= Number.MAX_SAFE_INTEGER ? Number.MAX_SAFE_INTEGER : value + 1;
|
|
1198
1294
|
}
|
|
@@ -1226,7 +1322,7 @@ function createEventStoreFromQueueFactory(eventQueueFactory, config) {
|
|
|
1226
1322
|
restoreEvent: restoreStoredEvent
|
|
1227
1323
|
});
|
|
1228
1324
|
validateEventQueue(queue);
|
|
1229
|
-
|
|
1325
|
+
const eventStore = {
|
|
1230
1326
|
load() {
|
|
1231
1327
|
const events = queue.events();
|
|
1232
1328
|
const count = queue.length();
|
|
@@ -1256,6 +1352,14 @@ function createEventStoreFromQueueFactory(eventQueueFactory, config) {
|
|
|
1256
1352
|
return queue.close();
|
|
1257
1353
|
}
|
|
1258
1354
|
};
|
|
1355
|
+
if (typeof queue.remove === "function") {
|
|
1356
|
+
eventStore.remove = (indexes) => queue.remove(indexes);
|
|
1357
|
+
}
|
|
1358
|
+
if (isClientReportStore(queue)) {
|
|
1359
|
+
eventStore.loadClientReport = () => queue.loadClientReport();
|
|
1360
|
+
eventStore.saveClientReport = (state) => queue.saveClientReport(state);
|
|
1361
|
+
}
|
|
1362
|
+
return eventStore;
|
|
1259
1363
|
}
|
|
1260
1364
|
|
|
1261
1365
|
function validateEventQueue(queue) {
|
|
@@ -2864,8 +2968,11 @@ function formatConsoleArgument(value, includeErrorStack) {
|
|
|
2864
2968
|
}
|
|
2865
2969
|
}
|
|
2866
2970
|
|
|
2971
|
+
const { installBrowserBreadcrumbs } = require("./browser-breadcrumbs.cjs");
|
|
2972
|
+
|
|
2867
2973
|
module.exports = {
|
|
2868
2974
|
PRODUCT_ANALYTICS_KINDS,
|
|
2975
|
+
installBrowserBreadcrumbs,
|
|
2869
2976
|
PRODUCT_ANALYTICS_SCHEMA_VERSION,
|
|
2870
2977
|
createBaggage,
|
|
2871
2978
|
createIssueAttributesFromError,
|
package/index.d.ts
CHANGED
|
@@ -535,6 +535,8 @@ export type WinstonLogInfo = Record<string, unknown> & {
|
|
|
535
535
|
/** Configuration for the dependency-free Winston transport adapter. */
|
|
536
536
|
export type WinstonTransportConfig = {
|
|
537
537
|
client: LogBrewClient;
|
|
538
|
+
/** Record each Winston message as an issue breadcrumb (category `log.winston`). Defaults to true. */
|
|
539
|
+
breadcrumbs?: boolean;
|
|
538
540
|
logger?: string;
|
|
539
541
|
metadata?: Metadata;
|
|
540
542
|
traceProvider?: () => LogCorrelationTraceContext | null | undefined;
|
|
@@ -925,6 +927,25 @@ export declare class LogBrewClient {
|
|
|
925
927
|
/** Install explicit console capture while preserving the target console's normal output behavior. */
|
|
926
928
|
export declare function installLogBrewConsoleCapture(config: ConsoleCaptureConfig): ConsoleCaptureHandle;
|
|
927
929
|
|
|
930
|
+
/** Options for automatic browser breadcrumbs. Every source defaults to true. */
|
|
931
|
+
export type BrowserBreadcrumbOptions = {
|
|
932
|
+
console?: boolean;
|
|
933
|
+
network?: boolean;
|
|
934
|
+
navigation?: boolean;
|
|
935
|
+
/** URLs whose requests are not recorded, such as the LogBrew ingest endpoint. */
|
|
936
|
+
ignoredUrls?: string[];
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Record console calls, fetch and XHR requests, and route changes in a browser window as issue
|
|
941
|
+
* breadcrumbs on the client. The wrappers only observe calls. Returns an uninstall function.
|
|
942
|
+
*/
|
|
943
|
+
export declare function installBrowserBreadcrumbs(
|
|
944
|
+
browserWindow: unknown,
|
|
945
|
+
client: LogBrewClient,
|
|
946
|
+
options?: BrowserBreadcrumbOptions
|
|
947
|
+
): () => void;
|
|
948
|
+
|
|
928
949
|
/** Create safe action attributes for an app-owned product step without automatic UI capture. */
|
|
929
950
|
export declare function createProductActionAttributes(
|
|
930
951
|
action: ProductActionInput,
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@logbrew/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
4
4
|
"description": "Public LogBrew JavaScript SDK for building, validating, and flushing event batches.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.cjs",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"files": [
|
|
45
45
|
"index.cjs",
|
|
46
|
+
"browser-breadcrumbs.cjs",
|
|
46
47
|
"core.cjs",
|
|
47
48
|
"index.js",
|
|
48
49
|
"index.d.ts",
|
package/react-native.d.ts
CHANGED
package/react-native.js
CHANGED
package/winston.cjs
CHANGED
|
@@ -3,6 +3,10 @@ const { Writable } = require("node:stream");
|
|
|
3
3
|
const { LogBrewClient, SdkError } = require("./core.cjs");
|
|
4
4
|
const { buildLogContextHelpers } = require("./log-context.cjs");
|
|
5
5
|
|
|
6
|
+
// Runtime packages with request scopes (such as @logbrew/node) register a recorder that keeps
|
|
7
|
+
// breadcrumbs with the active request instead of the whole client.
|
|
8
|
+
const BREADCRUMB_RECORDER = Symbol.for("@logbrew/sdk.breadcrumbRecorder");
|
|
9
|
+
const MAX_BREADCRUMB_MESSAGE_LENGTH = 512;
|
|
6
10
|
const WINSTON_RESERVED_FIELDS = new Set([
|
|
7
11
|
"level",
|
|
8
12
|
"message",
|
|
@@ -30,6 +34,7 @@ function createLogBrewWinstonTransport(config) {
|
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
const transport = config.transport;
|
|
37
|
+
const breadcrumbs = config.breadcrumbs !== false;
|
|
33
38
|
const flushOnWrite = config.flushOnWrite === true;
|
|
34
39
|
const includeErrorStack = config.includeErrorStack === true;
|
|
35
40
|
const logger = config.logger ?? "winston";
|
|
@@ -49,6 +54,9 @@ function createLogBrewWinstonTransport(config) {
|
|
|
49
54
|
objectMode: true,
|
|
50
55
|
write(info, _encoding, callback) {
|
|
51
56
|
try {
|
|
57
|
+
if (breadcrumbs) {
|
|
58
|
+
recordWinstonBreadcrumb(client, info);
|
|
59
|
+
}
|
|
52
60
|
captureWinstonInfo({
|
|
53
61
|
client,
|
|
54
62
|
eventIdPrefix,
|
|
@@ -128,6 +136,49 @@ function captureWinstonInfo(config) {
|
|
|
128
136
|
}
|
|
129
137
|
}
|
|
130
138
|
|
|
139
|
+
function recordWinstonBreadcrumb(client, info) {
|
|
140
|
+
if (!info || Array.isArray(info) || typeof info !== "object" || info.silent === true) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const breadcrumb = {
|
|
144
|
+
type: "debug",
|
|
145
|
+
category: "log.winston",
|
|
146
|
+
level: winstonBreadcrumbLevel(info.level),
|
|
147
|
+
message: Array.from(winstonMessage(info)).slice(0, MAX_BREADCRUMB_MESSAGE_LENGTH).join("")
|
|
148
|
+
};
|
|
149
|
+
const recorder = client.breadcrumbProvider ? globalThis[BREADCRUMB_RECORDER] : undefined;
|
|
150
|
+
try {
|
|
151
|
+
if (typeof recorder === "function") {
|
|
152
|
+
recorder(breadcrumb);
|
|
153
|
+
} else {
|
|
154
|
+
client.addBreadcrumb(breadcrumb);
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// Breadcrumbs are advisory; the log event below still goes out.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function winstonBreadcrumbLevel(level) {
|
|
162
|
+
switch (String(level).toLowerCase()) {
|
|
163
|
+
case "error":
|
|
164
|
+
case "crit":
|
|
165
|
+
case "critical":
|
|
166
|
+
case "fatal":
|
|
167
|
+
case "alert":
|
|
168
|
+
case "emerg":
|
|
169
|
+
return "error";
|
|
170
|
+
case "warn":
|
|
171
|
+
case "warning":
|
|
172
|
+
return "warning";
|
|
173
|
+
case "verbose":
|
|
174
|
+
case "debug":
|
|
175
|
+
case "silly":
|
|
176
|
+
return "debug";
|
|
177
|
+
default:
|
|
178
|
+
return "info";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
131
182
|
function logAttributesFromWinstonInfo(info, options = {}) {
|
|
132
183
|
if (!info || Array.isArray(info) || typeof info !== "object") {
|
|
133
184
|
throw new SdkError("validation_error", "Winston info must be an object");
|