import { gzipSync } from "node:zlib"; import type { FeedbackTraceBundle } from "@paperclipai/shared"; import type { Config } from "../config.js"; export const FEEDBACK_UPLOAD_TIMEOUT_MS = 30_000; const DEFAULT_FEEDBACK_EXPORT_BACKEND_URL = "https://telemetry.paperclip.ing"; function buildFeedbackShareObjectKey(bundle: FeedbackTraceBundle, exportedAt: Date) { const year = String(exportedAt.getUTCFullYear()); const month = String(exportedAt.getUTCMonth() + 1).padStart(2, "0"); const day = String(exportedAt.getUTCDate()).padStart(2, "0"); return `feedback-traces/${bundle.companyId}/${year}/${month}/${day}/${bundle.exportId ?? bundle.traceId}.json`; } export interface FeedbackTraceShareClient { uploadTraceBundle(bundle: FeedbackTraceBundle, signal?: AbortSignal): Promise<{ objectKey: string }>; } export function createFeedbackTraceShareClientFromConfig( config: Pick, ): FeedbackTraceShareClient { const baseUrl = config.feedbackExportBackendUrl?.trim() || DEFAULT_FEEDBACK_EXPORT_BACKEND_URL; const token = config.feedbackExportBackendToken?.trim(); const endpoint = new URL("/feedback-traces", baseUrl).toString(); return { async uploadTraceBundle(bundle, shutdownSignal) { const deadline = AbortSignal.timeout(FEEDBACK_UPLOAD_TIMEOUT_MS); const signal = shutdownSignal ? AbortSignal.any([shutdownSignal, deadline]) : deadline; signal.throwIfAborted(); const exportedAt = new Date(); const objectKey = buildFeedbackShareObjectKey(bundle, exportedAt); const requestBody = JSON.stringify({ objectKey, exportedAt: exportedAt.toISOString(), bundle, }); const response = await fetch(endpoint, { method: "POST", signal, headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ encoding: "gzip+base64+json", payload: gzipSync(requestBody).toString("base64"), }), }); if (!response.ok) { const detail = await response.text().catch(() => ""); throw new Error(detail.trim() || `Feedback trace upload failed with HTTP ${response.status}`); } const payload = await response.json().catch(() => null) as { objectKey?: unknown } | null; // A timed-out response body is not a successful upload acknowledgement. signal.throwIfAborted(); return { objectKey: typeof payload?.objectKey === "string" && payload.objectKey.trim().length > 0 ? payload.objectKey : objectKey, }; }, }; }