Files
orca/src/main/ipc/feedback.ts
T
Jinjing 746a8f3e48 fix: retry crash reports on primary endpoint instead of fallback (#10023)
Replace the api.onorca.dev fallback pattern with same-host retries on
www.onorca.dev, the endpoint that owns crash delivery. This ensures
consistent behavior and clearer error reporting when failures occur.
2026-07-23 00:13:43 -07:00

292 lines
10 KiB
TypeScript

import os from 'node:os'
import { app, ipcMain, net } from 'electron'
// Why: the production Mac build loads the renderer from a file:// origin, so a
// cross-origin POST from fetch() triggers a CORS preflight that the feedback
// endpoint rejects. Electron's net module runs in the main process and is not
// subject to CORS, so we proxy the submission through IPC. This mirrors the
// same pattern used by updater-changelog.ts and updater-nudge.ts.
const FEEDBACK_API_URL = 'https://www.onorca.dev/v1/feedback'
const FEEDBACK_REQUEST_TIMEOUT_MS = 10_000
const FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS = 60_000
const DIAGNOSTIC_BUNDLE_CONTENT_TYPE = 'application/x-ndjson'
// Why: corporate filters can reject multipart with 403 while allowing the
// small JSON report, so content-shaped failures should shed the attachment.
const DIAGNOSTIC_BUNDLE_JSON_RETRY_STATUSES = new Set([400, 403, 408, 413, 415, 422])
export type FeedbackSubmissionType = 'feedback' | 'crash'
export type FeedbackSubmitArgs = {
feedback: string
submitAnonymously?: boolean
githubLogin: string | null
githubEmail: string | null
}
export type FeedbackDiagnosticBundleAttachment = {
bundleSubmissionId: string
content: string
bytes: number
spanCount: number
}
type FeedbackSubmitBody = {
feedback: string
submissionType: FeedbackSubmissionType
githubLogin: string | null
githubEmail: string | null
appVersion: string
platform: NodeJS.Platform
osRelease: string
arch: string
diagnosticBundle?: FeedbackDiagnosticBundleAttachment
}
export type FeedbackRequestFailure = {
status: number | null
error: string
}
export type FeedbackSubmitResult =
| { ok: true; diagnosticBundleFailure?: FeedbackRequestFailure }
| ({ ok: false } & FeedbackRequestFailure & {
diagnosticBundleFailure?: FeedbackRequestFailure
})
type InternalFeedbackSubmitArgs = FeedbackSubmitArgs & {
submissionType?: FeedbackSubmissionType
diagnosticBundle?: FeedbackDiagnosticBundleAttachment
feedbackWithoutDiagnosticBundle?: string
}
// Why: the Slack notification and any follow-up investigation need to know
// which Orca build and which OS the feedback came from. The main process is
// the only place with trusted access to these values (app.getVersion and the
// node os module), so we enrich the payload here rather than trusting the
// renderer.
function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody {
const identity = args.submitAnonymously
? { githubLogin: null, githubEmail: null }
: { githubLogin: args.githubLogin, githubEmail: args.githubEmail }
// Why: anonymity is an IPC-only privacy decision. Allow-list fields here so
// stale renderer state or future identity-shaped fields cannot leak upstream.
return {
feedback: args.feedback,
submissionType: args.submissionType ?? 'feedback',
...identity,
appVersion: app.getVersion(),
platform: process.platform,
osRelease: os.release(),
arch: process.arch,
...(args.submissionType === 'crash' && args.diagnosticBundle
? { diagnosticBundle: args.diagnosticBundle }
: {})
}
}
async function postFeedback(
url: string,
body: FeedbackSubmitBody,
timeoutMs = FEEDBACK_REQUEST_TIMEOUT_MS
): Promise<Response> {
const controller = new AbortController()
// Why: a silent feedback endpoint should not leave IPC or crash-report
// submission flows pending forever.
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const init: RequestInit = {
method: 'POST',
...feedbackRequestBodyInit(body),
signal: controller.signal
}
return await net.fetch(url, init)
} catch (error) {
// Why: Electron and Node use different AbortError messages. Normalize our
// client deadline so support logs explain which request budget expired.
if (controller.signal.aborted) {
throw new Error(`request timed out after ${timeoutMs / 1000} seconds`)
}
throw error
} finally {
clearTimeout(timeout)
}
}
function feedbackRequestBodyInit(body: FeedbackSubmitBody): Pick<RequestInit, 'body' | 'headers'> {
if (!body.diagnosticBundle) {
return {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}
}
const formData = new FormData()
appendFeedbackFormField(formData, 'feedback', body.feedback)
appendFeedbackFormField(formData, 'submissionType', body.submissionType)
appendFeedbackFormField(formData, 'githubLogin', body.githubLogin)
appendFeedbackFormField(formData, 'githubEmail', body.githubEmail)
appendFeedbackFormField(formData, 'appVersion', body.appVersion)
appendFeedbackFormField(formData, 'platform', body.platform)
appendFeedbackFormField(formData, 'osRelease', body.osRelease)
appendFeedbackFormField(formData, 'arch', body.arch)
appendFeedbackFormField(
formData,
'diagnosticBundleSubmissionId',
body.diagnosticBundle.bundleSubmissionId
)
appendFeedbackFormField(formData, 'diagnosticBundleBytes', String(body.diagnosticBundle.bytes))
appendFeedbackFormField(
formData,
'diagnosticBundleSpanCount',
String(body.diagnosticBundle.spanCount)
)
formData.append(
'diagnosticBundleFile',
new Blob([body.diagnosticBundle.content], { type: DIAGNOSTIC_BUNDLE_CONTENT_TYPE }),
`orca-diagnostics-${body.diagnosticBundle.bundleSubmissionId}.ndjson`
)
// Why: multipart avoids JSON-escaping a near-cap NDJSON bundle over the
// backend request limit while still submitting one feedback request.
return { body: formData }
}
function appendFeedbackFormField(formData: FormData, key: string, value: string | null): void {
if (value !== null) {
formData.append(key, value)
}
}
function messageFromError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function responseFailure(response: Response): FeedbackRequestFailure {
return { status: response.status, error: `status ${response.status}` }
}
function errorFailure(error: unknown): FeedbackRequestFailure {
return { status: null, error: messageFromError(error) }
}
async function retryFeedbackOnPrimary(
body: FeedbackSubmitBody,
primaryError?: unknown
): Promise<FeedbackSubmitResult> {
try {
const retry = await postFeedback(FEEDBACK_API_URL, body)
if (retry.ok) {
return { ok: true }
}
const retryMessage = `status ${retry.status}`
if (primaryError === undefined) {
return { ok: false, status: retry.status, error: retryMessage }
}
// Why: keep the first failure visible so support can see 5xx → retry outcome,
// not only the last error in a same-host retry chain.
return {
ok: false,
status: retry.status,
error: `${messageFromError(primaryError)}; retry: ${retryMessage}`
}
} catch (retryError) {
const message = messageFromError(retryError)
if (primaryError === undefined) {
return { ok: false, status: null, error: message }
}
return {
ok: false,
status: null,
error: `${messageFromError(primaryError)}; retry: ${message}`
}
}
}
function shouldRetryWithoutDiagnosticBundle(status: number): boolean {
return DIAGNOSTIC_BUNDLE_JSON_RETRY_STATUSES.has(status) || status === 404 || status >= 500
}
async function submitFeedbackWithoutDiagnosticBundle(
body: FeedbackSubmitBody,
diagnosticBundleFailure: FeedbackRequestFailure
): Promise<FeedbackSubmitResult> {
try {
const response = await postFeedback(FEEDBACK_API_URL, body)
if (response.ok) {
return { ok: true, diagnosticBundleFailure }
}
return { ok: false, ...responseFailure(response), diagnosticBundleFailure }
} catch (error) {
return { ok: false, ...errorFailure(error), diagnosticBundleFailure }
}
}
async function submitFeedbackWithDiagnosticBundle(
body: FeedbackSubmitBody,
bodyWithoutDiagnosticBundle: FeedbackSubmitBody | null
): Promise<FeedbackSubmitResult> {
try {
// Why: diagnostic bundles can approach 4 MiB and need more upload time than
// the small JSON report-only path, especially on constrained connections.
const response = await postFeedback(
FEEDBACK_API_URL,
body,
FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS
)
if (response.ok) {
return { ok: true }
}
const failure = responseFailure(response)
if (bodyWithoutDiagnosticBundle && shouldRetryWithoutDiagnosticBundle(response.status)) {
return submitFeedbackWithoutDiagnosticBundle(bodyWithoutDiagnosticBundle, failure)
}
return { ok: false, ...failure }
} catch (error) {
const failure = errorFailure(error)
return bodyWithoutDiagnosticBundle
? submitFeedbackWithoutDiagnosticBundle(bodyWithoutDiagnosticBundle, failure)
: { ok: false, ...failure }
}
}
export async function submitFeedback(
args: InternalFeedbackSubmitArgs
): Promise<FeedbackSubmitResult> {
const body = buildSubmitBody(args)
if (body.diagnosticBundle) {
const bodyWithoutDiagnosticBundle =
args.feedbackWithoutDiagnosticBundle !== undefined
? buildSubmitBody({
...args,
feedback: args.feedbackWithoutDiagnosticBundle,
diagnosticBundle: undefined
})
: null
return submitFeedbackWithDiagnosticBundle(body, bodyWithoutDiagnosticBundle)
}
try {
const res = await postFeedback(FEEDBACK_API_URL, body)
if (res.ok) {
return { ok: true }
}
// Why: api.onorca.dev serves a different product, so transient failures
// retry the endpoint that owns feedback and crash delivery.
if (res.status >= 500) {
return retryFeedbackOnPrimary(body, new Error(`status ${res.status}`))
}
return { ok: false, status: res.status, error: `status ${res.status}` }
} catch (error) {
return retryFeedbackOnPrimary(body, error)
}
}
export function registerFeedbackHandlers(): void {
ipcMain.removeHandler('feedback:submit')
ipcMain.handle('feedback:submit', (_event, args: FeedbackSubmitArgs) =>
// Why: crash submissions are main-only. A compromised renderer can invoke
// this channel directly, so force the public feedback lane at the boundary.
submitFeedback({ ...args, submissionType: 'feedback' })
)
}