diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 044efc2b9696..61c24641c292 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -35,6 +35,7 @@ body: - '@sentry/angular' - '@sentry/angular-ivy' - '@sentry/bun' + - '@sentry/deno' - '@sentry/ember' - '@sentry/gatsby' - '@sentry/nextjs' diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cf73331536f..e2b8e6c9f0f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.81.1 + +- fix(astro): Remove method from span op (#9603) +- fix(deno): Make sure files get published (#9611) +- fix(nextjs): Use `globalThis` instead of `global` in edge runtime (#9612) +- fix(node): Improve error handling and shutdown handling for ANR (#9548) +- fix(tracing-internal): Fix case when originalURL contain query params (#9531) + +Work in this release contributed by @powerfulyang, @LubomirIgonda1, @joshkel, and @alexgleason. Thank you for your contributions! + ## 7.81.0 ### Important Changes diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index c04618cad33f..b5b7aa7d8c71 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -83,7 +83,7 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseH const res = await startSpan( { name: `${method} ${interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params)}`, - op: `http.server.${method.toLowerCase()}`, + op: 'http.server', origin: 'auto.http.astro', status: 'ok', ...traceparentData, diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 058982f06cc6..59ab8c18a3c4 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -47,7 +47,7 @@ describe('sentryMiddleware', () => { source: 'route', }, name: 'GET /users/[id]/details', - op: 'http.server.get', + op: 'http.server', origin: 'auto.http.astro', status: 'ok', }, diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index c811c4f827a3..cd440b376655 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -27,6 +27,7 @@ import type { Transport, TransportMakeRequestResponse, } from '@sentry/types'; +import type { FeedbackEvent } from '@sentry/types'; import { addItemToEnvelope, checkOrSetAlreadyCaught, @@ -413,6 +414,12 @@ export abstract class BaseClient implements Client { /** @inheritdoc */ public on(hook: 'otelSpanEnd', callback: (otelSpan: unknown, mutableOptions: { drop: boolean }) => void): void; + /** @inheritdoc */ + public on( + hook: 'beforeSendFeedback', + callback: (feedback: FeedbackEvent, options?: { includeReplay: boolean }) => void, + ): void; + /** @inheritdoc */ public on(hook: string, callback: unknown): void { if (!this._hooks[hook]) { @@ -450,6 +457,9 @@ export abstract class BaseClient implements Client { /** @inheritdoc */ public emit(hook: 'otelSpanEnd', otelSpan: unknown, mutableOptions: { drop: boolean }): void; + /** @inheritdoc */ + public emit(hook: 'beforeSendFeedback', feedback: FeedbackEvent, options?: { includeReplay: boolean }): void; + /** @inheritdoc */ public emit(hook: string, ...rest: unknown[]): void { if (this._hooks[hook]) { diff --git a/packages/deno/package.json b/packages/deno/package.json index d4ff30a59585..c7a5e844fd3b 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -6,14 +6,14 @@ "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/deno", "author": "Sentry", "license": "MIT", - "module": "build/index.js", + "module": "build/index.mjs", "types": "build/index.d.ts", "publishConfig": { "access": "public" }, "files": [ - "index.js", - "index.js.map", + "index.mjs", + "index.mjs.map", "index.d.ts" ], "dependencies": { diff --git a/packages/feedback/README.md b/packages/feedback/README.md index 53a4578d40b3..3772b65f95d6 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -65,7 +65,8 @@ The following options can be configured as options to the integration, in `new F | --------- | ------- | ------- | ----------- | | `showName` | `boolean` | `true` | Displays the name field on the feedback form, however will still capture the name (if available) from Sentry SDK context. | | `showEmail` | `boolean` | `true` | Displays the email field on the feedback form, however will still capture the email (if available) from Sentry SDK context. | -| `isAnonymous` | `boolean` | `false` | Hides both name and email fields and does not use Sentry SDK's user context. | +| `isNameRequired` | `boolean` | `false` | Requires the name field on the feedback form to be filled in. | +| `isEmailRequired` | `boolean` | `false` | Requires the email field on the feedback form to be filled in. | | `useSentryUser` | `Record` | `{ email: 'email', name: 'username'}` | Map of the `email` and `name` fields to the corresponding Sentry SDK user fields that were called with `Sentry.setUser`. | By default the Feedback integration will attempt to fill in the name/email fields if you have set a user context via [`Sentry.setUser`](https://docs.sentry.io/platforms/javascript/enriching-events/identify-user/). By default it expects the email and name fields to be `email` and `username`. Below is an example configuration with non-default user fields. @@ -133,7 +134,7 @@ Colors can be customized via the Feedback constructor or by defining CSS variabl | `submitForegroundHover` | `--submit-foreground-hover` | `#ffffff` | `#ffffff` | Foreground color for the submit button when hovering | | `cancelBackground` | `--cancel-background` | `transparent` | `transparent` | Background color for the cancel button | | `cancelBackgroundHover` | `--cancel-background-hover` | `var(--background-hover)` | `var(--background-hover)` | Background color when hovering over the cancel button | -| `cancelBorder` | `--cancel-border` | `var(--border)` | `var(--border)` | Border style for the cancel button | +| `cancelBorder` | `--cancel-border` | `var(--border)` | `var(--border)` | Border style for the cancel button | | `cancelOutlineFocus` | `--cancel-outline-focus` | `var(--input-outline-focus)` | `var(--input-outline-focus)` | Outline color for the cancel button, in the focused state | | `cancelForeground` | `--cancel-foreground` | `var(--foreground)` | `var(--foreground)` | Foreground color for the cancel button | | `cancelForegroundHover` | `--cancel-foreground-hover` | `var(--foreground)` | `var(--foreground)` | Foreground color for the cancel button when hovering | @@ -270,7 +271,7 @@ document.getElementById('my-feedback-form').addEventListener('submit', (event) = Note: The following instructions are to be followed in the Sentry product. -If you have Sentry's default issue alert ("Alert me on every new issue") turned on for the project you are setting up User Feedback on, no action is required to have alerting on each user feedback report. +If you have Sentry's default issue alert ("Alert me on every new issue") turned on for the project you are setting up User Feedback on, no action is required to have alerting on each user feedback report. If you don't have Sentry's default issue alert turned on, follow these steps: diff --git a/packages/feedback/package.json b/packages/feedback/package.json index 522fcb5b4b90..0f28b1736ab7 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -23,7 +23,6 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "7.81.0", "@sentry/core": "7.81.0", "@sentry/types": "7.81.0", "@sentry/utils": "7.81.0" diff --git a/packages/feedback/src/constants.ts b/packages/feedback/src/constants.ts index ea0db22525f4..48f699408762 100644 --- a/packages/feedback/src/constants.ts +++ b/packages/feedback/src/constants.ts @@ -1,3 +1,10 @@ +import { GLOBAL_OBJ } from '@sentry/utils'; + +// exporting a separate copy of `WINDOW` rather than exporting the one from `@sentry/browser` +// prevents the browser package from being bundled in the CDN bundle, and avoids a +// circular dependency between the browser and feedback packages +export const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window; + const LIGHT_BACKGROUND = '#ffffff'; const INHERIT = 'inherit'; const SUBMIT_COLOR = 'rgba(108, 95, 199, 1)'; diff --git a/packages/feedback/src/integration.ts b/packages/feedback/src/integration.ts index 052d57353957..cb27042c20fc 100644 --- a/packages/feedback/src/integration.ts +++ b/packages/feedback/src/integration.ts @@ -1,4 +1,3 @@ -import { WINDOW } from '@sentry/browser'; import type { Integration } from '@sentry/types'; import { isBrowser, logger } from '@sentry/utils'; @@ -15,6 +14,7 @@ import { NAME_PLACEHOLDER, SUBMIT_BUTTON_LABEL, SUCCESS_MESSAGE_TEXT, + WINDOW, } from './constants'; import type { FeedbackInternalOptions, FeedbackWidget, OptionalFeedbackConfiguration } from './types'; import { mergeOptions } from './util/mergeOptions'; @@ -80,7 +80,6 @@ export class Feedback implements Integration { email: 'email', name: 'username', }, - isAnonymous = false, isEmailRequired = false, isNameRequired = false, @@ -120,7 +119,6 @@ export class Feedback implements Integration { id, showBranding, autoInject, - isAnonymous, isEmailRequired, isNameRequired, showEmail, diff --git a/packages/feedback/src/sendFeedback.ts b/packages/feedback/src/sendFeedback.ts index 4f1b79761a96..ac60415d0462 100644 --- a/packages/feedback/src/sendFeedback.ts +++ b/packages/feedback/src/sendFeedback.ts @@ -1,5 +1,3 @@ -import type { BrowserClient, Replay } from '@sentry/browser'; -import { getCurrentHub } from '@sentry/core'; import { getLocationHref } from '@sentry/utils'; import { FEEDBACK_API_SOURCE } from './constants'; @@ -19,27 +17,22 @@ interface SendFeedbackParams { */ export function sendFeedback( { name, email, message, source = FEEDBACK_API_SOURCE, url = getLocationHref() }: SendFeedbackParams, - { includeReplay = true }: SendFeedbackOptions = {}, + options: SendFeedbackOptions = {}, ): ReturnType { - const client = getCurrentHub().getClient(); - const replay = includeReplay && client ? (client.getIntegrationById('Replay') as Replay | undefined) : undefined; - - // Prepare session replay - replay && replay.flush(); - const replayId = replay && replay.getReplayId(); - if (!message) { throw new Error('Unable to submit feedback with empty message'); } - return sendFeedbackRequest({ - feedback: { - name, - email, - message, - url, - replay_id: replayId, - source, + return sendFeedbackRequest( + { + feedback: { + name, + email, + message, + url, + source, + }, }, - }); + options, + ); } diff --git a/packages/feedback/src/types/index.ts b/packages/feedback/src/types/index.ts index 5772e6e8176b..89965f017cdd 100644 --- a/packages/feedback/src/types/index.ts +++ b/packages/feedback/src/types/index.ts @@ -52,11 +52,6 @@ export interface FeedbackGeneralConfiguration { */ autoInject: boolean; - /** - * If true, will not collect user data (email/name). - */ - isAnonymous: boolean; - /** * Should the email field be required? */ diff --git a/packages/feedback/src/util/prepareFeedbackEvent.ts b/packages/feedback/src/util/prepareFeedbackEvent.ts index 3ef01fb500c6..9b1b0f9c6e8b 100644 --- a/packages/feedback/src/util/prepareFeedbackEvent.ts +++ b/packages/feedback/src/util/prepareFeedbackEvent.ts @@ -27,6 +27,7 @@ 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 30e19bf0971f..b8ec16a15401 100644 --- a/packages/feedback/src/util/sendFeedbackRequest.ts +++ b/packages/feedback/src/util/sendFeedbackRequest.ts @@ -2,15 +2,16 @@ 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 type { SendFeedbackData, SendFeedbackOptions } from '../types'; import { prepareFeedbackEvent } from './prepareFeedbackEvent'; /** * Send feedback using transport */ -export async function sendFeedbackRequest({ - feedback: { message, email, name, source, replay_id, url }, -}: SendFeedbackData): Promise { +export async function sendFeedbackRequest( + { feedback: { message, email, name, source, url } }: SendFeedbackData, + { includeReplay = true }: SendFeedbackOptions = {}, +): Promise { const hub = getCurrentHub(); const client = hub.getClient(); const transport = client && client.getTransport(); @@ -26,7 +27,6 @@ export async function sendFeedbackRequest({ contact_email: email, name, message, - replay_id, url, source, }, @@ -54,6 +54,10 @@ export async function sendFeedbackRequest({ return; } + if (client && client.emit) { + client.emit('beforeSendFeedback', feedbackEvent, { includeReplay: Boolean(includeReplay) }); + } + const envelope = createEventEnvelope( feedbackEvent, dsn, diff --git a/packages/feedback/src/widget/Dialog.ts b/packages/feedback/src/widget/Dialog.ts index 8fdfbba72f20..84b4318b34d1 100644 --- a/packages/feedback/src/widget/Dialog.ts +++ b/packages/feedback/src/widget/Dialog.ts @@ -45,8 +45,9 @@ export function Dialog({ showBranding, showName, showEmail, + isNameRequired, + isEmailRequired, colorScheme, - isAnonymous, defaultName, defaultEmail, onClosed, @@ -101,7 +102,8 @@ export function Dialog({ } = Form({ showEmail, showName, - isAnonymous, + isEmailRequired, + isNameRequired, defaultName, defaultEmail, diff --git a/packages/feedback/src/widget/Form.ts b/packages/feedback/src/widget/Form.ts index 74ff06c015b9..9b90cf547477 100644 --- a/packages/feedback/src/widget/Form.ts +++ b/packages/feedback/src/widget/Form.ts @@ -7,7 +7,8 @@ export interface FormComponentProps FeedbackInternalOptions, | 'showName' | 'showEmail' - | 'isAnonymous' + | 'isNameRequired' + | 'isEmailRequired' | Exclude > { /** @@ -57,7 +58,8 @@ export function Form({ showName, showEmail, - isAnonymous, + isNameRequired, + isEmailRequired, defaultName, defaultEmail, @@ -113,6 +115,7 @@ export function Form({ type: showName ? 'text' : 'hidden', ['aria-hidden']: showName ? 'false' : 'true', name: 'name', + required: isNameRequired, className: 'form__input', placeholder: namePlaceholder, value: defaultName, @@ -123,6 +126,7 @@ export function Form({ type: showEmail ? 'text' : 'hidden', ['aria-hidden']: showEmail ? 'false' : 'true', name: 'email', + required: isEmailRequired, className: 'form__input', placeholder: emailPlaceholder, value: defaultEmail, @@ -160,29 +164,43 @@ export function Form({ [ errorEl, - !isAnonymous && - showName && + showName && createElement( 'label', { htmlFor: 'name', className: 'form__label', }, - [nameLabel, nameEl], + [ + createElement( + 'span', + { className: 'form__label__text' }, + nameLabel, + isNameRequired && createElement('span', { className: 'form__label__text--required' }, ' (required)'), + ), + nameEl, + ], ), - !isAnonymous && !showName && nameEl, + !showName && nameEl, - !isAnonymous && - showEmail && + showEmail && createElement( 'label', { htmlFor: 'email', className: 'form__label', }, - [emailLabel, emailEl], + [ + createElement( + 'span', + { className: 'form__label__text' }, + emailLabel, + isEmailRequired && createElement('span', { className: 'form__label__text--required' }, ' (required)'), + ), + emailEl, + ], ), - !isAnonymous && !showEmail && emailEl, + !showEmail && emailEl, createElement( 'label', diff --git a/packages/feedback/src/widget/Icon.ts b/packages/feedback/src/widget/Icon.ts index 998eaef1ebb8..29b2d25bee8d 100644 --- a/packages/feedback/src/widget/Icon.ts +++ b/packages/feedback/src/widget/Icon.ts @@ -1,5 +1,4 @@ -import { WINDOW } from '@sentry/browser'; - +import { WINDOW } from '../constants'; import { setAttributesNS } from '../util/setAttributesNS'; const SIZE = 20; diff --git a/packages/feedback/src/widget/Logo.ts b/packages/feedback/src/widget/Logo.ts index b8c7c72b1d39..17333bda87ed 100644 --- a/packages/feedback/src/widget/Logo.ts +++ b/packages/feedback/src/widget/Logo.ts @@ -1,5 +1,4 @@ -import { WINDOW } from '@sentry/browser'; - +import { WINDOW } from '../constants'; import type { FeedbackInternalOptions } from '../types'; import { setAttributesNS } from '../util/setAttributesNS'; diff --git a/packages/feedback/src/widget/SuccessIcon.ts b/packages/feedback/src/widget/SuccessIcon.ts index 672954eae864..6092481672b9 100644 --- a/packages/feedback/src/widget/SuccessIcon.ts +++ b/packages/feedback/src/widget/SuccessIcon.ts @@ -1,5 +1,4 @@ -import { WINDOW } from '@sentry/browser'; - +import { WINDOW } from '../constants'; import { setAttributesNS } from '../util/setAttributesNS'; const WIDTH = 16; diff --git a/packages/feedback/src/widget/createShadowHost.ts b/packages/feedback/src/widget/createShadowHost.ts index bb8c0643900a..a418906d4b5a 100644 --- a/packages/feedback/src/widget/createShadowHost.ts +++ b/packages/feedback/src/widget/createShadowHost.ts @@ -1,6 +1,6 @@ -import { WINDOW } from '@sentry/browser'; import { logger } from '@sentry/utils'; +import { WINDOW } from '../constants'; import type { FeedbackInternalOptions } from '../types'; import { createDialogStyles } from './Dialog.css'; import { createMainStyles } from './Main.css'; diff --git a/packages/feedback/src/widget/createWidget.ts b/packages/feedback/src/widget/createWidget.ts index 480e3d476219..1a1077b0c257 100644 --- a/packages/feedback/src/widget/createWidget.ts +++ b/packages/feedback/src/widget/createWidget.ts @@ -88,9 +88,19 @@ export function createWidget({ return; } - // Simple validation for now, just check for non-empty message + // Simple validation for now, just check for non-empty required fields + const emptyField = []; + if (options.isNameRequired && !feedback.name) { + emptyField.push(options.nameLabel); + } + if (options.isEmailRequired && !feedback.email) { + emptyField.push(options.emailLabel); + } if (!feedback.message) { - dialog.showError('Please enter in some feedback before submitting!'); + emptyField.push(options.messageLabel); + } + if (emptyField.length > 0) { + dialog.showError(`Please enter in the following required fields: ${emptyField.join(', ')}`); return; } @@ -149,16 +159,17 @@ export function createWidget({ return; } - const userKey = !options.isAnonymous && options.useSentryUser; + const userKey = options.useSentryUser; const scope = getCurrentHub().getScope(); const user = scope && scope.getUser(); dialog = Dialog({ colorScheme: options.colorScheme, showBranding: options.showBranding, - showName: options.showName, - showEmail: options.showEmail, - isAnonymous: options.isAnonymous, + showName: options.showName || options.isNameRequired, + showEmail: options.showEmail || options.isEmailRequired, + isNameRequired: options.isNameRequired, + isEmailRequired: options.isEmailRequired, formTitle: options.formTitle, cancelButtonLabel: options.cancelButtonLabel, submitButtonLabel: options.submitButtonLabel, diff --git a/packages/feedback/src/widget/util/createElement.ts b/packages/feedback/src/widget/util/createElement.ts index bf5f81868d68..e070e9a9553f 100644 --- a/packages/feedback/src/widget/util/createElement.ts +++ b/packages/feedback/src/widget/util/createElement.ts @@ -1,4 +1,4 @@ -import { WINDOW } from '@sentry/browser'; +import { WINDOW } from '../../constants'; /** * Helper function to create an element. Could be used as a JSX factory diff --git a/packages/feedback/test/widget/Dialog.test.ts b/packages/feedback/test/widget/Dialog.test.ts index d0968e6e6030..69a333f69813 100644 --- a/packages/feedback/test/widget/Dialog.test.ts +++ b/packages/feedback/test/widget/Dialog.test.ts @@ -9,7 +9,8 @@ function renderDialog({ showName = true, showEmail = true, showBranding = false, - isAnonymous = false, + isNameRequired = false, + isEmailRequired = false, formTitle = 'Feedback', defaultName = 'Foo Bar', defaultEmail = 'foo@example.com', @@ -27,9 +28,10 @@ function renderDialog({ return Dialog({ formTitle, - isAnonymous, showName, showEmail, + isNameRequired, + isEmailRequired, showBranding, defaultName, defaultEmail, diff --git a/packages/feedback/test/widget/Form.test.ts b/packages/feedback/test/widget/Form.test.ts index 4eb1c925c007..7b45cabd8503 100644 --- a/packages/feedback/test/widget/Form.test.ts +++ b/packages/feedback/test/widget/Form.test.ts @@ -8,7 +8,8 @@ type NonNullableFields = { function renderForm({ showName = true, showEmail = true, - isAnonymous = false, + isNameRequired = false, + isEmailRequired = false, defaultName = 'Foo Bar', defaultEmail = 'foo@example.com', nameLabel = 'Name', @@ -22,9 +23,10 @@ function renderForm({ ...rest }: Partial = {}) { return Form({ - isAnonymous, showName, showEmail, + isNameRequired, + isEmailRequired, defaultName, defaultEmail, nameLabel, @@ -80,13 +82,15 @@ describe('Form', () => { emailPlaceholder: 'foo@example.org!', messageLabel: 'Description!', messagePlaceholder: 'What is the issue?!', + isNameRequired: true, + isEmailRequired: true, }); const nameLabel = formComponent.el.querySelector('label[htmlFor="name"]') as HTMLLabelElement; const emailLabel = formComponent.el.querySelector('label[htmlFor="email"]') as HTMLLabelElement; const messageLabel = formComponent.el.querySelector('label[htmlFor="message"]') as HTMLLabelElement; - expect(nameLabel.textContent).toBe('Name!'); - expect(emailLabel.textContent).toBe('Email!'); + expect(nameLabel.textContent).toBe('Name! (required)'); + expect(emailLabel.textContent).toBe('Email! (required)'); expect(messageLabel.textContent).toBe('Description! (required)'); const nameInput = formComponent.el.querySelector('[name="name"]') as HTMLInputElement; @@ -98,18 +102,6 @@ describe('Form', () => { expect(messageInput.placeholder).toBe('What is the issue?!'); }); - it('submit is enabled if message is not empty', () => { - const formComponent = renderForm(); - - const message = formComponent.el.querySelector('[name="message"]') as HTMLTextAreaElement; - - message.value = 'Foo (message)'; - message.dispatchEvent(new KeyboardEvent('keyup')); - - message.value = ''; - message.dispatchEvent(new KeyboardEvent('keyup')); - }); - it('can show error', () => { const formComponent = renderForm(); const errorEl = formComponent.el.querySelector('.form__error-container') as HTMLDivElement; @@ -144,31 +136,4 @@ describe('Form', () => { name: 'Foo Bar', }); }); - - it('does not show name or email inputs for anonymous mode', () => { - const onSubmit = jest.fn(); - const formComponent = renderForm({ - isAnonymous: true, - onSubmit, - }); - const submitEvent = new Event('submit'); - - expect(formComponent.el).toBeInstanceOf(HTMLFormElement); - const nameInput = formComponent.el.querySelector('[name="name"][type="text"]') as HTMLInputElement; - const emailInput = formComponent.el.querySelector('[name="email"][type="text"]') as HTMLInputElement; - expect(nameInput).toBeNull(); - expect(emailInput).toBeNull(); - expect(formComponent.el.querySelector('[name="message"]')).not.toBeNull(); - - const message = formComponent.el.querySelector('[name="message"]') as HTMLTextAreaElement; - message.value = 'Foo (message)'; - message.dispatchEvent(new KeyboardEvent('keyup')); - - formComponent.el.dispatchEvent(submitEvent); - expect(onSubmit).toHaveBeenCalledWith({ - email: '', - message: 'Foo (message)', - name: '', - }); - }); }); diff --git a/packages/feedback/test/widget/createWidget.test.ts b/packages/feedback/test/widget/createWidget.test.ts index fb1809de7ecf..1776e14f5e21 100644 --- a/packages/feedback/test/widget/createWidget.test.ts +++ b/packages/feedback/test/widget/createWidget.test.ts @@ -30,7 +30,6 @@ const DEFAULT_OPTIONS = { email: 'email', name: 'username', }, - isAnonymous: false, isEmailRequired: false, isNameRequired: false, @@ -132,7 +131,7 @@ describe('createWidget', () => { }); (sendFeedbackRequest as jest.Mock).mockImplementation(() => { - return true; + return Promise.resolve(true); }); widget.actor?.el?.dispatchEvent(new Event('click')); @@ -148,20 +147,90 @@ describe('createWidget', () => { messageEl.dispatchEvent(new Event('change')); widget.dialog?.el?.querySelector('form')?.dispatchEvent(new Event('submit')); - expect(sendFeedbackRequest).toHaveBeenCalledWith({ - feedback: { - name: 'Jane Doe', - email: 'jane@example.com', - message: 'My feedback', - url: 'http://localhost/', - replay_id: undefined, - source: 'widget', + expect(sendFeedbackRequest).toHaveBeenCalledWith( + { + feedback: { + name: 'Jane Doe', + email: 'jane@example.com', + message: 'My feedback', + url: 'http://localhost/', + source: 'widget', + }, }, + {}, + ); + + // sendFeedbackRequest is async + await flushPromises(); + + expect(onSubmitSuccess).toHaveBeenCalledTimes(1); + + expect(widget.dialog).toBeUndefined(); + expect(shadow.querySelector('.success-message')?.textContent).toBe(SUCCESS_MESSAGE_TEXT); + + jest.runAllTimers(); + expect(shadow.querySelector('.success-message')).toBeNull(); + }); + + it('only submits feedback successfully when all required fields are filled', async () => { + const onSubmitSuccess = jest.fn(() => {}); + const { shadow, widget } = createShadowAndWidget({ + isNameRequired: true, + isEmailRequired: true, + onSubmitSuccess, }); + (sendFeedbackRequest as jest.Mock).mockImplementation(() => { + return true; + }); + widget.actor?.el?.dispatchEvent(new Event('click')); + + const nameEl = widget.dialog?.el?.querySelector('[name="name"]') as HTMLInputElement; + const emailEl = widget.dialog?.el?.querySelector('[name="email"]') as HTMLInputElement; + const messageEl = widget.dialog?.el?.querySelector('[name="message"]') as HTMLTextAreaElement; + + nameEl.value = ''; + emailEl.value = ''; + messageEl.value = ''; + + widget.dialog?.el?.querySelector('form')?.dispatchEvent(new Event('submit')); + expect(sendFeedbackRequest).toHaveBeenCalledTimes(0); + // sendFeedbackRequest is async await flushPromises(); + expect(onSubmitSuccess).toHaveBeenCalledTimes(0); + + nameEl.value = ''; + emailEl.value = ''; + messageEl.value = 'My feedback'; + + widget.dialog?.el?.querySelector('form')?.dispatchEvent(new Event('submit')); + expect(sendFeedbackRequest).toHaveBeenCalledTimes(0); + + // sendFeedbackRequest is async + await flushPromises(); + expect(onSubmitSuccess).toHaveBeenCalledTimes(0); + + nameEl.value = 'Jane Doe'; + emailEl.value = 'jane@example.com'; + messageEl.value = 'My feedback'; + widget.dialog?.el?.querySelector('form')?.dispatchEvent(new Event('submit')); + expect(sendFeedbackRequest).toHaveBeenCalledWith( + { + feedback: { + name: 'Jane Doe', + email: 'jane@example.com', + message: 'My feedback', + url: 'http://localhost/', + source: 'widget', + }, + }, + {}, + ); + + // sendFeedbackRequest is async + await flushPromises(); expect(onSubmitSuccess).toHaveBeenCalledTimes(1); expect(widget.dialog).toBeUndefined(); @@ -188,16 +257,18 @@ describe('createWidget', () => { messageEl.dispatchEvent(new Event('change')); widget.dialog?.el?.querySelector('form')?.dispatchEvent(new Event('submit')); - expect(sendFeedbackRequest).toHaveBeenCalledWith({ - feedback: { - name: '', - email: '', - message: 'My feedback', - url: 'http://localhost/', - replay_id: undefined, - source: 'widget', + expect(sendFeedbackRequest).toHaveBeenCalledWith( + { + feedback: { + name: '', + email: '', + message: 'My feedback', + url: 'http://localhost/', + source: 'widget', + }, }, - }); + {}, + ); // sendFeedbackRequest is async await flushPromises(); diff --git a/packages/nextjs/src/config/loaders/valueInjectionLoader.ts b/packages/nextjs/src/config/loaders/valueInjectionLoader.ts index 13f94e7128e4..5bc84baf95ce 100644 --- a/packages/nextjs/src/config/loaders/valueInjectionLoader.ts +++ b/packages/nextjs/src/config/loaders/valueInjectionLoader.ts @@ -19,7 +19,8 @@ export default function valueInjectionLoader(this: LoaderThis, us this.cacheable(false); // Define some global proxy that works on server and on the browser. - let injectedCode = 'var _sentryCollisionFreeGlobalObject = typeof window === "undefined" ? global : window;\n'; + let injectedCode = + 'var _sentryCollisionFreeGlobalObject = typeof window != "undefined" ? window : typeof global != "undefined" ? global : typeof self != "undefined" ? self : {};\n'; Object.entries(values).forEach(([key, value]) => { injectedCode += `_sentryCollisionFreeGlobalObject["${key}"] = ${JSON.stringify(value)};\n`; diff --git a/packages/node/src/anr/index.ts b/packages/node/src/anr/index.ts index caa384b3928f..549b962d4b70 100644 --- a/packages/node/src/anr/index.ts +++ b/packages/node/src/anr/index.ts @@ -215,15 +215,21 @@ function handleChildProcess(options: Options): void { async function watchdogTimeout(): Promise { log('Watchdog timeout'); - const pauseAndCapture = await debuggerPause; - - if (pauseAndCapture) { - log('Pausing debugger to capture stack trace'); - pauseAndCapture(); - } else { - log('Capturing event'); - sendAnrEvent(); + + try { + const pauseAndCapture = await debuggerPause; + + if (pauseAndCapture) { + log('Pausing debugger to capture stack trace'); + pauseAndCapture(); + return; + } + } catch (_) { + // ignore } + + log('Capturing event'); + sendAnrEvent(); } const { poll } = watchdogTimer(createHrTimer, options.pollInterval, options.anrThreshold, watchdogTimeout); @@ -234,6 +240,10 @@ function handleChildProcess(options: Options): void { } poll(); }); + process.on('disconnect', () => { + // Parent process has exited. + process.exit(); + }); } /** diff --git a/packages/replay/src/util/addGlobalListeners.ts b/packages/replay/src/util/addGlobalListeners.ts index 1f7945565524..bf133c3e2130 100644 --- a/packages/replay/src/util/addGlobalListeners.ts +++ b/packages/replay/src/util/addGlobalListeners.ts @@ -57,6 +57,17 @@ export function addGlobalListeners(replay: ReplayContainer): void { client.on('finishTransaction', transaction => { replay.lastTransaction = transaction; }); + + // We want to flush replay + client.on('beforeSendFeedback', (feedbackEvent, options) => { + const replayId = replay.getSessionId(); + if (options && options.includeReplay && replay.isEnabled() && replayId) { + void replay.flush(); + if (feedbackEvent.contexts && feedbackEvent.contexts.feedback) { + feedbackEvent.contexts.feedback.replay_id = replayId; + } + } + }); } } diff --git a/packages/tracing-internal/src/node/integrations/express.ts b/packages/tracing-internal/src/node/integrations/express.ts index b8874ec6f27a..e09ff0902193 100644 --- a/packages/tracing-internal/src/node/integrations/express.ts +++ b/packages/tracing-internal/src/node/integrations/express.ts @@ -545,7 +545,9 @@ export function preventDuplicateSegments( reconstructedRoute?: string, layerPath?: string, ): string | undefined { - const originalUrlSplit = originalUrl?.split('/').filter(v => !!v); + // filter query params + const normalizeURL = stripUrlQueryAndFragment(originalUrl || ''); + const originalUrlSplit = normalizeURL?.split('/').filter(v => !!v); let tempCounter = 0; const currentOffset = reconstructedRoute?.split('/').filter(v => !!v).length || 0; const result = layerPath diff --git a/packages/tracing-internal/test/node/express.test.ts b/packages/tracing-internal/test/node/express.test.ts index 4b8d31fb2cdc..1631971d9863 100644 --- a/packages/tracing-internal/test/node/express.test.ts +++ b/packages/tracing-internal/test/node/express.test.ts @@ -35,6 +35,22 @@ describe('unit Test for preventDuplicateSegments', () => { const result1 = preventDuplicateSegments(originalUrl, reconstructedRoute, layerPath); expect(result1).toBe('1234'); }); + + it('should prevent duplicate segment v1 originalUrl with query param without trailing slash', () => { + const originalUrl = '/api/v1/1234?queryParam=123'; + const reconstructedRoute = '/api/v1'; + const layerPath = '/v1/1234'; + const result1 = preventDuplicateSegments(originalUrl, reconstructedRoute, layerPath); + expect(result1).toBe('1234'); + }); + + it('should prevent duplicate segment v1 originalUrl with query param with trailing slash', () => { + const originalUrl = '/api/v1/1234/?queryParam=123'; + const reconstructedRoute = '/api/v1'; + const layerPath = '/v1/1234'; + const result1 = preventDuplicateSegments(originalUrl, reconstructedRoute, layerPath); + expect(result1).toBe('1234'); + }); }); describe('preventDuplicateSegments should handle empty input gracefully', () => { it('Empty input values', () => { diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 8aeabaa6cc8d..33fa749eb379 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -6,6 +6,7 @@ import type { DsnComponents } from './dsn'; import type { DynamicSamplingContext, Envelope } from './envelope'; import type { Event, EventHint } from './event'; import type { EventProcessor } from './eventprocessor'; +import type { FeedbackEvent } from './feedback'; import type { Integration, IntegrationClass } from './integration'; import type { ClientOptions } from './options'; import type { Scope } from './scope'; @@ -237,6 +238,16 @@ export interface Client { */ on?(hook: 'otelSpanEnd', callback: (otelSpan: unknown, mutableOptions: { drop: boolean }) => void): void; + /** + * Register a callback when a Feedback event has been prepared. + * This should be used to mutate the event. The options argument can hint + * about what kind of mutation it expects. + */ + on?( + hook: 'beforeSendFeedback', + callback: (feedback: FeedbackEvent, options?: { includeReplay?: boolean }) => void, + ): void; + /** * Fire a hook event for transaction start. * Expects to be given a transaction as the second argument. @@ -291,5 +302,12 @@ export interface Client { */ emit?(hook: 'otelSpanEnd', otelSpan: unknown, mutableOptions: { drop: boolean }): void; + /** + * Fire a hook event for after preparing a feedback event. Events to be given + * a feedback event as the second argument, and an optional options object as + * third argument. + */ + emit?(hook: 'beforeSendFeedback', feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void; + /* eslint-enable @typescript-eslint/unified-signatures */ }