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