Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Bug Fixes 🐛

- Report renderer envelope handoff, and ingest status for feedback, instead of always returning 200. Protocol `fetch` is awaited. A dropped or queued feedback is not a successful `sendFeedback`. Other envelopes return once main has accepted them.

## 7.19.0

### New Features ✨
Expand Down
11 changes: 11 additions & 0 deletions src/common/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ export function eventFromEnvelope(envelope: Envelope): [Event, Attachment[], Pro
return event ? [event, attachments, profile] : undefined;
}

/**
* Only feedback must report ingest status back to the renderer.
*
* `sendFeedback` resolves on a 2xx transport status. Errors, transactions, spans
* and replays do not. Waiting for ingest on those would hold the renderer
* transport buffer for a network round-trip.
*/
export function isFeedbackEvent(event: Event): boolean {
return event.type === 'feedback';
}

/** Extracts profile_chunk from an envelope if present */
export function profileChunkFromEnvelope(envelope: Envelope): ProfileChunk | undefined {
let profileChunk: ProfileChunk | undefined;
Expand Down
74 changes: 72 additions & 2 deletions src/common/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SerializedLog, SerializedMetric } from '@sentry/core';
import type { SerializedLog, SerializedMetric, TransportMakeRequestResponse } from '@sentry/core';

/** Ways to communicate between the renderer and main process */
export enum IPCMode {
Expand Down Expand Up @@ -84,12 +84,82 @@ export interface RendererStatus {
export interface IPCInterface {
sendRendererStart: () => void;
sendScope: (scope: string) => void;
sendEnvelope: (evn: Uint8Array | string) => void;
/**
* Hand an envelope to the main process and resolve with the ingest status.
*
* Rejects if the handoff itself fails (protocol fetch error, aborted upload).
* A resolved 2xx means Sentry ingest responded 2xx — not that the bytes were
* merely queued. See {@link envelopeDeliveryStatus}.
*/
sendEnvelope: (evn: Uint8Array | string) => Promise<TransportMakeRequestResponse>;
sendStatus: (state: RendererStatus) => void;
sendStructuredLog: (log: SerializedLog) => void;
sendMetric: (metric: SerializedMetric) => void;
}

/**
* Status reported when an envelope was not accepted by Sentry ingest.
*
* `sendFeedback` resolves only for 2xx. 0 is not a 2xx, so a queued, dropped,
* or disabled send is not shown as delivered.
*/
export const NOT_DELIVERED: TransportMakeRequestResponse = { statusCode: 0 };

/**
* Status the renderer may report after main has finished with an envelope.
*
* A 2xx means the main transport received a 2xx from Sentry ingest. Anything else
* is not delivery:
*
* - Network failures are written to the offline queue and `makeOfflineTransport`
* resolves with `{}` (no `statusCode`), not 200 (sentry-electron#942). Queuing
* is not "Sentry received this". `sendFeedback` rejects so the UI cannot treat
* a later retry as an already-successful submit. The envelope may still be sent
* from disk afterwards.
* - 413 and other 4xx/5xx are passed through so `sendFeedback` rejects.
* - `enabled: false` makes `Client.sendEnvelope` resolve with `{}`, which becomes 0.
*
* Rate-limit headers are not copied. The main process owns rate limiting; echoing
* them would make the renderer drop later envelopes as well.
*/
export function envelopeDeliveryStatus(
response: TransportMakeRequestResponse | void | null | undefined,
): TransportMakeRequestResponse {
const statusCode = response?.statusCode;
if (typeof statusCode !== 'number') {
return NOT_DELIVERED;
}

return { statusCode };
}

/**
* Reads the status main put in a protocol response.
*
* JSON `statusCode` is the contract. An empty body or a 2xx without a status
* is not delivery — do not invent a 200 because the custom protocol responded.
* A non-2xx HTTP status is used only when the body has no status of its own.
*/
export function decodeEnvelopeDeliveryStatus(body: string, httpStatus?: number): TransportMakeRequestResponse {
const trimmed = body.trim();
if (trimmed) {
try {
const parsed = JSON.parse(trimmed) as { statusCode?: unknown };
if (typeof parsed.statusCode === 'number') {
return envelopeDeliveryStatus({ statusCode: parsed.statusCode });
}
} catch {
// Body was not the status JSON. Fall through to the HTTP status.
}
}

if (typeof httpStatus === 'number' && (httpStatus < 200 || httpStatus >= 300)) {
return { statusCode: httpStatus };
}

return NOT_DELIVERED;
}

export const RENDERER_ID_HEADER = 'sentry-electron-renderer-id';

const UTILITY_PROCESS_MAGIC_MESSAGE_KEY = '__sentry_message_port_message__';
Expand Down
78 changes: 63 additions & 15 deletions src/main/electron-normalize.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { TransportMakeRequestResponse } from '@sentry/core';
import { parseSemver } from '@sentry/core';
import type { Session } from 'electron';
import { app } from 'electron';
import { join } from 'path';
import { RENDERER_ID_HEADER } from '../common/ipc.js';
import { envelopeDeliveryStatus, RENDERER_ID_HEADER } from '../common/ipc.js';

const parsed = parseSemver(process.versions.electron);
const version = { major: parsed.major || 0, minor: parsed.minor || 0, patch: parsed.patch || 0 };
Expand Down Expand Up @@ -39,36 +40,83 @@ interface InternalRequest {
body?: Buffer;
}

/**
* HTTP status for a completed protocol request.
*
* 0 is not a valid HTTP status. The JSON body is the contract the renderer
* reads; a non-2xx HTTP status is only a fallback if that body is missing.
*/
function protocolResponse(result: TransportMakeRequestResponse | void): { status: number; body: string } {
if (!result) {
return { status: 200, body: '' };
}

const status = envelopeDeliveryStatus(result);
const statusCode = status.statusCode ?? 0;
return {
status: statusCode >= 200 && statusCode < 600 ? statusCode : 503,
body: JSON.stringify(status),
};
}

function protocolHttpResponse(result: TransportMakeRequestResponse | void): Response {
const { status, body } = protocolResponse(result);
return new Response(body, {
status,
headers: body ? { 'content-type': 'application/json' } : undefined,
});
}

/**
* Registers a custom protocol to receive events from the renderer
*
* Uses `protocol.handle` if available, otherwise falls back to `protocol.registerStringProtocol`
*
* The response is sent only after `callback` settles, so a renderer `fetch` that
* resolves has finished the handoff. Envelope callbacks return the ingest status;
* other channels return nothing and get an empty 200.
*/
export function registerProtocol(
protocol: Electron.Protocol,
scheme: string,
callback: (request: InternalRequest) => void,
callback: (request: InternalRequest) => void | Promise<TransportMakeRequestResponse | void>,
): void {
if (supportsProtocolHandle()) {
protocol.handle(scheme, async (request) => {
callback({
windowId: request.headers.get(RENDERER_ID_HEADER) || undefined,
url: request.url,
body: Buffer.from(await request.arrayBuffer()),
});
try {
// Copy the body before doing more work. If the webContents is destroyed
// after this, the callback still has the envelope.
const body = Buffer.from(await request.arrayBuffer());
const result = await callback({
windowId: request.headers.get(RENDERER_ID_HEADER) || undefined,
url: request.url,
body,
});

return new Response('');
return protocolHttpResponse(result);
} catch {
return protocolHttpResponse(envelopeDeliveryStatus());
}
});
} else {
// eslint-disable-next-line deprecation/deprecation
protocol.registerStringProtocol(scheme, (request, complete) => {
callback({
windowId: request.headers[RENDERER_ID_HEADER],
url: request.url,
body: request.uploadData?.[0]?.bytes,
});

complete('');
void Promise.resolve(
callback({
windowId: request.headers[RENDERER_ID_HEADER],
url: request.url,
body: request.uploadData?.[0]?.bytes,
}),
).then(
(result) => {
const { status, body } = protocolResponse(result);
complete({ data: body, statusCode: status, mimeType: 'application/json' });
},
() => {
const failed = protocolResponse(envelopeDeliveryStatus());
complete({ data: failed.body, statusCode: failed.status, mimeType: 'application/json' });
},
);
});
}
}
Expand Down
Loading
Loading