posthog-node
Version:
PostHog Node.js integration
189 lines (188 loc) • 7.75 kB
JavaScript
import { gzipCompress, safeJsonStringify, safeSetTimeout, uuidv7 } from "@posthog/core";
import { CaptureV1Error } from "./errors.mjs";
import { buildV1Batch } from "./transform.mjs";
const V1_ANALYTICS_PATH = '/i/v1/analytics/events';
const DEFAULT_MAX_BACKOFF_MS = 30000;
const RETRYABLE_STATUSES = new Set([
408,
500,
502,
503,
504
]);
class V1CaptureSender {
constructor(config, hooks){
this.config = config;
this.maxBackoffMs = config.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
this.fetchFn = hooks.fetch;
this.onError = hooks.onError;
this.now = hooks.now ?? Date.now;
this.sleep = hooks.sleep ?? ((ms)=>new Promise((resolve)=>safeSetTimeout(resolve, ms)));
this.generateRequestId = hooks.generateRequestId ?? uuidv7;
this.compress = hooks.compress ?? gzipCompress;
}
async sendV1Batch(messages) {
if (0 === messages.length) return;
const requestId = this.generateRequestId();
const createdAt = new Date(this.now()).toISOString();
const { batch } = buildV1Batch(messages, {
createdAt,
historicalMigration: this.config.historicalMigration
});
const url = `${this.config.host}${V1_ANALYTICS_PATH}`;
const drops = [];
let pending = batch;
const maxAttempts = Math.max(1, this.config.maxAttempts);
for(let attempt = 1; attempt <= maxAttempts; attempt++){
const isLastAttempt = attempt === maxAttempts;
const payload = safeJsonStringify({
created_at: createdAt,
...this.config.historicalMigration ? {
historical_migration: true
} : {},
batch: pending
});
let response;
try {
response = await this.sendOnce(url, payload, attempt, requestId);
} catch (transportError) {
if (isLastAttempt) return this.surfaceBatchFailure(requestId, drops, pending, transportError);
await this.sleep(this.backoffDelay(attempt));
continue;
}
const { status } = response;
if (status < 200 || status >= 300) {
if (!isLastAttempt && RETRYABLE_STATUSES.has(status)) {
const retryAfterMs = this.parseRetryAfter(response);
await this.cancelBody(response);
await this.sleep(this.backoffDelay(attempt, retryAfterMs));
continue;
}
const httpError = await this.buildHttpError(response, status);
return this.surfaceBatchFailure(requestId, drops, pending, httpError);
}
let parsed;
try {
parsed = await this.parseResponse(response);
} catch {
return this.surfaceBatchFailure(requestId, drops, pending, new Error(`Capture V1 returned an unparseable ${status} response body`));
}
const retryable = this.classify(pending, parsed, drops);
if (0 === retryable.length) return this.surfacePartialDrops(requestId, drops);
if (isLastAttempt) return void this.onError(new CaptureV1Error({
requestId,
drops,
retryExhausted: retryable.map((event)=>event.uuid)
}));
pending = retryable;
const retryAfterMs = this.parseRetryAfter(response);
await this.sleep(this.backoffDelay(attempt, retryAfterMs));
}
}
async sendOnce(url, payload, attempt, requestId) {
const headers = this.buildHeaders(attempt, requestId);
let body = payload;
if (this.config.compressionEnabled) {
const compressed = await this.compress(payload, this.config.isDebug);
if (null !== compressed) {
body = compressed;
headers['Content-Encoding'] = 'gzip';
}
}
const controller = new AbortController();
const timer = safeSetTimeout(()=>controller.abort(), this.config.requestTimeoutMs);
try {
return await this.fetchFn(url, {
method: 'POST',
headers,
body,
signal: controller.signal
});
} finally{
clearTimeout(timer);
}
}
buildHeaders(attempt, requestId) {
const sdkInfo = `${this.config.libraryId}/${this.config.libraryVersion}`;
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiKey}`,
'PostHog-Sdk-Info': sdkInfo,
'PostHog-Attempt': String(attempt),
'PostHog-Request-Id': requestId,
'PostHog-Request-Timestamp': new Date(this.now()).toISOString()
};
if (this.config.userAgent) headers['User-Agent'] = this.config.userAgent;
return headers;
}
classify(pending, parsed, drops) {
const results = parsed.results ?? {};
const retryable = [];
for (const event of pending){
const result = results[event.uuid];
if (result) {
if ('drop' === result.result) drops.push({
uuid: event.uuid,
details: result.details ?? void 0
});
else if ('retry' === result.result) retryable.push(event);
}
}
return retryable;
}
backoffDelay(attempt, retryAfterMs) {
const exponential = Math.min(this.config.initialRetryDelayMs * 2 ** (attempt - 1), this.maxBackoffMs);
if (void 0 === retryAfterMs) return exponential;
return Math.max(exponential, Math.min(retryAfterMs, this.maxBackoffMs));
}
parseRetryAfter(response) {
const raw = response.headers?.get('Retry-After');
if (!raw) return;
const trimmed = raw.trim();
if (/^\d+$/.test(trimmed)) {
const seconds = parseInt(trimmed, 10);
return Number.isFinite(seconds) && seconds > 0 ? 1000 * seconds : void 0;
}
const dateMs = Date.parse(trimmed);
if (Number.isNaN(dateMs)) return;
const delta = dateMs - this.now();
return delta > 0 ? delta : void 0;
}
async parseResponse(response) {
const text = await response.text();
const parsed = JSON.parse(text);
if ('object' != typeof parsed || null === parsed || Array.isArray(parsed)) throw new Error('unexpected response shape');
const results = parsed.results;
if (void 0 !== results && ('object' != typeof results || null === results || Array.isArray(results))) throw new Error('unexpected results shape');
return {
results: results ?? {}
};
}
async buildHttpError(response, status) {
let bodyText = '';
try {
bodyText = (await response.text()).slice(0, 512);
} catch {}
const suffix = bodyText ? `: ${bodyText}` : '';
return new Error(`Capture V1 request failed with HTTP ${status}${suffix}`);
}
surfaceBatchFailure(requestId, drops, pending, cause) {
this.onError(new CaptureV1Error({
requestId,
drops,
retryExhausted: pending.map((event)=>event.uuid),
cause
}));
}
surfacePartialDrops(requestId, drops) {
if (drops.length > 0) this.onError(new CaptureV1Error({
requestId,
drops,
retryExhausted: []
}));
}
async cancelBody(response) {
await response.body?.cancel()?.catch(()=>{});
}
}
export { V1CaptureSender };