diff --git a/packages/browser-integration-tests/suites/feedback/captureFeedback/init.js b/packages/browser-integration-tests/suites/feedback/captureFeedback/init.js new file mode 100644 index 000000000000..6c9382f04575 --- /dev/null +++ b/packages/browser-integration-tests/suites/feedback/captureFeedback/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; +import { Feedback } from '@sentry-internal/feedback'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [new Feedback()], +}); diff --git a/packages/browser-integration-tests/suites/feedback/captureFeedback/template.html b/packages/browser-integration-tests/suites/feedback/captureFeedback/template.html new file mode 100644 index 000000000000..57334d4ad2f1 --- /dev/null +++ b/packages/browser-integration-tests/suites/feedback/captureFeedback/template.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/browser-integration-tests/suites/feedback/captureFeedback/test.ts b/packages/browser-integration-tests/suites/feedback/captureFeedback/test.ts new file mode 100644 index 000000000000..26ba665a418f --- /dev/null +++ b/packages/browser-integration-tests/suites/feedback/captureFeedback/test.ts @@ -0,0 +1,74 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, getEnvelopeType } from '../../../utils/helpers'; + +sentryTest('should capture feedback (@sentry-internal/feedback import)', async ({ getLocalTestPath, page }) => { + if (process.env.PW_BUNDLE) { + sentryTest.skip(); + } + + const feedbackRequestPromise = page.waitForResponse(res => { + const req = res.request(); + + const postData = req.postData(); + if (!postData) { + return false; + } + + try { + return getEnvelopeType(req) === 'feedback'; + } catch (err) { + return false; + } + }); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await page.getByText('Report a Bug').click(); + expect(await page.locator(':visible:text-is("Report a Bug")').count()).toEqual(1); + await page.locator('[name="name"]').fill('Jane Doe'); + await page.locator('[name="email"]').fill('janedoe@example.org'); + await page.locator('[name="message"]').fill('my example feedback'); + await page.getByLabel('Send Bug Report').click(); + + const feedbackEvent = envelopeRequestParser((await feedbackRequestPromise).request()); + expect(feedbackEvent).toEqual({ + type: 'feedback', + contexts: { + feedback: { + contact_email: 'janedoe@example.org', + message: 'my example feedback', + name: 'Jane Doe', + source: 'widget', + url: expect.stringContaining('/dist/index.html'), + }, + }, + level: 'info', + timestamp: expect.any(Number), + event_id: expect.stringMatching(/\w{32}/), + environment: 'production', + sdk: { + integrations: expect.arrayContaining(['Feedback']), + version: expect.any(String), + name: 'sentry.javascript.browser', + packages: expect.anything(), + }, + request: { + url: expect.stringContaining('/dist/index.html'), + headers: { + 'User-Agent': expect.stringContaining(''), + }, + }, + platform: 'javascript', + }); +}); diff --git a/packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/init.js b/packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/init.js new file mode 100644 index 000000000000..cceaaea1a373 --- /dev/null +++ b/packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; +import { Feedback } from '@sentry-internal/feedback'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + replaysOnErrorSampleRate: 1.0, + replaysSessionSampleRate: 0, + integrations: [ + new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, + }), + new Feedback(), + ], +}); diff --git a/packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/template.html b/packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/template.html new file mode 100644 index 000000000000..57334d4ad2f1 --- /dev/null +++ b/packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/template.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/test.ts b/packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/test.ts new file mode 100644 index 000000000000..1590c68652a3 --- /dev/null +++ b/packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/test.ts @@ -0,0 +1,90 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, getEnvelopeType } from '../../../utils/helpers'; +import { getCustomRecordingEvents, getReplayEvent, waitForReplayRequest } from '../../../utils/replayHelpers'; + +sentryTest('should capture feedback (@sentry-internal/feedback import)', async ({ getLocalTestPath, page }) => { + if (process.env.PW_BUNDLE) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + const feedbackRequestPromise = page.waitForResponse(res => { + const req = res.request(); + + const postData = req.postData(); + if (!postData) { + return false; + } + + try { + return getEnvelopeType(req) === 'feedback'; + } catch (err) { + return false; + } + }); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await page.getByText('Report a Bug').click(); + await page.locator('[name="name"]').fill('Jane Doe'); + await page.locator('[name="email"]').fill('janedoe@example.org'); + await page.locator('[name="message"]').fill('my example feedback'); + await page.getByLabel('Send Bug Report').click(); + + const [feedbackResp, replayReq] = await Promise.all([feedbackRequestPromise, reqPromise0]); + + const feedbackEvent = envelopeRequestParser(feedbackResp.request()); + const replayEvent = getReplayEvent(replayReq); + const { breadcrumbs } = getCustomRecordingEvents(replayReq); + + expect(breadcrumbs).toEqual( + expect.arrayContaining([ + { + category: 'sentry.feedback', + data: { feedbackId: expect.any(String) }, + }, + ]), + ); + + expect(feedbackEvent).toEqual({ + type: 'feedback', + contexts: { + feedback: { + contact_email: 'janedoe@example.org', + message: 'my example feedback', + name: 'Jane Doe', + replay_id: replayEvent.event_id, + source: 'widget', + url: expect.stringContaining('/dist/index.html'), + }, + }, + level: 'info', + timestamp: expect.any(Number), + event_id: expect.stringMatching(/\w{32}/), + environment: 'production', + sdk: { + integrations: expect.arrayContaining(['Feedback']), + version: expect.any(String), + name: 'sentry.javascript.browser', + packages: expect.anything(), + }, + request: { + url: expect.stringContaining('/dist/index.html'), + headers: { + 'User-Agent': expect.stringContaining(''), + }, + }, + platform: 'javascript', + }); +}); diff --git a/packages/feedback/src/constants.ts b/packages/feedback/src/constants.ts index a91283f4a541..ea0db22525f4 100644 --- a/packages/feedback/src/constants.ts +++ b/packages/feedback/src/constants.ts @@ -60,3 +60,6 @@ export const MESSAGE_LABEL = 'Description'; export const NAME_PLACEHOLDER = 'Your Name'; export const NAME_LABEL = 'Name'; export const SUCCESS_MESSAGE_TEXT = 'Thank you for your report!'; + +export const FEEDBACK_WIDGET_SOURCE = 'widget'; +export const FEEDBACK_API_SOURCE = 'api'; diff --git a/packages/feedback/src/sendFeedback.ts b/packages/feedback/src/sendFeedback.ts index c77013602d6b..4f1b79761a96 100644 --- a/packages/feedback/src/sendFeedback.ts +++ b/packages/feedback/src/sendFeedback.ts @@ -2,6 +2,7 @@ import type { BrowserClient, Replay } from '@sentry/browser'; import { getCurrentHub } from '@sentry/core'; import { getLocationHref } from '@sentry/utils'; +import { FEEDBACK_API_SOURCE } from './constants'; import type { SendFeedbackOptions } from './types'; import { sendFeedbackRequest } from './util/sendFeedbackRequest'; @@ -17,7 +18,7 @@ interface SendFeedbackParams { * Public API to send a Feedback item to Sentry */ export function sendFeedback( - { name, email, message, source = 'api', url = getLocationHref() }: SendFeedbackParams, + { name, email, message, source = FEEDBACK_API_SOURCE, url = getLocationHref() }: SendFeedbackParams, { includeReplay = true }: SendFeedbackOptions = {}, ): ReturnType { const client = getCurrentHub().getClient(); diff --git a/packages/feedback/src/util/handleFeedbackSubmit.ts b/packages/feedback/src/util/handleFeedbackSubmit.ts index 4e8f233b15fd..eef28c8a2f29 100644 --- a/packages/feedback/src/util/handleFeedbackSubmit.ts +++ b/packages/feedback/src/util/handleFeedbackSubmit.ts @@ -1,6 +1,7 @@ import type { TransportMakeRequestResponse } from '@sentry/types'; import { logger } from '@sentry/utils'; +import { FEEDBACK_WIDGET_SOURCE } from '../constants'; import { sendFeedback } from '../sendFeedback'; import type { FeedbackFormData, SendFeedbackOptions } from '../types'; import type { DialogComponent } from '../widget/Dialog'; @@ -29,7 +30,7 @@ export async function handleFeedbackSubmit( dialog.hideError(); try { - const resp = await sendFeedback({ ...feedback, source: 'widget' }, options); + const resp = await sendFeedback({ ...feedback, source: FEEDBACK_WIDGET_SOURCE }, options); // Success! return resp; diff --git a/packages/feedback/src/util/prepareFeedbackEvent.ts b/packages/feedback/src/util/prepareFeedbackEvent.ts index 9b1b0f9c6e8b..3ef01fb500c6 100644 --- a/packages/feedback/src/util/prepareFeedbackEvent.ts +++ b/packages/feedback/src/util/prepareFeedbackEvent.ts @@ -27,7 +27,6 @@ export async function prepareFeedbackEvent({ scope, client, )) as FeedbackEvent | null; - if (preparedEvent === null) { // Taken from baseclient's `_processEvent` method, where this is handled for errors/transactions client.recordDroppedEvent('event_processor', 'feedback', event); diff --git a/packages/feedback/src/util/sendFeedbackRequest.ts b/packages/feedback/src/util/sendFeedbackRequest.ts index d82375ded4d2..30e19bf0971f 100644 --- a/packages/feedback/src/util/sendFeedbackRequest.ts +++ b/packages/feedback/src/util/sendFeedbackRequest.ts @@ -1,6 +1,7 @@ import { createEventEnvelope, getCurrentHub } from '@sentry/core'; import type { FeedbackEvent, TransportMakeRequestResponse } from '@sentry/types'; +import { FEEDBACK_API_SOURCE, FEEDBACK_WIDGET_SOURCE } from '../constants'; import type { SendFeedbackData } from '../types'; import { prepareFeedbackEvent } from './prepareFeedbackEvent'; @@ -12,7 +13,6 @@ export async function sendFeedbackRequest({ }: SendFeedbackData): Promise { const hub = getCurrentHub(); const client = hub.getClient(); - const scope = hub.getScope(); const transport = client && client.getTransport(); const dsn = client && client.getDsn(); @@ -34,86 +34,104 @@ export async function sendFeedbackRequest({ type: 'feedback', }; - const feedbackEvent = await prepareFeedbackEvent({ - scope, - client, - event: baseEvent, - }); + return new Promise((resolve, reject) => { + hub.withScope(async scope => { + // No use for breadcrumbs in feedback + scope.clearBreadcrumbs(); - if (feedbackEvent === null) { - return; - } + if ([FEEDBACK_API_SOURCE, FEEDBACK_WIDGET_SOURCE].includes(String(source))) { + scope.setLevel('info'); + } - /* - For reference, the fully built event looks something like this: - { - "type": "feedback", - "event_id": "d2132d31b39445f1938d7e21b6bf0ec4", - "timestamp": 1597977777.6189718, - "dist": "1.12", - "platform": "javascript", - "environment": "production", - "release": 42, - "tags": {"transaction": "/organizations/:orgId/performance/:eventSlug/"}, - "sdk": {"name": "name", "version": "version"}, - "user": { - "id": "123", - "username": "user", - "email": "user@site.com", - "ip_address": "192.168.11.12", - }, - "request": { - "url": None, - "headers": { - "user-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15" - }, - }, - "contexts": { - "feedback": { - "message": "test message", - "contact_email": "test@example.com", - "type": "feedback", - }, - "trace": { - "trace_id": "4C79F60C11214EB38604F4AE0781BFB2", - "span_id": "FA90FDEAD5F74052", - "type": "trace", - }, - "replay": { - "replay_id": "e2d42047b1c5431c8cba85ee2a8ab25d", - }, - }, - } - */ - - const envelope = createEventEnvelope(feedbackEvent, dsn, client.getOptions()._metadata, client.getOptions().tunnel); - - let response: void | TransportMakeRequestResponse; - - try { - response = await transport.send(envelope); - } catch (err) { - const error = new Error('Unable to send Feedback'); - - try { - // In case browsers don't allow this property to be writable - // @ts-expect-error This needs lib es2022 and newer - error.cause = err; - } catch { - // nothing to do - } - throw error; - } + const feedbackEvent = await prepareFeedbackEvent({ + scope, + client, + event: baseEvent, + }); - // TODO (v8): we can remove this guard once transport.send's type signature doesn't include void anymore - if (!response) { - return response; - } + if (feedbackEvent === null) { + resolve(); + return; + } - // Require valid status codes, otherwise can assume feedback was not sent successfully - if (typeof response.statusCode === 'number' && (response.statusCode < 200 || response.statusCode >= 300)) { - throw new Error('Unable to send Feedback'); - } + const envelope = createEventEnvelope( + feedbackEvent, + dsn, + client.getOptions()._metadata, + client.getOptions().tunnel, + ); - return response; + let response: void | TransportMakeRequestResponse; + + try { + response = await transport.send(envelope); + } catch (err) { + const error = new Error('Unable to send Feedback'); + + try { + // In case browsers don't allow this property to be writable + // @ts-expect-error This needs lib es2022 and newer + error.cause = err; + } catch { + // nothing to do + } + reject(error); + } + + // TODO (v8): we can remove this guard once transport.send's type signature doesn't include void anymore + if (!response) { + resolve(response); + return; + } + + // Require valid status codes, otherwise can assume feedback was not sent successfully + if (typeof response.statusCode === 'number' && (response.statusCode < 200 || response.statusCode >= 300)) { + reject(new Error('Unable to send Feedback')); + } + + resolve(response); + }); + }); } + +/* + * For reference, the fully built event looks something like this: + * { + * "type": "feedback", + * "event_id": "d2132d31b39445f1938d7e21b6bf0ec4", + * "timestamp": 1597977777.6189718, + * "dist": "1.12", + * "platform": "javascript", + * "environment": "production", + * "release": 42, + * "tags": {"transaction": "/organizations/:orgId/performance/:eventSlug/"}, + * "sdk": {"name": "name", "version": "version"}, + * "user": { + * "id": "123", + * "username": "user", + * "email": "user@site.com", + * "ip_address": "192.168.11.12", + * }, + * "request": { + * "url": None, + * "headers": { + * "user-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15" + * }, + * }, + * "contexts": { + * "feedback": { + * "message": "test message", + * "contact_email": "test@example.com", + * "type": "feedback", + * }, + * "trace": { + * "trace_id": "4C79F60C11214EB38604F4AE0781BFB2", + * "span_id": "FA90FDEAD5F74052", + * "type": "trace", + * }, + * "replay": { + * "replay_id": "e2d42047b1c5431c8cba85ee2a8ab25d", + * }, + * }, + * } + */ diff --git a/packages/feedback/test/sendFeedback.test.ts b/packages/feedback/test/sendFeedback.test.ts index 33474e2df673..960411d8949e 100644 --- a/packages/feedback/test/sendFeedback.test.ts +++ b/packages/feedback/test/sendFeedback.test.ts @@ -30,6 +30,7 @@ describe('sendFeedback', () => { url: 'http://localhost/', }, }, + level: 'info', environment: 'production', event_id: expect.any(String), platform: 'javascript', diff --git a/packages/replay/src/coreHandlers/handleGlobalEvent.ts b/packages/replay/src/coreHandlers/handleGlobalEvent.ts index 2607c28c4348..b9fc329796f4 100644 --- a/packages/replay/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay/src/coreHandlers/handleGlobalEvent.ts @@ -43,6 +43,8 @@ export function handleGlobalEventListener( } if (isFeedbackEvent(event)) { + void replay.flush(); + event.contexts.feedback.replay_id = replay.getSessionId(); // Add a replay breadcrumb for this piece of feedback addFeedbackBreadcrumb(replay, event); return event;