@logbrew/sdk 0.1.28 → 0.1.30
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 +8 -4
- package/core.cjs +117 -37
- package/index.d.ts +6 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -456,7 +456,7 @@ Prefer removing sensitive values at the source before calling LogBrew. `eventFil
|
|
|
456
456
|
|
|
457
457
|
## Automatic Delivery
|
|
458
458
|
|
|
459
|
-
When a client owns a `transport`, it automatically sends queued work after
|
|
459
|
+
When a client owns a `transport`, it automatically sends queued work 1 second after the first queued event or when 50 events are waiting, whichever happens first, so a single event is readable in about a second while bursts still share one request. The one-shot timer starts only after capture, later events do not restart it, is `unref()`'d on Node.js, and is cancelled by manual flush, purge, or shutdown. Automatic sends reuse the same serialized flush path, immutable retry body, accepted-prefix acknowledgement, and persistent queue as manual calls. They do not install process, signal, page, or exit hooks.
|
|
460
460
|
|
|
461
461
|
```js
|
|
462
462
|
const transport = RecordingTransport.alwaysAccept();
|
|
@@ -465,7 +465,7 @@ const client = LogBrewClient.create({
|
|
|
465
465
|
sdkName: "checkout-api",
|
|
466
466
|
sdkVersion: "1.0.0",
|
|
467
467
|
transport,
|
|
468
|
-
deliveryIntervalMs:
|
|
468
|
+
deliveryIntervalMs: 1000,
|
|
469
469
|
deliveryQueueThreshold: 50
|
|
470
470
|
});
|
|
471
471
|
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
@@ -88,9 +88,12 @@ const DEFAULT_MAX_QUEUE_SIZE = 1000;
|
|
|
88
88
|
const DEFAULT_MAX_QUEUE_BYTES = 4 * 1024 * 1024;
|
|
89
89
|
const DEFAULT_MAX_BATCH_EVENTS = 100;
|
|
90
90
|
const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
|
|
91
|
-
|
|
91
|
+
// One second matches the OpenTelemetry log processor default, so a lone event becomes readable quickly.
|
|
92
|
+
const DEFAULT_DELIVERY_INTERVAL_MS = 1000;
|
|
92
93
|
const MAX_DELIVERY_INTERVAL_MS = 60 * 1000;
|
|
93
94
|
const DEFAULT_DELIVERY_QUEUE_THRESHOLD = 50;
|
|
95
|
+
const MAX_CLIENT_REPORT_REASONS = 16;
|
|
96
|
+
const MAX_CLIENT_REPORT_COUNT = 1_000_000_000;
|
|
94
97
|
const DELIVERY_HEALTH_SCHEMA_VERSION = 1;
|
|
95
98
|
const EVENT_QUEUE_FACTORY = Symbol.for("@logbrew/sdk.eventQueueFactory");
|
|
96
99
|
const CONTEXT_PROVIDER = Symbol.for("@logbrew/sdk.contextProvider");
|
|
@@ -366,9 +369,12 @@ class LogBrewClient {
|
|
|
366
369
|
this.droppedEventCount = 0;
|
|
367
370
|
this.droppedEventsByReason = {
|
|
368
371
|
event_too_large: 0,
|
|
372
|
+
evicted_for_fatal: 0,
|
|
369
373
|
queue_bytes_overflow: 0,
|
|
370
374
|
queue_overflow: 0
|
|
371
375
|
};
|
|
376
|
+
// In memory only: counts not yet acknowledged by a 2xx are lost on restart.
|
|
377
|
+
this.unreportedDropsByReason = {};
|
|
372
378
|
this.lastDropReason = "none";
|
|
373
379
|
this.storage = resolvedEventStore ? "persistent" : "memory";
|
|
374
380
|
this.hydratedEventCount = recovered.events.length;
|
|
@@ -390,7 +396,7 @@ class LogBrewClient {
|
|
|
390
396
|
this.lastAttemptAtUnixMs = 0;
|
|
391
397
|
this.lastAcceptedAtUnixMs = 0;
|
|
392
398
|
this.lastDroppedAtUnixMs = 0;
|
|
393
|
-
this.
|
|
399
|
+
this.unacknowledgedBatch = undefined;
|
|
394
400
|
this.issueBreadcrumbs = [];
|
|
395
401
|
this.issueBreadcrumbsTruncated = false;
|
|
396
402
|
this.#scheduleAutomaticDelivery();
|
|
@@ -488,7 +494,7 @@ class LogBrewClient {
|
|
|
488
494
|
}
|
|
489
495
|
this.#clearDeliveryTimer();
|
|
490
496
|
this.automaticFlushPending = false;
|
|
491
|
-
this.
|
|
497
|
+
this.unacknowledgedBatch = undefined;
|
|
492
498
|
this.events.splice(0, this.events.length);
|
|
493
499
|
this.serializedEvents.splice(0, this.serializedEvents.length);
|
|
494
500
|
this.serializedEventBytes.splice(0, this.serializedEventBytes.length);
|
|
@@ -666,32 +672,84 @@ class LogBrewClient {
|
|
|
666
672
|
this.#recordDroppedEvent(event, "event_too_large");
|
|
667
673
|
return EVENT_ADMISSIONS.event_too_large;
|
|
668
674
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
675
|
+
const fatal = isFatalEvent(event);
|
|
676
|
+
if (fatal) {
|
|
677
|
+
this.#synchronizeEventStore();
|
|
672
678
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
679
|
+
const overflow = this.#overflowReason(this.events.length, this.queuedEventBytes + eventBytes);
|
|
680
|
+
const evicted = overflow && fatal ? this.#evictForFatal(eventBytes) : [];
|
|
681
|
+
if (overflow && evicted.length === 0) {
|
|
682
|
+
this.#recordDroppedEvent(event, overflow);
|
|
683
|
+
return EVENT_ADMISSIONS[overflow];
|
|
676
684
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
685
|
+
try {
|
|
686
|
+
if (this.eventStore) {
|
|
687
|
+
requireSynchronousStoreResult("append", this.eventStore.append({
|
|
688
|
+
event: cloneEvent(event),
|
|
689
|
+
eventBytes,
|
|
690
|
+
serializedEvent
|
|
691
|
+
}));
|
|
692
|
+
}
|
|
693
|
+
this.events.push(event);
|
|
694
|
+
this.serializedEvents.push(serializedEvent);
|
|
695
|
+
this.serializedEventBytes.push(eventBytes);
|
|
696
|
+
this.queuedEventBytes += eventBytes;
|
|
697
|
+
} finally {
|
|
698
|
+
// Report evictions only after admission so drop callbacks cannot refill the freed room.
|
|
699
|
+
for (const evictedEvent of evicted) {
|
|
700
|
+
this.#recordDroppedEvent(evictedEvent, "evicted_for_fatal");
|
|
701
|
+
}
|
|
683
702
|
}
|
|
684
|
-
this.events.push(event);
|
|
685
|
-
this.serializedEvents.push(serializedEvent);
|
|
686
|
-
this.serializedEventBytes.push(eventBytes);
|
|
687
|
-
this.queuedEventBytes += eventBytes;
|
|
688
703
|
this.#scheduleAutomaticDelivery();
|
|
689
704
|
return EVENT_ADMISSIONS.admitted;
|
|
690
705
|
}
|
|
691
706
|
|
|
707
|
+
#overflowReason(count, bytes) {
|
|
708
|
+
if (count >= this.maxQueueSize) {
|
|
709
|
+
return "queue_overflow";
|
|
710
|
+
}
|
|
711
|
+
return bytes > this.maxQueueBytes ? "queue_bytes_overflow" : undefined;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Evicts the oldest non-fatal events outside the unacknowledged batch, only when that makes room.
|
|
715
|
+
#evictForFatal(eventBytes) {
|
|
716
|
+
if (this.eventStore && typeof this.eventStore.remove !== "function") {
|
|
717
|
+
return [];
|
|
718
|
+
}
|
|
719
|
+
const indexes = [];
|
|
720
|
+
let count = this.events.length;
|
|
721
|
+
let bytes = this.queuedEventBytes + eventBytes;
|
|
722
|
+
for (
|
|
723
|
+
let index = this.unacknowledgedBatch?.eventsCount ?? 0;
|
|
724
|
+
index < this.events.length && this.#overflowReason(count, bytes);
|
|
725
|
+
index += 1
|
|
726
|
+
) {
|
|
727
|
+
if (!isFatalEvent(this.events[index])) {
|
|
728
|
+
indexes.push(index);
|
|
729
|
+
count -= 1;
|
|
730
|
+
bytes -= this.serializedEventBytes[index];
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (this.#overflowReason(count, bytes)) {
|
|
734
|
+
return [];
|
|
735
|
+
}
|
|
736
|
+
if (this.eventStore) {
|
|
737
|
+
requireSynchronousStoreResult("remove", this.eventStore.remove(indexes));
|
|
738
|
+
}
|
|
739
|
+
const evicted = indexes.map((index) => this.events[index]);
|
|
740
|
+
for (let position = indexes.length - 1; position >= 0; position -= 1) {
|
|
741
|
+
const index = indexes[position];
|
|
742
|
+
this.events.splice(index, 1);
|
|
743
|
+
this.serializedEvents.splice(index, 1);
|
|
744
|
+
this.queuedEventBytes -= this.serializedEventBytes.splice(index, 1)[0];
|
|
745
|
+
}
|
|
746
|
+
return evicted;
|
|
747
|
+
}
|
|
748
|
+
|
|
692
749
|
#recordDroppedEvent(event, reason) {
|
|
693
750
|
this.droppedEventCount = incrementBounded(this.droppedEventCount);
|
|
694
751
|
this.droppedEventsByReason[reason] = incrementBounded(this.droppedEventsByReason[reason]);
|
|
752
|
+
this.unreportedDropsByReason[reason] = Math.min(MAX_CLIENT_REPORT_COUNT, (this.unreportedDropsByReason[reason] ?? 0) + 1);
|
|
695
753
|
this.lastDropReason = reason;
|
|
696
754
|
this.lastDroppedAtUnixMs = nextBoundedTimestamp(this.lastDroppedAtUnixMs);
|
|
697
755
|
if (!this.onEventDropped) {
|
|
@@ -849,15 +907,17 @@ class LogBrewClient {
|
|
|
849
907
|
let batches = 0;
|
|
850
908
|
let statusCode = 204;
|
|
851
909
|
while (remainingEvents > 0) {
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
910
|
+
// Eviction for a fatal event can shrink the snapshot while a batch is in flight.
|
|
911
|
+
this.unacknowledgedBatch ??= Object.freeze(
|
|
912
|
+
this.#nextBatch(Math.min(remainingEvents, this.events.length, this.maxBatchEvents))
|
|
913
|
+
);
|
|
914
|
+
const batch = this.unacknowledgedBatch;
|
|
915
|
+
const response = await this.#sendBatch(transport, batch.body);
|
|
916
|
+
this.unacknowledgedBatch = undefined;
|
|
917
|
+
// Clear only what this body carried; drops recorded while it was in flight stay pending.
|
|
918
|
+
for (const [reason, count] of Object.entries(batch.droppedEvents ?? {})) {
|
|
919
|
+
this.unreportedDropsByReason[reason] -= count;
|
|
859
920
|
}
|
|
860
|
-
this.failedBatch = undefined;
|
|
861
921
|
this.#acknowledge(batch.eventsCount);
|
|
862
922
|
this.acceptedBatchCount = incrementBounded(this.acceptedBatchCount);
|
|
863
923
|
remainingEvents -= batch.eventsCount;
|
|
@@ -870,10 +930,13 @@ class LogBrewClient {
|
|
|
870
930
|
}
|
|
871
931
|
|
|
872
932
|
#synchronizeEventStore() {
|
|
873
|
-
if (!this.eventStore)
|
|
933
|
+
if (!this.eventStore) {
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
874
936
|
const recovered = loadStoredEvents(this);
|
|
875
|
-
if (!isOrderedSubsequence(this.serializedEvents, recovered.serializedEvents))
|
|
937
|
+
if (!isOrderedSubsequence(this.serializedEvents, recovered.serializedEvents)) {
|
|
876
938
|
throw new SdkError("persistence_error", "event store lost a queued record");
|
|
939
|
+
}
|
|
877
940
|
this.events = recovered.events;
|
|
878
941
|
this.serializedEvents = recovered.serializedEvents;
|
|
879
942
|
this.serializedEventBytes = recovered.serializedEventBytes;
|
|
@@ -881,7 +944,25 @@ class LogBrewClient {
|
|
|
881
944
|
}
|
|
882
945
|
|
|
883
946
|
#nextBatch(maxEvents) {
|
|
884
|
-
|
|
947
|
+
const reported = Object.entries(this.unreportedDropsByReason).filter(([, count]) => count > 0);
|
|
948
|
+
const droppedEvents = reported.length > 0
|
|
949
|
+
? Object.fromEntries(reported.slice(0, MAX_CLIENT_REPORT_REASONS))
|
|
950
|
+
: undefined;
|
|
951
|
+
const reportSuffix = droppedEvents && `],"client_report":${JSON.stringify({ dropped_events: droppedEvents })}}`;
|
|
952
|
+
const reportedEvents = reportSuffix ? this.#batchEventCount(maxEvents, utf8ByteLength(reportSuffix)) : 0;
|
|
953
|
+
const eventsCount = reportedEvents || this.#batchEventCount(maxEvents, this.batchSuffixBytes);
|
|
954
|
+
if (eventsCount === 0) {
|
|
955
|
+
throw new SdkError("transport_error", "queued event cannot fit the configured batch byte limit");
|
|
956
|
+
}
|
|
957
|
+
return {
|
|
958
|
+
body: `${this.batchPrefix}${this.serializedEvents.slice(0, eventsCount).join(",")}${reportedEvents ? reportSuffix : this.batchSuffix}`,
|
|
959
|
+
droppedEvents: reportedEvents ? droppedEvents : undefined,
|
|
960
|
+
eventsCount
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
#batchEventCount(maxEvents, suffixBytes) {
|
|
965
|
+
let bodyBytes = this.batchPrefixBytes + suffixBytes;
|
|
885
966
|
let eventsCount = 0;
|
|
886
967
|
const eventIds = new Set();
|
|
887
968
|
for (let index = 0; index < maxEvents; index += 1) {
|
|
@@ -897,13 +978,7 @@ class LogBrewClient {
|
|
|
897
978
|
bodyBytes = nextBodyBytes;
|
|
898
979
|
eventsCount += 1;
|
|
899
980
|
}
|
|
900
|
-
|
|
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
|
-
};
|
|
981
|
+
return eventsCount;
|
|
907
982
|
}
|
|
908
983
|
|
|
909
984
|
#acknowledge(eventsCount) {
|
|
@@ -1078,6 +1153,11 @@ function utf8ByteLength(value) {
|
|
|
1078
1153
|
return bytes;
|
|
1079
1154
|
}
|
|
1080
1155
|
|
|
1156
|
+
// Fatal input severity is stored as critical; a critical issue is a crash report.
|
|
1157
|
+
function isFatalEvent(event) {
|
|
1158
|
+
return event.type === "issue" && event.attributes.level === "critical";
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1081
1161
|
function incrementBounded(value) {
|
|
1082
1162
|
return value >= Number.MAX_SAFE_INTEGER ? Number.MAX_SAFE_INTEGER : value + 1;
|
|
1083
1163
|
}
|
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;
|
|
@@ -883,7 +886,7 @@ export declare class LogBrewClient {
|
|
|
883
886
|
transport?: Transport;
|
|
884
887
|
/** Enable interval and queue-threshold delivery. Defaults to true when transport is supplied. */
|
|
885
888
|
automaticDelivery?: boolean;
|
|
886
|
-
/** One-shot delivery interval in milliseconds. Defaults to
|
|
889
|
+
/** One-shot delivery interval in milliseconds. Defaults to 1000 and must not exceed 60000. */
|
|
887
890
|
deliveryIntervalMs?: number;
|
|
888
891
|
/** Queue count that triggers delivery without waiting for the interval. Defaults to min(50, maxQueueSize). */
|
|
889
892
|
deliveryQueueThreshold?: number;
|