diff --git a/packages/browser-integration-tests/suites/public-api/captureUserFeedback/init.js b/packages/browser-integration-tests/suites/public-api/captureUserFeedback/init.js new file mode 100644 index 000000000000..d8c94f36fdd0 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureUserFeedback/init.js @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureUserFeedback/simple_feedback/subject.js b/packages/browser-integration-tests/suites/public-api/captureUserFeedback/simple_feedback/subject.js new file mode 100644 index 000000000000..035199ab42f1 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureUserFeedback/simple_feedback/subject.js @@ -0,0 +1,6 @@ +Sentry.captureUserFeedback({ + eventId: 'test_event_id', + email: 'test_email', + comments: 'test_comments', + name: 'test_name', +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureUserFeedback/simple_feedback/test.ts b/packages/browser-integration-tests/suites/public-api/captureUserFeedback/simple_feedback/test.ts new file mode 100644 index 000000000000..158fb60d3d93 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureUserFeedback/simple_feedback/test.ts @@ -0,0 +1,18 @@ +import { expect } from '@playwright/test'; +import type { UserFeedback } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; + +sentryTest('should capture simple user feedback', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData).toMatchObject({ + eventId: 'test_event_id', + email: 'test_email', + comments: 'test_comments', + name: 'test_name', + }); +}); diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index 1d0bd091cf19..ab877a782efa 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -8,6 +8,7 @@ import type { Options, Severity, SeverityLevel, + UserFeedback, } from '@sentry/types'; import { createClientReportEnvelope, dsnToString, getSDKSource, logger } from '@sentry/utils'; @@ -16,6 +17,7 @@ import { WINDOW } from './helpers'; import type { Breadcrumbs } from './integrations'; import { BREADCRUMB_INTEGRATION_ID } from './integrations/breadcrumbs'; import type { BrowserTransportOptions } from './transports/types'; +import { createUserFeedbackEnvelope } from './userfeedback'; /** * Configuration options for the Sentry Browser SDK. @@ -106,6 +108,23 @@ export class BrowserClient extends BaseClient { super.sendEvent(event, hint); } + /** + * Sends user feedback to Sentry. + */ + public captureUserFeedback(feedback: UserFeedback): void { + if (!this._isEnabled()) { + __DEBUG_BUILD__ && logger.warn('SDK not enabled, will not capture user feedback.'); + return; + } + + const envelope = createUserFeedbackEnvelope(feedback, { + metadata: this.getSdkMetadata(), + dsn: this.getDsn(), + tunnel: this.getOptions().tunnel, + }); + void this._sendEnvelope(envelope); + } + /** * @inheritDoc */ diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 321ebd2c6c51..f48b00c8c8e8 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -59,5 +59,17 @@ export { winjsStackLineParser, } from './stack-parsers'; export { eventFromException, eventFromMessage } from './eventbuilder'; -export { defaultIntegrations, forceLoad, init, lastEventId, onLoad, showReportDialog, flush, close, wrap } from './sdk'; +export { createUserFeedbackEnvelope } from './userfeedback'; +export { + defaultIntegrations, + forceLoad, + init, + lastEventId, + onLoad, + showReportDialog, + flush, + close, + wrap, + captureUserFeedback, +} from './sdk'; export { GlobalHandlers, TryCatch, Breadcrumbs, LinkedErrors, HttpContext, Dedupe } from './integrations'; diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index c7f2ed74194a..d58888260e3e 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -6,6 +6,7 @@ import { initAndBind, Integrations as CoreIntegrations, } from '@sentry/core'; +import type { UserFeedback } from '@sentry/types'; import { addInstrumentationHandler, logger, @@ -289,3 +290,13 @@ function startSessionTracking(): void { } }); } + +/** + * Captures user feedback and sends it to Sentry. + */ +export function captureUserFeedback(feedback: UserFeedback): void { + const client = getCurrentHub().getClient(); + if (client) { + client.captureUserFeedback(feedback); + } +} diff --git a/packages/browser/src/userfeedback.ts b/packages/browser/src/userfeedback.ts new file mode 100644 index 000000000000..c2c8b0a116fc --- /dev/null +++ b/packages/browser/src/userfeedback.ts @@ -0,0 +1,41 @@ +import type { DsnComponents, EventEnvelope, SdkMetadata, UserFeedback, UserFeedbackItem } from '@sentry/types'; +import { createEnvelope, dsnToString } from '@sentry/utils'; + +/** + * Creates an envelope from a user feedback. + */ +export function createUserFeedbackEnvelope( + feedback: UserFeedback, + { + metadata, + tunnel, + dsn, + }: { + metadata: SdkMetadata | undefined; + tunnel: string | undefined; + dsn: DsnComponents | undefined; + }, +): EventEnvelope { + const headers: EventEnvelope[0] = { + event_id: feedback.event_id, + sent_at: new Date().toISOString(), + ...(metadata && + metadata.sdk && { + sdk: { + name: metadata.sdk.name, + version: metadata.sdk.version, + }, + }), + ...(!!tunnel && !!dsn && { dsn: dsnToString(dsn) }), + }; + const item = createUserFeedbackEnvelopeItem(feedback); + + return createEnvelope(headers, [item]); +} + +function createUserFeedbackEnvelopeItem(feedback: UserFeedback): UserFeedbackItem { + const feedbackHeaders: UserFeedbackItem[0] = { + type: 'user_report', + }; + return [feedbackHeaders, feedback]; +} diff --git a/packages/browser/test/unit/userfeedback.test.ts b/packages/browser/test/unit/userfeedback.test.ts new file mode 100644 index 000000000000..cb498cac7893 --- /dev/null +++ b/packages/browser/test/unit/userfeedback.test.ts @@ -0,0 +1,68 @@ +import { createUserFeedbackEnvelope } from '../../src/userfeedback'; + +describe('userFeedback', () => { + test('creates user feedback envelope header', () => { + const envelope = createUserFeedbackEnvelope( + { + comments: 'Test Comments', + email: 'test@email.com', + name: 'Test User', + event_id: 'testEvent123', + }, + { + metadata: { + sdk: { + name: 'testSdkName', + version: 'testSdkVersion', + }, + }, + tunnel: 'testTunnel', + dsn: { + host: 'testHost', + projectId: 'testProjectId', + protocol: 'http', + }, + }, + ); + + expect(envelope[0]).toEqual({ + dsn: 'http://undefined@testHost/undefinedtestProjectId', + event_id: 'testEvent123', + sdk: { + name: 'testSdkName', + version: 'testSdkVersion', + }, + sent_at: expect.any(String), + }); + }); + + test('creates user feedback envelope item', () => { + const envelope = createUserFeedbackEnvelope( + { + comments: 'Test Comments', + email: 'test@email.com', + name: 'Test User', + event_id: 'testEvent123', + }, + { + metadata: undefined, + tunnel: undefined, + dsn: undefined, + }, + ); + + expect(envelope[1]).toEqual([ + [ + { + type: 'user_report', + }, + { + comments: 'Test Comments', + email: 'test@email.com', + name: 'Test User', + event_id: 'testEvent123', + }, + ], + ]); + }); +});