@posthog/agent 2.3.656 → 2.3.658

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.
@@ -0,0 +1,693 @@
1
+ import { Buffer } from "node:buffer";
2
+ import type { Logger } from "../utils/logger";
3
+ import {
4
+ createNodeStreamingUpload,
5
+ type StreamingUpload,
6
+ type StreamingUploadFactory,
7
+ } from "./streaming-upload";
8
+
9
+ interface TaskRunEventStreamSenderConfig {
10
+ apiUrl: string;
11
+ projectId: number;
12
+ taskId: string;
13
+ runId: string;
14
+ token: string;
15
+ logger: Logger;
16
+ maxBufferedEvents?: number;
17
+ flushDelayMs?: number;
18
+ retryDelayMs?: number;
19
+ requestTimeoutMs?: number;
20
+ stopTimeoutMs?: number;
21
+ maxEventBytes?: number;
22
+ maxStreamEvents?: number;
23
+ maxStreamBytes?: number;
24
+ streamWindowMs?: number;
25
+ createStreamingUpload?: StreamingUploadFactory;
26
+ }
27
+
28
+ interface EventEnvelope {
29
+ seq: number;
30
+ event: Record<string, unknown>;
31
+ }
32
+
33
+ interface IngestResponse {
34
+ last_accepted_seq?: unknown;
35
+ }
36
+
37
+ interface ActiveStream {
38
+ abortController: AbortController;
39
+ upload: StreamingUpload;
40
+ responsePromise: Promise<Response>;
41
+ startedAtMs: number;
42
+ sentThroughSeq: number;
43
+ sentEvents: number;
44
+ sentBytes: number;
45
+ windowTimer: ReturnType<typeof setTimeout> | null;
46
+ }
47
+
48
+ const DEFAULT_MAX_BUFFERED_EVENTS = 20_000;
49
+ const DEFAULT_MAX_STREAM_EVENTS = 900;
50
+ const DEFAULT_MAX_STREAM_BYTES = 4_000_000;
51
+ const DEFAULT_MAX_EVENT_BYTES = 900_000;
52
+ const DEFAULT_FLUSH_DELAY_MS = 0;
53
+ const DEFAULT_RETRY_DELAY_MS = 1_000;
54
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
55
+ const DEFAULT_STOP_TIMEOUT_MS = 30_000;
56
+ const DEFAULT_STREAM_WINDOW_MS = 5 * 60 * 1_000;
57
+ const STREAM_COMPLETE_CONTROL_TYPE = "_posthog/stream_complete";
58
+
59
+ export class TaskRunEventStreamSender {
60
+ private readonly ingestUrl: string;
61
+ private readonly maxBufferedEvents: number;
62
+ private readonly maxStreamEvents: number;
63
+ private readonly maxStreamBytes: number;
64
+ private readonly maxEventBytes: number;
65
+ private readonly flushDelayMs: number;
66
+ private readonly retryDelayMs: number;
67
+ private readonly requestTimeoutMs: number;
68
+ private readonly stopTimeoutMs: number;
69
+ private readonly streamWindowMs: number;
70
+ private readonly createStreamingUpload: StreamingUploadFactory;
71
+ private readonly encoder = new TextEncoder();
72
+ private sequence = 0;
73
+ private lastKnownAcceptedSeq = 0;
74
+ private bufferedEvents: EventEnvelope[] = [];
75
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
76
+ private flushPromise: Promise<void> | null = null;
77
+ private streamClosePromise: Promise<void> | null = null;
78
+ private activeStream: ActiveStream | null = null;
79
+ private stopPromise: Promise<void> | null = null;
80
+ private stopped = false;
81
+ private sequenceSynced = false;
82
+ private sequenceInitialized = false;
83
+ private transportCompleted = false;
84
+ private droppedBeforeSequenceCount = 0;
85
+ private bufferRevision = 0;
86
+
87
+ constructor(private readonly config: TaskRunEventStreamSenderConfig) {
88
+ const apiUrl = config.apiUrl.replace(/\/$/, "");
89
+ this.ingestUrl = `${apiUrl}/api/projects/${config.projectId}/tasks/${encodeURIComponent(
90
+ config.taskId,
91
+ )}/runs/${encodeURIComponent(config.runId)}/event_stream/`;
92
+ this.maxBufferedEvents =
93
+ config.maxBufferedEvents ?? DEFAULT_MAX_BUFFERED_EVENTS;
94
+ this.maxStreamEvents = config.maxStreamEvents ?? DEFAULT_MAX_STREAM_EVENTS;
95
+ this.maxStreamBytes = config.maxStreamBytes ?? DEFAULT_MAX_STREAM_BYTES;
96
+ this.maxEventBytes = config.maxEventBytes ?? DEFAULT_MAX_EVENT_BYTES;
97
+ this.flushDelayMs = config.flushDelayMs ?? DEFAULT_FLUSH_DELAY_MS;
98
+ this.retryDelayMs = config.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
99
+ this.requestTimeoutMs =
100
+ config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
101
+ this.stopTimeoutMs = config.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
102
+ this.streamWindowMs = config.streamWindowMs ?? DEFAULT_STREAM_WINDOW_MS;
103
+ this.createStreamingUpload =
104
+ config.createStreamingUpload ?? createNodeStreamingUpload;
105
+ }
106
+
107
+ enqueue(event: Record<string, unknown>): void {
108
+ if (this.stopped) return;
109
+
110
+ if (!this.canAcceptEvent(event)) {
111
+ return;
112
+ }
113
+
114
+ const envelope: EventEnvelope = {
115
+ seq: ++this.sequence,
116
+ event,
117
+ };
118
+ this.bufferedEvents.push(envelope);
119
+ this.scheduleFlush();
120
+ }
121
+
122
+ async stop(): Promise<void> {
123
+ if (this.stopPromise) {
124
+ await this.stopPromise;
125
+ return;
126
+ }
127
+
128
+ this.stopped = true;
129
+
130
+ if (this.flushTimer) {
131
+ clearTimeout(this.flushTimer);
132
+ this.flushTimer = null;
133
+ }
134
+
135
+ this.stopPromise = this.drainForStop();
136
+ await this.stopPromise;
137
+ }
138
+
139
+ private scheduleFlush(delayMs = this.flushDelayMs): void {
140
+ if (this.flushTimer || this.flushPromise || this.stopped) return;
141
+
142
+ this.flushTimer = setTimeout(() => {
143
+ this.flushTimer = null;
144
+ void this.flush();
145
+ }, delayMs);
146
+ }
147
+
148
+ private async drainForStop(): Promise<void> {
149
+ const startedAtMs = Date.now();
150
+ const deadlineAtMs = startedAtMs + this.stopTimeoutMs;
151
+
152
+ while (!this.transportCompleted) {
153
+ const previousLength = this.bufferedEvents.length;
154
+ const previousRevision = this.bufferRevision;
155
+
156
+ try {
157
+ await this.flush();
158
+ await this.writeCompletionLine();
159
+ await this.closeActiveStream();
160
+ this.transportCompleted = true;
161
+ return;
162
+ } catch (error) {
163
+ this.config.logger.warn(
164
+ "Task run event ingest stop request failed",
165
+ this.describeError(error),
166
+ );
167
+ }
168
+
169
+ const madeProgress =
170
+ this.bufferedEvents.length < previousLength ||
171
+ this.bufferRevision !== previousRevision;
172
+ if (!madeProgress && !(await this.waitBeforeStopRetry(deadlineAtMs))) {
173
+ this.warnStopDeadlineReached(startedAtMs);
174
+ return;
175
+ }
176
+
177
+ if (Date.now() >= deadlineAtMs && !this.transportCompleted) {
178
+ this.warnStopDeadlineReached(startedAtMs);
179
+ return;
180
+ }
181
+ }
182
+ }
183
+
184
+ private async flush(): Promise<boolean> {
185
+ if (this.flushPromise) {
186
+ await this.flushPromise.catch(() => undefined);
187
+ }
188
+
189
+ if (this.bufferedEvents.length === 0) {
190
+ return true;
191
+ }
192
+
193
+ const previousBufferLength = this.bufferedEvents.length;
194
+ const flushPromise = this.flushBufferedEvents();
195
+ this.flushPromise = flushPromise;
196
+
197
+ try {
198
+ await flushPromise;
199
+ return this.bufferedEvents.length < previousBufferLength;
200
+ } catch (error) {
201
+ this.config.logger.warn(
202
+ "Task run event ingest stream write failed",
203
+ this.describeError(error),
204
+ );
205
+ await this.abortActiveStream();
206
+ if (!this.stopped) {
207
+ this.scheduleFlush(this.retryDelayMs);
208
+ }
209
+ return false;
210
+ } finally {
211
+ if (this.flushPromise === flushPromise) {
212
+ this.flushPromise = null;
213
+ }
214
+ if (!this.stopped && this.hasUnwrittenBufferedEvents()) {
215
+ this.scheduleFlush(0);
216
+ }
217
+ }
218
+ }
219
+
220
+ private async flushBufferedEvents(): Promise<void> {
221
+ while (true) {
222
+ const stream = await this.ensureActiveStream();
223
+ const nextEvent = this.bufferedEvents.find(
224
+ (event) => event.seq > stream.sentThroughSeq,
225
+ );
226
+ if (!nextEvent) {
227
+ return;
228
+ }
229
+
230
+ const line = `${this.serializeEnvelope(nextEvent)}\n`;
231
+ const lineBytes = Buffer.byteLength(line, "utf8");
232
+ if (this.shouldRollStreamBeforeWriting(stream, lineBytes)) {
233
+ await this.closeActiveStream();
234
+ continue;
235
+ }
236
+
237
+ await stream.upload.write(this.encoder.encode(line));
238
+ stream.sentThroughSeq = nextEvent.seq;
239
+ stream.sentEvents += 1;
240
+ stream.sentBytes += lineBytes;
241
+ }
242
+ }
243
+
244
+ private hasUnwrittenBufferedEvents(): boolean {
245
+ const sentThroughSeq =
246
+ this.activeStream?.sentThroughSeq ?? this.lastKnownAcceptedSeq;
247
+ return this.bufferedEvents.some((event) => event.seq > sentThroughSeq);
248
+ }
249
+
250
+ private async writeCompletionLine(): Promise<void> {
251
+ await this.syncSequenceWithServer();
252
+
253
+ while (true) {
254
+ const stream = await this.ensureActiveStream();
255
+ const hasUnwrittenEvents = this.bufferedEvents.some(
256
+ (event) => event.seq > stream.sentThroughSeq,
257
+ );
258
+ if (hasUnwrittenEvents) {
259
+ await this.flushBufferedEvents();
260
+ continue;
261
+ }
262
+
263
+ const line = `${JSON.stringify({
264
+ type: STREAM_COMPLETE_CONTROL_TYPE,
265
+ final_seq: this.sequence,
266
+ })}\n`;
267
+ const lineBytes = Buffer.byteLength(line, "utf8");
268
+ if (
269
+ this.shouldRollStreamBeforeWriting(stream, lineBytes, {
270
+ ignoreEventCount: true,
271
+ })
272
+ ) {
273
+ await this.closeActiveStream();
274
+ continue;
275
+ }
276
+
277
+ await stream.upload.write(this.encoder.encode(line));
278
+ stream.sentBytes += lineBytes;
279
+ return;
280
+ }
281
+ }
282
+
283
+ private shouldRollStreamBeforeWriting(
284
+ stream: ActiveStream,
285
+ lineBytes: number,
286
+ options: { ignoreEventCount?: boolean } = {},
287
+ ): boolean {
288
+ if (
289
+ !options.ignoreEventCount &&
290
+ stream.sentEvents > 0 &&
291
+ stream.sentEvents >= this.maxStreamEvents
292
+ ) {
293
+ return true;
294
+ }
295
+ if (
296
+ stream.sentBytes > 0 &&
297
+ stream.sentBytes + lineBytes > this.maxStreamBytes
298
+ ) {
299
+ return true;
300
+ }
301
+ return Date.now() - stream.startedAtMs >= this.streamWindowMs;
302
+ }
303
+
304
+ private async ensureActiveStream(): Promise<ActiveStream> {
305
+ if (this.streamClosePromise) {
306
+ await this.streamClosePromise.catch(() => undefined);
307
+ }
308
+
309
+ if (this.activeStream) {
310
+ return this.activeStream;
311
+ }
312
+
313
+ await this.syncSequenceWithServer();
314
+
315
+ const abortController = new AbortController();
316
+ const upload = this.createStreamingUpload({
317
+ url: this.ingestUrl,
318
+ headers: this.buildHeaders(),
319
+ abortController,
320
+ });
321
+ const activeStream: ActiveStream = {
322
+ abortController,
323
+ upload,
324
+ responsePromise: upload.responsePromise,
325
+ startedAtMs: Date.now(),
326
+ sentThroughSeq: this.lastKnownAcceptedSeq,
327
+ sentEvents: 0,
328
+ sentBytes: 0,
329
+ windowTimer: null,
330
+ };
331
+ this.activeStream = activeStream;
332
+ this.scheduleStreamWindowClose(activeStream);
333
+ upload.responsePromise.catch((error) => {
334
+ void this.handleActiveStreamResponseFailure(activeStream, error);
335
+ });
336
+ return activeStream;
337
+ }
338
+
339
+ private scheduleStreamWindowClose(
340
+ stream: ActiveStream,
341
+ delayOverrideMs?: number,
342
+ ): void {
343
+ this.clearStreamWindowClose(stream);
344
+ // Rotate long-lived uploads even when the agent goes idle; this is a
345
+ // transport boundary, not a batching window.
346
+ const delayMs =
347
+ delayOverrideMs ??
348
+ Math.max(0, stream.startedAtMs + this.streamWindowMs - Date.now());
349
+ stream.windowTimer = setTimeout(() => {
350
+ stream.windowTimer = null;
351
+ void this.closeExpiredStream(stream);
352
+ }, delayMs);
353
+ }
354
+
355
+ private clearStreamWindowClose(stream: ActiveStream): void {
356
+ if (!stream.windowTimer) {
357
+ return;
358
+ }
359
+ clearTimeout(stream.windowTimer);
360
+ stream.windowTimer = null;
361
+ }
362
+
363
+ private async closeExpiredStream(stream: ActiveStream): Promise<void> {
364
+ if (this.activeStream !== stream || this.stopped) {
365
+ return;
366
+ }
367
+
368
+ if (this.flushPromise) {
369
+ this.scheduleStreamWindowClose(stream, 50);
370
+ return;
371
+ }
372
+
373
+ try {
374
+ await this.closeActiveStream();
375
+ } catch (error) {
376
+ this.config.logger.warn(
377
+ "Task run event ingest stream window close failed",
378
+ this.describeError(error),
379
+ );
380
+ if (!this.stopped && this.bufferedEvents.length > 0) {
381
+ this.scheduleFlush(this.retryDelayMs);
382
+ }
383
+ }
384
+ }
385
+
386
+ private async handleActiveStreamResponseFailure(
387
+ stream: ActiveStream,
388
+ error: unknown,
389
+ ): Promise<void> {
390
+ if (this.activeStream !== stream) {
391
+ return;
392
+ }
393
+
394
+ this.config.logger.warn(
395
+ "Task run event ingest stream request failed",
396
+ this.describeError(error),
397
+ );
398
+ try {
399
+ await this.abortActiveStream();
400
+ } catch (abortError) {
401
+ this.config.logger.warn(
402
+ "Task run event ingest stream abort failed",
403
+ this.describeError(abortError),
404
+ );
405
+ }
406
+ if (!this.stopped && this.bufferedEvents.length > 0) {
407
+ this.scheduleFlush(this.retryDelayMs);
408
+ }
409
+ }
410
+
411
+ private async closeActiveStream(): Promise<void> {
412
+ if (this.streamClosePromise) {
413
+ await this.streamClosePromise;
414
+ return;
415
+ }
416
+
417
+ const stream = this.activeStream;
418
+ if (!stream) {
419
+ return;
420
+ }
421
+
422
+ const closePromise = this.closeStream(stream);
423
+ this.streamClosePromise = closePromise;
424
+ try {
425
+ await closePromise;
426
+ } finally {
427
+ this.clearStreamWindowClose(stream);
428
+ if (this.activeStream === stream) {
429
+ this.activeStream = null;
430
+ }
431
+ if (this.streamClosePromise === closePromise) {
432
+ this.streamClosePromise = null;
433
+ }
434
+ }
435
+ }
436
+
437
+ private async closeStream(stream: ActiveStream): Promise<void> {
438
+ try {
439
+ await stream.upload.close();
440
+ } catch (error) {
441
+ stream.abortController.abort();
442
+ this.sequenceSynced = false;
443
+ throw error;
444
+ }
445
+
446
+ let response: Response;
447
+ try {
448
+ response = await this.waitForResponseWithTimeout(
449
+ stream.responsePromise,
450
+ stream.abortController,
451
+ );
452
+ } catch (error) {
453
+ stream.abortController.abort();
454
+ this.sequenceSynced = false;
455
+ throw error;
456
+ }
457
+
458
+ await this.applyIngestResponse(response, "Event ingest stream");
459
+ this.sequenceSynced = true;
460
+ }
461
+
462
+ private async abortActiveStream(): Promise<void> {
463
+ const stream = this.activeStream;
464
+ if (!stream) {
465
+ return;
466
+ }
467
+
468
+ stream.abortController.abort();
469
+ this.clearStreamWindowClose(stream);
470
+ try {
471
+ await stream.upload.abort();
472
+ } catch {
473
+ // The upload may already be closed by the transport after the abort.
474
+ } finally {
475
+ if (this.activeStream === stream) {
476
+ this.activeStream = null;
477
+ }
478
+ this.sequenceSynced = false;
479
+ }
480
+ }
481
+
482
+ private async waitBeforeStopRetry(deadlineAtMs: number): Promise<boolean> {
483
+ const remainingMs = deadlineAtMs - Date.now();
484
+ if (remainingMs <= 0) {
485
+ return false;
486
+ }
487
+
488
+ await new Promise((resolve) =>
489
+ setTimeout(resolve, Math.min(this.retryDelayMs, remainingMs)),
490
+ );
491
+ return Date.now() < deadlineAtMs;
492
+ }
493
+
494
+ private warnStopDeadlineReached(startedAtMs: number): void {
495
+ this.config.logger.warn(
496
+ "Task run event ingest stop deadline reached before fully completing transport",
497
+ {
498
+ remaining: this.bufferedEvents.length,
499
+ stopTimeoutMs: this.stopTimeoutMs,
500
+ elapsedMs: Date.now() - startedAtMs,
501
+ },
502
+ );
503
+ }
504
+
505
+ private async syncSequenceWithServer(): Promise<void> {
506
+ if (this.sequenceSynced) return;
507
+
508
+ const response = await this.fetchWithTimeout({
509
+ method: "POST",
510
+ headers: this.buildHeaders(),
511
+ body: "",
512
+ });
513
+ const responseBody = await this.parseResponse(response);
514
+
515
+ if (!response.ok) {
516
+ throw new Error(
517
+ `Event ingest sequence sync returned HTTP ${response.status}: ${responseBody.text.slice(0, 300)}`,
518
+ );
519
+ }
520
+
521
+ const lastAcceptedSeq = responseBody.parsed?.last_accepted_seq;
522
+ if (typeof lastAcceptedSeq === "number" && lastAcceptedSeq > 0) {
523
+ if (!this.sequenceInitialized) {
524
+ this.bufferedEvents = this.bufferedEvents.map((event) => ({
525
+ ...event,
526
+ seq: event.seq + lastAcceptedSeq,
527
+ }));
528
+ this.sequence += lastAcceptedSeq;
529
+ this.bufferRevision += 1;
530
+ } else {
531
+ this.acceptThrough(lastAcceptedSeq);
532
+ if (lastAcceptedSeq > this.sequence) {
533
+ this.sequence = lastAcceptedSeq;
534
+ }
535
+ }
536
+ this.lastKnownAcceptedSeq = lastAcceptedSeq;
537
+ }
538
+
539
+ this.sequenceSynced = true;
540
+ this.sequenceInitialized = true;
541
+ }
542
+
543
+ private async fetchWithTimeout(init: RequestInit): Promise<Response> {
544
+ const abortController = new AbortController();
545
+ const timeout = setTimeout(() => {
546
+ abortController.abort();
547
+ }, this.requestTimeoutMs);
548
+
549
+ try {
550
+ return await fetch(this.ingestUrl, {
551
+ ...init,
552
+ signal: abortController.signal,
553
+ });
554
+ } finally {
555
+ clearTimeout(timeout);
556
+ }
557
+ }
558
+
559
+ private async waitForResponseWithTimeout(
560
+ responsePromise: Promise<Response>,
561
+ abortController: AbortController,
562
+ ): Promise<Response> {
563
+ const timeout = setTimeout(() => {
564
+ abortController.abort();
565
+ }, this.requestTimeoutMs);
566
+
567
+ try {
568
+ return await responsePromise;
569
+ } finally {
570
+ clearTimeout(timeout);
571
+ }
572
+ }
573
+
574
+ private async applyIngestResponse(
575
+ response: Response,
576
+ label: string,
577
+ ): Promise<void> {
578
+ const responseBody = await this.parseResponse(response);
579
+ const lastAcceptedSeq = responseBody.parsed?.last_accepted_seq;
580
+ if (typeof lastAcceptedSeq === "number") {
581
+ this.acceptThrough(lastAcceptedSeq);
582
+ if (lastAcceptedSeq > this.sequence) {
583
+ this.sequence = lastAcceptedSeq;
584
+ }
585
+ this.lastKnownAcceptedSeq = lastAcceptedSeq;
586
+ if (response.status === 409) {
587
+ this.rebaseBufferedEvents(lastAcceptedSeq);
588
+ }
589
+ }
590
+
591
+ if (!response.ok) {
592
+ throw new Error(
593
+ `${label} returned HTTP ${response.status}: ${responseBody.text.slice(0, 300)}`,
594
+ );
595
+ }
596
+ }
597
+
598
+ private acceptThrough(lastAcceptedSeq: number): void {
599
+ const previousLength = this.bufferedEvents.length;
600
+ this.bufferedEvents = this.bufferedEvents.filter(
601
+ (event) => event.seq > lastAcceptedSeq,
602
+ );
603
+ if (this.bufferedEvents.length !== previousLength) {
604
+ this.bufferRevision += 1;
605
+ }
606
+ }
607
+
608
+ private buildHeaders(): Record<string, string> {
609
+ return {
610
+ Authorization: `Bearer ${this.config.token}`,
611
+ "Content-Type": "application/x-ndjson",
612
+ };
613
+ }
614
+
615
+ private rebaseBufferedEvents(lastAcceptedSeq: number): void {
616
+ let nextSeq = lastAcceptedSeq + 1;
617
+ this.bufferedEvents = this.bufferedEvents.map((event) => ({
618
+ ...event,
619
+ seq: nextSeq++,
620
+ }));
621
+ this.sequence = nextSeq - 1;
622
+ this.sequenceSynced = true;
623
+ this.sequenceInitialized = true;
624
+ this.lastKnownAcceptedSeq = lastAcceptedSeq;
625
+ this.bufferRevision += 1;
626
+ }
627
+
628
+ private async parseResponse(
629
+ response: Response,
630
+ ): Promise<{ parsed: IngestResponse | null; text: string }> {
631
+ const text = await response.text();
632
+ if (!text) {
633
+ return { parsed: null, text };
634
+ }
635
+
636
+ try {
637
+ return { parsed: JSON.parse(text) as IngestResponse, text };
638
+ } catch {
639
+ return { parsed: null, text };
640
+ }
641
+ }
642
+
643
+ private canAcceptEvent(event: Record<string, unknown>): boolean {
644
+ const eventBytes = Buffer.byteLength(
645
+ this.serializeEnvelope({ seq: this.sequence + 1, event }),
646
+ "utf8",
647
+ );
648
+ if (eventBytes > this.maxEventBytes) {
649
+ this.config.logger.warn("Dropped oversized task run event", {
650
+ eventBytes,
651
+ maxEventBytes: this.maxEventBytes,
652
+ });
653
+ return false;
654
+ }
655
+
656
+ if (this.bufferedEvents.length >= this.maxBufferedEvents) {
657
+ this.droppedBeforeSequenceCount += 1;
658
+ if (
659
+ this.droppedBeforeSequenceCount === 1 ||
660
+ this.droppedBeforeSequenceCount % 100 === 0
661
+ ) {
662
+ this.config.logger.warn(
663
+ "Dropped task run event before assigning sequence due to backpressure",
664
+ {
665
+ dropped: this.droppedBeforeSequenceCount,
666
+ maxBufferedEvents: this.maxBufferedEvents,
667
+ },
668
+ );
669
+ }
670
+ return false;
671
+ }
672
+
673
+ if (this.droppedBeforeSequenceCount > 0) {
674
+ this.config.logger.warn("Task run event ingest recovered after drops", {
675
+ dropped: this.droppedBeforeSequenceCount,
676
+ });
677
+ this.droppedBeforeSequenceCount = 0;
678
+ }
679
+
680
+ return true;
681
+ }
682
+
683
+ private serializeEnvelope(envelope: EventEnvelope): string {
684
+ return JSON.stringify({ seq: envelope.seq, event: envelope.event });
685
+ }
686
+
687
+ private describeError(error: unknown): unknown {
688
+ if (error instanceof Error) {
689
+ return { message: error.message, stack: error.stack };
690
+ }
691
+ return error;
692
+ }
693
+ }