@logbrew/sdk 0.1.30 → 0.1.32

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
@@ -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
@@ -97,6 +97,8 @@ 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
99
  const CONTEXT_PROVIDER = Symbol.for("@logbrew/sdk.contextProvider");
100
+ // Runtime packages supply request-scoped breadcrumbs through this seam; core merges them into issues.
101
+ const BREADCRUMB_PROVIDER = Symbol.for("@logbrew/sdk.breadcrumbProvider");
100
102
  const MAX_ERROR_CAUSES = 5;
101
103
  const BUILTIN_ERROR_NAMES = new Set([
102
104
  "AggregateError",
@@ -216,6 +218,7 @@ class LogBrewClient {
216
218
  sdkVersion,
217
219
  context,
218
220
  [CONTEXT_PROVIDER]: contextProvider,
221
+ [BREADCRUMB_PROVIDER]: breadcrumbProvider,
219
222
  maxRetries = 2,
220
223
  eventFilter,
221
224
  maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
@@ -237,6 +240,9 @@ class LogBrewClient {
237
240
  if (contextProvider !== undefined && typeof contextProvider !== "function") {
238
241
  throw new SdkError("validation_error", "context provider must be a function");
239
242
  }
243
+ if (breadcrumbProvider !== undefined && typeof breadcrumbProvider !== "function") {
244
+ throw new SdkError("validation_error", "breadcrumb provider must be a function");
245
+ }
240
246
  requireNonNegativeInteger("maxRetries", maxRetries);
241
247
  if (eventFilter !== undefined && typeof eventFilter !== "function") {
242
248
  throw new SdkError("validation_error", "eventFilter must be a function");
@@ -286,6 +292,7 @@ class LogBrewClient {
286
292
  onEventDropped,
287
293
  context: clientContext,
288
294
  contextProvider,
295
+ breadcrumbProvider,
289
296
  sdk: {
290
297
  name: sdkName,
291
298
  language: "javascript",
@@ -308,6 +315,7 @@ class LogBrewClient {
308
315
  onEventDropped,
309
316
  context,
310
317
  contextProvider,
318
+ breadcrumbProvider,
311
319
  eventStore,
312
320
  eventQueueFactory,
313
321
  transport,
@@ -327,6 +335,7 @@ class LogBrewClient {
327
335
  this.onEventDropped = onEventDropped;
328
336
  this.context = cloneTelemetryContext(context);
329
337
  this.contextProvider = contextProvider;
338
+ this.breadcrumbProvider = breadcrumbProvider;
330
339
  this.transport = transport;
331
340
  this.sdk = sdk;
332
341
  this.maxRetries = maxRetries;
@@ -632,20 +641,46 @@ class LogBrewClient {
632
641
  }
633
642
 
634
643
  #pushIssue(id, timestamp, attributes) {
635
- const withBreadcrumbs = attributes.breadcrumbs !== undefined || this.issueBreadcrumbs.length === 0
644
+ if (attributes.breadcrumbs !== undefined) {
645
+ return this.#pushEvent("issue", id, timestamp, validateIssue(attributes));
646
+ }
647
+ const provided = this.#providedBreadcrumbs();
648
+ const merged = provided.breadcrumbs.length === 0
649
+ ? this.issueBreadcrumbs
650
+ : [...this.issueBreadcrumbs, ...provided.breadcrumbs]
651
+ .sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp));
652
+ const breadcrumbs = merged.slice(-MAX_ISSUE_BREADCRUMBS);
653
+ const truncated = attributes.breadcrumbsTruncated === true || this.issueBreadcrumbsTruncated
654
+ || provided.truncated || merged.length > breadcrumbs.length;
655
+ const withBreadcrumbs = breadcrumbs.length === 0
636
656
  ? attributes
637
- : {
638
- ...attributes,
639
- breadcrumbs: this.issueBreadcrumbs,
640
- ...(
641
- attributes.breadcrumbsTruncated === true || this.issueBreadcrumbsTruncated
642
- ? { breadcrumbsTruncated: true }
643
- : {}
644
- )
645
- };
657
+ : { ...attributes, breadcrumbs, ...(truncated ? { breadcrumbsTruncated: true } : {}) };
646
658
  return this.#pushEvent("issue", id, timestamp, validateIssue(withBreadcrumbs));
647
659
  }
648
660
 
661
+ // Provider failures and invalid entries never cost the issue itself.
662
+ #providedBreadcrumbs() {
663
+ let provided;
664
+ try {
665
+ provided = this.breadcrumbProvider?.();
666
+ } catch {
667
+ return { breadcrumbs: [], truncated: false };
668
+ }
669
+ if (!provided || !Array.isArray(provided.breadcrumbs)) {
670
+ return { breadcrumbs: [], truncated: false };
671
+ }
672
+ const breadcrumbs = [];
673
+ let truncated = provided.truncated === true || provided.breadcrumbs.length > MAX_ISSUE_BREADCRUMBS;
674
+ for (const breadcrumb of provided.breadcrumbs.slice(-MAX_ISSUE_BREADCRUMBS)) {
675
+ try {
676
+ breadcrumbs.push(validateIssueBreadcrumb(breadcrumb));
677
+ } catch {
678
+ truncated = true;
679
+ }
680
+ }
681
+ return { breadcrumbs, truncated };
682
+ }
683
+
649
684
  #pushEvent(eventType, id, timestamp, attributes) {
650
685
  if (this.closed) {
651
686
  throw new SdkError("shutdown_error", "client is already shut down");
@@ -2829,8 +2864,11 @@ function formatConsoleArgument(value, includeErrorStack) {
2829
2864
  }
2830
2865
  }
2831
2866
 
2867
+ const { installBrowserBreadcrumbs } = require("./browser-breadcrumbs.cjs");
2868
+
2832
2869
  module.exports = {
2833
2870
  PRODUCT_ANALYTICS_KINDS,
2871
+ installBrowserBreadcrumbs,
2834
2872
  PRODUCT_ANALYTICS_SCHEMA_VERSION,
2835
2873
  createBaggage,
2836
2874
  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
@@ -16,6 +16,7 @@ export const {
16
16
  createTracestate,
17
17
  createLogBrewPinoDestination,
18
18
  createLogBrewWinstonTransport,
19
+ installBrowserBreadcrumbs,
19
20
  installLogBrewConsoleCapture,
20
21
  LogBrewClient,
21
22
  logbrewTraceContextFromCurrentOpenTelemetrySpan,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logbrew/sdk",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
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
@@ -81,6 +81,7 @@ export {
81
81
  createTraceparent,
82
82
  createTraceparentHeaders,
83
83
  createTracestate,
84
+ installBrowserBreadcrumbs,
84
85
  installLogBrewConsoleCapture,
85
86
  LogBrewClient,
86
87
  logAttributesFromConsoleArgs,
package/react-native.js CHANGED
@@ -3,6 +3,7 @@ import sdk from "./core.cjs";
3
3
  export const {
4
4
  PRODUCT_ANALYTICS_KINDS,
5
5
  PRODUCT_ANALYTICS_SCHEMA_VERSION,
6
+ installBrowserBreadcrumbs,
6
7
  createBaggage,
7
8
  createIssueAttributesFromError,
8
9
  createNetworkMilestoneAttributes,
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");