diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4b3125c09eb1..690e076ce309 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -98,6 +98,7 @@ jobs: - *shared - 'packages/browser/**' - 'packages/replay/**' + - 'packages/feedback/**' browser_integration: - *shared - *browser @@ -168,7 +169,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' # we use a hash of yarn.lock as our cache key, because if it hasn't changed, our dependencies haven't changed, @@ -216,7 +217,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Check dependency cache @@ -274,7 +275,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: # The size limit action runs `yarn` and `yarn build` when this job is executed on # use Node 14 for now. @@ -306,7 +307,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Restore caches @@ -329,7 +330,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Restore caches @@ -351,7 +352,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Restore caches @@ -382,7 +383,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Restore caches @@ -409,7 +410,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Set up Bun @@ -438,7 +439,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Set up Deno @@ -472,7 +473,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - name: Restore caches @@ -504,7 +505,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - name: Restore caches @@ -569,7 +570,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Restore caches @@ -626,7 +627,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Restore caches @@ -679,7 +680,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Restore caches @@ -705,7 +706,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Restore caches @@ -745,7 +746,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - name: Restore caches @@ -781,7 +782,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - name: Restore caches @@ -810,7 +811,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Restore caches @@ -900,7 +901,7 @@ jobs: with: version: 8.3.1 - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'packages/e2e-tests/package.json' - name: Restore caches @@ -983,7 +984,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Restore caches diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 04adce087fd4..ed05e9bfd1af 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -27,7 +27,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Check canary cache @@ -89,7 +89,7 @@ jobs: with: version: 8.3.1 - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' @@ -152,7 +152,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Install dependencies diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index 4f3c92da09ba..9d067eafdbf9 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -32,7 +32,7 @@ jobs: - name: Check out current branch uses: actions/checkout@v4 - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: 'yarn' diff --git a/.size-limit.js b/.size-limit.js index 81c2c01b1eb0..36c4212adea6 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -18,7 +18,6 @@ module.exports = [ config.plugins.push( new webpack.DefinePlugin({ __SENTRY_DEBUG__: false, - __RRWEB_EXCLUDE_CANVAS__: true, __RRWEB_EXCLUDE_SHADOW_DOM__: true, __RRWEB_EXCLUDE_IFRAME__: true, __SENTRY_EXCLUDE_REPLAY_WORKER__: true, @@ -122,4 +121,11 @@ module.exports = [ gzip: true, limit: '57 KB', }, + { + name: '@sentry-internal/feedback - Webpack (gzipped)', + path: 'packages/feedback/build/npm/esm/index.js', + import: '{ Feedback }', + gzip: true, + limit: '35 KB', + }, ]; diff --git a/CHANGELOG.md b/CHANGELOG.md index d45fd6480d71..2cd0a7a30c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.78.0 + +### Important Changes + +- **Replay Bundle Size improvements** + +We've dramatically decreased the bundle size of our Replay package, reducing the minified & gzipped bundle size by ~20 KB! +This was possible by extensive use of tree shaking and a host of small changes to reduce our footprint: + +- feat(replay): Update rrweb to 2.2.0 (#9414) +- ref(replay): Use fflate instead of pako for compression (#9436) + +By using [tree shaking](https://docs.sentry.io/platforms/javascript/configuration/tree-shaking/) it is possible to shave up to 10 additional KB off the bundle. + +#### Other Changes + +- feat(astro): Add Sentry middleware (#9445) +- feat(feedback): Add "outline focus" and "foreground hover" vars (#9462) +- feat(feedback): Add `openDialog` and `closeDialog` onto integration interface (#9464) +- feat(feedback): Implement new user feedback embeddable widget (#9217) +- feat(nextjs): Add automatic sourcemapping for edge part of the SDK (#9454) +- feat(nextjs): Add client routing instrumentation for app router (#9446) +- feat(node-experimental): Add hapi tracing support (#9449) +- feat(replay): Allow to configure `beforeErrorSampling` (#9470) +- feat(replay): Stop fixing truncated JSONs in SDK (#9437) +- fix(nextjs): Fix sourcemaps resolving for local dev when basePath is set (#9457) +- fix(nextjs): Only inject basepath in dev mode (#9465) +- fix(replay): Ensure we stop for rate limit headers (#9420) +- ref(feedback): Add treeshaking for logger statements (#9475) +- ref(replay): Use rrweb for slow click detection (#9408) +- build(polyfills): Remove output format specific logic (#9467) + ## 7.77.0 - feat: Move LinkedErrors integration to @sentry/core (#9404) diff --git a/nx.json b/nx.json index c25aeaa1f0e7..da4ed2456a75 100644 --- a/nx.json +++ b/nx.json @@ -26,7 +26,7 @@ }, "build:transpile": { "inputs": ["production", "^production"], - "dependsOn": ["^build:transpile:uncached", "^build:transpile", "build:transpile:uncached"], + "dependsOn": ["^build:transpile"], "outputs": ["{projectRoot}/build/npm", "{projectRoot}/build/esm", "{projectRoot}/build/cjs"] }, "build:types": { diff --git a/packages/angular-ivy/package.json b/packages/angular-ivy/package.json index 78c36cf46ab9..ce265938953b 100644 --- a/packages/angular-ivy/package.json +++ b/packages/angular-ivy/package.json @@ -67,7 +67,6 @@ "build:transpile": { "dependsOn": [ "^build:transpile", - "^build:transpile:uncached", "^build:types" ] } diff --git a/packages/angular/package.json b/packages/angular/package.json index 133d30bc738a..690980b1c7de 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -71,7 +71,6 @@ "build:transpile": { "dependsOn": [ "^build:transpile", - "^build:transpile:uncached", "^build:types" ] } diff --git a/packages/astro/README.md b/packages/astro/README.md index 4470d854c717..df68adfa1037 100644 --- a/packages/astro/README.md +++ b/packages/astro/README.md @@ -31,7 +31,7 @@ Install the Sentry Astro SDK with the `astro` CLI: npx astro add @sentry/astro ``` -Complete the setup by adding your DSN and source maps upload configuration: +Add your DSN and source maps upload configuration: ```javascript import { defineConfig } from "astro/config"; @@ -56,6 +56,22 @@ Follow [this guide](https://docs.sentry.io/product/accounts/auth-tokens/#organiz SENTRY_AUTH_TOKEN="your-token" ``` +Complete the setup by adding the Sentry middleware to your `src/middleware.js` file: + +```javascript +// src/middleware.js +import { sequence } from "astro:middleware"; +import * as Sentry from "@sentry/astro"; + +export const onRequest = sequence( + Sentry.sentryMiddleware(), + // Add your other handlers after sentryMiddleware +); +``` + +This middleware creates server-side spans to monitor performance on the server for page load and endpoint requests. + + ## Configuration Check out our docs for configuring your SDK setup: diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 2d174277b0af..5ec649c81584 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -62,5 +62,6 @@ export { export * from '@sentry/node'; export { init } from './server/sdk'; +export { handleRequest } from './server/middleware'; export default sentryAstro; diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts new file mode 100644 index 000000000000..ff4ac1d44c78 --- /dev/null +++ b/packages/astro/src/server/middleware.ts @@ -0,0 +1,123 @@ +import { captureException, configureScope, startSpan } from '@sentry/node'; +import { addExceptionMechanism, objectify, stripUrlQueryAndFragment, tracingContextFromHeaders } from '@sentry/utils'; +import type { APIContext, MiddlewareResponseHandler } from 'astro'; + +type MiddlewareOptions = { + /** + * If true, the client IP will be attached to the event by calling `setUser`. + * Only set this to `true` if you're fine with collecting potentially personally identifiable information (PII). + * + * This will only work if your app is configured for SSR + * + * @default false (recommended) + */ + trackClientIp?: boolean; + + /** + * If true, the headers from the request will be attached to the event by calling `setExtra`. + * Only set this to `true` if you're fine with collecting potentially personally identifiable information (PII). + * + * @default false (recommended) + */ + trackHeaders?: boolean; +}; + +function sendErrorToSentry(e: unknown): unknown { + // In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can + // store a seen flag on it. + const objectifiedErr = objectify(e); + + captureException(objectifiedErr, scope => { + scope.addEventProcessor(event => { + addExceptionMechanism(event, { + type: 'astro', + handled: false, + data: { + function: 'astroMiddleware', + }, + }); + return event; + }); + + return scope; + }); + + return objectifiedErr; +} + +export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseHandler = ( + options = { trackClientIp: false, trackHeaders: false }, +) => { + return async (ctx, next) => { + const method = ctx.request.method; + const headers = ctx.request.headers; + + const { dynamicSamplingContext, traceparentData, propagationContext } = tracingContextFromHeaders( + headers.get('sentry-trace') || undefined, + headers.get('baggage'), + ); + + const allHeaders: Record = {}; + headers.forEach((value, key) => { + allHeaders[key] = value; + }); + + configureScope(scope => { + scope.setPropagationContext(propagationContext); + + if (options.trackClientIp) { + scope.setUser({ ip_address: ctx.clientAddress }); + } + }); + + try { + // storing res in a variable instead of directly returning is necessary to + // invoke the catch block if next() throws + const res = await startSpan( + { + name: `${method} ${interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params)}`, + op: `http.server.${method.toLowerCase()}`, + origin: 'auto.http.astro', + status: 'ok', + ...traceparentData, + metadata: { + source: 'route', + dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, + }, + data: { + method, + url: stripUrlQueryAndFragment(ctx.url.href), + ...(ctx.url.search && { 'http.query': ctx.url.search }), + ...(ctx.url.hash && { 'http.fragment': ctx.url.hash }), + ...(options.trackHeaders && { headers: allHeaders }), + }, + }, + async span => { + const res = await next(); + if (span && res.status) { + span.setHttpStatus(res.status); + } + return res; + }, + ); + return res; + } catch (e) { + sendErrorToSentry(e); + throw e; + } + // TODO: flush if serveless (first extract function) + }; +}; + +/** + * Interpolates the route from the URL and the passed params. + * Best we can do to get a route name instead of a raw URL. + * + * exported for testing + */ +export function interpolateRouteFromUrlAndParams(rawUrl: string, params: APIContext['params']): string { + return Object.entries(params).reduce((interpolateRoute, value) => { + const [paramId, paramValue] = value; + return interpolateRoute.replace(new RegExp(`(/|-)${paramValue}(/|-|$)`), `$1[${paramId}]$2`); + }, rawUrl); +} diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts new file mode 100644 index 000000000000..af42348a94b9 --- /dev/null +++ b/packages/astro/test/server/middleware.test.ts @@ -0,0 +1,210 @@ +import * as SentryNode from '@sentry/node'; +import * as SentryUtils from '@sentry/utils'; +import { vi } from 'vitest'; + +import { handleRequest, interpolateRouteFromUrlAndParams } from '../../src/server/middleware'; + +describe('sentryMiddleware', () => { + const startSpanSpy = vi.spyOn(SentryNode, 'startSpan'); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('creates a span for an incoming request', async () => { + const middleware = handleRequest(); + const ctx = { + request: { + method: 'GET', + url: '/users/123/details', + headers: new Headers(), + }, + url: new URL('https://myDomain.io/users/123/details'), + params: { + id: '123', + }, + }; + const nextResult = Promise.resolve({ status: 200 }); + const next = vi.fn(() => nextResult); + + // @ts-expect-error, a partial ctx object is fine here + const resultFromNext = middleware(ctx, next); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + data: { + method: 'GET', + url: 'https://mydomain.io/users/123/details', + }, + metadata: { + source: 'route', + }, + name: 'GET /users/[id]/details', + op: 'http.server.get', + origin: 'auto.http.astro', + status: 'ok', + }, + expect.any(Function), // the `next` function + ); + + expect(next).toHaveBeenCalled(); + expect(resultFromNext).toStrictEqual(nextResult); + }); + + it('throws and sends an error to sentry if `next()` throws', async () => { + const scope = { + addEventProcessor: vi.fn().mockImplementation(cb => cb({})), + }; + // @ts-expect-error, just testing the callback, this is okay for this test + const captureExceptionSpy = vi.spyOn(SentryNode, 'captureException').mockImplementation((ex, cb) => cb(scope)); + const addExMechanismSpy = vi.spyOn(SentryUtils, 'addExceptionMechanism'); + + const middleware = handleRequest(); + const ctx = { + request: { + method: 'GET', + url: '/users', + headers: new Headers(), + }, + url: new URL('https://myDomain.io/users/'), + params: {}, + }; + + const error = new Error('Something went wrong'); + + const next = vi.fn(() => { + throw error; + }); + + // @ts-expect-error, a partial ctx object is fine here + await expect(async () => middleware(ctx, next)).rejects.toThrowError(); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, expect.any(Function)); + expect(scope.addEventProcessor).toHaveBeenCalledTimes(1); + expect(addExMechanismSpy).toHaveBeenCalledWith( + {}, // the mocked event + { + handled: false, + type: 'astro', + data: { function: 'astroMiddleware' }, + }, + ); + }); + + it('attaches tracing headers', async () => { + const scope = { setUser: vi.fn(), setPropagationContext: vi.fn() }; + // @ts-expect-error, only passing a partial Scope object + const configureScopeSpy = vi.spyOn(SentryNode, 'configureScope').mockImplementation(cb => cb(scope)); + + const middleware = handleRequest(); + const ctx = { + request: { + method: 'GET', + url: '/users', + headers: new Headers({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-release=1.0.0', + }), + }, + params: {}, + url: new URL('https://myDomain.io/users/'), + }; + const next = vi.fn(); + + // @ts-expect-error, a partial ctx object is fine here + await middleware(ctx, next); + + expect(configureScopeSpy).toHaveBeenCalledTimes(1); + expect(scope.setPropagationContext).toHaveBeenCalledWith({ + dsc: { + release: '1.0.0', + }, + parentSpanId: '1234567890123456', + sampled: true, + spanId: expect.any(String), + traceId: '12345678901234567890123456789012', + }); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { + source: 'route', + dynamicSamplingContext: { + release: '1.0.0', + }, + }, + parentSampled: true, + parentSpanId: '1234567890123456', + traceId: '12345678901234567890123456789012', + }), + expect.any(Function), // the `next` function + ); + }); + + it('attaches client IP and request headers if options are set', async () => { + const scope = { setUser: vi.fn(), setPropagationContext: vi.fn() }; + // @ts-expect-error, only passing a partial Scope object + const configureScopeSpy = vi.spyOn(SentryNode, 'configureScope').mockImplementation(cb => cb(scope)); + + const middleware = handleRequest({ trackClientIp: true, trackHeaders: true }); + const ctx = { + request: { + method: 'GET', + url: '/users', + headers: new Headers({ + 'some-header': 'some-value', + }), + }, + clientAddress: '192.168.0.1', + params: {}, + url: new URL('https://myDomain.io/users/'), + }; + const next = vi.fn(); + + // @ts-expect-error, a partial ctx object is fine here + await middleware(ctx, next); + + expect(configureScopeSpy).toHaveBeenCalledTimes(1); + expect(scope.setUser).toHaveBeenCalledWith({ ip_address: '192.168.0.1' }); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + headers: { + 'some-header': 'some-value', + }, + }), + }), + expect.any(Function), // the `next` function + ); + }); +}); + +describe('interpolateRouteFromUrlAndParams', () => { + it.each([ + ['/foo/bar', {}, '/foo/bar'], + ['/users/123', { id: '123' }, '/users/[id]'], + ['/users/123', { id: '123', foo: 'bar' }, '/users/[id]'], + ['/lang/en-US', { lang: 'en', region: 'US' }, '/lang/[lang]-[region]'], + ['/lang/en-US/posts', { lang: 'en', region: 'US' }, '/lang/[lang]-[region]/posts'], + ])('interpolates route from URL and params %s', (rawUrl, params, expectedRoute) => { + expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute); + }); + + it('handles params across multiple URL segments in catchall routes', () => { + // Ideally, Astro would let us know that this is a catchall route so we can make the param [...catchall] but it doesn't + expect( + interpolateRouteFromUrlAndParams('/someroute/catchall-123/params/foo/bar', { + catchall: 'catchall-123/params/foo', + params: 'foo', + }), + ).toEqual('/someroute/[catchall]/bar'); + }); + + it("doesn't replace partially matching route segments", () => { + const rawUrl = '/usernames/username'; + const params = { name: 'username' }; + const expectedRoute = '/usernames/[name]'; + expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute); + }); +}); diff --git a/packages/browser-integration-tests/fixtures/loader.js b/packages/browser-integration-tests/fixtures/loader.js index a6fa24465a4f..9387f3982997 100644 --- a/packages/browser-integration-tests/fixtures/loader.js +++ b/packages/browser-integration-tests/fixtures/loader.js @@ -1,4 +1,4 @@ -!function(n,e,r,t,i,o,a,c,u){for(var s=u,f=0;f-1){s&&"no"===document.scripts[f].getAttribute("data-lazy")&&(s=!1);break}var p=[];function l(n){return"e"in n}function d(n){return"p"in n}function _(n){return"f"in n}var v=[];function h(n){s&&(l(n)||d(n)||_(n)&&n.f.indexOf("capture")>-1||_(n)&&n.f.indexOf("showReportDialog")>-1)&&O(),v.push(n)}function y(){h({e:[].slice.call(arguments)})}function E(n){h({p:"reason"in n?n.reason:"detail"in n&&"reason"in n.detail?n.detail.reason:n})}function m(){try{n.SENTRY_SDK_SOURCE="loader";var e=n[i],o=e.init;e.init=function(i){n.removeEventListener(r,y),n.removeEventListener(t,E);var a=c;for(var u in i)Object.prototype.hasOwnProperty.call(i,u)&&(a[u]=i[u]);!function(n,e){var r=n.integrations||[];if(!Array.isArray(r))return;var t=r.map((function(n){return n.name}));n.tracesSampleRate&&-1===t.indexOf("BrowserTracing")&&r.push(new e.BrowserTracing);(n.replaysSessionSampleRate||n.replaysOnErrorSampleRate)&&-1===t.indexOf("Replay")&&r.push(new e.Replay);n.integrations=r}(a,e),o(a)},setTimeout((function(){return function(e){try{for(var r=0;r-1){u&&"no"===document.scripts[f].getAttribute("data-lazy")&&(u=!1);break}var p=[];function d(n){return"e"in n}function l(n){return"p"in n}function _(n){return"f"in n}var v=[];function y(n){u&&(d(n)||l(n)||_(n)&&n.f.indexOf("capture")>-1||_(n)&&n.f.indexOf("showReportDialog")>-1)&&L(),v.push(n)}function h(){y({e:[].slice.call(arguments)})}function E(n){y({p:"reason"in n?n.reason:"detail"in n&&"reason"in n.detail?n.detail.reason:n})}function O(){try{n.SENTRY_SDK_SOURCE="loader";var e=n[i],o=e.init;e.init=function(i){n.removeEventListener(t,h),n.removeEventListener(r,E);var a=c;for(var s in i)Object.prototype.hasOwnProperty.call(i,s)&&(a[s]=i[s]);!function(n,e){var t=n.integrations||[];if(!Array.isArray(t))return;var r=t.map((function(n){return n.name}));n.tracesSampleRate&&-1===r.indexOf("BrowserTracing")&&t.push(new e.BrowserTracing);(n.replaysSessionSampleRate||n.replaysOnErrorSampleRate)&&-1===r.indexOf("Replay")&&t.push(new e.Replay);n.integrations=t}(a,e),o(a)},setTimeout((function(){return function(e){try{"function"==typeof n.sentryOnLoad&&(n.sentryOnLoad(),n.sentryOnLoad=void 0);for(var t=0;t + + + + + + + diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoad/test.ts b/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoad/test.ts new file mode 100644 index 000000000000..1a08f07b4abb --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoad/test.ts @@ -0,0 +1,15 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequestOnUrl } from '../../../../utils/helpers'; + +sentryTest('sentryOnLoad callback is called before Sentry.onLoad()', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + const req = await waitForErrorRequestOnUrl(page, url); + + const eventData = envelopeRequestParser(req); + + expect(eventData.message).toBe('Test exception'); + + expect(await page.evaluate('Sentry.getCurrentHub().getClient().getOptions().tracesSampleRate')).toEqual(0.123); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoadAndOnLoad/init.js b/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoadAndOnLoad/init.js new file mode 100644 index 000000000000..c922dae2569a --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoadAndOnLoad/init.js @@ -0,0 +1,4 @@ +Sentry.onLoad(function () { + // this should be called _after_ window.sentryOnLoad + Sentry.captureException(`Test exception: ${Sentry.getCurrentHub().getClient().getOptions().tracesSampleRate}`); +}); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoadAndOnLoad/subject.js b/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoadAndOnLoad/subject.js new file mode 100644 index 000000000000..fb0796f7f299 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoadAndOnLoad/subject.js @@ -0,0 +1 @@ +Sentry.captureException('Test exception'); diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoadAndOnLoad/template.html b/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoadAndOnLoad/template.html new file mode 100644 index 000000000000..ed23488f1bf3 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoadAndOnLoad/template.html @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoadAndOnLoad/test.ts b/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoadAndOnLoad/test.ts new file mode 100644 index 000000000000..00a72b7613b7 --- /dev/null +++ b/packages/browser-integration-tests/loader-suites/loader/onLoad/sentryOnLoadAndOnLoad/test.ts @@ -0,0 +1,15 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequestOnUrl } from '../../../../utils/helpers'; + +sentryTest('sentryOnLoad callback is used', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + const req = await waitForErrorRequestOnUrl(page, url); + + const eventData = envelopeRequestParser(req); + + expect(eventData.message).toBe('Test exception: 0.123'); + + expect(await page.evaluate('Sentry.getCurrentHub().getClient().getOptions().tracesSampleRate')).toEqual(0.123); +}); diff --git a/packages/browser-integration-tests/package.json b/packages/browser-integration-tests/package.json index 03e6531b05f4..95fb8cfeda47 100644 --- a/packages/browser-integration-tests/package.json +++ b/packages/browser-integration-tests/package.json @@ -59,6 +59,7 @@ "devDependencies": { "@types/glob": "8.0.0", "@types/node": "^14.6.4", + "@types/pako": "^2.0.0", "glob": "8.0.3" }, "volta": { diff --git a/packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts b/packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts index 13353bf1fb6c..d7052b1384ad 100644 --- a/packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts +++ b/packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts @@ -99,23 +99,7 @@ sentryTest('should capture very large console logs', async ({ getLocalTestPath, type: 'default', category: 'console', data: { - arguments: [ - expect.objectContaining({ - 'item-0': { - aa: expect.objectContaining({ - 'item-0': { - aa: expect.any(Object), - bb: expect.any(String), - cc: expect.any(String), - dd: expect.any(String), - }, - }), - bb: expect.any(String), - cc: expect.any(String), - dd: expect.any(String), - }, - }), - ], + arguments: [expect.any(String)], logger: 'console', _meta: { warnings: ['CONSOLE_ARG_TRUNCATED'], diff --git a/packages/browser-integration-tests/suites/replay/compression/init.js b/packages/browser-integration-tests/suites/replay/compressionEnabled/init.js similarity index 100% rename from packages/browser-integration-tests/suites/replay/compression/init.js rename to packages/browser-integration-tests/suites/replay/compressionEnabled/init.js diff --git a/packages/browser-integration-tests/suites/replay/compression/template.html b/packages/browser-integration-tests/suites/replay/compressionEnabled/template.html similarity index 100% rename from packages/browser-integration-tests/suites/replay/compression/template.html rename to packages/browser-integration-tests/suites/replay/compressionEnabled/template.html diff --git a/packages/browser-integration-tests/suites/replay/compression/test.ts b/packages/browser-integration-tests/suites/replay/compressionEnabled/test.ts similarity index 100% rename from packages/browser-integration-tests/suites/replay/compression/test.ts rename to packages/browser-integration-tests/suites/replay/compressionEnabled/test.ts diff --git a/packages/browser-integration-tests/suites/replay/errors/beforeErrorSampling/init.js b/packages/browser-integration-tests/suites/replay/errors/beforeErrorSampling/init.js new file mode 100644 index 000000000000..a4d39ad78e7e --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/errors/beforeErrorSampling/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, + beforeErrorSampling: event => !event.message.includes('[drop]'), +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1, + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 1.0, + + integrations: [window.Replay], +}); diff --git a/packages/browser-integration-tests/suites/replay/errors/beforeErrorSampling/test.ts b/packages/browser-integration-tests/suites/replay/errors/beforeErrorSampling/test.ts new file mode 100644 index 000000000000..1f42c14e80ad --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/errors/beforeErrorSampling/test.ts @@ -0,0 +1,38 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getReplaySnapshot, shouldSkipReplayTest, waitForReplayRunning } from '../../../../utils/replayHelpers'; + +sentryTest( + '[error-mode] should not flush if error event is ignored in beforeErrorSampling', + async ({ getLocalTestPath, page, browserName, forceFlushReplay }) => { + // Skipping this in webkit because it is flakey there + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await waitForReplayRunning(page); + + await page.click('#drop'); + await forceFlushReplay(); + + expect(await getReplaySnapshot(page)).toEqual( + expect.objectContaining({ + _isEnabled: true, + _isPaused: false, + recordingMode: 'buffer', + }), + ); + }, +); diff --git a/packages/browser-integration-tests/suites/replay/slowClick/error/init.js b/packages/browser-integration-tests/suites/replay/slowClick/error/init.js new file mode 100644 index 000000000000..b253a8bca6e9 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/slowClick/error/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, + slowClickTimeout: 3500, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1, + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 1.0, + + integrations: [window.Replay], +}); diff --git a/packages/browser-integration-tests/suites/replay/slowClick/error/template.html b/packages/browser-integration-tests/suites/replay/slowClick/error/template.html new file mode 100644 index 000000000000..1a394556896f --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/slowClick/error/template.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/packages/browser-integration-tests/suites/replay/slowClick/error/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/error/test.ts new file mode 100644 index 000000000000..09c1fc29d3c0 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/slowClick/error/test.ts @@ -0,0 +1,140 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { + getCustomRecordingEvents, + getReplayEventFromRequest, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../utils/replayHelpers'; + +sentryTest('slow click that triggers error is captured', async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const [req0] = await Promise.all([ + waitForReplayRequest(page, (_event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); + + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); + }), + page.click('#buttonError'), + ]); + + const { breadcrumbs } = getCustomRecordingEvents(req0); + + const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); + + expect(slowClickBreadcrumbs).toEqual([ + { + category: 'ui.slowClickDetected', + type: 'default', + data: { + endReason: 'timeout', + clickCount: 1, + node: { + attributes: { + id: 'buttonError', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '******* *****', + }, + nodeId: expect.any(Number), + timeAfterClickMs: 3500, + url: 'http://sentry-test.io/index.html', + }, + message: 'body > button#buttonError', + timestamp: expect.any(Number), + }, + ]); +}); + +sentryTest( + 'click that triggers error & mutation is not captured', + async ({ getLocalTestUrl, page, forceFlushReplay }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + let slowClickCount = 0; + + page.on('response', res => { + const req = res.request(); + + const event = getReplayEventFromRequest(req); + + if (!event) { + return; + } + + const { breadcrumbs } = getCustomRecordingEvents(res); + + const slowClicks = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); + slowClickCount += slowClicks.length; + }); + + const [req1] = await Promise.all([ + waitForReplayRequest(page, (_event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); + + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }), + page.click('#buttonErrorMutation'), + ]); + + const { breadcrumbs } = getCustomRecordingEvents(req1); + + expect(breadcrumbs).toEqual([ + { + category: 'ui.click', + data: { + node: { + attributes: { + id: 'buttonErrorMutation', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '******* *****', + }, + nodeId: expect.any(Number), + }, + message: 'body > button#buttonErrorMutation', + timestamp: expect.any(Number), + type: 'default', + }, + ]); + + // Ensure we wait for timeout, to make sure no slow click is created + // Waiting for 3500 + 1s rounding room + await new Promise(resolve => setTimeout(resolve, 4500)); + await forceFlushReplay(); + + expect(slowClickCount).toBe(0); + }, +); diff --git a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts index 196719783229..39599c84cd32 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts @@ -141,8 +141,17 @@ sentryTest('immediate mutation does not trigger slow click', async ({ forceFlush await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); await forceFlushReplay(); + let slowClickCount = 0; + + page.on('response', res => { + const { breadcrumbs } = getCustomRecordingEvents(res); + + const slowClicks = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); + slowClickCount += slowClicks.length; + }); + const [req1] = await Promise.all([ - waitForReplayRequest(page, (event, res) => { + waitForReplayRequest(page, (_event, res) => { const { breadcrumbs } = getCustomRecordingEvents(res); return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); @@ -171,6 +180,13 @@ sentryTest('immediate mutation does not trigger slow click', async ({ forceFlush type: 'default', }, ]); + + // Ensure we wait for timeout, to make sure no slow click is created + // Waiting for 3500 + 1s rounding room + await new Promise(resolve => setTimeout(resolve, 4500)); + await forceFlushReplay(); + + expect(slowClickCount).toBe(0); }); sentryTest('inline click handler does not trigger slow click', async ({ forceFlushReplay, getLocalTestUrl, page }) => { diff --git a/packages/bun/src/client.ts b/packages/bun/src/client.ts index b8cebbe7463e..27d6cebd0630 100644 --- a/packages/bun/src/client.ts +++ b/packages/bun/src/client.ts @@ -30,7 +30,7 @@ export class BunClient extends ServerRuntimeClient { const clientOptions: ServerRuntimeClientOptions = { ...options, - platform: 'bun', + platform: 'javascript', runtime: { name: 'bun', version: Bun.version }, serverName: options.serverName || global.process.env.SENTRY_NAME || os.hostname(), }; diff --git a/packages/core/test/lib/serverruntimeclient.test.ts b/packages/core/test/lib/serverruntimeclient.test.ts index a0cd2e5a3f96..4ffed6c68f81 100644 --- a/packages/core/test/lib/serverruntimeclient.test.ts +++ b/packages/core/test/lib/serverruntimeclient.test.ts @@ -22,13 +22,13 @@ describe('ServerRuntimeClient', () => { describe('_prepareEvent', () => { test('adds platform to event', () => { const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); - const client = new ServerRuntimeClient({ ...options, platform: 'edge' }); + const client = new ServerRuntimeClient({ ...options, platform: 'blargh' }); const event: Event = {}; const hint: EventHint = {}; (client as any)._prepareEvent(event, hint); - expect(event.platform).toEqual('edge'); + expect(event.platform).toEqual('blargh'); }); test('adds server_name to event', () => { diff --git a/packages/deno/src/client.ts b/packages/deno/src/client.ts index 3eb7db655428..1db45a5cb960 100644 --- a/packages/deno/src/client.ts +++ b/packages/deno/src/client.ts @@ -34,7 +34,7 @@ export class DenoClient extends ServerRuntimeClient { const clientOptions: ServerRuntimeClientOptions = { ...options, - platform: 'deno', + platform: 'javascript', runtime: { name: 'deno', version: Deno.version.deno }, serverName: options.serverName || getHostName(), }; diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index 67823708e07e..dfa86aa5fd72 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -135,7 +135,7 @@ snapshot[`captureException 1`] = ` }, ], }, - platform: "deno", + platform: "javascript", sdk: { integrations: [ "InboundFilters", @@ -195,7 +195,7 @@ snapshot[`captureMessage 1`] = ` event_id: "{{id}}", level: "info", message: "Some error message", - platform: "deno", + platform: "javascript", sdk: { integrations: [ "InboundFilters", diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts new file mode 100644 index 000000000000..3fa3691e0637 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; +import { waitForTransaction } from '../event-proxy-server'; + +test('Creates a pageload transaction for app router routes', async ({ page }) => { + const randomRoute = String(Math.random()); + + const clientPageloadTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => { + return ( + transactionEvent?.transaction === `/server-component/parameter/${randomRoute}` && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/server-component/parameter/${randomRoute}`); + + expect(await clientPageloadTransactionPromise).toBeDefined(); +}); + +test('Creates a navigation transaction for app router routes', async ({ page }) => { + const randomRoute = String(Math.random()); + + const clientPageloadTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => { + return ( + transactionEvent?.transaction === `/server-component/parameter/${randomRoute}` && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/server-component/parameter/${randomRoute}`); + await clientPageloadTransactionPromise; + await page.getByText('Page (/server-component/parameter/[parameter])').isVisible(); + + const clientNavigationTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => { + return ( + transactionEvent?.transaction === '/server-component/parameter/foo/bar/baz' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + const servercomponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Page Server Component (/server-component/parameter/[...parameters])' && + (await clientNavigationTransactionPromise).contexts?.trace?.trace_id === + transactionEvent.contexts?.trace?.trace_id + ); + }); + + await page.getByText('/server-component/parameter/foo/bar/baz').click(); + + expect(await clientNavigationTransactionPromise).toBeDefined(); + expect(await servercomponentTransactionPromise).toBeDefined(); +}); diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/trace.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/trace.test.ts deleted file mode 100644 index ed9a68513a19..000000000000 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/trace.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { test } from '@playwright/test'; -import { waitForTransaction } from '../event-proxy-server'; - -if (process.env.TEST_ENV === 'production') { - // TODO: Fix that this is flakey on dev server - might be an SDK bug - test('Sends connected traces for server components', async ({ page }, testInfo) => { - await page.goto('/client-component'); - - const clientTransactionName = `e2e-next-js-app-dir: ${testInfo.title}`; - - const serverComponentTransaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { - return ( - transactionEvent?.transaction === 'Page Server Component (/server-component)' && - (await clientTransactionPromise).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id - ); - }); - - const clientTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => { - return transactionEvent?.transaction === clientTransactionName; - }); - - await page.getByPlaceholder('Transaction name').fill(clientTransactionName); - await page.getByText('Start transaction').click(); - await page.getByRole('link', { name: /^\/server-component$/ }).click(); - await page.getByText('Page (/server-component)').isVisible(); - await page.getByText('Stop transaction').click(); - - await serverComponentTransaction; - }); -} diff --git a/packages/ember/ember-cli-build.js b/packages/ember/ember-cli-build.js index 366cbe507834..1d3c053ee219 100644 --- a/packages/ember/ember-cli-build.js +++ b/packages/ember/ember-cli-build.js @@ -3,8 +3,17 @@ const EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); module.exports = function (defaults) { + const environment = process.env.EMBER_ENV || 'development'; + const isProd = environment === 'production'; + const app = new EmberAddon(defaults, { // Add options here + sourcemaps: { + enabled: isProd, + }, + 'ember-cli-terser': { + enabled: isProd, + }, }); /* diff --git a/packages/ember/package.json b/packages/ember/package.json index 3d537ac5cabf..1190e52f7575 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -27,7 +27,7 @@ "fix:eslint": "eslint . --format stylish --fix", "fix:prettier": "prettier --write \"{addon,app,tests,config}/**/**.{ts,js}\"", "start": "ember serve", - "test": "ember test", + "test": "ember b --prod && ember test", "test:all": "ember try:each", "prepack": "ember ts:precompile", "postpack": "ember ts:clean" @@ -61,7 +61,7 @@ "ember-cli-inject-live-reload": "~2.1.0", "ember-cli-sri": "~2.1.1", "ember-cli-typescript-blueprints": "~3.0.0", - "ember-cli-uglify": "~3.0.0", + "ember-cli-terser": "~4.0.2", "ember-disable-prototype-extensions": "~1.1.3", "ember-load-initializers": "~2.1.1", "ember-maybe-import-regenerator": "~1.0.0", diff --git a/packages/feedback/README.md b/packages/feedback/README.md index ef7754701af5..382b31139dd7 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -55,6 +55,7 @@ The following options can be configured as options to the integration, in `new F | key | type | default | description | | --------- | ------- | ------- | ----------- | | `autoInject` | `boolean` | `true` | Injects the Feedback widget into the application when the integration is added. This is useful to turn off if you bring your own button, or only want to show the widget on certain views. | +| `showBranding` | `boolean` | `true` | Displays the Sentry logo inside of the dialog | | `colorScheme` | `"system" \| "light" \| "dark"` | `"system"` | The color theme to use. `"system"` will follow your OS colorscheme. | ### User/form Related Configuration @@ -87,23 +88,26 @@ Most text that you see in the default Feedback widget can be customized. | key | default | description | | --------- | ------- | ----------- | -| `buttonLabel` | `"Feedback"` | The label of the widget button. | -| `submitButtonLabel` | `"Send Feedback"` | The label of the submit button used in the feedback form dialog. | -| `cancelButtonLabel` | `"Cancel"` | The label of the cancel button used in the feedback form dialog. | -| `formTitle` | `"Send Feedback"` | The title at the top of the feedback form dialog. | -| `nameLabel` | `"Full Name"` | The label of the name input field. | -| `namePlaceholder` | `"Full Name"` | The placeholder for the name input field. | -| `emailLabel` | `"Email"` | The label of the email input field. || -| `emailPlaceholder` | `"Email"` | The placeholder for the email input field. | -| `messageLabel` | `"Description"` | The label for the feedback description input field. | -| `messagePlaceholder` | `"What's the issue? What did you expect?"` | The placeholder for the feedback description input field. | -| `successMessageText` | `"Thank you for your report!"` | The message to be displayed after a succesful feedback submission. | +| `buttonLabel` | `Report a Bug` | The label of the widget button. | +| `submitButtonLabel` | `Send Bug Report` | The label of the submit button used in the feedback form dialog. | +| `cancelButtonLabel` | `Cancel` | The label of the cancel button used in the feedback form dialog. | +| `formTitle` | `Report a Bug` | The title at the top of the feedback form dialog. | +| `nameLabel` | `Name` | The label of the name input field. | +| `namePlaceholder` | `Your Name` | The placeholder for the name input field. | +| `emailLabel` | `Email` | The label of the email input field. | +| `emailPlaceholder` | `your.email@example.org` | The placeholder for the email input field. | +| `messageLabel` | `Description` | The label for the feedback description input field. | +| `messagePlaceholder` | `What's the bug? What did you expect?` | The placeholder for the feedback description input field. | +| `successMessageText` | `Thank you for your report!` | The message to be displayed after a succesful feedback submission. | + + +Example of customization ```javascript new Feedback({ - buttonLabel: 'Bug Report', - submitButtonLabel: 'Send Report', - formTitle: 'Send Bug Report', + buttonLabel: 'Feedback', + submitButtonLabel: 'Send Feedback', + formTitle: 'Send Feedback', }); ``` @@ -112,13 +116,29 @@ Colors can be customized via the Feedback constructor or by defining CSS variabl | key | css variable | light | dark | description | | --- | --- | --- | --- | --- | -| `background` | `--bg-color` | `#ffffff` | `#29232f` | Background color of the widget actor and dialog. | -| `backgroundHover` | `--bg-hover-color` | `#f6f6f7` | `#352f3b` | The background color of widget actor when in a hover state | -| `foreground` | `--fg-color` | `#2b2233` | `#ebe6ef` | The foreground color, e.g. text color | -| `error` | `--error-color` | `#df3338` | `#f55459` | Color used for error related components (e.g. text color when there was an error submitting feedback) | -| `success` | `--success-color` | `#268d75` | `#2da98c` | Color used for success-related components (e.g. text color when feedback is submitted successfully) | +| `background` | `--background` | `#ffffff` | `#29232f` | Background color of the widget actor and dialog | +| `backgroundHover` | `--background-hover` | `#f6f6f7` | `#352f3b` | Background color of widget actor when in a hover state | +| `foreground` | `--foreground` | `#2b2233` | `#ebe6ef` | Foreground color, e.g. text color | +| `error` | `--error` | `#df3338` | `#f55459` | Color used for error related components (e.g. text color when there was an error submitting feedback) | +| `success` | `--success` | `#268d75` | `#2da98c` | Color used for success-related components (e.g. text color when feedback is submitted successfully) | | `border` | `--border` | `1.5px solid rgba(41, 35, 47, 0.13)` | `1.5px solid rgba(235, 230, 239, 0.15)` | The border style used for the widget actor and dialog | | `boxShadow` | `--box-shadow` | `0px 4px 24px 0px rgba(43, 34, 51, 0.12)` | `0px 4px 24px 0px rgba(43, 34, 51, 0.12)` | The box shadow style used for the widget actor and dialog | +| `submitBackground` | `--submit-background` | `rgba(88, 74, 192, 1)` | `rgba(88, 74, 192, 1)` | Background color for the submit button | +| `submitBackgroundHover` | `--submit-background-hover` | `rgba(108, 95, 199, 1)` | `rgba(108, 95, 199, 1)` | Background color when hovering over the submit button | +| `submitBorder` | `--submit-border` | `rgba(108, 95, 199, 1)` | `rgba(108, 95, 199, 1)` | Border style for the submit button | +| `submitOutlineFocus` | `--submit-outline-focus` | `rgba(108, 95, 199, 1)` | `rgba(108, 95, 199, 1)` | Outline color for the submit button, in the focused state | +| `submitForeground` | `--submit-foreground` | `#ffffff` | `#ffffff` | Foreground color for the submit button | +| `submitForegroundHover` | `--submit-foreground-hover` | `#ffffff` | `#ffffff` | Foreground color for the submit button when hovering | +| `cancelBackground` | `--cancel-background` | `transparent` | `transparent` | Background color for the cancel button | +| `cancelBackgroundHover` | `--cancel-background-hover` | `var(--background-hover)` | `var(--background-hover)` | Background color when hovering over the cancel button | +| `cancelBorder` | `--cancel-border` | `var(--border)` | `var(--border)` | Border style for the cancel button | +| `cancelOutlineFocus` | `--cancel-outline-focus` | `var(--input-outline-focus)` | `var(--input-outline-focus)` | Outline color for the cancel button, in the focused state | +| `cancelForeground` | `--cancel-foreground` | `var(--foreground)` | `var(--foreground)` | Foreground color for the cancel button | +| `cancelForegroundHover` | `--cancel-foreground-hover` | `var(--foreground)` | `var(--foreground)` | Foreground color for the cancel button when hovering | +| `inputBackground` | `--input-background` | `inherit` | `inherit` | Background color for form inputs | +| `inputForeground` | `--input-foreground` | `inherit` | `inherit` | Foreground color for form inputs | +| `inputBorder` | `--input-border` | `var(--border)` | `var(--border)` | Border styles for form inputs | +| `inputOutlineFocus` | `--input-outline-focus` | `rgba(108, 95, 199, 1)` | `rgba(108, 95, 199, 1)` | Outline color for form inputs when focused | Here is an example of customizing only the background color for the light theme using the Feedback constructor configuration. ```javascript @@ -133,7 +153,7 @@ Or the same example above but using the CSS variables method: ```css #sentry-feedback { - --bg-color: #cccccc; + --background: #cccccc; } ``` @@ -183,12 +203,35 @@ feedback.attachTo(document.querySelector('#your-button'), { }); ``` +Alternatively you can call `feedback.openDialog()`: + +```typescript +import {BrowserClient, getCurrentHub} from '@sentry/react'; +import {Feedback} from '@sentry-internal/feedback'; + +function MyFeedbackButton() { + const client = hub && getCurrentHub().getClient(); + const feedback = client?.getIntegration(Feedback); + + // Don't render custom feedback button if Feedback integration not installed + if (!feedback) { + return null; + } + + return ( + + ) +} +``` + ### Bring Your Own Widget You can also bring your own widget and UI and simply pass a feedback object to the `sendFeedback()` function. ```html -