@logbrew/sdk 0.1.28 → 0.1.29

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
@@ -502,9 +502,13 @@ const client = LogBrewClient.create({
502
502
  });
503
503
  ```
504
504
 
505
- When either queue limit is full, LogBrew drops the incoming event and keeps earlier context unchanged. `queue_overflow` means the event-count limit was reached, `queue_bytes_overflow` means the compact event-byte limit was reached, and `event_too_large` means one event could not fit a request body by itself. 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.
505
+ When either queue limit is full, LogBrew drops the incoming event and keeps earlier context unchanged. `queue_overflow` means the event-count limit was reached, `queue_bytes_overflow` means the compact event-byte limit was reached, and `event_too_large` means one event could not fit a request body by itself.
506
506
 
507
- 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.
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
+
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.
510
+
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.
508
512
 
509
513
  Flush requests are also bounded. Core and Node default to 100 events and 256 KiB of exact UTF-8 JSON per request. The browser factory defaults to 64 KiB so its normal request body cannot exceed the existing keepalive transport ceiling. A flush sends one queue snapshot in order, splitting it on either limit. The response reports total `attempts` and acknowledged `batches`. Existing clients that only set `maxQueueSize` keep working; the byte and batch settings are additive and are also accepted by the Node and browser client factories.
510
514
 
package/core.cjs CHANGED
@@ -91,6 +91,8 @@ const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
91
91
  const DEFAULT_DELIVERY_INTERVAL_MS = 5000;
92
92
  const MAX_DELIVERY_INTERVAL_MS = 60 * 1000;
93
93
  const DEFAULT_DELIVERY_QUEUE_THRESHOLD = 50;
94
+ const MAX_CLIENT_REPORT_REASONS = 16;
95
+ const MAX_CLIENT_REPORT_COUNT = 1_000_000_000;
94
96
  const DELIVERY_HEALTH_SCHEMA_VERSION = 1;
95
97
  const EVENT_QUEUE_FACTORY = Symbol.for("@logbrew/sdk.eventQueueFactory");
96
98
  const CONTEXT_PROVIDER = Symbol.for("@logbrew/sdk.contextProvider");
@@ -366,9 +368,12 @@ class LogBrewClient {
366
368
  this.droppedEventCount = 0;
367
369
  this.droppedEventsByReason = {
368
370
  event_too_large: 0,
371
+ evicted_for_fatal: 0,
369
372
  queue_bytes_overflow: 0,
370
373
  queue_overflow: 0
371
374
  };
375
+ // In memory only: counts not yet acknowledged by a 2xx are lost on restart.
376
+ this.unreportedDropsByReason = {};
372
377
  this.lastDropReason = "none";
373
378
  this.storage = resolvedEventStore ? "persistent" : "memory";
374
379
  this.hydratedEventCount = recovered.events.length;
@@ -390,7 +395,7 @@ class LogBrewClient {
390
395
  this.lastAttemptAtUnixMs = 0;
391
396
  this.lastAcceptedAtUnixMs = 0;
392
397
  this.lastDroppedAtUnixMs = 0;
393
- this.failedBatch = undefined;
398
+ this.unacknowledgedBatch = undefined;
394
399
  this.issueBreadcrumbs = [];
395
400
  this.issueBreadcrumbsTruncated = false;
396
401
  this.#scheduleAutomaticDelivery();
@@ -488,7 +493,7 @@ class LogBrewClient {
488
493
  }
489
494
  this.#clearDeliveryTimer();
490
495
  this.automaticFlushPending = false;
491
- this.failedBatch = undefined;
496
+ this.unacknowledgedBatch = undefined;
492
497
  this.events.splice(0, this.events.length);
493
498
  this.serializedEvents.splice(0, this.serializedEvents.length);
494
499
  this.serializedEventBytes.splice(0, this.serializedEventBytes.length);
@@ -666,32 +671,84 @@ class LogBrewClient {
666
671
  this.#recordDroppedEvent(event, "event_too_large");
667
672
  return EVENT_ADMISSIONS.event_too_large;
668
673
  }
669
- if (this.events.length >= this.maxQueueSize) {
670
- this.#recordDroppedEvent(event, "queue_overflow");
671
- return EVENT_ADMISSIONS.queue_overflow;
674
+ const fatal = isFatalEvent(event);
675
+ if (fatal) {
676
+ this.#synchronizeEventStore();
672
677
  }
673
- if (this.queuedEventBytes + eventBytes > this.maxQueueBytes) {
674
- this.#recordDroppedEvent(event, "queue_bytes_overflow");
675
- return EVENT_ADMISSIONS.queue_bytes_overflow;
678
+ const overflow = this.#overflowReason(this.events.length, this.queuedEventBytes + eventBytes);
679
+ const evicted = overflow && fatal ? this.#evictForFatal(eventBytes) : [];
680
+ if (overflow && evicted.length === 0) {
681
+ this.#recordDroppedEvent(event, overflow);
682
+ return EVENT_ADMISSIONS[overflow];
676
683
  }
677
- if (this.eventStore) {
678
- requireSynchronousStoreResult("append", this.eventStore.append({
679
- event: cloneEvent(event),
680
- eventBytes,
681
- serializedEvent
682
- }));
684
+ try {
685
+ if (this.eventStore) {
686
+ requireSynchronousStoreResult("append", this.eventStore.append({
687
+ event: cloneEvent(event),
688
+ eventBytes,
689
+ serializedEvent
690
+ }));
691
+ }
692
+ this.events.push(event);
693
+ this.serializedEvents.push(serializedEvent);
694
+ this.serializedEventBytes.push(eventBytes);
695
+ this.queuedEventBytes += eventBytes;
696
+ } finally {
697
+ // Report evictions only after admission so drop callbacks cannot refill the freed room.
698
+ for (const evictedEvent of evicted) {
699
+ this.#recordDroppedEvent(evictedEvent, "evicted_for_fatal");
700
+ }
683
701
  }
684
- this.events.push(event);
685
- this.serializedEvents.push(serializedEvent);
686
- this.serializedEventBytes.push(eventBytes);
687
- this.queuedEventBytes += eventBytes;
688
702
  this.#scheduleAutomaticDelivery();
689
703
  return EVENT_ADMISSIONS.admitted;
690
704
  }
691
705
 
706
+ #overflowReason(count, bytes) {
707
+ if (count >= this.maxQueueSize) {
708
+ return "queue_overflow";
709
+ }
710
+ return bytes > this.maxQueueBytes ? "queue_bytes_overflow" : undefined;
711
+ }
712
+
713
+ // Evicts the oldest non-fatal events outside the unacknowledged batch, only when that makes room.
714
+ #evictForFatal(eventBytes) {
715
+ if (this.eventStore && typeof this.eventStore.remove !== "function") {
716
+ return [];
717
+ }
718
+ const indexes = [];
719
+ let count = this.events.length;
720
+ let bytes = this.queuedEventBytes + eventBytes;
721
+ for (
722
+ let index = this.unacknowledgedBatch?.eventsCount ?? 0;
723
+ index < this.events.length && this.#overflowReason(count, bytes);
724
+ index += 1
725
+ ) {
726
+ if (!isFatalEvent(this.events[index])) {
727
+ indexes.push(index);
728
+ count -= 1;
729
+ bytes -= this.serializedEventBytes[index];
730
+ }
731
+ }
732
+ if (this.#overflowReason(count, bytes)) {
733
+ return [];
734
+ }
735
+ if (this.eventStore) {
736
+ requireSynchronousStoreResult("remove", this.eventStore.remove(indexes));
737
+ }
738
+ const evicted = indexes.map((index) => this.events[index]);
739
+ for (let position = indexes.length - 1; position >= 0; position -= 1) {
740
+ const index = indexes[position];
741
+ this.events.splice(index, 1);
742
+ this.serializedEvents.splice(index, 1);
743
+ this.queuedEventBytes -= this.serializedEventBytes.splice(index, 1)[0];
744
+ }
745
+ return evicted;
746
+ }
747
+
692
748
  #recordDroppedEvent(event, reason) {
693
749
  this.droppedEventCount = incrementBounded(this.droppedEventCount);
694
750
  this.droppedEventsByReason[reason] = incrementBounded(this.droppedEventsByReason[reason]);
751
+ this.unreportedDropsByReason[reason] = Math.min(MAX_CLIENT_REPORT_COUNT, (this.unreportedDropsByReason[reason] ?? 0) + 1);
695
752
  this.lastDropReason = reason;
696
753
  this.lastDroppedAtUnixMs = nextBoundedTimestamp(this.lastDroppedAtUnixMs);
697
754
  if (!this.onEventDropped) {
@@ -849,15 +906,17 @@ class LogBrewClient {
849
906
  let batches = 0;
850
907
  let statusCode = 204;
851
908
  while (remainingEvents > 0) {
852
- const batch = this.failedBatch ?? this.#nextBatch(Math.min(remainingEvents, this.maxBatchEvents));
853
- let response;
854
- try {
855
- response = await this.#sendBatch(transport, batch.body);
856
- } catch (error) {
857
- this.failedBatch ??= Object.freeze({ body: batch.body, eventsCount: batch.eventsCount });
858
- throw error;
909
+ // Eviction for a fatal event can shrink the snapshot while a batch is in flight.
910
+ this.unacknowledgedBatch ??= Object.freeze(
911
+ this.#nextBatch(Math.min(remainingEvents, this.events.length, this.maxBatchEvents))
912
+ );
913
+ const batch = this.unacknowledgedBatch;
914
+ const response = await this.#sendBatch(transport, batch.body);
915
+ this.unacknowledgedBatch = undefined;
916
+ // Clear only what this body carried; drops recorded while it was in flight stay pending.
917
+ for (const [reason, count] of Object.entries(batch.droppedEvents ?? {})) {
918
+ this.unreportedDropsByReason[reason] -= count;
859
919
  }
860
- this.failedBatch = undefined;
861
920
  this.#acknowledge(batch.eventsCount);
862
921
  this.acceptedBatchCount = incrementBounded(this.acceptedBatchCount);
863
922
  remainingEvents -= batch.eventsCount;
@@ -870,10 +929,13 @@ class LogBrewClient {
870
929
  }
871
930
 
872
931
  #synchronizeEventStore() {
873
- if (!this.eventStore) return;
932
+ if (!this.eventStore) {
933
+ return;
934
+ }
874
935
  const recovered = loadStoredEvents(this);
875
- if (!isOrderedSubsequence(this.serializedEvents, recovered.serializedEvents))
936
+ if (!isOrderedSubsequence(this.serializedEvents, recovered.serializedEvents)) {
876
937
  throw new SdkError("persistence_error", "event store lost a queued record");
938
+ }
877
939
  this.events = recovered.events;
878
940
  this.serializedEvents = recovered.serializedEvents;
879
941
  this.serializedEventBytes = recovered.serializedEventBytes;
@@ -881,7 +943,25 @@ class LogBrewClient {
881
943
  }
882
944
 
883
945
  #nextBatch(maxEvents) {
884
- let bodyBytes = this.batchPrefixBytes + this.batchSuffixBytes;
946
+ const reported = Object.entries(this.unreportedDropsByReason).filter(([, count]) => count > 0);
947
+ const droppedEvents = reported.length > 0
948
+ ? Object.fromEntries(reported.slice(0, MAX_CLIENT_REPORT_REASONS))
949
+ : undefined;
950
+ const reportSuffix = droppedEvents && `],"client_report":${JSON.stringify({ dropped_events: droppedEvents })}}`;
951
+ const reportedEvents = reportSuffix ? this.#batchEventCount(maxEvents, utf8ByteLength(reportSuffix)) : 0;
952
+ const eventsCount = reportedEvents || this.#batchEventCount(maxEvents, this.batchSuffixBytes);
953
+ if (eventsCount === 0) {
954
+ throw new SdkError("transport_error", "queued event cannot fit the configured batch byte limit");
955
+ }
956
+ return {
957
+ body: `${this.batchPrefix}${this.serializedEvents.slice(0, eventsCount).join(",")}${reportedEvents ? reportSuffix : this.batchSuffix}`,
958
+ droppedEvents: reportedEvents ? droppedEvents : undefined,
959
+ eventsCount
960
+ };
961
+ }
962
+
963
+ #batchEventCount(maxEvents, suffixBytes) {
964
+ let bodyBytes = this.batchPrefixBytes + suffixBytes;
885
965
  let eventsCount = 0;
886
966
  const eventIds = new Set();
887
967
  for (let index = 0; index < maxEvents; index += 1) {
@@ -897,13 +977,7 @@ class LogBrewClient {
897
977
  bodyBytes = nextBodyBytes;
898
978
  eventsCount += 1;
899
979
  }
900
- if (eventsCount === 0) {
901
- throw new SdkError("transport_error", "queued event cannot fit the configured batch byte limit");
902
- }
903
- return {
904
- body: `${this.batchPrefix}${this.serializedEvents.slice(0, eventsCount).join(",")}${this.batchSuffix}`,
905
- eventsCount
906
- };
980
+ return eventsCount;
907
981
  }
908
982
 
909
983
  #acknowledge(eventsCount) {
@@ -1078,6 +1152,11 @@ function utf8ByteLength(value) {
1078
1152
  return bytes;
1079
1153
  }
1080
1154
 
1155
+ // Fatal input severity is stored as critical; a critical issue is a crash report.
1156
+ function isFatalEvent(event) {
1157
+ return event.type === "issue" && event.attributes.level === "critical";
1158
+ }
1159
+
1081
1160
  function incrementBounded(value) {
1082
1161
  return value >= Number.MAX_SAFE_INTEGER ? Number.MAX_SAFE_INTEGER : value + 1;
1083
1162
  }
package/index.d.ts CHANGED
@@ -749,13 +749,15 @@ export type EventStore = {
749
749
  load(): StoredEvent[];
750
750
  append(record: StoredEvent): void;
751
751
  acknowledge(count: number): void;
752
+ /** Removes the records at ascending `load()` indexes; without it, fatal issues cannot evict queued events. */
753
+ remove?(indexes: number[]): void;
752
754
  purge(): void;
753
755
  close(): void;
754
756
  };
755
757
 
756
758
  /** Queue drop notification emitted when a bounded in-memory queue is full. */
757
759
  export type DroppedEvent = {
758
- reason: "event_too_large" | "queue_bytes_overflow" | "queue_overflow";
760
+ reason: "event_too_large" | "evicted_for_fatal" | "queue_bytes_overflow" | "queue_overflow";
759
761
  eventType: Event["type"];
760
762
  eventId: string;
761
763
  droppedEvents: number;
@@ -794,10 +796,11 @@ export type DeliveryHealthSnapshot = Readonly<{
794
796
  droppedEvents: number;
795
797
  droppedByReason: Readonly<{
796
798
  event_too_large: number;
799
+ evicted_for_fatal: number;
797
800
  queue_bytes_overflow: number;
798
801
  queue_overflow: number;
799
802
  }>;
800
- lastDropReason: "none" | "event_too_large" | "queue_bytes_overflow" | "queue_overflow";
803
+ lastDropReason: "none" | "event_too_large" | "evicted_for_fatal" | "queue_bytes_overflow" | "queue_overflow";
801
804
  scheduled: boolean;
802
805
  inFlight: boolean;
803
806
  coalesced: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logbrew/sdk",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "Public LogBrew JavaScript SDK for building, validating, and flushing event batches.",
5
5
  "type": "module",
6
6
  "main": "./index.cjs",