From 3d9c698969d0406b0a3b5ad8e8bc66356cd817f3 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 26 Sep 2023 09:16:43 -0400 Subject: [PATCH 01/19] wip --- package.json | 1 + packages/feedback/.eslintignore | 6 + packages/feedback/.eslintrc.js | 42 +++ packages/feedback/.gitignore | 4 + packages/feedback/CONTRIBUTING.md | 4 + packages/feedback/LICENSE | 14 + packages/feedback/MIGRATION.md | 149 ++++++++++ packages/feedback/README.md | 100 +++++++ packages/feedback/jest.config.ts | 17 ++ packages/feedback/jest.setup.ts | 272 +++++++++++++++++ packages/feedback/package.json | 67 +++++ packages/feedback/rollup.bundle.config.js | 13 + packages/feedback/rollup.npm.config.js | 16 + .../feedback/scripts/craft-pre-release.sh | 8 + packages/feedback/scripts/repl.ts | 276 ++++++++++++++++++ packages/feedback/src/index.ts | 16 + packages/feedback/src/types/index.ts | 5 + packages/feedback/test/index.ts | 4 + packages/feedback/tsconfig.json | 7 + packages/feedback/tsconfig.test.json | 15 + packages/feedback/tsconfig.types.json | 10 + 21 files changed, 1046 insertions(+) create mode 100644 packages/feedback/.eslintignore create mode 100644 packages/feedback/.eslintrc.js create mode 100644 packages/feedback/.gitignore create mode 100644 packages/feedback/CONTRIBUTING.md create mode 100644 packages/feedback/LICENSE create mode 100644 packages/feedback/MIGRATION.md create mode 100644 packages/feedback/README.md create mode 100644 packages/feedback/jest.config.ts create mode 100644 packages/feedback/jest.setup.ts create mode 100644 packages/feedback/package.json create mode 100644 packages/feedback/rollup.bundle.config.js create mode 100644 packages/feedback/rollup.npm.config.js create mode 100644 packages/feedback/scripts/craft-pre-release.sh create mode 100644 packages/feedback/scripts/repl.ts create mode 100644 packages/feedback/src/index.ts create mode 100644 packages/feedback/src/types/index.ts create mode 100644 packages/feedback/test/index.ts create mode 100644 packages/feedback/tsconfig.json create mode 100644 packages/feedback/tsconfig.test.json create mode 100644 packages/feedback/tsconfig.types.json diff --git a/package.json b/package.json index ee3951a3369c..364c97a53a75 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "packages/ember", "packages/eslint-config-sdk", "packages/eslint-plugin-sdk", + "packages/feedback", "packages/gatsby", "packages/hub", "packages/integrations", diff --git a/packages/feedback/.eslintignore b/packages/feedback/.eslintignore new file mode 100644 index 000000000000..c76c6c2d64d1 --- /dev/null +++ b/packages/feedback/.eslintignore @@ -0,0 +1,6 @@ +node_modules/ +build/ +demo/build/ +# TODO: Check if we can re-introduce linting in demo +demo +metrics diff --git a/packages/feedback/.eslintrc.js b/packages/feedback/.eslintrc.js new file mode 100644 index 000000000000..4f69827ac50b --- /dev/null +++ b/packages/feedback/.eslintrc.js @@ -0,0 +1,42 @@ +// Note: All paths are relative to the directory in which eslint is being run, rather than the directory where this file +// lives + +// ESLint config docs: https://eslint.org/docs/user-guide/configuring/ + +module.exports = { + extends: ['../../.eslintrc.js'], + overrides: [ + { + files: ['src/**/*.ts'], + rules: { + '@sentry-internal/sdk/no-unsupported-es6-methods': 'off', + }, + }, + { + files: ['jest.setup.ts', 'jest.config.ts'], + parserOptions: { + project: ['tsconfig.test.json'], + }, + rules: { + 'no-console': 'off', + }, + }, + { + files: ['test/**/*.ts'], + + rules: { + // most of these errors come from `new Promise(process.nextTick)` + '@typescript-eslint/unbound-method': 'off', + // TODO: decide if we want to enable this again after the migration + // We can take the freedom to be a bit more lenient with tests + '@typescript-eslint/no-floating-promises': 'off', + }, + }, + { + files: ['src/types/deprecated.ts'], + rules: { + '@typescript-eslint/naming-convention': 'off', + }, + }, + ], +}; diff --git a/packages/feedback/.gitignore b/packages/feedback/.gitignore new file mode 100644 index 000000000000..363d3467c6fa --- /dev/null +++ b/packages/feedback/.gitignore @@ -0,0 +1,4 @@ +node_modules +/*.tgz +.eslintcache +build diff --git a/packages/feedback/CONTRIBUTING.md b/packages/feedback/CONTRIBUTING.md new file mode 100644 index 000000000000..829930e2b05e --- /dev/null +++ b/packages/feedback/CONTRIBUTING.md @@ -0,0 +1,4 @@ +## Updating the rrweb dependency + +When [updating the `rrweb` dependency](https://github.com/getsentry/sentry-javascript/blob/a493aa6a46555b944c8d896a2164bcd8b11caaf5/packages/replay/package.json?plain=1#LL55), +please be aware that [`@sentry/replay`'s README.md](https://github.com/getsentry/sentry-javascript/blob/a493aa6a46555b944c8d896a2164bcd8b11caaf5/packages/replay/README.md?plain=1#LL204) also needs to be updated. diff --git a/packages/feedback/LICENSE b/packages/feedback/LICENSE new file mode 100644 index 000000000000..4ac873d49f33 --- /dev/null +++ b/packages/feedback/LICENSE @@ -0,0 +1,14 @@ +Copyright (c) 2022 Sentry (https://sentry.io) and individual contributors. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/feedback/MIGRATION.md b/packages/feedback/MIGRATION.md new file mode 100644 index 000000000000..ba6326939970 --- /dev/null +++ b/packages/feedback/MIGRATION.md @@ -0,0 +1,149 @@ +# End of Replay Beta + +Sentry Replay is now out of Beta. This means that the usual stability guarantees apply. + +Because of experimentation and rapid iteration, during the Beta period some bugs and problems came up which have since been fixed/improved. +We **strongly** recommend anyone using Replay in a version before 7.39.0 to update to 7.39.0 or newer, in order to prevent running Replay with known problems that have since been fixed. + +Below you can find a list of relevant replay issues that have been resolved until 7.39.0: + +## New features / improvements + +- Remove `autoplay` attribute from audio/video tags ([#59](https://github.com/getsentry/rrweb/pull/59)) +- Exclude fetching scripts that use `` ([#52](https://github.com/getsentry/rrweb/pull/52)) +- With maskAllText, mask the attributes: placeholder, title, `aria-label` +- Lower the flush max delay from 15 seconds to 5 seconds (#6761) +- Stop recording when retry fails (#6765) +- Stop without retry when receiving bad API response (#6773) +- Send client_report when replay sending fails (#7093) +- Stop recording when hitting a rate limit (#7018) +- Allow Replay to be used in Electron renderers with nodeIntegration enabled (#6644) +- Do not renew session in error mode (#6948) +- Remove default sample rates for replay (#6878) +- Add `flush` method to integration (#6776) +- Improve compression worker & fallback behavior (#6988, #6936, #6827) +- Improve error handling (#7087, #7094, #7010, getsentry/rrweb#16, #6856) +- Add more default block filters (#7233) + +## Fixes + +- Fix masking inputs on change when `maskAllInputs:false` ([#61](https://github.com/getsentry/rrweb/pull/61)) +- More robust `rootShadowHost` check ([#50](https://github.com/getsentry/rrweb/pull/50)) +- Fix duplicated textarea value ([#62](https://github.com/getsentry/rrweb/pull/62)) +- Handle removed attributes ([#65](https://github.com/getsentry/rrweb/pull/65)) +- Change LCP calculation (#7187, #7225) +- Fix debounced flushes not respecting `maxWait` (#7207, #7208) +- Fix svgs not getting unblocked (#7132) +- Fix missing fetch/xhr requests (#7134) +- Fix feature detection of PerformanceObserver (#7029) +- Fix `checkoutEveryNms` (#6722) +- Fix incorrect uncompressed recording size due to encoding (#6740) +- Ensure dropping replays works (#6522) +- Envelope send should be awaited in try/catch (#6625) +- Improve handling of `maskAllText` selector (#6637) + +# Upgrading Replay from 7.34.0 to 7.35.0 - #6645 + +This release will remove the ability to change the default rrweb recording options (outside of privacy options). The following are the new configuration values all replays will use: +`slimDOMOptions: 'all'` - Removes `script`, comments, `favicon`, whitespace in `head`, and a few `meta` tags in `head` +`recordCanvas: false` - This option did not do anything as playback of recorded canvas means we would have to remove the playback sandbox (which is a security concern). +`inlineStylesheet: true` - Inlines styles into the recording itself instead of attempting to fetch it remotely. This means that styles in the replay will reflect the styles at the time of recording and not the current styles of the remote stylesheet. +`collectFonts: true` - Attempts to load custom fonts. +`inlineImages: false` - Does not inline images to recording and instead loads the asset remotely. During playback, images may not load due to CORS (add sentry.io as an origin). + +Additionally, we have streamlined the privacy options. The following table lists the deprecated value, and what it is replaced by: + +| deprecated key | replaced by | description | +| ---------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| maskInputOptions | mask | Use CSS selectors in `mask` in order to mask all inputs of a certain type. For example, `input[type="address"]` | +| blockSelector | block | The selector(s) can be moved directly in the `block` array. | +| blockClass | block | Convert the class name to a CSS selector and add to `block` array. For example, `first-name` becomes `.first-name`. Regexes can be moved as-is. | +| maskClass | mask | Convert the class name to a CSS selector and add to `mask` array. For example, `first-name` becomes `.first-name`. Regexes can be moved as-is. | +| maskSelector | mask | The selector(s) can be moved directly in the `mask` array. | +| ignoreClass | ignore | Convert the class name to a CSS selector and add to `ignore` array. For example, `first-name` becomes `.first-name`. Regexes can be moved as-is. | + +# Upgrading Replay from 7.31.0 to 7.32.0 + +In 7.32.0, we have removed the default values for the replay sample rates. +Previously, they were: + +* `replaysSessionSampleRate: 0.1` +* `replaysOnErrorSampleRate: 1.0` + +Now, you have to explicitly set the sample rates, otherwise they default to 0. + +# Upgrading Replay from 0.6.x to 7.24.0 + +The Sentry Replay integration was moved to the Sentry JavaScript SDK monorepo. Hence we're jumping from version 0.x to the monorepo's 7.x version which is shared across all JS SDK packages. + +## Replay sample rates are defined on top level (https://github.com/getsentry/sentry-javascript/issues/6351) + +Instead of defining the sample rates on the integration like this: + +```js +Sentry.init({ + dsn: '__DSN__', + integrations: [ + new Replay({ + sessionSampleRate: 0.1, + errorSampleRate: 1.0, + }) + ], + // ... +}); +``` + +They are now defined on the top level of the SDK: + +```js +Sentry.init({ + dsn: '__DSN__', + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + integrations: [ + new Replay({ + // other replay config still goes in here + }) + ], +}); +``` + +Note that the sample rate options inside of `new Replay({})` have been deprecated and will be removed in a future update. + +## Removed deprecated options (https://github.com/getsentry/sentry-javascript/pull/6370) + +Two options, which have been deprecated for some time, have been removed: + +* `replaysSamplingRate` - instead use `sessionSampleRate` +* `captureOnlyOnError` - instead use `errorSampleRate` + +## New NPM package structure (https://github.com/getsentry/sentry-javascript/issues/6280) + +The internal structure of the npm package has changed. This is unlikely to affect you, unless you have imported something from e.g.: + +```js +import something from '@sentry/replay/submodule'; +``` + +If you only imported from `@sentry/replay`, this will not affect you. + +## Changed type name from `IEventBuffer` to `EventBuffer` (https://github.com/getsentry/sentry-javascript/pull/6416) + +It is highly unlikely to affect anybody, but the type `IEventBuffer` was renamed to `EventBuffer` for consistency. +Unless you manually imported this and used it somewhere in your codebase, this will not affect you. + +## Session object is now a plain object (https://github.com/getsentry/sentry-javascript/pull/6417) + +The `Session` object exported from Replay is now a plain object, instead of a class. +This should not affect you unless you specifically accessed this class & did custom things with it. + +## Reduce public API of Replay integration (https://github.com/getsentry/sentry-javascript/pull/6407) + +The result of `new Replay()` now has a much more limited public API. Only the following methods are exposed: + +```js +const replay = new Replay(); + +replay.start(); +replay.stop(); +``` diff --git a/packages/feedback/README.md b/packages/feedback/README.md new file mode 100644 index 000000000000..3d835d1838e5 --- /dev/null +++ b/packages/feedback/README.md @@ -0,0 +1,100 @@ +

+ + Sentry + +

