@logbrew/sdk 0.1.31 → 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
@@ -2864,8 +2864,11 @@ function formatConsoleArgument(value, includeErrorStack) {
2864
2864
  }
2865
2865
  }
2866
2866
 
2867
+ const { installBrowserBreadcrumbs } = require("./browser-breadcrumbs.cjs");
2868
+
2867
2869
  module.exports = {
2868
2870
  PRODUCT_ANALYTICS_KINDS,
2871
+ installBrowserBreadcrumbs,
2869
2872
  PRODUCT_ANALYTICS_SCHEMA_VERSION,
2870
2873
  createBaggage,
2871
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.31",
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");