@logbrew/sdk 0.1.32 → 0.1.34

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
@@ -367,7 +367,7 @@ If your app already owns an OpenTelemetry provider, `createLogBrewOpenTelemetryS
367
367
 
368
368
  ```js
369
369
  import { SpanKind } from "@opentelemetry/api";
370
- import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base";
370
+ import { TracerProvider } from "@opentelemetry/sdk-trace";
371
371
  import {
372
372
  createLogBrewOpenTelemetrySpanProcessor,
373
373
  LogBrewClient,
@@ -387,7 +387,7 @@ const processor = createLogBrewOpenTelemetrySpanProcessor({
387
387
  linkAttributeKeys: ["messaging.operation.name"],
388
388
  metadata: { release: "checkout@1.2.3", environment: "production" }
389
389
  });
390
- const provider = new BasicTracerProvider({ spanProcessors: [processor] });
390
+ const provider = new TracerProvider({ spanProcessors: [processor] });
391
391
  const tracer = provider.getTracer("checkout-api");
392
392
 
393
393
  const span = tracer.startSpan("GET /orders/:id", {
@@ -407,7 +407,7 @@ await processor.forceFlush();
407
407
  If your OpenTelemetry setup already uses standard processors such as `SimpleSpanProcessor` or `BatchSpanProcessor`, use `createLogBrewOpenTelemetrySpanExporter()` instead:
408
408
 
409
409
  ```js
410
- import { BasicTracerProvider, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
410
+ import { TracerProvider, SimpleSpanProcessor } from "@opentelemetry/sdk-trace";
411
411
  import {
412
412
  createLogBrewOpenTelemetrySpanExporter,
413
413
  LogBrewClient,
@@ -425,11 +425,13 @@ const exporter = createLogBrewOpenTelemetrySpanExporter({
425
425
  includeTraceSummary: true,
426
426
  metadata: { release: "checkout@1.2.3", environment: "production" }
427
427
  });
428
- const provider = new BasicTracerProvider({
429
- spanProcessors: [new SimpleSpanProcessor(exporter)]
428
+ const provider = new TracerProvider({
429
+ spanProcessors: [new SimpleSpanProcessor({ exporter })]
430
430
  });
431
431
  ```
432
432
 
433
+ These examples use `@opentelemetry/sdk-trace` 3. With `@opentelemetry/sdk-trace-base` 2, use `BasicTracerProvider` and `new SimpleSpanProcessor(exporter)`; the LogBrew processor and exporter work the same with both.
434
+
433
435
  The OTel processor and exporter follow normal OTel sampled-span behavior by default and summarize up to eight span events and eight links. Set `includeTraceSummary: true` when you also want one synthetic `opentelemetry.trace:<root-name>` span per trace on processor `forceFlush()`/`shutdown()` or exporter `export()`/`forceFlush()`/`shutdown()`; the summary carries the trace ID, span count, error count, root span ID/name/kind, duration, and safe service/environment/route metadata so a batch reads like one request or transaction. They copy only a small safe default set such as service, environment, route, method, status code, span kind, instrumentation scope, exception type, exception event count, escaped exception count, and dropped-count metadata. Exception counts scan the readable span's events, while emitted exception type lists stay bounded. Escaped exception events on unset-status spans mark the LogBrew span as `error`; explicit OTel `OK` status stays `ok`. Additional event/link/span/resource attributes require explicit allowlists, and high-risk keys such as full URLs, headers, query strings, payloads, cookies, private auth values, DB statements, exception messages, and stacks stay blocked. Concurrent flush calls use one active send and one coalesced trailing drain: later spans leave the queue before their caller completes, while a failed active send consumes no extra retry budget. These helpers do not add an OpenTelemetry dependency, patch clients, own tracer providers, serialize baggage or tracestate, copy raw propagation headers, or capture request/response bodies.
434
436
 
435
437
  LogBrew severity categories are `info`, `warning`, `error`, and `critical`. The JavaScript SDK accepts common runtime aliases such as `trace`, `debug`, `warn`, and `fatal` for compatibility, then serializes canonical values before queued events are sent. The shared mapping is documented in the [LogBrew severity contract](../../docs/severity-contract.md).
@@ -506,7 +508,7 @@ When either queue limit is full, LogBrew drops the incoming event and keeps earl
506
508
 
507
509
  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
510
 
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 client clears exactly those counts after a `2xx` response for that request; drops recorded while it is in flight, and counts in a failed or retried request, wait for the next one. 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`. Counts live in memory only, so drops not yet reported are lost when the process restarts.
511
+ 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
512
 
511
513
  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
514
 
@@ -49,6 +49,7 @@ function patchConsole(target, add) {
49
49
  return [];
50
50
  }
51
51
  return Object.entries(CONSOLE_LEVELS).flatMap(([method, level]) => replaceMethod(target, method, (original) => (
52
+ /** @this {Console} */
52
53
  function logBrewConsoleBreadcrumb(...args) {
53
54
  const message = boundedText(consoleTemplate(args), MAX_MESSAGE_LENGTH);
54
55
  if (message) {
@@ -60,7 +61,7 @@ function patchConsole(target, add) {
60
61
  }
61
62
 
62
63
  function patchFetch(browserWindow, add, ignored) {
63
- return replaceMethod(browserWindow, "fetch", (original) => async function logBrewFetchBreadcrumb(input, init) {
64
+ return replaceMethod(browserWindow, "fetch", (original) => /** @this {*} */ async function logBrewFetchBreadcrumb(input, init) {
64
65
  const method = String(init?.method ?? input?.method ?? "GET").toUpperCase();
65
66
  const url = sanitizeUrl(typeof input === "string" || input instanceof URL ? input : input?.url, browserWindow);
66
67
  try {
@@ -85,11 +86,11 @@ function patchXhr(browserWindow, add, ignored) {
85
86
  }
86
87
  const state = new WeakMap();
87
88
  return [
88
- ...replaceMethod(prototype, "open", (original) => function logBrewXhrOpen(method, url, ...rest) {
89
+ ...replaceMethod(prototype, "open", (original) => /** @this {XMLHttpRequest} */ function logBrewXhrOpen(method, url, ...rest) {
89
90
  state.set(this, { method: String(method ?? "GET").toUpperCase(), url: sanitizeUrl(url, browserWindow) });
90
91
  return original.call(this, method, url, ...rest);
91
92
  }),
92
- ...replaceMethod(prototype, "send", (original) => function logBrewXhrSend(...args) {
93
+ ...replaceMethod(prototype, "send", (original) => /** @this {XMLHttpRequest} */ function logBrewXhrSend(...args) {
93
94
  const request = state.get(this);
94
95
  if (request && !ignored.has(request.url) && typeof this.addEventListener === "function") {
95
96
  this.addEventListener("loadend", () => {
@@ -115,6 +116,7 @@ function patchNavigation(browserWindow, add) {
115
116
  }
116
117
  };
117
118
  const restores = ["pushState", "replaceState"].flatMap((method) => replaceMethod(history, method, (original) => (
119
+ /** @this {History} */
118
120
  function logBrewNavigationBreadcrumb(...args) {
119
121
  const result = original.apply(this, args);
120
122
  record();
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
- // In memory only: counts not yet acknowledged by a 2xx are lost on restart.
386
- this.unreportedDropsByReason = {};
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
- // Clear only what this body carried; drops recorded while it was in flight stay pending.
953
- for (const [reason, count] of Object.entries(batch.droppedEvents ?? {})) {
954
- this.unreportedDropsByReason[reason] -= count;
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 reported = Object.entries(this.unreportedDropsByReason).filter(([, count]) => count > 0);
983
- const droppedEvents = reported.length > 0
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
- droppedEvents: reportedEvents ? droppedEvents : undefined,
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
- return {
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) {
@@ -1491,6 +1595,7 @@ function installLogBrewConsoleCapture(config) {
1491
1595
  }
1492
1596
 
1493
1597
  function createConsoleCaptureMethod(config) {
1598
+ /** @this {*} */
1494
1599
  return function logBrewConsoleMethod(...args) {
1495
1600
  config.original.apply(this, args);
1496
1601
  if (!config.state.installed) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logbrew/sdk",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "Public LogBrew JavaScript SDK for building, validating, and flushing event batches.",
5
5
  "type": "module",
6
6
  "main": "./index.cjs",