+ +# Sentry Feedback + +[![npm version](https://img.shields.io/npm/v/@sentry/feedback.svg)](https://www.npmjs.com/package/@sentry/feedback) +[![npm dm](https://img.shields.io/npm/dm/@sentry/feedback.svg)](https://www.npmjs.com/package/@sentry/feedback) +[![npm dt](https://img.shields.io/npm/dt/@sentry/feedback.svg)](https://www.npmjs.com/package/@sentry/feedback) + +## Pre-requisites + +`@sentry/feedback` requires Node 12+, and browsers newer than IE11. + +## Installation + +Feedback can be imported from `@sentry/browser`, or a respective SDK package like `@sentry/react` or `@sentry/vue`. +You don't need to install anything in order to use Feedback. The minimum version that includes Feedback is <>. + +For details on using Feedback when using Sentry via the CDN bundles, see [CDN bundle](#loading-feedback-as-a-cdn-bundle). + +## Setup + +To set up the integration, add the following to your Sentry initialization. Several options are supported and passable via the integration constructor. +See the [configuration section](#configuration) below for more details. + +```javascript +import * as Sentry from '@sentry/browser'; +// or e.g. import * as Sentry from '@sentry/react'; + +Sentry.init({ + dsn: '__DSN__', + integrations: [ + new Sentry.Feedback({ + // Additional SDK configuration goes in here, for example: + // See below for all available options + }) + ], + // ... +}); +``` + +### Lazy loading Feedback + +Feedback will start automatically when you add the integration. +If you do not want to start Feedback immediately (e.g. if you want to lazy-load it), +you can also use `addIntegration` to load it later: + +```js +import * as Sentry from "@sentry/react"; +import { BrowserClient } from "@sentry/browser"; + +Sentry.init({ + // Do not load it initially + integrations: [] +}); + +// Sometime later +const { Feedback } = await import('@sentry/browser'); +const client = Sentry.getCurrentHub().getClient(); + +// Client can be undefined +client?.addIntegration(new Feedback()); +``` + +### Identifying Users + +If you have only followed the above instructions to setup session feedbacks, you will only see IP addresses in Sentry's UI. In order to associate a user identity to a session feedback, use [`setUser`](https://docs.sentry.io/platforms/javascript/enriching-events/identify-user/). + +```javascript +import * as Sentry from "@sentry/browser"; + +Sentry.setUser({ email: "jane.doe@example.com" }); +``` + +## Loading Feedback as a CDN Bundle + +As an alternative to the NPM package, you can use Feedback as a CDN bundle. +Please refer to the [Feedback installation guide](https://docs.sentry.io/platforms/javascript/session-feedback/#install) for CDN bundle instructions. + + +## Configuration + +### General Integration Configuration + +The following options can be configured as options to the integration, in `new Feedback({})`: + +| key | type | default | description | +| --------- | ------- | ------- | ----------- | +| tbd | boolean | `true` | tbd | + + + +## Manually Sending Feedback Data + +Connect your own feedback UI to Sentry's You can use `feedback.flush()` to immediately send all currently captured feedback data. +When Feedback is currently in buffering mode, this will send up to the last 60 seconds of feedback data, +and also continue sending afterwards, similar to when an error happens & is recorded. diff --git a/packages/feedback/jest.config.ts b/packages/feedback/jest.config.ts new file mode 100644 index 000000000000..90a3cf471f8d --- /dev/null +++ b/packages/feedback/jest.config.ts @@ -0,0 +1,17 @@ +import type { Config } from '@jest/types'; +import { jsWithTs as jsWithTsPreset } from 'ts-jest/presets'; + +export default async (): Promise => { + return { + ...jsWithTsPreset, + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.test.json', + }, + __DEBUG_BUILD__: true, + }, + setupFilesAfterEnv: ['./jest.setup.ts'], + testEnvironment: 'jsdom', + testMatch: ['/test/**/*(*.)@(spec|test).ts'], + }; +}; diff --git a/packages/feedback/jest.setup.ts b/packages/feedback/jest.setup.ts new file mode 100644 index 000000000000..093c97dcdce4 --- /dev/null +++ b/packages/feedback/jest.setup.ts @@ -0,0 +1,272 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { getCurrentHub } from '@sentry/core'; +import type { ReplayRecordingData, Transport } from '@sentry/types'; +import { TextEncoder } from 'util'; + +import type { ReplayContainer, Session } from './src/types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).TextEncoder = TextEncoder; + +type MockTransport = jest.MockedFunction; + +jest.mock('./src/util/isBrowser', () => { + return { + isBrowser: () => true, + }; +}); + +type EnvelopeHeader = { + event_id: string; + sent_at: string; + sdk: { + name: string; + version?: string; + }; +}; + +type ReplayEventHeader = { type: 'replay_event' }; +type ReplayEventPayload = Record; +type RecordingHeader = { type: 'replay_recording'; length: number }; +type RecordingPayloadHeader = Record; +type SentReplayExpected = { + envelopeHeader?: EnvelopeHeader; + replayEventHeader?: ReplayEventHeader; + replayEventPayload?: ReplayEventPayload; + recordingHeader?: RecordingHeader; + recordingPayloadHeader?: RecordingPayloadHeader; + recordingData?: ReplayRecordingData; +}; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const toHaveSameSession = function (received: jest.Mocked, expected: undefined | Session) { + const pass = this.equals(received.session?.id, expected?.id) as boolean; + + const options = { + isNot: this.isNot, + promise: this.promise, + }; + + return { + pass, + message: () => + `${this.utils.matcherHint( + 'toHaveSameSession', + undefined, + undefined, + options, + )}\n\n${this.utils.printDiffOrStringify(expected, received.session, 'Expected', 'Received')}`, + }; +}; + +type Result = { + passed: boolean; + key: string; + expectedVal: SentReplayExpected[keyof SentReplayExpected]; + actualVal: SentReplayExpected[keyof SentReplayExpected]; +}; +type Call = [ + EnvelopeHeader, + [ + [ReplayEventHeader | undefined, ReplayEventPayload | undefined], + [RecordingHeader | undefined, RecordingPayloadHeader | undefined], + ], +]; +type CheckCallForSentReplayResult = { pass: boolean; call: Call | undefined; results: Result[] }; + +function checkCallForSentReplay( + call: Call | undefined, + expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, +): CheckCallForSentReplayResult { + const envelopeHeader = call?.[0]; + const envelopeItems = call?.[1] || [[], []]; + const [[replayEventHeader, replayEventPayload], [recordingHeader, recordingPayload] = []] = envelopeItems; + + // @ts-ignore recordingPayload is always a string in our tests + const [recordingPayloadHeader, recordingData] = recordingPayload?.split('\n') || []; + + const actualObj: Required = { + // @ts-ignore Custom envelope + envelopeHeader: envelopeHeader, + // @ts-ignore Custom envelope + replayEventHeader: replayEventHeader, + // @ts-ignore Custom envelope + replayEventPayload: replayEventPayload, + // @ts-ignore Custom envelope + recordingHeader: recordingHeader, + recordingPayloadHeader: recordingPayloadHeader && JSON.parse(recordingPayloadHeader), + recordingData, + }; + + const isObjectContaining = expected && 'sample' in expected && 'inverse' in expected; + const expectedObj = isObjectContaining + ? (expected as { sample: SentReplayExpected }).sample + : (expected as SentReplayExpected); + + if (isObjectContaining) { + console.warn('`expect.objectContaining` is unnecessary when using the `toHaveSentReplay` matcher'); + } + + const results = expected + ? Object.keys(expectedObj) + .map(key => { + const actualVal = actualObj[key as keyof SentReplayExpected]; + const expectedVal = expectedObj[key as keyof SentReplayExpected]; + const passed = !expectedVal || this.equals(actualVal, expectedVal); + + return { passed, key, expectedVal, actualVal }; + }) + .filter(({ passed }) => !passed) + : []; + + const pass = Boolean(call && (!expected || results.length === 0)); + + return { + pass, + call, + results, + }; +} + +/** + * Only want calls that send replay events, i.e. ignore error events + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getReplayCalls(calls: any[][][]): any[][][] { + return calls + .map(call => { + const arg = call[0]; + if (arg.length !== 2) { + return []; + } + + if (!arg[1][0].find(({ type }: { type: string }) => ['replay_event', 'replay_recording'].includes(type))) { + return []; + } + + return [arg]; + }) + .filter(Boolean); +} + +/** + * Checks all calls to `fetch` and ensures a replay was uploaded by + * checking the `fetch()` request's body. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const toHaveSentReplay = function ( + _received: jest.Mocked, + expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, +) { + const { calls } = (getCurrentHub().getClient()?.getTransport()?.send as MockTransport).mock; + + let result: CheckCallForSentReplayResult; + + const expectedKeysLength = expected + ? ('sample' in expected ? Object.keys(expected.sample) : Object.keys(expected)).length + : 0; + + const replayCalls = getReplayCalls(calls); + + for (const currentCall of replayCalls) { + result = checkCallForSentReplay.call(this, currentCall[0], expected); + if (result.pass) { + break; + } + + // stop on the first call where any of the expected obj passes + if (result.results.length < expectedKeysLength) { + break; + } + } + + // @ts-ignore use before assigned + const { results, call, pass } = result; + + const options = { + isNot: this.isNot, + promise: this.promise, + }; + + return { + pass, + message: () => + !call + ? pass + ? 'Expected Replay to not have been sent, but a request was attempted' + : 'Expected Replay to have been sent, but a request was not attempted' + : `${this.utils.matcherHint('toHaveSentReplay', undefined, undefined, options)}\n\n${results + .map(({ key, expectedVal, actualVal }: Result) => + this.utils.printDiffOrStringify( + expectedVal, + actualVal, + `Expected (key: ${key})`, + `Received (key: ${key})`, + ), + ) + .join('\n')}`, + }; +}; + +/** + * Checks the last call to `fetch` and ensures a replay was uploaded by + * checking the `fetch()` request's body. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const toHaveLastSentReplay = function ( + _received: jest.Mocked, + expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, +) { + const { calls } = (getCurrentHub().getClient()?.getTransport()?.send as MockTransport).mock; + const replayCalls = getReplayCalls(calls); + + const lastCall = replayCalls[calls.length - 1]?.[0]; + + const { results, call, pass } = checkCallForSentReplay.call(this, lastCall, expected); + + const options = { + isNot: this.isNot, + promise: this.promise, + }; + + return { + pass, + message: () => + !call + ? pass + ? 'Expected Replay to not have been sent, but a request was attempted' + : 'Expected Replay to have last been sent, but a request was not attempted' + : `${this.utils.matcherHint('toHaveSentReplay', undefined, undefined, options)}\n\n${results + .map(({ key, expectedVal, actualVal }: Result) => + this.utils.printDiffOrStringify( + expectedVal, + actualVal, + `Expected (key: ${key})`, + `Received (key: ${key})`, + ), + ) + .join('\n')}`, + }; +}; + +expect.extend({ + toHaveSameSession, + toHaveSentReplay, + toHaveLastSentReplay, +}); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface AsymmetricMatchers { + toHaveSentReplay(expected?: SentReplayExpected): void; + toHaveLastSentReplay(expected?: SentReplayExpected): void; + toHaveSameSession(expected: undefined | Session): void; + } + interface Matchers { + toHaveSentReplay(expected?: SentReplayExpected): R; + toHaveLastSentReplay(expected?: SentReplayExpected): R; + toHaveSameSession(expected: undefined | Session): R; + } + } +} diff --git a/packages/feedback/package.json b/packages/feedback/package.json new file mode 100644 index 000000000000..f7011aee9181 --- /dev/null +++ b/packages/feedback/package.json @@ -0,0 +1,67 @@ +{ + "name": "@sentry-internal/feedback", + "version": "7.70.0", + "description": "User feedback for Sentry", + "main": "build/npm/cjs/index.js", + "module": "build/npm/esm/index.js", + "types": "build/npm/types/index.d.ts", + "typesVersions": { + "<4.9": { + "build/npm/types/index.d.ts": [ + "build/npm/types-ts3.8/index.d.ts" + ] + } + }, + "sideEffects": false, + "scripts": { + "build": "run-p build:transpile build:types build:bundle", + "build:transpile": "rollup -c rollup.npm.config.js", + "build:bundle": "rollup -c rollup.bundle.config.js", + "build:dev": "run-p build:transpile build:types", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8", + "build:watch": "run-p build:transpile:watch build:bundle:watch build:types:watch", + "build:dev:watch": "run-p build:transpile:watch build:types:watch", + "build:transpile:watch": "yarn build:transpile --watch", + "build:bundle:watch": "yarn build:bundle --watch", + "build:types:watch": "tsc -p tsconfig.types.json --watch", + "build:tarball": "ts-node ../../scripts/prepack.ts --bundles && npm pack ./build/npm", + "circularDepCheck": "madge --circular src/index.ts", + "clean": "rimraf build sentry-replay-*.tgz", + "fix": "run-s fix:eslint fix:prettier", + "fix:eslint": "eslint . --format stylish --fix", + "fix:prettier": "prettier --write \"{src,test,scripts}/**/*.ts\"", + "lint": "run-s lint:prettier lint:eslint", + "lint:eslint": "eslint . --format stylish", + "lint:prettier": "prettier --check \"{src,test,scripts}/**/*.ts\"", + "test": "jest", + "test:watch": "jest --watch", + "yalc:publish": "ts-node ../../scripts/prepack.ts --bundles && yalc publish ./build/npm --push" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/getsentry/sentry-javascript.git" + }, + "author": "Sentry", + "license": "MIT", + "bugs": { + "url": "https://github.com/getsentry/sentry-javascript/issues" + }, + "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", + "devDependencies": { + "@babel/core": "^7.17.5", + "tslib": "^2.4.1 || ^1.9.3" + }, + "dependencies": { + "@sentry/core": "7.70.0", + "@sentry/types": "7.70.0", + "@sentry/utils": "7.70.0" + }, + "engines": { + "node": ">=12" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/feedback/rollup.bundle.config.js b/packages/feedback/rollup.bundle.config.js new file mode 100644 index 000000000000..75f240f85822 --- /dev/null +++ b/packages/feedback/rollup.bundle.config.js @@ -0,0 +1,13 @@ +import { makeBaseBundleConfig, makeBundleConfigVariants } from '../../rollup/index.js'; + +const baseBundleConfig = makeBaseBundleConfig({ + bundleType: 'addon', + entrypoints: ['src/index.ts'], + jsVersion: 'es6', + licenseTitle: '@sentry/replay', + outputFileBase: () => 'bundles/replay', +}); + +const builds = makeBundleConfigVariants(baseBundleConfig); + +export default builds; diff --git a/packages/feedback/rollup.npm.config.js b/packages/feedback/rollup.npm.config.js new file mode 100644 index 000000000000..c3c2db72bebf --- /dev/null +++ b/packages/feedback/rollup.npm.config.js @@ -0,0 +1,16 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + hasBundles: true, + packageSpecificConfig: { + output: { + // set exports to 'named' or 'auto' so that rollup doesn't warn + exports: 'named', + // set preserveModules to false because for Replay we actually want + // to bundle everything into one file. + preserveModules: false, + }, + }, + }), +); diff --git a/packages/feedback/scripts/craft-pre-release.sh b/packages/feedback/scripts/craft-pre-release.sh new file mode 100644 index 000000000000..bae7c3246cdb --- /dev/null +++ b/packages/feedback/scripts/craft-pre-release.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -eux +OLD_VERSION="${1}" +NEW_VERSION="${2}" + +# Do not tag and commit changes made by "npm version" +export npm_config_git_tag_version=false +npm version "${NEW_VERSION}" diff --git a/packages/feedback/scripts/repl.ts b/packages/feedback/scripts/repl.ts new file mode 100644 index 000000000000..e94a9797d1a1 --- /dev/null +++ b/packages/feedback/scripts/repl.ts @@ -0,0 +1,276 @@ +/* eslint:disable: no-console */ +import * as fs from 'fs'; +import inquirer from 'inquirer'; +import { EventEmitter } from 'node:events'; +import * as path from 'path'; +import type { Frame} from 'playwright'; +import {chromium} from 'playwright'; +// import puppeteer from 'puppeteer'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const emitter = new EventEmitter(); + +function getCode(): string { + const bundlePath = path.resolve(__dirname, '../replay/build/bundles/replay.debug.min.js'); + return fs.readFileSync(bundlePath, 'utf8'); +} + +void (async () => { + const code = getCode(); + + async function injectRecording(frame: Frame) { + await frame.evaluate((rrwebCode: string) => { + const win = window; + // @ts-expect-error global + if (win.__IS_RECORDING__) return; + // @ts-expect-error global + win.__IS_RECORDING__ = true; + + (async () => { + function loadScript(code: string) { + const s = document.createElement('script'); + const r = false; + s.type = 'text/javascript'; + s.innerHTML = code; + if (document.head) { + document.head.append(s); + } else { + requestAnimationFrame(() => { + document.head.append(s); + }); + } + } + loadScript(rrwebCode); + + // @ts-expect-error global + win.__replay = new Sentry.Replay({ + blockAllMedia: false, + maskAllText: false, + useCompression: false, + mutationBreadcrumbLimit: 250, + }) + // @ts-expect-error global + Sentry.init({ + debug: true, + dsn: '', + environment: 'repl', + tracesSampleRate: 1.0, + replaysSessionSampleRate: 1.0, + integrations: [ + // @ts-expect-error global + win.__replay + // new BrowserTracing({ + // tracingOrigins: ["localhost:3000", "localhost", /^\//], + // }), + ], + }) + })(); + }, code); + } + + await start('https://react-redux.realworld.io'); + + // const fakeGoto = async (page, url) => { + // const intercept = async (request) => { + // await request.respond({ + // status: 200, + // contentType: 'text/html', + // body: ' ', // non-empty string or page will load indefinitely + // }); + // }; + // await page.setRequestInterception(true); + // page.on('request', intercept); + // await page.goto(url); + // await page.setRequestInterception(false); + // page.off('request', intercept); + // }; + + async function start(defaultURL: string) { + let { url } = await inquirer.prompt([ + { + type: 'input', + name: 'url', + message: `Enter the url you want to record, e.g [${defaultURL}]: `, + }, + ]); + + if (url === '') { + url = defaultURL; + } + + console.log(`Going to open ${url}...`); + await record(url); + console.log('Ready to record. You can do any interaction on the page.'); + + // const { shouldReplay } = await inquirer.prompt([ + // { + // type: 'list', + // choices: [ + // { name: 'Start replay (default)', value: 'default' }, + // { + // name: 'Start replay on original url (helps when experiencing CORS issues)', + // value: 'replayWithFakeURL', + // }, + // { name: 'Skip replay', value: false }, + // ], + // name: 'shouldReplay', + // message: 'Once you want to finish the recording, choose the following to start replay: ', + // }, + // ]); + + emitter.emit('done'); + + /** + * not needed atm as we always save to Sentry + */ + // const { shouldStore } = await inquirer.prompt([ + // { + // type: 'confirm', + // name: 'shouldStore', + // message: 'Persistently store these recorded events?', + // }, + // ]); + + // if (shouldStore) { + // saveEvents(); + // } + + const { shouldRecordAnother } = await inquirer.prompt([ + { + type: 'confirm', + name: 'shouldRecordAnother', + message: 'Record another one?', + }, + ]); + + if (shouldRecordAnother) { + start(url); + } else { + process.exit(); + } + } + + async function record(url: string) { + const browser = await chromium.launch({ + headless: false, + args: [ + '--start-maximized', + '--ignore-certificate-errors', + '--no-sandbox', + '--auto-open-devtools-for-tabs', + ], + }); + const context = await browser.newContext({ + viewport: { + width: 1600, + height: 900, + }, + }); + const page = await context.newPage(); + + // await page.exposeFunction('_replLog', (event) => { + // events.push(event); + // }); + + page.on('framenavigated', async (frame: Frame) => { + await injectRecording(frame); + }); + + await page.goto(url, { + waitUntil: 'domcontentloaded', + timeout: 300000, + }); + + emitter.once('done', async () => { + await context.close(); + await browser.close(); + console.log('go to sentry to view this replay'); + // if (shouldReplay) { + // await replay(url, shouldReplay === 'replayWithFakeURL'); + // } + }); + } + + // async function replay(url, useSpoofedUrl) { + // const browser = await puppeteer.launch({ + // headless: false, + // defaultViewport: { + // width: 1600, + // height: 900, + // }, + // args: ['--start-maximized', '--no-sandbox'], + // }); + // const page = await browser.newPage(); + // if (useSpoofedUrl) { + // await fakeGoto(page, url); + // } else { + // await page.goto('about:blank'); + // } + // + // await page.addStyleTag({ + // path: path.resolve(__dirname, '../dist/rrweb.css'), + // }); + // await page.evaluate(`${code} + // const events = ${JSON.stringify(events)}; + // const replayer = new rrweb.Replayer(events, { + // UNSAFE_replayCanvas: true + // }); + // replayer.play(); + // `); + // } + +// function saveEvents() { +// const tempFolder = path.join(__dirname, '../temp'); +// console.log(tempFolder); +// +// if (!fs.existsSync(tempFolder)) { +// fs.mkdirSync(tempFolder); +// } +// const time = new Date() +// .toISOString() +// .replace(/[-|:]/g, '_') +// .replace(/\..+/, ''); +// const fileName = `replay_${time}.html`; +// const content = ` +// +// +// +// +// +// +// Record @${time} +// +// +// +// +// +// +// +// `; +// const savePath = path.resolve(tempFolder, fileName); +// fs.writeFileSync(savePath, content); +// console.log(`Saved at ${savePath}`); +// } + + process + .on('uncaughtException', (error) => { + console.error(error); + }) + .on('unhandledRejection', (error) => { + console.error(error); + }); +})(); diff --git a/packages/feedback/src/index.ts b/packages/feedback/src/index.ts new file mode 100644 index 000000000000..412354f1dc54 --- /dev/null +++ b/packages/feedback/src/index.ts @@ -0,0 +1,16 @@ +export { Replay } from './integration'; + +export type { + ReplayEventType, + ReplayEventWithTime, + ReplayBreadcrumbFrame, + ReplayBreadcrumbFrameEvent, + ReplayOptionFrameEvent, + ReplayFrame, + ReplayFrameEvent, + ReplaySpanFrame, + ReplaySpanFrameEvent, +} from './types'; + +// TODO (v8): Remove deprecated types +export * from './types/deprecated'; diff --git a/packages/feedback/src/types/index.ts b/packages/feedback/src/types/index.ts new file mode 100644 index 000000000000..0fc39c9cd411 --- /dev/null +++ b/packages/feedback/src/types/index.ts @@ -0,0 +1,5 @@ +export * from './performance'; +export * from './replay'; +export * from './replayFrame'; +export * from './request'; +export * from './rrweb'; diff --git a/packages/feedback/test/index.ts b/packages/feedback/test/index.ts new file mode 100644 index 000000000000..ed4b82a6c780 --- /dev/null +++ b/packages/feedback/test/index.ts @@ -0,0 +1,4 @@ +export * from './mocks/mockRrweb'; // XXX: Needs to happen before `mockSdk` or importing Replay! +export * from './mocks/mockSdk'; + +export const BASE_TIMESTAMP = new Date('2020-02-02 00:00:00').getTime(); // 1580619600000 diff --git a/packages/feedback/tsconfig.json b/packages/feedback/tsconfig.json new file mode 100644 index 000000000000..f8f54556da93 --- /dev/null +++ b/packages/feedback/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "esnext" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/feedback/tsconfig.test.json b/packages/feedback/tsconfig.test.json new file mode 100644 index 000000000000..ad87caa06c48 --- /dev/null +++ b/packages/feedback/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*.ts", "jest.config.ts", "jest.setup.ts"], + + "compilerOptions": { + "types": ["node", "jest"], + "esModuleInterop": true, + "allowJs": true, + "noImplicitAny": true, + "noImplicitThis": false, + "strictNullChecks": true, + "strictPropertyInitialization": false + } +} diff --git a/packages/feedback/tsconfig.types.json b/packages/feedback/tsconfig.types.json new file mode 100644 index 000000000000..374fd9bc9364 --- /dev/null +++ b/packages/feedback/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/npm/types" + } +} From 482e461c80262d2eb829292601225c549bec61ef Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 26 Sep 2023 15:21:50 -0400 Subject: [PATCH 02/19] remove sessionreplay url --- packages/feedback/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/feedback/package.json b/packages/feedback/package.json index f7011aee9181..b88412947698 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -48,7 +48,7 @@ "bugs": { "url": "https://github.com/getsentry/sentry-javascript/issues" }, - "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", + "homepage": "https://docs.sentry.io/platforms/javascript/", "devDependencies": { "@babel/core": "^7.17.5", "tslib": "^2.4.1 || ^1.9.3" From 98d24fcf270fdf9e951c0d7d9e57db28c7c11f6d Mon Sep 17 00:00:00 2001 From: Catherine Lee <55311782+c298lee@users.noreply.github.com> Date: Wed, 27 Sep 2023 10:37:03 -0400 Subject: [PATCH 03/19] add sendfeedback to sdk --- packages/feedback/src/index.ts | 16 --- packages/feedback/src/types/feedback.ts | 6 + packages/feedback/src/types/index.ts | 6 +- .../feedback/src/util/prepareFeedbackEvent.ts | 44 +++++++ .../feedback/src/util/sendFeedbackRequest.ts | 115 ++++++++++++++++++ packages/replay/tsconfig.json | 2 +- packages/types/src/feedback.ts | 16 +++ packages/types/src/index.ts | 1 + 8 files changed, 184 insertions(+), 22 deletions(-) delete mode 100644 packages/feedback/src/index.ts create mode 100644 packages/feedback/src/types/feedback.ts create mode 100644 packages/feedback/src/util/prepareFeedbackEvent.ts create mode 100644 packages/feedback/src/util/sendFeedbackRequest.ts create mode 100644 packages/types/src/feedback.ts diff --git a/packages/feedback/src/index.ts b/packages/feedback/src/index.ts deleted file mode 100644 index 412354f1dc54..000000000000 --- a/packages/feedback/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { Replay } from './integration'; - -export type { - ReplayEventType, - ReplayEventWithTime, - ReplayBreadcrumbFrame, - ReplayBreadcrumbFrameEvent, - ReplayOptionFrameEvent, - ReplayFrame, - ReplayFrameEvent, - ReplaySpanFrame, - ReplaySpanFrameEvent, -} from './types'; - -// TODO (v8): Remove deprecated types -export * from './types/deprecated'; diff --git a/packages/feedback/src/types/feedback.ts b/packages/feedback/src/types/feedback.ts new file mode 100644 index 000000000000..2ba51585e9be --- /dev/null +++ b/packages/feedback/src/types/feedback.ts @@ -0,0 +1,6 @@ +export interface SendFeedbackData { + message: string, + email: string, + replay_id: string, + url: string, +} diff --git a/packages/feedback/src/types/index.ts b/packages/feedback/src/types/index.ts index 0fc39c9cd411..3c6cb93bb7b3 100644 --- a/packages/feedback/src/types/index.ts +++ b/packages/feedback/src/types/index.ts @@ -1,5 +1 @@ -export * from './performance'; -export * from './replay'; -export * from './replayFrame'; -export * from './request'; -export * from './rrweb'; +export * from './feedback'; diff --git a/packages/feedback/src/util/prepareFeedbackEvent.ts b/packages/feedback/src/util/prepareFeedbackEvent.ts new file mode 100644 index 000000000000..3b179b4449ca --- /dev/null +++ b/packages/feedback/src/util/prepareFeedbackEvent.ts @@ -0,0 +1,44 @@ +import type {Scope} from '@sentry/core'; +import {prepareEvent} from '@sentry/core'; +import type {Client, FeedbackEvent} from '@sentry/types'; + +/** + * Prepare a feedback event & enrich it with the SDK metadata. + */ +export async function prepareFeedbackEvent({ + client, + scope, + event, +}: { + client: Client; + event: FeedbackEvent; + scope: Scope; +}): Promise { + const preparedEvent = (await prepareEvent( + client.getOptions(), + event, + {integrations: []}, + scope + )) as FeedbackEvent | null; + + // If e.g. a global event processor returned null + if (!preparedEvent) { + return null; + } + + // This normally happens in browser client "_prepareEvent" + // but since we do not use this private method from the client, but rather the plain import + // we need to do this manually. + preparedEvent.platform = preparedEvent.platform || 'javascript'; + + // extract the SDK name because `client._prepareEvent` doesn't add it to the event + const metadata = client.getSdkMetadata && client.getSdkMetadata(); + const {name, version} = (metadata && metadata.sdk) || {}; + + preparedEvent.sdk = { + ...preparedEvent.sdk, + name: name || 'sentry.javascript.unknown', + version: version || '0.0.0', + }; + return preparedEvent; +} diff --git a/packages/feedback/src/util/sendFeedbackRequest.ts b/packages/feedback/src/util/sendFeedbackRequest.ts new file mode 100644 index 000000000000..db911f42a546 --- /dev/null +++ b/packages/feedback/src/util/sendFeedbackRequest.ts @@ -0,0 +1,115 @@ +import { getCurrentHub } from '@sentry/core'; +import { dsnToString } from '@sentry/utils'; + +import type { SendFeedbackData } from '../types'; +import { prepareFeedbackEvent } from './prepareFeedbackEvent'; + +/** + * Send feedback using `fetch()` + */ +export async function sendFeedbackRequest({ + message, + email, + replay_id, + url, +}: SendFeedbackData): Promise { + const hub = getCurrentHub(); + + if (!hub) { + return null; + } + + const client = hub.getClient(); + const scope = hub.getScope(); + const transport = client && client.getTransport(); + const dsn = client && client.getDsn(); + + if (!client || !transport || !dsn) { + return null; + } + + const baseEvent = { + feedback: { + contact_email: email, + message, + replay_id, + url, + }, + // type: 'feedback_event', + }; + + const feedbackEvent = await prepareFeedbackEvent({ + scope, + client, + event: baseEvent, + }); + + if (!feedbackEvent) { + // Taken from baseclient's `_processEvent` method, where this is handled for errors/transactions + // client.recordDroppedEvent('event_processor', 'feedback', baseEvent); + return null; + } + + /* + For reference, the fully built event looks something like this: + { + "data": { + "dist": "abc123", + "environment": "production", + "feedback": { + "contact_email": "colton.allen@sentry.io", + "message": "I really like this user-feedback feature!", + "replay_id": "ec3b4dc8b79f417596f7a1aa4fcca5d2", + "url": "https://docs.sentry.io/platforms/javascript/" + }, + "id": "1ffe0775ac0f4417aed9de36d9f6f8dc", + "platform": "javascript", + "release": "version@1.3", + "request": { + "headers": { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36" + } + }, + "sdk": { + "name": "sentry.javascript.react", + "version": "6.18.1" + }, + "tags": { + "key": "value" + }, + "timestamp": "2023-08-31T14:10:34.954048", + "user": { + "email": "username@example.com", + "id": "123", + "ip_address": "127.0.0.1", + "name": "user", + "username": "user2270129" + } + } + } + */ + + // Prevent this data (which, if it exists, was used in earlier steps in the processing pipeline) from being sent to + // sentry. (Note: Our use of this property comes and goes with whatever we might be debugging, whatever hacks we may + // 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 + // of this `delete`, lest we miss putting it back in the next time the property is in use.) + delete feedbackEvent.sdkProcessingMetadata; + + try { + const path = 'https://sentry.io/api/0/feedback/'; + const response = await fetch(path, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `DSN ${dsnToString(dsn)}`, + }, + body: JSON.stringify(feedbackEvent), + }); + if (!response.ok) { + return null; + } + return response; + } catch (err) { + return null; + } +} diff --git a/packages/replay/tsconfig.json b/packages/replay/tsconfig.json index f8f54556da93..600c2b4fb078 100644 --- a/packages/replay/tsconfig.json +++ b/packages/replay/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "module": "esnext" }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "../feedback/src/util/sendFeedbackRequest.ts", "../feedback/src/types/feedback.ts"] } diff --git a/packages/types/src/feedback.ts b/packages/types/src/feedback.ts new file mode 100644 index 000000000000..d13b59595c8e --- /dev/null +++ b/packages/types/src/feedback.ts @@ -0,0 +1,16 @@ +import type {Event} from './event'; + +/** + * NOTE: These types are still considered Beta and subject to change. + * @hidden + */ +export interface FeedbackEvent extends Event { + feedback: { + contact_email: string; + message: string; + replay_id: string; + url: string; + }; + // TODO: Add this event type to Event + // type: 'feedback_event'; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 8a93681aa938..556dde26662c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -69,6 +69,7 @@ export type { Profile, } from './profiling'; export type { ReplayEvent, ReplayRecordingData, ReplayRecordingMode } from './replay'; +export type { FeedbackEvent } from './feedback'; export type { QueryParams, Request, SanitizedRequestData } from './request'; export type { Runtime } from './runtime'; export type { CaptureContext, Scope, ScopeContext } from './scope'; From a0ceb97921d66ffe21208b767bea431ab509ffe0 Mon Sep 17 00:00:00 2001 From: Catherine Lee <55311782+c298lee@users.noreply.github.com> Date: Wed, 27 Sep 2023 11:34:46 -0400 Subject: [PATCH 04/19] added feedback type into feedback folder --- packages/feedback/src/index.ts | 1 + packages/feedback/src/types/feedback.ts | 17 +++++++++++++++++ .../feedback/src/util/prepareFeedbackEvent.ts | 3 ++- packages/replay/tsconfig.json | 2 +- 4 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 packages/feedback/src/index.ts diff --git a/packages/feedback/src/index.ts b/packages/feedback/src/index.ts new file mode 100644 index 000000000000..5d4adc0af6e2 --- /dev/null +++ b/packages/feedback/src/index.ts @@ -0,0 +1 @@ +export type { SendFeedbackData } from './types' diff --git a/packages/feedback/src/types/feedback.ts b/packages/feedback/src/types/feedback.ts index 2ba51585e9be..ee779869fb19 100644 --- a/packages/feedback/src/types/feedback.ts +++ b/packages/feedback/src/types/feedback.ts @@ -1,3 +1,20 @@ +import type {Event} from '@sentry/types'; + +/** + * NOTE: These types are still considered Beta and subject to change. + * @hidden + */ +export interface FeedbackEvent extends Event { + feedback: { + contact_email: string; + message: string; + replay_id: string; + url: string; + }; + // TODO: Add this event type to Event + // type: 'feedback_event'; +} + export interface SendFeedbackData { message: string, email: string, diff --git a/packages/feedback/src/util/prepareFeedbackEvent.ts b/packages/feedback/src/util/prepareFeedbackEvent.ts index 3b179b4449ca..c174cd3f4c1c 100644 --- a/packages/feedback/src/util/prepareFeedbackEvent.ts +++ b/packages/feedback/src/util/prepareFeedbackEvent.ts @@ -1,6 +1,7 @@ import type {Scope} from '@sentry/core'; import {prepareEvent} from '@sentry/core'; -import type {Client, FeedbackEvent} from '@sentry/types'; +import type { Client, FeedbackEvent } from '@sentry/types'; +// import type { FeedbackEvent } from '../types'; /** * Prepare a feedback event & enrich it with the SDK metadata. diff --git a/packages/replay/tsconfig.json b/packages/replay/tsconfig.json index 600c2b4fb078..f8f54556da93 100644 --- a/packages/replay/tsconfig.json +++ b/packages/replay/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "module": "esnext" }, - "include": ["src/**/*.ts", "../feedback/src/util/sendFeedbackRequest.ts", "../feedback/src/types/feedback.ts"] + "include": ["src/**/*.ts"] } From 39ab4456412eed8db40fd101e21360157517f4d5 Mon Sep 17 00:00:00 2001 From: Catherine Lee <55311782+c298lee@users.noreply.github.com> Date: Wed, 27 Sep 2023 17:59:25 -0400 Subject: [PATCH 05/19] added prepareFeedbackEvent test case --- .../unit/util/prepareFeedbackEvent.test.ts | 87 +++++++++++++++++++ packages/feedback/test/utils/TestClient.ts | 44 ++++++++++ 2 files changed, 131 insertions(+) create mode 100644 packages/feedback/test/unit/util/prepareFeedbackEvent.test.ts create mode 100644 packages/feedback/test/utils/TestClient.ts diff --git a/packages/feedback/test/unit/util/prepareFeedbackEvent.test.ts b/packages/feedback/test/unit/util/prepareFeedbackEvent.test.ts new file mode 100644 index 000000000000..cee0d99f0b5c --- /dev/null +++ b/packages/feedback/test/unit/util/prepareFeedbackEvent.test.ts @@ -0,0 +1,87 @@ +import type { Hub, Scope } from '@sentry/core'; +import { getCurrentHub } from '@sentry/core'; +import type { Client } from '@sentry/types'; + +import type { FeedbackEvent } from '../../../src/types'; +import { prepareFeedbackEvent } from '../../../src/util/prepareFeedbackEvent'; +import { getDefaultClientOptions, TestClient } from '../../utils/TestClient'; + +describe('Unit | util | prepareFeedbackEvent', () => { + let hub: Hub; + let client: Client; + let scope: Scope; + + beforeEach(() => { + hub = getCurrentHub(); + client = new TestClient(getDefaultClientOptions()); + hub.bindClient(client); + + client = hub.getClient()!; + scope = hub.getScope()!; + + jest.spyOn(client, 'getSdkMetadata').mockImplementation(() => { + return { + sdk: { + name: 'sentry.javascript.testSdk', + version: '1.0.0', + }, + }; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('works', async () => { + expect(client).toBeDefined(); + expect(scope).toBeDefined(); + + const replayId = 'replay-ID'; + const event: FeedbackEvent = { + timestamp: 1670837008.634, + event_id: 'feedback-ID', + feedback: { + contact_email: 'test@test.com', + message: 'looks great!', + replay_id: replayId, + url: 'https://sentry.io/', + }, + contexts: { + replay: { + error_sample_rate: 1.0, + session_sample_rate: 0.1, + }, + }, + }; + + const feedbackEvent = await prepareFeedbackEvent({ scope, client, event }); + + expect(client.getSdkMetadata).toHaveBeenCalledTimes(1); + + expect(feedbackEvent).toEqual({ + timestamp: 1670837008.634, + event_id: 'feedback-ID', + feedback: { + contact_email: 'test@test.com', + message: 'looks great!', + replay_id: replayId, + url: 'https://sentry.io/', + }, + platform: 'javascript', + environment: 'production', + contexts: { + replay: { + error_sample_rate: 1.0, + session_sample_rate: 0.1, + }, + }, + sdk: { + name: 'sentry.javascript.testSdk', + version: '1.0.0', + }, + sdkProcessingMetadata: expect.any(Object), + breadcrumbs: undefined, + }); + }); +}); diff --git a/packages/feedback/test/utils/TestClient.ts b/packages/feedback/test/utils/TestClient.ts new file mode 100644 index 000000000000..ad39b82084a9 --- /dev/null +++ b/packages/feedback/test/utils/TestClient.ts @@ -0,0 +1,44 @@ +import { BaseClient, createTransport, initAndBind } from '@sentry/core'; +import type { BrowserClientReplayOptions, ClientOptions, Event, SeverityLevel } from '@sentry/types'; +import { resolvedSyncPromise } from '@sentry/utils'; + +export interface TestClientOptions extends ClientOptions, BrowserClientReplayOptions {} + +export class TestClient extends BaseClient { + public constructor(options: TestClientOptions) { + super(options); + } + + public eventFromException(exception: any): PromiseLike { + return resolvedSyncPromise({ + exception: { + values: [ + { + /* eslint-disable @typescript-eslint/no-unsafe-member-access */ + type: exception.name, + value: exception.message, + /* eslint-enable @typescript-eslint/no-unsafe-member-access */ + }, + ], + }, + }); + } + + public eventFromMessage(message: string, level: SeverityLevel = 'info'): PromiseLike { + return resolvedSyncPromise({ message, level }); + } +} + +export function init(options: TestClientOptions): void { + initAndBind(TestClient, options); +} + +export function getDefaultClientOptions(options: Partial = {}): ClientOptions { + return { + integrations: [], + dsn: 'https://username@domain/123', + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => resolvedSyncPromise({})), + stackParser: () => [], + ...options, + }; +} From 7f539668a949ea5daeaa14bf9465743a8512e738 Mon Sep 17 00:00:00 2001 From: Catherine Lee <55311782+c298lee@users.noreply.github.com> Date: Thu, 28 Sep 2023 10:35:14 -0400 Subject: [PATCH 06/19] fix files changed --- packages/core/src/utils/isSentryRequestUrl.ts | 29 ------------------- .../test/lib/utils/isSentryRequestUrl.test.ts | 26 ----------------- .../feedback/src/util/prepareFeedbackEvent.ts | 5 ++-- .../src/utils/getRequestUrl.ts | 15 ---------- .../test/utils/getRequestUrl.test.ts | 20 ------------- 5 files changed, 3 insertions(+), 92 deletions(-) delete mode 100644 packages/core/src/utils/isSentryRequestUrl.ts delete mode 100644 packages/core/test/lib/utils/isSentryRequestUrl.test.ts delete mode 100644 packages/node-experimental/src/utils/getRequestUrl.ts delete mode 100644 packages/node-experimental/test/utils/getRequestUrl.test.ts diff --git a/packages/core/src/utils/isSentryRequestUrl.ts b/packages/core/src/utils/isSentryRequestUrl.ts deleted file mode 100644 index 0256e3cf7835..000000000000 --- a/packages/core/src/utils/isSentryRequestUrl.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { DsnComponents, Hub } from '@sentry/types'; - -/** - * Checks whether given url points to Sentry server - * @param url url to verify - */ -export function isSentryRequestUrl(url: string, hub: Hub): boolean { - const client = hub.getClient(); - const dsn = client && client.getDsn(); - const tunnel = client && client.getOptions().tunnel; - - return checkDsn(url, dsn) || checkTunnel(url, tunnel); -} - -function checkTunnel(url: string, tunnel: string | undefined): boolean { - if (!tunnel) { - return false; - } - - return removeTrailingSlash(url) === removeTrailingSlash(tunnel); -} - -function checkDsn(url: string, dsn: DsnComponents | undefined): boolean { - return dsn ? url.includes(dsn.host) : false; -} - -function removeTrailingSlash(str: string): string { - return str[str.length - 1] === '/' ? str.slice(0, -1) : str; -} diff --git a/packages/core/test/lib/utils/isSentryRequestUrl.test.ts b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts deleted file mode 100644 index b1671b9410e8..000000000000 --- a/packages/core/test/lib/utils/isSentryRequestUrl.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Hub } from '@sentry/types'; - -import { isSentryRequestUrl } from '../../../src'; - -describe('isSentryRequestUrl', () => { - it.each([ - ['', 'sentry-dsn.com', '', false], - ['http://sentry-dsn.com/my-url', 'sentry-dsn.com', '', true], - ['http://sentry-dsn.com', 'sentry-dsn.com', '', true], - ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200', true], - ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200/', true], - ['http://tunnel:4200/', 'sentry-dsn.com', 'http://tunnel:4200', true], - ['http://tunnel:4200/a', 'sentry-dsn.com', 'http://tunnel:4200', false], - ])('works with url=%s, dsn=%s, tunnel=%s', (url: string, dsn: string, tunnel: string, expected: boolean) => { - const hub = { - getClient: () => { - return { - getOptions: () => ({ tunnel }), - getDsn: () => ({ host: dsn }), - }; - }, - } as unknown as Hub; - - expect(isSentryRequestUrl(url, hub)).toBe(expected); - }); -}); diff --git a/packages/feedback/src/util/prepareFeedbackEvent.ts b/packages/feedback/src/util/prepareFeedbackEvent.ts index c174cd3f4c1c..8f7c5ba71940 100644 --- a/packages/feedback/src/util/prepareFeedbackEvent.ts +++ b/packages/feedback/src/util/prepareFeedbackEvent.ts @@ -1,7 +1,8 @@ import type {Scope} from '@sentry/core'; import {prepareEvent} from '@sentry/core'; -import type { Client, FeedbackEvent } from '@sentry/types'; -// import type { FeedbackEvent } from '../types'; +import type { Client } from '@sentry/types'; + +import type { FeedbackEvent } from '../types'; /** * Prepare a feedback event & enrich it with the SDK metadata. diff --git a/packages/node-experimental/src/utils/getRequestUrl.ts b/packages/node-experimental/src/utils/getRequestUrl.ts deleted file mode 100644 index 1e4dcfb71232..000000000000 --- a/packages/node-experimental/src/utils/getRequestUrl.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { RequestOptions } from 'http'; - -/** Build a full URL from request options. */ -export function getRequestUrl(requestOptions: RequestOptions): string { - const protocol = requestOptions.protocol || ''; - const hostname = requestOptions.hostname || requestOptions.host || ''; - // Don't log standard :80 (http) and :443 (https) ports to reduce the noise - // Also don't add port if the hostname already includes a port - const port = - !requestOptions.port || requestOptions.port === 80 || requestOptions.port === 443 || /^(.*):(\d+)$/.test(hostname) - ? '' - : `:${requestOptions.port}`; - const path = requestOptions.path ? requestOptions.path : '/'; - return `${protocol}//${hostname}${port}${path}`; -} diff --git a/packages/node-experimental/test/utils/getRequestUrl.test.ts b/packages/node-experimental/test/utils/getRequestUrl.test.ts deleted file mode 100644 index caa92aa10a59..000000000000 --- a/packages/node-experimental/test/utils/getRequestUrl.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { RequestOptions } from 'http'; - -import { getRequestUrl } from '../../src/utils/getRequestUrl'; - -describe('getRequestUrl', () => { - it.each([ - [{ protocol: 'http:', hostname: 'localhost', port: 80 }, 'http://localhost/'], - [{ protocol: 'http:', hostname: 'localhost', host: 'localhost:80', port: 80 }, 'http://localhost/'], - [{ protocol: 'http:', hostname: 'localhost', port: 3000 }, 'http://localhost:3000/'], - [{ protocol: 'http:', host: 'localhost:3000', port: 3000 }, 'http://localhost:3000/'], - [{ protocol: 'https:', hostname: 'localhost', port: 443 }, 'https://localhost/'], - [{ protocol: 'https:', hostname: 'localhost', port: 443, path: '/my-path' }, 'https://localhost/my-path'], - [ - { protocol: 'https:', hostname: 'www.example.com', port: 443, path: '/my-path' }, - 'https://www.example.com/my-path', - ], - ])('works with %s', (input: RequestOptions, expected: string | undefined) => { - expect(getRequestUrl(input)).toBe(expected); - }); -}); From cd92c194e3f7e7002105daa09e4288d6ac6742ab Mon Sep 17 00:00:00 2001 From: Catherine Lee <55311782+c298lee@users.noreply.github.com> Date: Fri, 29 Sep 2023 17:07:50 -0400 Subject: [PATCH 07/19] linting and PR comment changes --- packages/feedback/.eslintignore | 4 - packages/feedback/.eslintrc.js | 6 - packages/feedback/CONTRIBUTING.md | 4 - packages/feedback/LICENSE | 2 +- packages/feedback/MIGRATION.md | 149 ------------------ packages/feedback/README.md | 3 + packages/feedback/rollup.bundle.config.js | 4 +- packages/feedback/rollup.npm.config.js | 2 +- packages/feedback/src/index.ts | 3 +- packages/feedback/src/types/feedback.ts | 14 +- .../feedback/src/util/prepareFeedbackEvent.ts | 15 +- .../feedback/src/util/sendFeedbackRequest.ts | 6 +- packages/replay/.eslintignore | 1 - packages/types/src/feedback.ts | 2 +- 14 files changed, 31 insertions(+), 184 deletions(-) delete mode 100644 packages/feedback/CONTRIBUTING.md delete mode 100644 packages/feedback/MIGRATION.md diff --git a/packages/feedback/.eslintignore b/packages/feedback/.eslintignore index c76c6c2d64d1..b38db2f296ff 100644 --- a/packages/feedback/.eslintignore +++ b/packages/feedback/.eslintignore @@ -1,6 +1,2 @@ node_modules/ build/ -demo/build/ -# TODO: Check if we can re-introduce linting in demo -demo -metrics diff --git a/packages/feedback/.eslintrc.js b/packages/feedback/.eslintrc.js index 4f69827ac50b..cf9985e769c0 100644 --- a/packages/feedback/.eslintrc.js +++ b/packages/feedback/.eslintrc.js @@ -6,12 +6,6 @@ module.exports = { extends: ['../../.eslintrc.js'], overrides: [ - { - files: ['src/**/*.ts'], - rules: { - '@sentry-internal/sdk/no-unsupported-es6-methods': 'off', - }, - }, { files: ['jest.setup.ts', 'jest.config.ts'], parserOptions: { diff --git a/packages/feedback/CONTRIBUTING.md b/packages/feedback/CONTRIBUTING.md deleted file mode 100644 index 829930e2b05e..000000000000 --- a/packages/feedback/CONTRIBUTING.md +++ /dev/null @@ -1,4 +0,0 @@ -## Updating the rrweb dependency - -When [updating the `rrweb` dependency](https://github.com/getsentry/sentry-javascript/blob/a493aa6a46555b944c8d896a2164bcd8b11caaf5/packages/replay/package.json?plain=1#LL55), -please be aware that [`@sentry/replay`'s README.md](https://github.com/getsentry/sentry-javascript/blob/a493aa6a46555b944c8d896a2164bcd8b11caaf5/packages/replay/README.md?plain=1#LL204) also needs to be updated. diff --git a/packages/feedback/LICENSE b/packages/feedback/LICENSE index 4ac873d49f33..d11896ba1181 100644 --- a/packages/feedback/LICENSE +++ b/packages/feedback/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2022 Sentry (https://sentry.io) and individual contributors. All rights reserved. +Copyright (c) 2023 Sentry (https://sentry.io) and individual contributors. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the diff --git a/packages/feedback/MIGRATION.md b/packages/feedback/MIGRATION.md deleted file mode 100644 index ba6326939970..000000000000 --- a/packages/feedback/MIGRATION.md +++ /dev/null @@ -1,149 +0,0 @@ -# End of Replay Beta - -Sentry Replay is now out of Beta. This means that the usual stability guarantees apply. - -Because of experimentation and rapid iteration, during the Beta period some bugs and problems came up which have since been fixed/improved. -We **strongly** recommend anyone using Replay in a version before 7.39.0 to update to 7.39.0 or newer, in order to prevent running Replay with known problems that have since been fixed. - -Below you can find a list of relevant replay issues that have been resolved until 7.39.0: - -## New features / improvements - -- Remove `autoplay` attribute from audio/video tags ([#59](https://github.com/getsentry/rrweb/pull/59)) -- Exclude fetching scripts that use `` ([#52](https://github.com/getsentry/rrweb/pull/52)) -- With maskAllText, mask the attributes: placeholder, title, `aria-label` -- Lower the flush max delay from 15 seconds to 5 seconds (#6761) -- Stop recording when retry fails (#6765) -- Stop without retry when receiving bad API response (#6773) -- Send client_report when replay sending fails (#7093) -- Stop recording when hitting a rate limit (#7018) -- Allow Replay to be used in Electron renderers with nodeIntegration enabled (#6644) -- Do not renew session in error mode (#6948) -- Remove default sample rates for replay (#6878) -- Add `flush` method to integration (#6776) -- Improve compression worker & fallback behavior (#6988, #6936, #6827) -- Improve error handling (#7087, #7094, #7010, getsentry/rrweb#16, #6856) -- Add more default block filters (#7233) - -## Fixes - -- Fix masking inputs on change when `maskAllInputs:false` ([#61](https://github.com/getsentry/rrweb/pull/61)) -- More robust `rootShadowHost` check ([#50](https://github.com/getsentry/rrweb/pull/50)) -- Fix duplicated textarea value ([#62](https://github.com/getsentry/rrweb/pull/62)) -- Handle removed attributes ([#65](https://github.com/getsentry/rrweb/pull/65)) -- Change LCP calculation (#7187, #7225) -- Fix debounced flushes not respecting `maxWait` (#7207, #7208) -- Fix svgs not getting unblocked (#7132) -- Fix missing fetch/xhr requests (#7134) -- Fix feature detection of PerformanceObserver (#7029) -- Fix `checkoutEveryNms` (#6722) -- Fix incorrect uncompressed recording size due to encoding (#6740) -- Ensure dropping replays works (#6522) -- Envelope send should be awaited in try/catch (#6625) -- Improve handling of `maskAllText` selector (#6637) - -# Upgrading Replay from 7.34.0 to 7.35.0 - #6645 - -This release will remove the ability to change the default rrweb recording options (outside of privacy options). The following are the new configuration values all replays will use: -`slimDOMOptions: 'all'` - Removes `script`, comments, `favicon`, whitespace in `head`, and a few `meta` tags in `head` -`recordCanvas: false` - This option did not do anything as playback of recorded canvas means we would have to remove the playback sandbox (which is a security concern). -`inlineStylesheet: true` - Inlines styles into the recording itself instead of attempting to fetch it remotely. This means that styles in the replay will reflect the styles at the time of recording and not the current styles of the remote stylesheet. -`collectFonts: true` - Attempts to load custom fonts. -`inlineImages: false` - Does not inline images to recording and instead loads the asset remotely. During playback, images may not load due to CORS (add sentry.io as an origin). - -Additionally, we have streamlined the privacy options. The following table lists the deprecated value, and what it is replaced by: - -| deprecated key | replaced by | description | -| ---------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| maskInputOptions | mask | Use CSS selectors in `mask` in order to mask all inputs of a certain type. For example, `input[type="address"]` | -| blockSelector | block | The selector(s) can be moved directly in the `block` array. | -| blockClass | block | Convert the class name to a CSS selector and add to `block` array. For example, `first-name` becomes `.first-name`. Regexes can be moved as-is. | -| maskClass | mask | Convert the class name to a CSS selector and add to `mask` array. For example, `first-name` becomes `.first-name`. Regexes can be moved as-is. | -| maskSelector | mask | The selector(s) can be moved directly in the `mask` array. | -| ignoreClass | ignore | Convert the class name to a CSS selector and add to `ignore` array. For example, `first-name` becomes `.first-name`. Regexes can be moved as-is. | - -# Upgrading Replay from 7.31.0 to 7.32.0 - -In 7.32.0, we have removed the default values for the replay sample rates. -Previously, they were: - -* `replaysSessionSampleRate: 0.1` -* `replaysOnErrorSampleRate: 1.0` - -Now, you have to explicitly set the sample rates, otherwise they default to 0. - -# Upgrading Replay from 0.6.x to 7.24.0 - -The Sentry Replay integration was moved to the Sentry JavaScript SDK monorepo. Hence we're jumping from version 0.x to the monorepo's 7.x version which is shared across all JS SDK packages. - -## Replay sample rates are defined on top level (https://github.com/getsentry/sentry-javascript/issues/6351) - -Instead of defining the sample rates on the integration like this: - -```js -Sentry.init({ - dsn: '__DSN__', - integrations: [ - new Replay({ - sessionSampleRate: 0.1, - errorSampleRate: 1.0, - }) - ], - // ... -}); -``` - -They are now defined on the top level of the SDK: - -```js -Sentry.init({ - dsn: '__DSN__', - replaysSessionSampleRate: 0.1, - replaysOnErrorSampleRate: 1.0, - integrations: [ - new Replay({ - // other replay config still goes in here - }) - ], -}); -``` - -Note that the sample rate options inside of `new Replay({})` have been deprecated and will be removed in a future update. - -## Removed deprecated options (https://github.com/getsentry/sentry-javascript/pull/6370) - -Two options, which have been deprecated for some time, have been removed: - -* `replaysSamplingRate` - instead use `sessionSampleRate` -* `captureOnlyOnError` - instead use `errorSampleRate` - -## New NPM package structure (https://github.com/getsentry/sentry-javascript/issues/6280) - -The internal structure of the npm package has changed. This is unlikely to affect you, unless you have imported something from e.g.: - -```js -import something from '@sentry/replay/submodule'; -``` - -If you only imported from `@sentry/replay`, this will not affect you. - -## Changed type name from `IEventBuffer` to `EventBuffer` (https://github.com/getsentry/sentry-javascript/pull/6416) - -It is highly unlikely to affect anybody, but the type `IEventBuffer` was renamed to `EventBuffer` for consistency. -Unless you manually imported this and used it somewhere in your codebase, this will not affect you. - -## Session object is now a plain object (https://github.com/getsentry/sentry-javascript/pull/6417) - -The `Session` object exported from Replay is now a plain object, instead of a class. -This should not affect you unless you specifically accessed this class & did custom things with it. - -## Reduce public API of Replay integration (https://github.com/getsentry/sentry-javascript/pull/6407) - -The result of `new Replay()` now has a much more limited public API. Only the following methods are exposed: - -```js -const replay = new Replay(); - -replay.start(); -replay.stop(); -``` diff --git a/packages/feedback/README.md b/packages/feedback/README.md index 3d835d1838e5..4600f0803465 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -10,6 +10,9 @@ [![npm dm](https://img.shields.io/npm/dm/@sentry/feedback.svg)](https://www.npmjs.com/package/@sentry/feedback) [![npm dt](https://img.shields.io/npm/dt/@sentry/feedback.svg)](https://www.npmjs.com/package/@sentry/feedback) +This SDK is **considered experimental and in an alpha state**. It may experience breaking changes, and may be discontinued at any time. Please reach out on +[GitHub](https://github.com/getsentry/sentry-javascript/issues/new/choose) if you have any feedback/concerns. + ## Pre-requisites `@sentry/feedback` requires Node 12+, and browsers newer than IE11. diff --git a/packages/feedback/rollup.bundle.config.js b/packages/feedback/rollup.bundle.config.js index 75f240f85822..185b38249dc0 100644 --- a/packages/feedback/rollup.bundle.config.js +++ b/packages/feedback/rollup.bundle.config.js @@ -4,8 +4,8 @@ const baseBundleConfig = makeBaseBundleConfig({ bundleType: 'addon', entrypoints: ['src/index.ts'], jsVersion: 'es6', - licenseTitle: '@sentry/replay', - outputFileBase: () => 'bundles/replay', + licenseTitle: '@sentry-internal/feedback', + outputFileBase: () => 'bundles/feedback', }); const builds = makeBundleConfigVariants(baseBundleConfig); diff --git a/packages/feedback/rollup.npm.config.js b/packages/feedback/rollup.npm.config.js index c3c2db72bebf..e823e7b18863 100644 --- a/packages/feedback/rollup.npm.config.js +++ b/packages/feedback/rollup.npm.config.js @@ -7,7 +7,7 @@ export default makeNPMConfigVariants( output: { // set exports to 'named' or 'auto' so that rollup doesn't warn exports: 'named', - // set preserveModules to false because for Replay we actually want + // set preserveModules to false because for feedback we actually want // to bundle everything into one file. preserveModules: false, }, diff --git a/packages/feedback/src/index.ts b/packages/feedback/src/index.ts index 5d4adc0af6e2..740959599fdd 100644 --- a/packages/feedback/src/index.ts +++ b/packages/feedback/src/index.ts @@ -1 +1,2 @@ -export type { SendFeedbackData } from './types' +export type { SendFeedbackData } from './types'; +export { sendFeedbackRequest } from './util/sendFeedbackRequest'; diff --git a/packages/feedback/src/types/feedback.ts b/packages/feedback/src/types/feedback.ts index ee779869fb19..28e5f82f31ac 100644 --- a/packages/feedback/src/types/feedback.ts +++ b/packages/feedback/src/types/feedback.ts @@ -1,4 +1,4 @@ -import type {Event} from '@sentry/types'; +import type { Event, Primitive } from '@sentry/types'; /** * NOTE: These types are still considered Beta and subject to change. @@ -16,8 +16,12 @@ export interface FeedbackEvent extends Event { } export interface SendFeedbackData { - message: string, - email: string, - replay_id: string, - url: string, + feedback: { + message: string; + email: string; + replay_id: string | undefined; + name: string; + url: string; + }; + tags: { [key: string]: Primitive } | undefined; } diff --git a/packages/feedback/src/util/prepareFeedbackEvent.ts b/packages/feedback/src/util/prepareFeedbackEvent.ts index 8f7c5ba71940..7d3defbc2523 100644 --- a/packages/feedback/src/util/prepareFeedbackEvent.ts +++ b/packages/feedback/src/util/prepareFeedbackEvent.ts @@ -1,5 +1,5 @@ -import type {Scope} from '@sentry/core'; -import {prepareEvent} from '@sentry/core'; +import type { Scope } from '@sentry/core'; +import { prepareEvent } from '@sentry/core'; import type { Client } from '@sentry/types'; import type { FeedbackEvent } from '../types'; @@ -16,11 +16,16 @@ export async function prepareFeedbackEvent({ event: FeedbackEvent; scope: Scope; }): Promise { + const eventHint = { integrations: undefined }; + if (client.emit) { + client.emit('preprocessEvent', event, eventHint); + } + const preparedEvent = (await prepareEvent( client.getOptions(), event, - {integrations: []}, - scope + { integrations: undefined }, + scope, )) as FeedbackEvent | null; // If e.g. a global event processor returned null @@ -35,7 +40,7 @@ export async function prepareFeedbackEvent({ // extract the SDK name because `client._prepareEvent` doesn't add it to the event const metadata = client.getSdkMetadata && client.getSdkMetadata(); - const {name, version} = (metadata && metadata.sdk) || {}; + const { name, version } = (metadata && metadata.sdk) || {}; preparedEvent.sdk = { ...preparedEvent.sdk, diff --git a/packages/feedback/src/util/sendFeedbackRequest.ts b/packages/feedback/src/util/sendFeedbackRequest.ts index db911f42a546..5bc5714c96b3 100644 --- a/packages/feedback/src/util/sendFeedbackRequest.ts +++ b/packages/feedback/src/util/sendFeedbackRequest.ts @@ -8,10 +8,8 @@ import { prepareFeedbackEvent } from './prepareFeedbackEvent'; * Send feedback using `fetch()` */ export async function sendFeedbackRequest({ - message, - email, - replay_id, - url, + feedback: { message, email, name, replay_id, url }, + tags, }: SendFeedbackData): Promise { const hub = getCurrentHub(); diff --git a/packages/replay/.eslintignore b/packages/replay/.eslintignore index 3a347e0e1879..b38db2f296ff 100644 --- a/packages/replay/.eslintignore +++ b/packages/replay/.eslintignore @@ -1,3 +1,2 @@ node_modules/ build/ - diff --git a/packages/types/src/feedback.ts b/packages/types/src/feedback.ts index d13b59595c8e..2a904edbc57a 100644 --- a/packages/types/src/feedback.ts +++ b/packages/types/src/feedback.ts @@ -1,4 +1,4 @@ -import type {Event} from './event'; +import type { Event } from './event'; /** * NOTE: These types are still considered Beta and subject to change. From 730c9e52084138142031fc2e285edceecc0baeaa Mon Sep 17 00:00:00 2001 From: Catherine Lee <55311782+c298lee@users.noreply.github.com> Date: Fri, 29 Sep 2023 17:09:08 -0400 Subject: [PATCH 08/19] Revert "fix files changed" This reverts commit f71e4137fe5b2d71d496951e62c8f609cdddebba. --- packages/core/src/utils/isSentryRequestUrl.ts | 29 +++++++++++++++++++ .../test/lib/utils/isSentryRequestUrl.test.ts | 26 +++++++++++++++++ .../src/utils/getRequestUrl.ts | 15 ++++++++++ .../test/utils/getRequestUrl.test.ts | 20 +++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 packages/core/src/utils/isSentryRequestUrl.ts create mode 100644 packages/core/test/lib/utils/isSentryRequestUrl.test.ts create mode 100644 packages/node-experimental/src/utils/getRequestUrl.ts create mode 100644 packages/node-experimental/test/utils/getRequestUrl.test.ts diff --git a/packages/core/src/utils/isSentryRequestUrl.ts b/packages/core/src/utils/isSentryRequestUrl.ts new file mode 100644 index 000000000000..0256e3cf7835 --- /dev/null +++ b/packages/core/src/utils/isSentryRequestUrl.ts @@ -0,0 +1,29 @@ +import type { DsnComponents, Hub } from '@sentry/types'; + +/** + * Checks whether given url points to Sentry server + * @param url url to verify + */ +export function isSentryRequestUrl(url: string, hub: Hub): boolean { + const client = hub.getClient(); + const dsn = client && client.getDsn(); + const tunnel = client && client.getOptions().tunnel; + + return checkDsn(url, dsn) || checkTunnel(url, tunnel); +} + +function checkTunnel(url: string, tunnel: string | undefined): boolean { + if (!tunnel) { + return false; + } + + return removeTrailingSlash(url) === removeTrailingSlash(tunnel); +} + +function checkDsn(url: string, dsn: DsnComponents | undefined): boolean { + return dsn ? url.includes(dsn.host) : false; +} + +function removeTrailingSlash(str: string): string { + return str[str.length - 1] === '/' ? str.slice(0, -1) : str; +} diff --git a/packages/core/test/lib/utils/isSentryRequestUrl.test.ts b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts new file mode 100644 index 000000000000..b1671b9410e8 --- /dev/null +++ b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts @@ -0,0 +1,26 @@ +import type { Hub } from '@sentry/types'; + +import { isSentryRequestUrl } from '../../../src'; + +describe('isSentryRequestUrl', () => { + it.each([ + ['', 'sentry-dsn.com', '', false], + ['http://sentry-dsn.com/my-url', 'sentry-dsn.com', '', true], + ['http://sentry-dsn.com', 'sentry-dsn.com', '', true], + ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200', true], + ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200/', true], + ['http://tunnel:4200/', 'sentry-dsn.com', 'http://tunnel:4200', true], + ['http://tunnel:4200/a', 'sentry-dsn.com', 'http://tunnel:4200', false], + ])('works with url=%s, dsn=%s, tunnel=%s', (url: string, dsn: string, tunnel: string, expected: boolean) => { + const hub = { + getClient: () => { + return { + getOptions: () => ({ tunnel }), + getDsn: () => ({ host: dsn }), + }; + }, + } as unknown as Hub; + + expect(isSentryRequestUrl(url, hub)).toBe(expected); + }); +}); diff --git a/packages/node-experimental/src/utils/getRequestUrl.ts b/packages/node-experimental/src/utils/getRequestUrl.ts new file mode 100644 index 000000000000..1e4dcfb71232 --- /dev/null +++ b/packages/node-experimental/src/utils/getRequestUrl.ts @@ -0,0 +1,15 @@ +import type { RequestOptions } from 'http'; + +/** Build a full URL from request options. */ +export function getRequestUrl(requestOptions: RequestOptions): string { + const protocol = requestOptions.protocol || ''; + const hostname = requestOptions.hostname || requestOptions.host || ''; + // Don't log standard :80 (http) and :443 (https) ports to reduce the noise + // Also don't add port if the hostname already includes a port + const port = + !requestOptions.port || requestOptions.port === 80 || requestOptions.port === 443 || /^(.*):(\d+)$/.test(hostname) + ? '' + : `:${requestOptions.port}`; + const path = requestOptions.path ? requestOptions.path : '/'; + return `${protocol}//${hostname}${port}${path}`; +} diff --git a/packages/node-experimental/test/utils/getRequestUrl.test.ts b/packages/node-experimental/test/utils/getRequestUrl.test.ts new file mode 100644 index 000000000000..caa92aa10a59 --- /dev/null +++ b/packages/node-experimental/test/utils/getRequestUrl.test.ts @@ -0,0 +1,20 @@ +import type { RequestOptions } from 'http'; + +import { getRequestUrl } from '../../src/utils/getRequestUrl'; + +describe('getRequestUrl', () => { + it.each([ + [{ protocol: 'http:', hostname: 'localhost', port: 80 }, 'http://localhost/'], + [{ protocol: 'http:', hostname: 'localhost', host: 'localhost:80', port: 80 }, 'http://localhost/'], + [{ protocol: 'http:', hostname: 'localhost', port: 3000 }, 'http://localhost:3000/'], + [{ protocol: 'http:', host: 'localhost:3000', port: 3000 }, 'http://localhost:3000/'], + [{ protocol: 'https:', hostname: 'localhost', port: 443 }, 'https://localhost/'], + [{ protocol: 'https:', hostname: 'localhost', port: 443, path: '/my-path' }, 'https://localhost/my-path'], + [ + { protocol: 'https:', hostname: 'www.example.com', port: 443, path: '/my-path' }, + 'https://www.example.com/my-path', + ], + ])('works with %s', (input: RequestOptions, expected: string | undefined) => { + expect(getRequestUrl(input)).toBe(expected); + }); +}); From ac9fafb1a2f361640f5820e6e3a06df23492c370 Mon Sep 17 00:00:00 2001 From: Catherine Lee <55311782+c298lee@users.noreply.github.com> Date: Tue, 3 Oct 2023 09:30:00 -0400 Subject: [PATCH 09/19] Update replay .eslintignore --- packages/replay/.eslintignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/replay/.eslintignore b/packages/replay/.eslintignore index b38db2f296ff..c76c6c2d64d1 100644 --- a/packages/replay/.eslintignore +++ b/packages/replay/.eslintignore @@ -1,2 +1,6 @@ node_modules/ build/ +demo/build/ +# TODO: Check if we can re-introduce linting in demo +demo +metrics From 262e249839126af9fa43ff7c32fb17be7fe21691 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 3 Oct 2023 10:28:47 -0400 Subject: [PATCH 10/19] cleanup --- packages/feedback/jest.setup.ts | 267 ----------------------------- packages/feedback/scripts/repl.ts | 276 ------------------------------ packages/feedback/test/index.ts | 4 - 3 files changed, 547 deletions(-) delete mode 100644 packages/feedback/scripts/repl.ts diff --git a/packages/feedback/jest.setup.ts b/packages/feedback/jest.setup.ts index 093c97dcdce4..518917f4ef04 100644 --- a/packages/feedback/jest.setup.ts +++ b/packages/feedback/jest.setup.ts @@ -1,272 +1,5 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { getCurrentHub } from '@sentry/core'; -import type { ReplayRecordingData, Transport } from '@sentry/types'; -import { TextEncoder } from 'util'; - -import type { ReplayContainer, Session } from './src/types'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(global as any).TextEncoder = TextEncoder; - -type MockTransport = jest.MockedFunction; - jest.mock('./src/util/isBrowser', () => { return { isBrowser: () => true, }; }); - -type EnvelopeHeader = { - event_id: string; - sent_at: string; - sdk: { - name: string; - version?: string; - }; -}; - -type ReplayEventHeader = { type: 'replay_event' }; -type ReplayEventPayload = Record; -type RecordingHeader = { type: 'replay_recording'; length: number }; -type RecordingPayloadHeader = Record; -type SentReplayExpected = { - envelopeHeader?: EnvelopeHeader; - replayEventHeader?: ReplayEventHeader; - replayEventPayload?: ReplayEventPayload; - recordingHeader?: RecordingHeader; - recordingPayloadHeader?: RecordingPayloadHeader; - recordingData?: ReplayRecordingData; -}; - -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -const toHaveSameSession = function (received: jest.Mocked, expected: undefined | Session) { - const pass = this.equals(received.session?.id, expected?.id) as boolean; - - const options = { - isNot: this.isNot, - promise: this.promise, - }; - - return { - pass, - message: () => - `${this.utils.matcherHint( - 'toHaveSameSession', - undefined, - undefined, - options, - )}\n\n${this.utils.printDiffOrStringify(expected, received.session, 'Expected', 'Received')}`, - }; -}; - -type Result = { - passed: boolean; - key: string; - expectedVal: SentReplayExpected[keyof SentReplayExpected]; - actualVal: SentReplayExpected[keyof SentReplayExpected]; -}; -type Call = [ - EnvelopeHeader, - [ - [ReplayEventHeader | undefined, ReplayEventPayload | undefined], - [RecordingHeader | undefined, RecordingPayloadHeader | undefined], - ], -]; -type CheckCallForSentReplayResult = { pass: boolean; call: Call | undefined; results: Result[] }; - -function checkCallForSentReplay( - call: Call | undefined, - expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, -): CheckCallForSentReplayResult { - const envelopeHeader = call?.[0]; - const envelopeItems = call?.[1] || [[], []]; - const [[replayEventHeader, replayEventPayload], [recordingHeader, recordingPayload] = []] = envelopeItems; - - // @ts-ignore recordingPayload is always a string in our tests - const [recordingPayloadHeader, recordingData] = recordingPayload?.split('\n') || []; - - const actualObj: Required = { - // @ts-ignore Custom envelope - envelopeHeader: envelopeHeader, - // @ts-ignore Custom envelope - replayEventHeader: replayEventHeader, - // @ts-ignore Custom envelope - replayEventPayload: replayEventPayload, - // @ts-ignore Custom envelope - recordingHeader: recordingHeader, - recordingPayloadHeader: recordingPayloadHeader && JSON.parse(recordingPayloadHeader), - recordingData, - }; - - const isObjectContaining = expected && 'sample' in expected && 'inverse' in expected; - const expectedObj = isObjectContaining - ? (expected as { sample: SentReplayExpected }).sample - : (expected as SentReplayExpected); - - if (isObjectContaining) { - console.warn('`expect.objectContaining` is unnecessary when using the `toHaveSentReplay` matcher'); - } - - const results = expected - ? Object.keys(expectedObj) - .map(key => { - const actualVal = actualObj[key as keyof SentReplayExpected]; - const expectedVal = expectedObj[key as keyof SentReplayExpected]; - const passed = !expectedVal || this.equals(actualVal, expectedVal); - - return { passed, key, expectedVal, actualVal }; - }) - .filter(({ passed }) => !passed) - : []; - - const pass = Boolean(call && (!expected || results.length === 0)); - - return { - pass, - call, - results, - }; -} - -/** - * Only want calls that send replay events, i.e. ignore error events - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function getReplayCalls(calls: any[][][]): any[][][] { - return calls - .map(call => { - const arg = call[0]; - if (arg.length !== 2) { - return []; - } - - if (!arg[1][0].find(({ type }: { type: string }) => ['replay_event', 'replay_recording'].includes(type))) { - return []; - } - - return [arg]; - }) - .filter(Boolean); -} - -/** - * Checks all calls to `fetch` and ensures a replay was uploaded by - * checking the `fetch()` request's body. - */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -const toHaveSentReplay = function ( - _received: jest.Mocked, - expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, -) { - const { calls } = (getCurrentHub().getClient()?.getTransport()?.send as MockTransport).mock; - - let result: CheckCallForSentReplayResult; - - const expectedKeysLength = expected - ? ('sample' in expected ? Object.keys(expected.sample) : Object.keys(expected)).length - : 0; - - const replayCalls = getReplayCalls(calls); - - for (const currentCall of replayCalls) { - result = checkCallForSentReplay.call(this, currentCall[0], expected); - if (result.pass) { - break; - } - - // stop on the first call where any of the expected obj passes - if (result.results.length < expectedKeysLength) { - break; - } - } - - // @ts-ignore use before assigned - const { results, call, pass } = result; - - const options = { - isNot: this.isNot, - promise: this.promise, - }; - - return { - pass, - message: () => - !call - ? pass - ? 'Expected Replay to not have been sent, but a request was attempted' - : 'Expected Replay to have been sent, but a request was not attempted' - : `${this.utils.matcherHint('toHaveSentReplay', undefined, undefined, options)}\n\n${results - .map(({ key, expectedVal, actualVal }: Result) => - this.utils.printDiffOrStringify( - expectedVal, - actualVal, - `Expected (key: ${key})`, - `Received (key: ${key})`, - ), - ) - .join('\n')}`, - }; -}; - -/** - * Checks the last call to `fetch` and ensures a replay was uploaded by - * checking the `fetch()` request's body. - */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -const toHaveLastSentReplay = function ( - _received: jest.Mocked, - expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, -) { - const { calls } = (getCurrentHub().getClient()?.getTransport()?.send as MockTransport).mock; - const replayCalls = getReplayCalls(calls); - - const lastCall = replayCalls[calls.length - 1]?.[0]; - - const { results, call, pass } = checkCallForSentReplay.call(this, lastCall, expected); - - const options = { - isNot: this.isNot, - promise: this.promise, - }; - - return { - pass, - message: () => - !call - ? pass - ? 'Expected Replay to not have been sent, but a request was attempted' - : 'Expected Replay to have last been sent, but a request was not attempted' - : `${this.utils.matcherHint('toHaveSentReplay', undefined, undefined, options)}\n\n${results - .map(({ key, expectedVal, actualVal }: Result) => - this.utils.printDiffOrStringify( - expectedVal, - actualVal, - `Expected (key: ${key})`, - `Received (key: ${key})`, - ), - ) - .join('\n')}`, - }; -}; - -expect.extend({ - toHaveSameSession, - toHaveSentReplay, - toHaveLastSentReplay, -}); - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace jest { - interface AsymmetricMatchers { - toHaveSentReplay(expected?: SentReplayExpected): void; - toHaveLastSentReplay(expected?: SentReplayExpected): void; - toHaveSameSession(expected: undefined | Session): void; - } - interface Matchers { - toHaveSentReplay(expected?: SentReplayExpected): R; - toHaveLastSentReplay(expected?: SentReplayExpected): R; - toHaveSameSession(expected: undefined | Session): R; - } - } -} diff --git a/packages/feedback/scripts/repl.ts b/packages/feedback/scripts/repl.ts deleted file mode 100644 index e94a9797d1a1..000000000000 --- a/packages/feedback/scripts/repl.ts +++ /dev/null @@ -1,276 +0,0 @@ -/* eslint:disable: no-console */ -import * as fs from 'fs'; -import inquirer from 'inquirer'; -import { EventEmitter } from 'node:events'; -import * as path from 'path'; -import type { Frame} from 'playwright'; -import {chromium} from 'playwright'; -// import puppeteer from 'puppeteer'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const emitter = new EventEmitter(); - -function getCode(): string { - const bundlePath = path.resolve(__dirname, '../replay/build/bundles/replay.debug.min.js'); - return fs.readFileSync(bundlePath, 'utf8'); -} - -void (async () => { - const code = getCode(); - - async function injectRecording(frame: Frame) { - await frame.evaluate((rrwebCode: string) => { - const win = window; - // @ts-expect-error global - if (win.__IS_RECORDING__) return; - // @ts-expect-error global - win.__IS_RECORDING__ = true; - - (async () => { - function loadScript(code: string) { - const s = document.createElement('script'); - const r = false; - s.type = 'text/javascript'; - s.innerHTML = code; - if (document.head) { - document.head.append(s); - } else { - requestAnimationFrame(() => { - document.head.append(s); - }); - } - } - loadScript(rrwebCode); - - // @ts-expect-error global - win.__replay = new Sentry.Replay({ - blockAllMedia: false, - maskAllText: false, - useCompression: false, - mutationBreadcrumbLimit: 250, - }) - // @ts-expect-error global - Sentry.init({ - debug: true, - dsn: '', - environment: 'repl', - tracesSampleRate: 1.0, - replaysSessionSampleRate: 1.0, - integrations: [ - // @ts-expect-error global - win.__replay - // new BrowserTracing({ - // tracingOrigins: ["localhost:3000", "localhost", /^\//], - // }), - ], - }) - })(); - }, code); - } - - await start('https://react-redux.realworld.io'); - - // const fakeGoto = async (page, url) => { - // const intercept = async (request) => { - // await request.respond({ - // status: 200, - // contentType: 'text/html', - // body: ' ', // non-empty string or page will load indefinitely - // }); - // }; - // await page.setRequestInterception(true); - // page.on('request', intercept); - // await page.goto(url); - // await page.setRequestInterception(false); - // page.off('request', intercept); - // }; - - async function start(defaultURL: string) { - let { url } = await inquirer.prompt([ - { - type: 'input', - name: 'url', - message: `Enter the url you want to record, e.g [${defaultURL}]: `, - }, - ]); - - if (url === '') { - url = defaultURL; - } - - console.log(`Going to open ${url}...`); - await record(url); - console.log('Ready to record. You can do any interaction on the page.'); - - // const { shouldReplay } = await inquirer.prompt([ - // { - // type: 'list', - // choices: [ - // { name: 'Start replay (default)', value: 'default' }, - // { - // name: 'Start replay on original url (helps when experiencing CORS issues)', - // value: 'replayWithFakeURL', - // }, - // { name: 'Skip replay', value: false }, - // ], - // name: 'shouldReplay', - // message: 'Once you want to finish the recording, choose the following to start replay: ', - // }, - // ]); - - emitter.emit('done'); - - /** - * not needed atm as we always save to Sentry - */ - // const { shouldStore } = await inquirer.prompt([ - // { - // type: 'confirm', - // name: 'shouldStore', - // message: 'Persistently store these recorded events?', - // }, - // ]); - - // if (shouldStore) { - // saveEvents(); - // } - - const { shouldRecordAnother } = await inquirer.prompt([ - { - type: 'confirm', - name: 'shouldRecordAnother', - message: 'Record another one?', - }, - ]); - - if (shouldRecordAnother) { - start(url); - } else { - process.exit(); - } - } - - async function record(url: string) { - const browser = await chromium.launch({ - headless: false, - args: [ - '--start-maximized', - '--ignore-certificate-errors', - '--no-sandbox', - '--auto-open-devtools-for-tabs', - ], - }); - const context = await browser.newContext({ - viewport: { - width: 1600, - height: 900, - }, - }); - const page = await context.newPage(); - - // await page.exposeFunction('_replLog', (event) => { - // events.push(event); - // }); - - page.on('framenavigated', async (frame: Frame) => { - await injectRecording(frame); - }); - - await page.goto(url, { - waitUntil: 'domcontentloaded', - timeout: 300000, - }); - - emitter.once('done', async () => { - await context.close(); - await browser.close(); - console.log('go to sentry to view this replay'); - // if (shouldReplay) { - // await replay(url, shouldReplay === 'replayWithFakeURL'); - // } - }); - } - - // async function replay(url, useSpoofedUrl) { - // const browser = await puppeteer.launch({ - // headless: false, - // defaultViewport: { - // width: 1600, - // height: 900, - // }, - // args: ['--start-maximized', '--no-sandbox'], - // }); - // const page = await browser.newPage(); - // if (useSpoofedUrl) { - // await fakeGoto(page, url); - // } else { - // await page.goto('about:blank'); - // } - // - // await page.addStyleTag({ - // path: path.resolve(__dirname, '../dist/rrweb.css'), - // }); - // await page.evaluate(`${code} - // const events = ${JSON.stringify(events)}; - // const replayer = new rrweb.Replayer(events, { - // UNSAFE_replayCanvas: true - // }); - // replayer.play(); - // `); - // } - -// function saveEvents() { -// const tempFolder = path.join(__dirname, '../temp'); -// console.log(tempFolder); -// -// if (!fs.existsSync(tempFolder)) { -// fs.mkdirSync(tempFolder); -// } -// const time = new Date() -// .toISOString() -// .replace(/[-|:]/g, '_') -// .replace(/\..+/, ''); -// const fileName = `replay_${time}.html`; -// const content = ` -// -// -// -// -// -// -// Record @${time} -// -// -// -// -// -// -// -// `; -// const savePath = path.resolve(tempFolder, fileName); -// fs.writeFileSync(savePath, content); -// console.log(`Saved at ${savePath}`); -// } - - process - .on('uncaughtException', (error) => { - console.error(error); - }) - .on('unhandledRejection', (error) => { - console.error(error); - }); -})(); diff --git a/packages/feedback/test/index.ts b/packages/feedback/test/index.ts index ed4b82a6c780..e69de29bb2d1 100644 --- a/packages/feedback/test/index.ts +++ b/packages/feedback/test/index.ts @@ -1,4 +0,0 @@ -export * from './mocks/mockRrweb'; // XXX: Needs to happen before `mockSdk` or importing Replay! -export * from './mocks/mockSdk'; - -export const BASE_TIMESTAMP = new Date('2020-02-02 00:00:00').getTime(); // 1580619600000 From 0cb65f0eb8c6c7ccd93eaac89cf9c2aa12b2bf7f Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 3 Oct 2023 11:05:25 -0400 Subject: [PATCH 11/19] cleanup --- packages/feedback/jest.setup.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/feedback/jest.setup.ts b/packages/feedback/jest.setup.ts index 518917f4ef04..2476318128db 100644 --- a/packages/feedback/jest.setup.ts +++ b/packages/feedback/jest.setup.ts @@ -1,5 +1,3 @@ -jest.mock('./src/util/isBrowser', () => { - return { - isBrowser: () => true, - }; -}); +// Required: +// 'jest.setup.ts' cannot be compiled under '--isolatedModules' because it is considered a global script file. Add an import, export, or an empty 'export {}' statement to make it a module. +export {} From 15eae9e65a782dcd08cee89003a068ce124af8ef Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 3 Oct 2023 14:09:41 -0400 Subject: [PATCH 12/19] remove types for now --- packages/feedback/src/index.ts | 3 +-- packages/types/src/feedback.ts | 16 ---------------- packages/types/src/index.ts | 1 - 3 files changed, 1 insertion(+), 19 deletions(-) delete mode 100644 packages/types/src/feedback.ts diff --git a/packages/feedback/src/index.ts b/packages/feedback/src/index.ts index 740959599fdd..18cd7f0607b4 100644 --- a/packages/feedback/src/index.ts +++ b/packages/feedback/src/index.ts @@ -1,2 +1 @@ -export type { SendFeedbackData } from './types'; -export { sendFeedbackRequest } from './util/sendFeedbackRequest'; +export {sendFeedbackRequest} from './util/sendFeedbackRequest'; diff --git a/packages/types/src/feedback.ts b/packages/types/src/feedback.ts deleted file mode 100644 index 2a904edbc57a..000000000000 --- a/packages/types/src/feedback.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Event } from './event'; - -/** - * NOTE: These types are still considered Beta and subject to change. - * @hidden - */ -export interface FeedbackEvent extends Event { - feedback: { - contact_email: string; - message: string; - replay_id: string; - url: string; - }; - // TODO: Add this event type to Event - // type: 'feedback_event'; -} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 556dde26662c..8a93681aa938 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -69,7 +69,6 @@ export type { Profile, } from './profiling'; export type { ReplayEvent, ReplayRecordingData, ReplayRecordingMode } from './replay'; -export type { FeedbackEvent } from './feedback'; export type { QueryParams, Request, SanitizedRequestData } from './request'; export type { Runtime } from './runtime'; export type { CaptureContext, Scope, ScopeContext } from './scope'; From b5deaae950fb2a2b180235c8b35cda14fc98fe25 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 4 Oct 2023 16:41:50 -0400 Subject: [PATCH 13/19] move feedback into index --- packages/feedback/src/types/feedback.ts | 27 ------------------------ packages/feedback/src/types/index.ts | 28 ++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 28 deletions(-) delete mode 100644 packages/feedback/src/types/feedback.ts diff --git a/packages/feedback/src/types/feedback.ts b/packages/feedback/src/types/feedback.ts deleted file mode 100644 index 28e5f82f31ac..000000000000 --- a/packages/feedback/src/types/feedback.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Event, Primitive } from '@sentry/types'; - -/** - * NOTE: These types are still considered Beta and subject to change. - * @hidden - */ -export interface FeedbackEvent extends Event { - feedback: { - contact_email: string; - message: string; - replay_id: string; - url: string; - }; - // TODO: Add this event type to Event - // type: 'feedback_event'; -} - -export interface SendFeedbackData { - feedback: { - message: string; - email: string; - replay_id: string | undefined; - name: string; - url: string; - }; - tags: { [key: string]: Primitive } | undefined; -} diff --git a/packages/feedback/src/types/index.ts b/packages/feedback/src/types/index.ts index 3c6cb93bb7b3..b732688941a4 100644 --- a/packages/feedback/src/types/index.ts +++ b/packages/feedback/src/types/index.ts @@ -1 +1,27 @@ -export * from './feedback'; +import type { Event, Primitive } from '@sentry/types'; + +/** + * NOTE: These types are still considered Beta and subject to change. + * @hidden + */ +export interface FeedbackEvent extends Event { + feedback: { + contact_email: string; + message: string; + replay_id: string | undefined; + url: string; + }; + // TODO: Add this event type to Event + // type: 'feedback_event'; +} + +export interface SendFeedbackData { + feedback: { + message: string; + email: string; + replay_id: string | undefined; + name: string; + url: string; + }; + tags: { [key: string]: Primitive } | undefined; +} From 5ed838b718d831464a2af4d9569786b7b6840047 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 4 Oct 2023 16:51:01 -0400 Subject: [PATCH 14/19] add name --- packages/feedback/src/types/index.ts | 14 ++++++++------ packages/feedback/src/util/prepareFeedbackEvent.ts | 11 ++++++----- packages/feedback/src/util/sendFeedbackRequest.ts | 2 +- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/feedback/src/types/index.ts b/packages/feedback/src/types/index.ts index b732688941a4..01a12814c88b 100644 --- a/packages/feedback/src/types/index.ts +++ b/packages/feedback/src/types/index.ts @@ -1,15 +1,18 @@ import type { Event, Primitive } from '@sentry/types'; +export type SentryTags = { [key: string]: Primitive } | undefined; + /** * NOTE: These types are still considered Beta and subject to change. * @hidden */ export interface FeedbackEvent extends Event { feedback: { - contact_email: string; message: string; - replay_id: string | undefined; url: string; + contact_email?: string; + name?: string; + replay_id?: string; }; // TODO: Add this event type to Event // type: 'feedback_event'; @@ -18,10 +21,9 @@ export interface FeedbackEvent extends Event { export interface SendFeedbackData { feedback: { message: string; - email: string; - replay_id: string | undefined; - name: string; url: string; + email?: string; + replay_id?: string; + name?: string; }; - tags: { [key: string]: Primitive } | undefined; } diff --git a/packages/feedback/src/util/prepareFeedbackEvent.ts b/packages/feedback/src/util/prepareFeedbackEvent.ts index 7d3defbc2523..6d32506d3be4 100644 --- a/packages/feedback/src/util/prepareFeedbackEvent.ts +++ b/packages/feedback/src/util/prepareFeedbackEvent.ts @@ -4,6 +4,11 @@ import type { Client } from '@sentry/types'; import type { FeedbackEvent } from '../types'; +interface PrepareFeedbackEventParams { + client: Client; + event: FeedbackEvent; + scope: Scope; +} /** * Prepare a feedback event & enrich it with the SDK metadata. */ @@ -11,11 +16,7 @@ export async function prepareFeedbackEvent({ client, scope, event, -}: { - client: Client; - event: FeedbackEvent; - scope: Scope; -}): Promise { +}: PrepareFeedbackEventParams): Promise { const eventHint = { integrations: undefined }; if (client.emit) { client.emit('preprocessEvent', event, eventHint); diff --git a/packages/feedback/src/util/sendFeedbackRequest.ts b/packages/feedback/src/util/sendFeedbackRequest.ts index 5bc5714c96b3..626457d6122b 100644 --- a/packages/feedback/src/util/sendFeedbackRequest.ts +++ b/packages/feedback/src/util/sendFeedbackRequest.ts @@ -9,7 +9,6 @@ import { prepareFeedbackEvent } from './prepareFeedbackEvent'; */ export async function sendFeedbackRequest({ feedback: { message, email, name, replay_id, url }, - tags, }: SendFeedbackData): Promise { const hub = getCurrentHub(); @@ -29,6 +28,7 @@ export async function sendFeedbackRequest({ const baseEvent = { feedback: { contact_email: email, + name, message, replay_id, url, From 2a57ef099fb386584cd56bd4528d351828810aa2 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 10 Oct 2023 13:22:25 -0400 Subject: [PATCH 15/19] add feedback to DEFAULT_SKIP_TESTS_PACKAGES --- scripts/node-unit-tests.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/node-unit-tests.ts b/scripts/node-unit-tests.ts index 8824cee77d66..432e3e8ccf1f 100644 --- a/scripts/node-unit-tests.ts +++ b/scripts/node-unit-tests.ts @@ -20,6 +20,7 @@ const DEFAULT_SKIP_TESTS_PACKAGES = [ '@sentry/angular', '@sentry/svelte', '@sentry/replay', + '@sentry/feedback', '@sentry/wasm', '@sentry/bun', '@sentry/deno', From 4967dbcecfdb177e9c963f2a9344e049bb4653d1 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Fri, 13 Oct 2023 14:59:30 -0400 Subject: [PATCH 16/19] lint --- packages/feedback/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/feedback/src/index.ts b/packages/feedback/src/index.ts index 18cd7f0607b4..834e9dcce670 100644 --- a/packages/feedback/src/index.ts +++ b/packages/feedback/src/index.ts @@ -1 +1 @@ -export {sendFeedbackRequest} from './util/sendFeedbackRequest'; +export { sendFeedbackRequest } from './util/sendFeedbackRequest'; From 4b3edc249b53d1189753591c2d678f602ae6c39c Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 16 Oct 2023 11:15:18 -0400 Subject: [PATCH 17/19] review feedback --- packages/feedback/README.md | 8 +--- packages/feedback/jest.config.ts | 18 +------- packages/feedback/jest.setup.ts | 3 -- packages/feedback/package.json | 44 ++++++++----------- .../feedback/scripts/craft-pre-release.sh | 8 ---- yarn.lock | 22 ++++++++++ 6 files changed, 44 insertions(+), 59 deletions(-) delete mode 100644 packages/feedback/jest.setup.ts delete mode 100644 packages/feedback/scripts/craft-pre-release.sh diff --git a/packages/feedback/README.md b/packages/feedback/README.md index 4600f0803465..799cea6b59b8 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -4,18 +4,14 @@

