Skip to content

Commit 22efe6e

Browse files
committed
working screenshot attachment
1 parent 354bb52 commit 22efe6e

File tree

11 files changed

+167
-44
lines changed

11 files changed

+167
-44
lines changed

packages/core/src/envelope.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type {
2+
Attachment,
3+
AttachmentItem,
24
DsnComponents,
35
Event,
46
EventEnvelope,
@@ -9,8 +11,10 @@ import type {
911
SessionAggregates,
1012
SessionEnvelope,
1113
SessionItem,
14+
TextEncoderInternal,
1215
} from '@sentry/types';
1316
import {
17+
createAttachmentEnvelopeItem,
1418
createEnvelope,
1519
createEventEnvelopeHeaders,
1620
dsnToString,
@@ -86,3 +90,29 @@ export function createEventEnvelope(
8690
const eventItem: EventItem = [{ type: eventType }, event];
8791
return createEnvelope<EventEnvelope>(envelopeHeaders, [eventItem]);
8892
}
93+
94+
/**
95+
* Create an Envelope from an event.
96+
*/
97+
export function createAttachmentEnvelope(
98+
event: Event,
99+
attachment: Attachment,
100+
dsn?: DsnComponents,
101+
metadata?: SdkMetadata,
102+
tunnel?: string,
103+
textEncoder?: TextEncoderInternal,
104+
): EventEnvelope {
105+
const sdkInfo = getSdkMetadataForEnvelopeHeader(metadata);
106+
enhanceEventWithSdkInfo(event, metadata && metadata.sdk);
107+
108+
const envelopeHeaders = createEventEnvelopeHeaders(event, sdkInfo, tunnel, dsn);
109+
110+
// Prevent this data (which, if it exists, was used in earlier steps in the processing pipeline) from being sent to
111+
// sentry. (Note: Our use of this property comes and goes with whatever we might be debugging, whatever hacks we may
112+
// have temporarily added, etc. Even if we don't happen to be using it at some point in the future, let's not get rid
113+
// of this `delete`, lest we miss putting it back in the next time the property is in use.)
114+
delete event.sdkProcessingMetadata;
115+
116+
const attachmentItem: AttachmentItem = createAttachmentEnvelopeItem(attachment, textEncoder);
117+
return createEnvelope<EventEnvelope>(envelopeHeaders, [attachmentItem]);
118+
}

packages/core/src/index.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export type { IntegrationIndex } from './integration';
77

88
export * from './tracing';
99
export * from './semanticAttributes';
10-
export { createEventEnvelope, createSessionEnvelope } from './envelope';
10+
export { createEventEnvelope, createSessionEnvelope, createAttachmentEnvelope } from './envelope';
1111
export {
1212
addBreadcrumb,
1313
captureCheckIn,
@@ -83,12 +83,7 @@ export { hasTracingEnabled } from './utils/hasTracingEnabled';
8383
export { isSentryRequestUrl } from './utils/isSentryRequestUrl';
8484
export { handleCallbackErrors } from './utils/handleCallbackErrors';
8585
export { parameterize } from './utils/parameterize';
86-
export {
87-
spanToTraceHeader,
88-
spanToJSON,
89-
spanIsSampled,
90-
spanToTraceContext,
91-
} from './utils/spanUtils';
86+
export { spanToTraceHeader, spanToJSON, spanIsSampled, spanToTraceContext } from './utils/spanUtils';
9287
export { getRootSpan } from './utils/getRootSpan';
9388
export { applySdkMetadata } from './utils/sdkMetadata';
9489
export { DEFAULT_ENVIRONMENT } from './constants';

packages/feedback-screenshot/src/screenshot.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,26 @@ import { h, render } from 'preact';
77
interface FeedbackScreenshotOptions {
88
buttonRef: HTMLDivElement;
99
croppingRef: HTMLDivElement;
10-
props: unknown;
10+
props: {
11+
screenshotImage: HTMLCanvasElement | null;
12+
setScreenshotImage: (screenshot: HTMLCanvasElement | null) => void;
13+
};
1114
}
1215

1316
export interface FeedbackScreenshotIntegrationOptions {
1417
buttonRef: HTMLDivElement;
1518
croppingRef: HTMLDivElement;
16-
props: unknown;
19+
props: {
20+
screenshotImage: HTMLCanvasElement | null;
21+
setScreenshotImage: (screenshot: HTMLCanvasElement | null) => void;
22+
};
1723
}
1824

1925
const INTEGRATION_NAME = 'FeedbackScreenshot';
2026
const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window;
2127

2228
/** Exported only for type safe tests. */
23-
export const _feedbackScreenshotIntegration = ((options: Partial<FeedbackScreenshotOptions> = {}) => {
29+
export const _feedbackScreenshotIntegration = ((options: FeedbackScreenshotOptions) => {
2430
return {
2531
name: INTEGRATION_NAME,
2632
// eslint-disable-next-line @typescript-eslint/no-empty-function
@@ -29,11 +35,21 @@ export const _feedbackScreenshotIntegration = ((options: Partial<FeedbackScreens
2935
return {
3036
buttonRef: options.buttonRef || WINDOW.document.createElement('div'),
3137
croppingRef: options.croppingRef || WINDOW.document.createElement('div'),
32-
props: options.props || null,
38+
props: {
39+
screenshotImage: options.props.screenshotImage,
40+
setScreenshotImage: options.props.setScreenshotImage,
41+
},
3342
};
3443
},
3544
renderScreenshotWidget: (options: FeedbackScreenshotOptions) => {
36-
return render(<ScreenshotButton croppingRef={options.croppingRef} />, options.buttonRef);
45+
return render(
46+
<ScreenshotButton
47+
croppingRef={options.croppingRef}
48+
screenshotImage={options.props.screenshotImage}
49+
setScreenshotImage={options.props.setScreenshotImage}
50+
/>,
51+
options.buttonRef,
52+
);
3753
},
3854
};
3955
}) satisfies IntegrationFn;

packages/feedback-screenshot/src/screenshotButton.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,23 @@ import { useTakeScreenshot } from './useTakeScreenshot';
44
import type { VNode } from 'preact';
55
import { ScreenshotWidget } from './screenshotWidget';
66

7-
type Props = { croppingRef: HTMLDivElement };
7+
type Props = {
8+
croppingRef: HTMLDivElement;
9+
screenshotImage: HTMLCanvasElement | null;
10+
setScreenshotImage: (screenshot: HTMLCanvasElement | null) => void;
11+
};
812

9-
export function ScreenshotButton({ croppingRef }: Props): VNode {
13+
export function ScreenshotButton({ croppingRef, screenshotImage, setScreenshotImage }: Props): VNode {
1014
const [clicked, setClicked] = useState(false);
1115
const { isInProgress, takeScreenshot } = useTakeScreenshot();
1216

1317
const handleClick = useCallback(async () => {
1418
if (!clicked) {
1519
const image = await takeScreenshot();
16-
render(<ScreenshotWidget image={image} />, croppingRef);
20+
setScreenshotImage(image);
21+
render(<ScreenshotWidget screenshotImage={image} setScreenshotImage={setScreenshotImage} />, croppingRef);
1722
} else {
23+
setScreenshotImage(null);
1824
render(null, croppingRef);
1925
}
2026
setClicked(prev => !prev);

packages/feedback-screenshot/src/screenshotWidget.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { h, render } from 'preact';
22
import type { VNode } from 'preact';
33

4-
type Props = { image: HTMLCanvasElement };
4+
type Props = {
5+
screenshotImage: HTMLCanvasElement | null;
6+
setScreenshotImage: (screenshot: HTMLCanvasElement | null) => void;
7+
};
58
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
6-
export function ScreenshotWidget({ image }: Props): VNode | null {
9+
export function ScreenshotWidget({ screenshotImage, setScreenshotImage }: Props): VNode | null {
10+
const image = screenshotImage;
711
return image ? (
812
<div style="padding-right: 16px;">
913
<img

packages/feedback/src/sendFeedback.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { getLocationHref } from '@sentry/utils';
22

33
import { FEEDBACK_API_SOURCE } from './constants';
4-
import type { SendFeedbackOptions } from './types';
4+
import type { Screenshot, SendFeedbackOptions } from './types';
55
import { sendFeedbackRequest } from './util/sendFeedbackRequest';
66

77
interface SendFeedbackParams {
@@ -18,6 +18,7 @@ interface SendFeedbackParams {
1818
export function sendFeedback(
1919
{ name, email, message, source = FEEDBACK_API_SOURCE, url = getLocationHref() }: SendFeedbackParams,
2020
options: SendFeedbackOptions = {},
21+
screenshots: Screenshot[] = [],
2122
): ReturnType<typeof sendFeedbackRequest> {
2223
if (!message) {
2324
throw new Error('Unable to submit feedback with empty message');
@@ -34,5 +35,6 @@ export function sendFeedback(
3435
},
3536
},
3637
options,
38+
screenshots,
3739
);
3840
}

packages/feedback/src/types/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface SendFeedbackOptions {
2121
* Should include replay with the feedback?
2222
*/
2323
includeReplay?: boolean;
24+
screenshots?: Screenshot[];
2425
}
2526

2627
/**
@@ -30,7 +31,6 @@ export interface FeedbackFormData {
3031
message: string;
3132
email?: string;
3233
name?: string;
33-
screenshot?: Uint8Array;
3434
}
3535

3636
/**
@@ -343,3 +343,9 @@ export interface FeedbackWidget {
343343
closeDialog: () => void;
344344
removeDialog: () => void;
345345
}
346+
347+
export interface Screenshot {
348+
filename: string;
349+
data: Uint8Array;
350+
contentType: string;
351+
}

packages/feedback/src/util/handleFeedbackSubmit.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { logger } from '@sentry/utils';
44
import { FEEDBACK_WIDGET_SOURCE } from '../constants';
55
import { DEBUG_BUILD } from '../debug-build';
66
import { sendFeedback } from '../sendFeedback';
7-
import type { FeedbackFormData, SendFeedbackOptions } from '../types';
7+
import type { FeedbackFormData, Screenshot, SendFeedbackOptions } from '../types';
88
import type { DialogComponent } from '../widget/Dialog';
99

1010
/**
@@ -14,6 +14,7 @@ import type { DialogComponent } from '../widget/Dialog';
1414
export async function handleFeedbackSubmit(
1515
dialog: DialogComponent | null,
1616
feedback: FeedbackFormData,
17+
screenshots?: Screenshot[],
1718
options?: SendFeedbackOptions,
1819
): Promise<TransportMakeRequestResponse | void> {
1920
if (!dialog) {
@@ -31,7 +32,7 @@ export async function handleFeedbackSubmit(
3132
dialog.hideError();
3233

3334
try {
34-
const resp = await sendFeedback({ ...feedback, source: FEEDBACK_WIDGET_SOURCE }, options);
35+
const resp = await sendFeedback({ ...feedback, source: FEEDBACK_WIDGET_SOURCE }, options, screenshots);
3536

3637
// Success!
3738
return resp;

packages/feedback/src/util/sendFeedbackRequest.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { createEventEnvelope, getClient, withScope } from '@sentry/core';
1+
import { createEventEnvelope, getClient, withScope, createAttachmentEnvelope } from '@sentry/core';
22
import type { FeedbackEvent, TransportMakeRequestResponse } from '@sentry/types';
33

44
import { FEEDBACK_API_SOURCE, FEEDBACK_WIDGET_SOURCE } from '../constants';
5-
import type { SendFeedbackData, SendFeedbackOptions } from '../types';
5+
import type { Screenshot, SendFeedbackData, SendFeedbackOptions } from '../types';
66
import { prepareFeedbackEvent } from './prepareFeedbackEvent';
77

88
/**
@@ -11,6 +11,7 @@ import { prepareFeedbackEvent } from './prepareFeedbackEvent';
1111
export async function sendFeedbackRequest(
1212
{ feedback: { message, email, name, source, url } }: SendFeedbackData,
1313
{ includeReplay = true }: SendFeedbackOptions = {},
14+
screenshots: Screenshot[],
1415
): Promise<void | TransportMakeRequestResponse> {
1516
const client = getClient();
1617
const transport = client && client.getTransport();
@@ -57,6 +58,19 @@ export async function sendFeedbackRequest(
5758

5859
const envelope = createEventEnvelope(feedbackEvent, dsn, client.getOptions()._metadata, client.getOptions().tunnel);
5960

61+
let attachment_envelope;
62+
for (const attachment of screenshots || []) {
63+
attachment_envelope = createAttachmentEnvelope(
64+
feedbackEvent,
65+
attachment,
66+
dsn,
67+
client.getOptions()._metadata,
68+
client.getOptions().tunnel,
69+
// eslint-disable-next-line @sentry-internal/sdk/no-optional-chaining
70+
client.getOptions().transportOptions && client.getOptions().transportOptions?.textEncoder,
71+
);
72+
}
73+
6074
let response: void | TransportMakeRequestResponse;
6175

6276
try {
@@ -84,6 +98,33 @@ export async function sendFeedbackRequest(
8498
throw new Error('Unable to send Feedback');
8599
}
86100

101+
if (attachment_envelope) {
102+
try {
103+
response = await transport.send(attachment_envelope);
104+
} catch (err) {
105+
const error = new Error('Unable to send Feedback screenshot');
106+
107+
try {
108+
// In case browsers don't allow this property to be writable
109+
// @ts-expect-error This needs lib es2022 and newer
110+
error.cause = err;
111+
} catch {
112+
// nothing to do
113+
}
114+
throw error;
115+
}
116+
117+
// TODO (v8): we can remove this guard once transport.send's type signature doesn't include void anymore
118+
if (!response) {
119+
return;
120+
}
121+
122+
// Require valid status codes, otherwise can assume feedback was not sent successfully
123+
if (typeof response.statusCode === 'number' && (response.statusCode < 200 || response.statusCode >= 300)) {
124+
throw new Error('Unable to send Feedback screenshot');
125+
}
126+
}
127+
87128
return response;
88129
});
89130
}

0 commit comments

Comments
 (0)