-# Sentry Feedback - -[![npm version](https://img.shields.io/npm/v/@sentry/feedback.svg)](https://www.npmjs.com/package/@sentry/feedback) -[![npm dm](https://img.shields.io/npm/dm/@sentry/feedback.svg)](https://www.npmjs.com/package/@sentry/feedback) -[![npm dt](https://img.shields.io/npm/dt/@sentry/feedback.svg)](https://www.npmjs.com/package/@sentry/feedback) +# Sentry Integration for Feedback This SDK is **considered experimental and in an alpha state**. It may experience breaking changes, and may be discontinued at any time. Please reach out on [GitHub](https://github.com/getsentry/sentry-javascript/issues/new/choose) if you have any feedback/concerns. ## Pre-requisites -`@sentry/feedback` requires Node 12+, and browsers newer than IE11. +`@sentry/feedback` currently can only be used by browsers with [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM) support. ## Installation diff --git a/packages/feedback/jest.config.ts b/packages/feedback/jest.config.ts index 90a3cf471f8d..24f49ab59a4c 100644 --- a/packages/feedback/jest.config.ts +++ b/packages/feedback/jest.config.ts @@ -1,17 +1 @@ -import type { Config } from '@jest/types'; -import { jsWithTs as jsWithTsPreset } from 'ts-jest/presets'; - -export default async (): Promise => { - return { - ...jsWithTsPreset, - globals: { - 'ts-jest': { - tsconfig: '/tsconfig.test.json', - }, - __DEBUG_BUILD__: true, - }, - setupFilesAfterEnv: ['./jest.setup.ts'], - testEnvironment: 'jsdom', - testMatch: ['/test/**/*(*.)@(spec|test).ts'], - }; -}; +module.exports = require('../../jest/jest.config.js'); diff --git a/packages/feedback/jest.setup.ts b/packages/feedback/jest.setup.ts deleted file mode 100644 index 2476318128db..000000000000 --- a/packages/feedback/jest.setup.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Required: -// 'jest.setup.ts' cannot be compiled under '--isolatedModules' because it is considered a global script file. Add an import, export, or an empty 'export {}' statement to make it a module. -export {} diff --git a/packages/feedback/package.json b/packages/feedback/package.json index b88412947698..23e2d3c81f09 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -1,7 +1,14 @@ { "name": "@sentry-internal/feedback", "version": "7.70.0", - "description": "User feedback for Sentry", + "description": "Sentry SDK integration for user feedback", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/feedback", + "author": "Sentry", + "license": "MIT", + "engines": { + "node": ">=12" + }, "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", "types": "build/npm/types/index.d.ts", @@ -12,7 +19,15 @@ ] } }, - "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@sentry/core": "7.70.0", + "@sentry/types": "7.70.0", + "@sentry/utils": "7.70.0", + "tslib": "^2.4.1 || ^1.9.3" + }, "scripts": { "build": "run-p build:transpile build:types build:bundle", "build:transpile": "rollup -c rollup.npm.config.js", @@ -39,29 +54,8 @@ "test:watch": "jest --watch", "yalc:publish": "ts-node ../../scripts/prepack.ts --bundles && yalc publish ./build/npm --push" }, - "repository": { - "type": "git", - "url": "git+https://github.com/getsentry/sentry-javascript.git" - }, - "author": "Sentry", - "license": "MIT", - "bugs": { - "url": "https://github.com/getsentry/sentry-javascript/issues" - }, - "homepage": "https://docs.sentry.io/platforms/javascript/", - "devDependencies": { - "@babel/core": "^7.17.5", - "tslib": "^2.4.1 || ^1.9.3" - }, - "dependencies": { - "@sentry/core": "7.70.0", - "@sentry/types": "7.70.0", - "@sentry/utils": "7.70.0" - }, - "engines": { - "node": ">=12" - }, "volta": { "extends": "../../package.json" - } + }, + "sideEffects": false } diff --git a/packages/feedback/scripts/craft-pre-release.sh b/packages/feedback/scripts/craft-pre-release.sh deleted file mode 100644 index bae7c3246cdb..000000000000 --- a/packages/feedback/scripts/craft-pre-release.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -set -eux -OLD_VERSION="${1}" -NEW_VERSION="${2}" - -# Do not tag and commit changes made by "npm version" -export npm_config_git_tag_version=false -npm version "${NEW_VERSION}" diff --git a/yarn.lock b/yarn.lock index 48eac907fe7a..e4e1855b1d18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5052,6 +5052,28 @@ proxy-from-env "^1.1.0" which "^2.0.2" +"@sentry/core@7.70.0": + version "7.70.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.70.0.tgz#c481ef27cf05293fb681ee4ff4d4b0b1e8664bb5" + integrity sha512-voUsGVM+jwRp99AQYFnRvr7sVd2tUhIMj1L6F42LtD3vp7t5ZnKp3NpXagtFW2vWzXESfyJUBhM0qI/bFvn7ZA== + dependencies: + "@sentry/types" "7.70.0" + "@sentry/utils" "7.70.0" + tslib "^2.4.1 || ^1.9.3" + +"@sentry/types@7.70.0": + version "7.70.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.70.0.tgz#c7b533bb18144e3b020550b38cf4812c32d05ffe" + integrity sha512-rY4DqpiDBtXSk4MDNBH3dwWqfPbNBI/9GA7Y5WJSIcObBtfBKp0fzYliHJZD0pgM7d4DPFrDn42K9Iiumgymkw== + +"@sentry/utils@7.70.0": + version "7.70.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.70.0.tgz#825387ceb10cbb1e145357394b697a1a6d60eb74" + integrity sha512-0cChMH0lsGp+5I3D4wOHWwjFN19HVrGUs7iWTLTO5St3EaVbdeLbI1vFXHxMxvopbwgpeZafbreHw/loIdZKpw== + dependencies: + "@sentry/types" "7.70.0" + tslib "^2.4.1 || ^1.9.3" + "@sentry/vite-plugin@^0.6.1": version "0.6.1" resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-0.6.1.tgz#31eb744e8d87b1528eed8d41433647727a62e7c0" From 841eb3f83b074132b9fb017d2d45c8cdd28d662f Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 16 Oct 2023 13:30:34 -0400 Subject: [PATCH 18/19] ts -> js for jest.config --- packages/feedback/{jest.config.ts => jest.config.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/feedback/{jest.config.ts => jest.config.js} (100%) diff --git a/packages/feedback/jest.config.ts b/packages/feedback/jest.config.js similarity index 100% rename from packages/feedback/jest.config.ts rename to packages/feedback/jest.config.js From a55c0af1b2ed8c09e048343ae09fd1b99641351d Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 16 Oct 2023 13:39:47 -0400 Subject: [PATCH 19/19] wrong package name --- scripts/node-unit-tests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/node-unit-tests.ts b/scripts/node-unit-tests.ts index 432e3e8ccf1f..fc0f855fd5d4 100644 --- a/scripts/node-unit-tests.ts +++ b/scripts/node-unit-tests.ts @@ -20,7 +20,7 @@ const DEFAULT_SKIP_TESTS_PACKAGES = [ '@sentry/angular', '@sentry/svelte', '@sentry/replay', - '@sentry/feedback', + '@sentry-internal/feedback', '@sentry/wasm', '@sentry/bun', '@sentry/deno',