diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml index 076645f22773..782d95d60269 100644 --- a/.github/workflows/external-contributors.yml +++ b/.github/workflows/external-contributors.yml @@ -36,7 +36,7 @@ jobs: author_association: ${{ github.event.pull_request.author_association }} - name: Create PR with changes - uses: peter-evans/create-pull-request@dd2324fc52d5d43c699a5636bcf19fceaa70c284 + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e with: # This token is scoped to Daniel Griesser # If we used the default GITHUB_TOKEN, the resulting PR would not trigger CI :( diff --git a/.size-limit.js b/.size-limit.js index eed705e16da6..ca26288b07b3 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -139,7 +139,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '39.5 KB', + limit: '40 KB', }, // Svelte SDK (ESM) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 266092c9064e..a90046b67055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,64 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.12.0 + +### Important Changes + +- **feat(feedback): Implement highlighting and hiding controls for screenshots ([#15951](https://github.com/getsentry/sentry-javascript/pull/15951))** + + The Sentry SDK now supports highlighting and hiding controls for screenshots in [user feedback reports](https://docs.sentry.io/platforms/javascript/user-feedback/). This functionality is enabled by default. + +- **feat(node): Add `ignoreIncomingRequestBody` callback to `httpIntegration` ([#15959](https://github.com/getsentry/sentry-javascript/pull/15959))** + + The `httpIntegration` now supports an optional `ignoreIncomingRequestBody` callback that can be used to skip capturing the body of incoming requests. + + ```ts + Sentry.init({ + integrations: [ + Sentry.httpIntegration({ + ignoreIncomingRequestBody: (url, request) => { + return request.method === 'GET' && url.includes('/api/large-payload'); + }, + }), + ], + }); + ``` + + The `ignoreIncomingRequestBody` callback receives the URL of the request and should return `true` if the body should be ignored. + +- **Logging Improvements** + + Sentry is adding support for [structured logging](https://github.com/getsentry/sentry-javascript/discussions/15916). In this release we've made a variety of improvements to logging functionality in the Sentry SDKs. + + - feat(node): Add server.address to nodejs logs ([#16006](https://github.com/getsentry/sentry-javascript/pull/16006)) + - feat(core): Add sdk name and version to logs ([#16005](https://github.com/getsentry/sentry-javascript/pull/16005)) + - feat(core): Add sentry origin attribute to console logs integration ([#15998](https://github.com/getsentry/sentry-javascript/pull/15998)) + - fix(core): Do not abbreviate message parameter attribute ([#15987](https://github.com/getsentry/sentry-javascript/pull/15987)) + - fix(core): Prefix release and environment correctly ([#15999](https://github.com/getsentry/sentry-javascript/pull/15999)) + - fix(node): Make log flushing logic more robust ([#15991](https://github.com/getsentry/sentry-javascript/pull/15991)) + +### Other Changes + +- build(aws-serverless): Include debug logs in lambda layer SDK bundle ([#15974](https://github.com/getsentry/sentry-javascript/pull/15974)) +- feat(astro): Add tracking of errors during HTML streaming ([#15995](https://github.com/getsentry/sentry-javascript/pull/15995)) +- feat(browser): Add `onRequestSpanStart` hook to browser tracing integration ([#15979](https://github.com/getsentry/sentry-javascript/pull/15979)) +- feat(deps): Bump @sentry/cli from 2.42.3 to 2.43.0 ([#16001](https://github.com/getsentry/sentry-javascript/pull/16001)) +- feat(nextjs): Add `captureRouterTransitionStart` hook for capturing navigations ([#15981](https://github.com/getsentry/sentry-javascript/pull/15981)) +- feat(nextjs): Mark clientside prefetch request spans with `http.request.prefetch: true` attribute ([#15980](https://github.com/getsentry/sentry-javascript/pull/15980)) +- feat(nextjs): Un experimentify `clientInstrumentationHook` ([#15992](https://github.com/getsentry/sentry-javascript/pull/15992)) +- feat(nextjs): Warn when client was initialized more than once ([#15971](https://github.com/getsentry/sentry-javascript/pull/15971)) +- feat(node): Add support for `SENTRY_DEBUG` env variable ([#15972](https://github.com/getsentry/sentry-javascript/pull/15972)) +- fix(tss-react): Change `authToken` type to `string` ([#15985](https://github.com/getsentry/sentry-javascript/pull/15985)) + +Work in this release was contributed by @Page- and @Fryuni. Thank you for your contributions! + ## 9.11.0 - feat(browser): Add `http.redirect_count` attribute to `browser.redirect` span ([#15943](https://github.com/getsentry/sentry-javascript/pull/15943)) - feat(core): Add `consoleLoggingIntegration` for logs ([#15955](https://github.com/getsentry/sentry-javascript/pull/15955)) - feat(core): Don't truncate error messages ([#15818](https://github.com/getsentry/sentry-javascript/pull/15818)) +- feat(core): Emit debug log when transport execution fails ([#16009](https://github.com/getsentry/sentry-javascript/pull/16009)) - feat(nextjs): Add release injection in Turbopack ([#15958](https://github.com/getsentry/sentry-javascript/pull/15958)) - feat(nextjs): Record `turbopack` as tag ([#15928](https://github.com/getsentry/sentry-javascript/pull/15928)) - feat(nuxt): Base decision on source maps upload only on Nuxt source map settings ([#15859](https://github.com/getsentry/sentry-javascript/pull/15859)) @@ -117,7 +170,7 @@ - **feat(nextjs): Support `instrumentation-client.ts` ([#15705](https://github.com/getsentry/sentry-javascript/pull/15705))** - Next.js recently added a feature to support [client-side (browser) instrumentation via the `experimental.clientInstrumentationHook` flag and the `instrumentation-client.ts` file](https://nextjs.org/docs/app/api-reference/config/next-config-js/clientInstrumentationHook). + Next.js recently added a feature to support [client-side (browser) instrumentation via a `instrumentation-client.ts` file](https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client). To be forwards compatible, the Sentry Next.js SDK will now pick up `instrumentation-client.ts` files even on older Next.js versions and add them to your client bundles. It is suggested that you either rename your `sentry.client.config.ts` file to `instrumentation-client.ts`, or if you already happen to have a `instrumentation-client.ts` file move the contents of `sentry.client.config.ts` to `instrumentation-client.ts`. diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts index 00e918ce9719..e255a6afda23 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts @@ -23,7 +23,26 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page { severityText: 'trace', body: { stringValue: 'console.trace 123 false' }, - attributes: [], + attributes: [ + { + key: 'sentry.origin', + value: { + stringValue: 'auto.console.logging', + }, + }, + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + ], timeUnixNano: expect.any(String), traceId: expect.any(String), severityNumber: 1, @@ -37,7 +56,26 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page { severityText: 'debug', body: { stringValue: 'console.debug 123 false' }, - attributes: [], + attributes: [ + { + key: 'sentry.origin', + value: { + stringValue: 'auto.console.logging', + }, + }, + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + ], timeUnixNano: expect.any(String), traceId: expect.any(String), severityNumber: 5, @@ -51,7 +89,26 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page { severityText: 'info', body: { stringValue: 'console.log 123 false' }, - attributes: [], + attributes: [ + { + key: 'sentry.origin', + value: { + stringValue: 'auto.console.logging', + }, + }, + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + ], timeUnixNano: expect.any(String), traceId: expect.any(String), severityNumber: 10, @@ -65,7 +122,26 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page { severityText: 'info', body: { stringValue: 'console.info 123 false' }, - attributes: [], + attributes: [ + { + key: 'sentry.origin', + value: { + stringValue: 'auto.console.logging', + }, + }, + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + ], timeUnixNano: expect.any(String), traceId: expect.any(String), severityNumber: 9, @@ -79,7 +155,26 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page { severityText: 'warn', body: { stringValue: 'console.warn 123 false' }, - attributes: [], + attributes: [ + { + key: 'sentry.origin', + value: { + stringValue: 'auto.console.logging', + }, + }, + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + ], timeUnixNano: expect.any(String), traceId: expect.any(String), severityNumber: 13, @@ -93,7 +188,26 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page { severityText: 'error', body: { stringValue: 'console.error 123 false' }, - attributes: [], + attributes: [ + { + key: 'sentry.origin', + value: { + stringValue: 'auto.console.logging', + }, + }, + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + ], timeUnixNano: expect.any(String), traceId: expect.any(String), severityNumber: 17, @@ -107,7 +221,26 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page { severityText: 'error', body: { stringValue: 'Assertion failed: console.assert 123 false' }, - attributes: [], + attributes: [ + { + key: 'sentry.origin', + value: { + stringValue: 'auto.console.logging', + }, + }, + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + ], timeUnixNano: expect.any(String), traceId: expect.any(String), severityNumber: 17, diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts index 84f2a34e8fe5..5995058867a0 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts @@ -23,7 +23,20 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page { severityText: 'trace', body: { stringValue: 'test trace' }, - attributes: [], + attributes: [ + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + ], timeUnixNano: expect.any(String), traceId: expect.any(String), severityNumber: 1, @@ -37,7 +50,20 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page { severityText: 'debug', body: { stringValue: 'test debug' }, - attributes: [], + attributes: [ + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + ], timeUnixNano: expect.any(String), traceId: expect.any(String), severityNumber: 5, @@ -51,7 +77,20 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page { severityText: 'info', body: { stringValue: 'test info' }, - attributes: [], + attributes: [ + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + ], timeUnixNano: expect.any(String), traceId: expect.any(String), severityNumber: 9, @@ -65,7 +104,20 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page { severityText: 'warn', body: { stringValue: 'test warn' }, - attributes: [], + attributes: [ + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + ], timeUnixNano: expect.any(String), traceId: expect.any(String), severityNumber: 13, @@ -79,7 +131,20 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page { severityText: 'error', body: { stringValue: 'test error' }, - attributes: [], + attributes: [ + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + ], timeUnixNano: expect.any(String), traceId: expect.any(String), severityNumber: 17, @@ -93,7 +158,20 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page { severityText: 'fatal', body: { stringValue: 'test fatal' }, - attributes: [], + attributes: [ + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + ], timeUnixNano: expect.any(String), traceId: expect.any(String), severityNumber: 21, @@ -108,6 +186,18 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page severityText: 'trace', body: { stringValue: 'test trace stringArg false 123' }, attributes: [ + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, { key: 'sentry.message.template', value: { @@ -115,25 +205,25 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page }, }, { - key: 'sentry.message.param.0', + key: 'sentry.message.parameter.0', value: { stringValue: 'trace', }, }, { - key: 'sentry.message.param.1', + key: 'sentry.message.parameter.1', value: { stringValue: 'stringArg', }, }, { - key: 'sentry.message.param.2', + key: 'sentry.message.parameter.2', value: { boolValue: false, }, }, { - key: 'sentry.message.param.3', + key: 'sentry.message.parameter.3', value: { doubleValue: 123, }, @@ -153,6 +243,18 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page severityText: 'debug', body: { stringValue: 'test debug stringArg false 123' }, attributes: [ + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, { key: 'sentry.message.template', value: { @@ -160,25 +262,25 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page }, }, { - key: 'sentry.message.param.0', + key: 'sentry.message.parameter.0', value: { stringValue: 'debug', }, }, { - key: 'sentry.message.param.1', + key: 'sentry.message.parameter.1', value: { stringValue: 'stringArg', }, }, { - key: 'sentry.message.param.2', + key: 'sentry.message.parameter.2', value: { boolValue: false, }, }, { - key: 'sentry.message.param.3', + key: 'sentry.message.parameter.3', value: { doubleValue: 123, }, @@ -198,6 +300,18 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page severityText: 'info', body: { stringValue: 'test info stringArg false 123' }, attributes: [ + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, { key: 'sentry.message.template', value: { @@ -205,25 +319,25 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page }, }, { - key: 'sentry.message.param.0', + key: 'sentry.message.parameter.0', value: { stringValue: 'info', }, }, { - key: 'sentry.message.param.1', + key: 'sentry.message.parameter.1', value: { stringValue: 'stringArg', }, }, { - key: 'sentry.message.param.2', + key: 'sentry.message.parameter.2', value: { boolValue: false, }, }, { - key: 'sentry.message.param.3', + key: 'sentry.message.parameter.3', value: { doubleValue: 123, }, @@ -243,6 +357,18 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page severityText: 'warn', body: { stringValue: 'test warn stringArg false 123' }, attributes: [ + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, { key: 'sentry.message.template', value: { @@ -250,25 +376,25 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page }, }, { - key: 'sentry.message.param.0', + key: 'sentry.message.parameter.0', value: { stringValue: 'warn', }, }, { - key: 'sentry.message.param.1', + key: 'sentry.message.parameter.1', value: { stringValue: 'stringArg', }, }, { - key: 'sentry.message.param.2', + key: 'sentry.message.parameter.2', value: { boolValue: false, }, }, { - key: 'sentry.message.param.3', + key: 'sentry.message.parameter.3', value: { doubleValue: 123, }, @@ -288,6 +414,18 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page severityText: 'error', body: { stringValue: 'test error stringArg false 123' }, attributes: [ + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, { key: 'sentry.message.template', value: { @@ -295,25 +433,25 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page }, }, { - key: 'sentry.message.param.0', + key: 'sentry.message.parameter.0', value: { stringValue: 'error', }, }, { - key: 'sentry.message.param.1', + key: 'sentry.message.parameter.1', value: { stringValue: 'stringArg', }, }, { - key: 'sentry.message.param.2', + key: 'sentry.message.parameter.2', value: { boolValue: false, }, }, { - key: 'sentry.message.param.3', + key: 'sentry.message.parameter.3', value: { doubleValue: 123, }, @@ -333,6 +471,18 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page severityText: 'fatal', body: { stringValue: 'test fatal stringArg false 123' }, attributes: [ + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.browser', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, { key: 'sentry.message.template', value: { @@ -340,25 +490,25 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page }, }, { - key: 'sentry.message.param.0', + key: 'sentry.message.parameter.0', value: { stringValue: 'fatal', }, }, { - key: 'sentry.message.param.1', + key: 'sentry.message.parameter.1', value: { stringValue: 'stringArg', }, }, { - key: 'sentry.message.param.2', + key: 'sentry.message.parameter.2', value: { boolValue: false, }, }, { - key: 'sentry.message.param.3', + key: 'sentry.message.parameter.3', value: { doubleValue: 123, }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js new file mode 100644 index 000000000000..2c85bd05b765 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + onRequestSpanStart(span, { headers }) { + if (headers) { + span.setAttribute('hook.called.headers', headers.get('foo')); + } + }, + }), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/subject.js new file mode 100644 index 000000000000..494ce7d23a05 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/subject.js @@ -0,0 +1,11 @@ +fetch('http://sentry-test-site-fetch.example/', { + headers: { + foo: 'fetch', + }, +}); + +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://sentry-test-site-xhr.example/'); +xhr.setRequestHeader('foo', 'xhr'); +xhr.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/test.ts new file mode 100644 index 000000000000..91b0c1333298 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/test.ts @@ -0,0 +1,52 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('should call onRequestSpanStart hook', async ({ browserName, getLocalTestUrl, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site-fetch.example/', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '', + }); + }); + await page.route('http://sentry-test-site-xhr.example/', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '', + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const envelopes = await getMultipleSentryEnvelopeRequests(page, 2, { url, timeout: 10000 }); + + const tracingEvent = envelopes[envelopes.length - 1]; // last envelope contains tracing data on all browsers + + expect(tracingEvent.spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + 'hook.called.headers': 'xhr', + }), + }), + ); + + expect(tracingEvent.spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + 'hook.called.headers': 'fetch', + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/instrumentation-client.ts similarity index 78% rename from dev-packages/e2e-tests/test-applications/nextjs-13/sentry.client.config.ts rename to dev-packages/e2e-tests/test-applications/nextjs-13/instrumentation-client.ts index f2c7e4aef94d..4870c64e7959 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/instrumentation-client.ts @@ -1,5 +1,3 @@ -'use client'; - import * as Sentry from '@sentry/nextjs'; Sentry.init({ @@ -9,3 +7,5 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, }); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/instrumentation-client.ts similarity index 78% rename from dev-packages/e2e-tests/test-applications/nextjs-14/sentry.client.config.ts rename to dev-packages/e2e-tests/test-applications/nextjs-14/instrumentation-client.ts index f2c7e4aef94d..4870c64e7959 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/instrumentation-client.ts @@ -1,5 +1,3 @@ -'use client'; - import * as Sentry from '@sentry/nextjs'; Sentry.init({ @@ -9,3 +7,5 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, }); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/prefetching/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/prefetching/page.tsx new file mode 100644 index 000000000000..4cb811ecf1b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/prefetching/page.tsx @@ -0,0 +1,9 @@ +import Link from 'next/link'; + +export default function Page() { + return ( + + link + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/prefetching/to-be-prefetched/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/prefetching/to-be-prefetched/page.tsx new file mode 100644 index 000000000000..83aac90d65cf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/prefetching/to-be-prefetched/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

Hello

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts similarity index 78% rename from dev-packages/e2e-tests/test-applications/nextjs-15/sentry.client.config.ts rename to dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts index f2c7e4aef94d..4870c64e7959 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts @@ -1,5 +1,3 @@ -'use client'; - import * as Sentry from '@sentry/nextjs'; Sentry.init({ @@ -9,3 +7,5 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, }); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts index 40c3d68096c2..1b3be0840f3f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index 66b38e2e5cc0..a79d34746ee4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -18,7 +18,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.0.0-canary.182", + "next": "15.3.0-canary.33", "react": "beta", "react-dom": "beta", "typescript": "~5.0.0" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/prefetch-spans.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/prefetch-spans.test.ts new file mode 100644 index 000000000000..b59a45f31f8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/prefetch-spans.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Prefetch client spans should have a http.request.prefetch attribute', async ({ page }) => { + test.skip(process.env.TEST_ENV === 'development', "Prefetch requests don't have the prefetch header in dev mode"); + + const pageloadTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent?.transaction === '/prefetching'; + }); + + await page.goto(`/prefetching`); + + // Make it more likely that nextjs prefetches + await page.hover('#prefetch-link'); + + expect((await pageloadTransactionPromise).spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + 'http.request.prefetch': true, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/instrumentation-client.ts similarity index 78% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/sentry.client.config.ts rename to dev-packages/e2e-tests/test-applications/nextjs-app-dir/instrumentation-client.ts index f2c7e4aef94d..4870c64e7959 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/instrumentation-client.ts @@ -1,5 +1,3 @@ -'use client'; - import * as Sentry from '@sentry/nextjs'; Sentry.init({ @@ -9,3 +7,5 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, }); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/next-env.d.ts index fd36f9494e2c..725dd6f24515 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/next-env.d.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/next-env.d.ts @@ -3,4 +3,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts index abfe9b323d0f..8069a1d1395b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts @@ -106,7 +106,7 @@ test('Creates a navigation transaction for `router.back()`', async ({ page }) => contexts: { trace: { data: { - 'navigation.type': 'router.back', + 'navigation.type': expect.stringMatching(/router\.(back|traverse)/), // back is Next.js < 15.3.0, traverse >= 15.3.0 }, }, }, @@ -118,7 +118,8 @@ test('Creates a navigation transaction for `router.forward()`', async ({ page }) return ( transactionEvent?.transaction === `/navigation/42/router-push` && transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.forward' + (transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.forward' || + transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse') ); }); @@ -169,7 +170,8 @@ test('Creates a navigation transaction for browser-back', async ({ page }) => { return ( transactionEvent?.transaction === `/navigation/42/browser-back` && transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' + (transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' || + transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse') ); }); @@ -187,7 +189,8 @@ test('Creates a navigation transaction for browser-forward', async ({ page }) => return ( transactionEvent?.transaction === `/navigation/42/router-push` && transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' + (transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' || + transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse') ); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/instrumentation-client.ts index 85bd765c9c44..4870c64e7959 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/instrumentation-client.ts @@ -7,3 +7,5 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, }); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json index 94e762a859a9..99679ba13deb 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json @@ -17,7 +17,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.3.0-canary.26", + "next": "15.3.0-canary.40", "react": "rc", "react-dom": "rc", "typescript": "~5.0.0" diff --git a/dev-packages/node-integration-tests/suites/express/tracing/server.js b/dev-packages/node-integration-tests/suites/express/tracing/server.js index f9b4ae24b339..0291ee656995 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/server.js +++ b/dev-packages/node-integration-tests/suites/express/tracing/server.js @@ -8,6 +8,16 @@ Sentry.init({ tracePropagationTargets: [/^(?!.*test).*$/], tracesSampleRate: 1.0, transport: loggingTransport, + integrations: [ + Sentry.httpIntegration({ + ignoreIncomingRequestBody: (url) => { + if (url.includes('/test-post-ignore-body')) { + return true; + } + return false; + }, + }), + ], }); // express must be required after Sentry is initialized @@ -43,6 +53,10 @@ app.post('/test-post', function (req, res) { res.send({ status: 'ok', body: req.body }); }); +app.post('/test-post-ignore-body', function (req, res) { + res.send({ status: 'ok', body: req.body }); +}); + Sentry.setupExpressErrorHandler(app); startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/test.ts b/dev-packages/node-integration-tests/suites/express/tracing/test.ts index ebf9977e722b..ebe710bdc0d0 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/tracing/test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { assertSentryTransaction } from '../../../utils/assertions'; describe('express tracing', () => { afterAll(() => { @@ -244,6 +245,34 @@ describe('express tracing', () => { }); await runner.completed(); }); + + test('correctly ignores request data', async () => { + const runner = createRunner(__dirname, 'server.js') + .expect({ + transaction: e => { + assertSentryTransaction(e, { + transaction: 'POST /test-post-ignore-body', + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post-ignore-body$/), + method: 'POST', + headers: { + 'user-agent': expect.stringContaining(''), + 'content-type': 'application/octet-stream', + }, + }, + }); + // Ensure the request body has been ignored + expect(e).have.property('request').that.does.not.have.property('data'); + }, + }) + .start(); + + runner.makeRequest('post', '/test-post-ignore-body', { + headers: { 'Content-Type': 'application/octet-stream' }, + data: Buffer.from('some plain text in buffer'), + }); + await runner.completed(); + }); }); }); }); diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 6b55dbd8a976..692a8a7256a3 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -184,12 +184,18 @@ async function instrumentRequest( const newResponseStream = new ReadableStream({ start: async controller => { - for await (const chunk of originalBody) { - const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); - const modifiedHtml = addMetaTagToHead(html); - controller.enqueue(new TextEncoder().encode(modifiedHtml)); + try { + for await (const chunk of originalBody) { + const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); + const modifiedHtml = addMetaTagToHead(html); + controller.enqueue(new TextEncoder().encode(modifiedHtml)); + } + } catch (e) { + sendErrorToSentry(e); + controller.error(e); + } finally { + controller.close(); } - controller.close(); }, }); diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index eff473d4401f..08dcb6112421 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -149,6 +149,49 @@ describe('sentryMiddleware', () => { }); }); + it('throws and sends an error to sentry if response streaming throws', async () => { + const captureExceptionSpy = vi.spyOn(SentryNode, 'captureException'); + + 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 faultyStream = new ReadableStream({ + pull: controller => { + controller.error(error); + controller.close(); + }, + }); + + const next = vi.fn(() => + Promise.resolve( + new Response(faultyStream, { + headers: new Headers({ 'content-type': 'text/html' }), + }), + ), + ); + + // @ts-expect-error, a partial ctx object is fine here + const resultFromNext = await middleware(ctx, next); + + expect(resultFromNext?.headers.get('content-type')).toEqual('text/html'); + + await expect(() => resultFromNext!.text()).rejects.toThrowError(); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'astro', data: { function: 'astroMiddleware' } }, + }); + }); + describe('track client IP address', () => { it('attaches client IP if `trackClientIp=true` when handling dynamic page requests', async () => { const middleware = handleRequest({ trackClientIp: true }); diff --git a/packages/aws-serverless/rollup.aws.config.mjs b/packages/aws-serverless/rollup.aws.config.mjs index 6348d3b1ae74..d9f0720886ef 100644 --- a/packages/aws-serverless/rollup.aws.config.mjs +++ b/packages/aws-serverless/rollup.aws.config.mjs @@ -19,7 +19,7 @@ export default [ // We only need one copy of the SDK, and we pick the minified one because there's a cap on how big a lambda function // plus its dependencies can be, and we might as well take up as little of that space as is necessary. We'll rename // it to be `index.js` in the build script, since it's standing in for the index file of the npm package. - { variants: ['.min.js'] }, + { variants: ['.debug.min.js'] }, ), makeBaseNPMConfig({ entrypoints: ['src/awslambda-auto.ts'], diff --git a/packages/aws-serverless/scripts/buildLambdaLayer.ts b/packages/aws-serverless/scripts/buildLambdaLayer.ts index 6062db947ceb..7ce98ca64a5c 100644 --- a/packages/aws-serverless/scripts/buildLambdaLayer.ts +++ b/packages/aws-serverless/scripts/buildLambdaLayer.ts @@ -19,7 +19,7 @@ async function buildLambdaLayer(): Promise { // We build a minified bundle, but it's standing in for the regular `index.js` file listed in `package.json`'s `main` // property, so we have to rename it so it's findable. fs.renameSync( - 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs/index.min.js', + 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs/index.debug.min.js', 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs/index.js', ); diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 062b308527d6..fab45cd1ed4f 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -9,7 +9,7 @@ import { startTrackingLongTasks, startTrackingWebVitals, } from '@sentry-internal/browser-utils'; -import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource } from '@sentry/core'; +import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource, WebFetchHeaders } from '@sentry/core'; import { GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, @@ -195,6 +195,13 @@ export interface BrowserTracingOptions { * Default: (url: string) => true */ shouldCreateSpanForRequest?(this: void, url: string): boolean; + + /** + * This callback is invoked directly after a span is started for an outgoing fetch or XHR request. + * You can use it to annotate the span with additional data or attributes, for example by setting + * attributes based on the passed request headers. + */ + onRequestSpanStart?(span: Span, requestInformation: { headers?: WebFetchHeaders }): void; } const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { @@ -246,6 +253,7 @@ export const browserTracingIntegration = ((_options: Partial true */ shouldCreateSpanForRequest?(this: void, url: string): boolean; + + /** + * Is called when spans are started for outgoing requests. + */ + onRequestSpanStart?(span: Span, requestInformation: { headers?: WebFetchHeaders }): void; } const responseToSpanId = new WeakMap(); @@ -119,10 +124,9 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { const createdSpan = xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); - if (enableHTTPTimings && createdSpan) { - addHTTPTimings(createdSpan); + if (createdSpan) { + if (enableHTTPTimings) { + addHTTPTimings(createdSpan); + } + + let headers; + try { + headers = new Headers(handlerData.xhr.__sentry_xhr_v3__?.request_headers); + } catch { + // noop + } + onRequestSpanStart?.(createdSpan, { headers }); } }); } diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index de54f5351332..99e2a91b8cf0 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -22,16 +22,21 @@ To get started, first install the `@sentry/cloudflare` package: npm install @sentry/cloudflare ``` -Then set either the `nodejs_compat` or `nodejs_als` compatibility flags in your `wrangler.toml`. This is because the SDK -needs access to the `AsyncLocalStorage` API to work correctly. - -```toml -compatibility_flags = ["nodejs_compat"] -# compatibility_flags = ["nodejs_als"] +Then either set the `nodejs_als` or `nodejs_compat` compatibility flags in your `wrangler.jsonc`/`wrangler.toml` config. This is because the SDK needs access to the `AsyncLocalStorage` API to work correctly. + +```jsonc {tabTitle:JSON} {filename:wrangler.jsonc} +{ + "compatibility_flags": [ + "nodejs_als" + // "nodejs_compat" + ] +} ``` -Then you can either setup up the SDK for [Cloudflare Pages](#setup-cloudflare-pages) or -[Cloudflare Workers](#setup-cloudflare-workers). +```toml {tabTitle:Toml} {filename:wrangler.toml} +compatibility_flags = ["nodejs_als"] +# compatibility_flags = ["nodejs_compat"] +``` ## Setup (Cloudflare Pages) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 6deea6ac38ce..3599448abf4c 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -670,6 +670,13 @@ export abstract class Client { */ public on(hook: 'afterCaptureLog', callback: (log: Log) => void): () => void; + /** + * A hook that is called when the client is flushing logs + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'flushLogs', callback: () => void): () => void; + /** * Register a hook on this client. */ @@ -827,6 +834,11 @@ export abstract class Client { */ public emit(hook: 'afterCaptureLog', log: Log): void; + /** + * Emit a hook event for client flush logs + */ + public emit(hook: 'flushLogs'): void; + /** * Emit a hook that was previously registered via `on()`. */ diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 5f0c9cc30b56..379097936ef4 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -4,7 +4,7 @@ import { SPAN_STATUS_ERROR, setHttpStatus, startInactiveSpan } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; import type { FetchBreadcrumbHint, HandlerDataFetch, Span, SpanAttributes, SpanOrigin } from './types-hoist'; import { SENTRY_BAGGAGE_KEY_PREFIX } from './utils-hoist/baggage'; -import { isInstanceOf } from './utils-hoist/is'; +import { isInstanceOf, isRequest } from './utils-hoist/is'; import { getSanitizedUrlStringFromUrlObject, isURLObjectRelative, parseStringToURLObject } from './utils-hoist/url'; import { hasSpansEnabled } from './utils/hasSpansEnabled'; import { getActiveSpan } from './utils/spanUtils'; @@ -227,10 +227,6 @@ function stripBaggageHeaderOfSentryBaggageValues(baggageHeader: string): string ); } -function isRequest(request: unknown): request is Request { - return typeof Request !== 'undefined' && isInstanceOf(request, Request); -} - function isHeaders(headers: unknown): headers is Headers { return typeof Headers !== 'undefined' && isInstanceOf(headers, Headers); } diff --git a/packages/core/src/logs/console-integration.ts b/packages/core/src/logs/console-integration.ts index 6dbb829db5a6..d0fe2a639738 100644 --- a/packages/core/src/logs/console-integration.ts +++ b/packages/core/src/logs/console-integration.ts @@ -1,6 +1,7 @@ import { getClient } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { defineIntegration } from '../integration'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; import type { ConsoleLevel, IntegrationFn } from '../types-hoist'; import { CONSOLE_LEVELS, GLOBAL_OBJ, addConsoleInstrumentationHandler, logger, safeJoin } from '../utils-hoist'; import { _INTERNAL_captureLog } from './exports'; @@ -17,6 +18,10 @@ type GlobalObjectWithUtil = typeof GLOBAL_OBJ & { const INTEGRATION_NAME = 'ConsoleLogs'; +const DEFAULT_ATTRIBUTES = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.console.logging', +}; + const _consoleLoggingIntegration = ((options: Partial = {}) => { const levels = options.levels || CONSOLE_LEVELS; @@ -38,7 +43,7 @@ const _consoleLoggingIntegration = ((options: Partial = { const followingArgs = args.slice(1); const message = followingArgs.length > 0 ? `Assertion failed: ${formatConsoleArgs(followingArgs)}` : 'Assertion failed'; - _INTERNAL_captureLog({ level: 'error', message }); + _INTERNAL_captureLog({ level: 'error', message, attributes: DEFAULT_ATTRIBUTES }); } return; } @@ -48,6 +53,7 @@ const _consoleLoggingIntegration = ((options: Partial = { level: isLevelLog ? 'info' : level, message: formatConsoleArgs(args), severityNumber: isLevelLog ? 10 : undefined, + attributes: DEFAULT_ATTRIBUTES, }); }); }, diff --git a/packages/core/src/logs/exports.ts b/packages/core/src/logs/exports.ts index 5e12f5739729..8b5f0c76bc17 100644 --- a/packages/core/src/logs/exports.ts +++ b/packages/core/src/logs/exports.ts @@ -97,18 +97,24 @@ export function _INTERNAL_captureLog( }; if (release) { - logAttributes.release = release; + logAttributes['sentry.release'] = release; } if (environment) { - logAttributes.environment = environment; + logAttributes['sentry.environment'] = environment; + } + + const { sdk } = client.getSdkMetadata() ?? {}; + if (sdk) { + logAttributes['sentry.sdk.name'] = sdk.name; + logAttributes['sentry.sdk.version'] = sdk.version; } if (isParameterizedString(message)) { const { __sentry_template_string__, __sentry_template_values__ = [] } = message; logAttributes['sentry.message.template'] = __sentry_template_string__; __sentry_template_values__.forEach((param, index) => { - logAttributes[`sentry.message.param.${index}`] = param; + logAttributes[`sentry.message.parameter.${index}`] = param; }); } @@ -163,6 +169,8 @@ export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array // Clear the log buffer after envelopes have been constructed. logBuffer.length = 0; + client.emit('flushLogs'); + // sendEnvelope should not throw // eslint-disable-next-line @typescript-eslint/no-floating-promises client.sendEnvelope(envelope); diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index dfada209fcfd..b7910ed23d0a 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -25,6 +25,9 @@ import { resolvedSyncPromise } from './utils-hoist/syncpromise'; import { _INTERNAL_flushLogsBuffer } from './logs/exports'; import { isPrimitive } from './utils-hoist'; +// TODO: Make this configurable +const DEFAULT_LOG_FLUSH_INTERVAL = 5000; + export interface ServerRuntimeClientOptions extends ClientOptions { platform?: string; runtime?: { name: string; version?: string }; @@ -37,6 +40,7 @@ export interface ServerRuntimeClientOptions extends ClientOptions extends Client { + private _logFlushIdleTimeout: ReturnType | undefined; private _logWeight: number; /** @@ -54,9 +58,9 @@ export class ServerRuntimeClient< if (this._options._experiments?.enableLogs) { // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this; - client.on('flush', () => { - _INTERNAL_flushLogsBuffer(client); + client.on('flushLogs', () => { client._logWeight = 0; + clearTimeout(client._logFlushIdleTimeout); }); client.on('afterCaptureLog', log => { @@ -67,7 +71,11 @@ export class ServerRuntimeClient< // the payload gets too big. if (client._logWeight >= 800_000) { _INTERNAL_flushLogsBuffer(client); - client._logWeight = 0; + } else { + // start an idle timeout to flush the logs buffer if no logs are captured for a while + client._logFlushIdleTimeout = setTimeout(() => { + _INTERNAL_flushLogsBuffer(client); + }, DEFAULT_LOG_FLUSH_INTERVAL); } }); } diff --git a/packages/core/src/transports/base.ts b/packages/core/src/transports/base.ts index 2b7de82ba4e1..e928e5d856ce 100644 --- a/packages/core/src/transports/base.ts +++ b/packages/core/src/transports/base.ts @@ -77,6 +77,7 @@ export function createTransport( }, error => { recordEnvelopeLoss('network_error'); + DEBUG_BUILD && logger.error('Encountered error running transport request:', error); throw error; }, ); diff --git a/packages/core/src/types-hoist/feedback/config.ts b/packages/core/src/types-hoist/feedback/config.ts index d7b3d78995bb..4ec846c7d98d 100644 --- a/packages/core/src/types-hoist/feedback/config.ts +++ b/packages/core/src/types-hoist/feedback/config.ts @@ -57,15 +57,6 @@ export interface FeedbackGeneralConfiguration { name: string; }; - /** - * _experiments allows users to enable experimental or internal features. - * We don't consider such features as part of the public API and hence we don't guarantee semver for them. - * Experimental features can be added, changed or removed at any time. - * - * Default: undefined - */ - _experiments: Partial<{ annotations: boolean }>; - /** * Set an object that will be merged sent as tags data with the event. */ diff --git a/packages/core/src/types-hoist/instrument.ts b/packages/core/src/types-hoist/instrument.ts index 420482579dd9..5eba6066432a 100644 --- a/packages/core/src/types-hoist/instrument.ts +++ b/packages/core/src/types-hoist/instrument.ts @@ -61,6 +61,8 @@ export interface HandlerDataFetch { error?: unknown; // This is to be consumed by the HttpClient integration virtualError?: unknown; + /** Headers that the user passed to the fetch request. */ + headers?: WebFetchHeaders; } export interface HandlerDataDom { diff --git a/packages/core/src/utils-hoist/instrument/fetch.ts b/packages/core/src/utils-hoist/instrument/fetch.ts index 71c5148fae9c..7d6185d00639 100644 --- a/packages/core/src/utils-hoist/instrument/fetch.ts +++ b/packages/core/src/utils-hoist/instrument/fetch.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { HandlerDataFetch } from '../../types-hoist'; +import type { HandlerDataFetch, WebFetchHeaders } from '../../types-hoist'; -import { isError } from '../is'; +import { isError, isRequest } from '../is'; import { addNonEnumerableProperty, fill } from '../object'; import { supportsNativeFetch } from '../supports'; import { timestampInSeconds } from '../time'; @@ -67,6 +67,7 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat startTimestamp: timestampInSeconds() * 1000, // // Adding the error to be able to fingerprint the failed fetch event in HttpClient instrumentation virtualError, + headers: getHeadersFromFetchArgs(args), }; // if there is no callback, fetch is instrumented directly @@ -253,3 +254,26 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str method: hasProp(arg, 'method') ? String(arg.method).toUpperCase() : 'GET', }; } + +function getHeadersFromFetchArgs(fetchArgs: unknown[]): WebFetchHeaders | undefined { + const [requestArgument, optionsArgument] = fetchArgs; + + try { + if ( + typeof optionsArgument === 'object' && + optionsArgument !== null && + 'headers' in optionsArgument && + optionsArgument.headers + ) { + return new Headers(optionsArgument.headers as any); + } + + if (isRequest(requestArgument)) { + return new Headers(requestArgument.headers); + } + } catch { + // noop + } + + return; +} diff --git a/packages/core/src/utils-hoist/is.ts b/packages/core/src/utils-hoist/is.ts index cfa9bc141e20..ab5e150e2394 100644 --- a/packages/core/src/utils-hoist/is.ts +++ b/packages/core/src/utils-hoist/is.ts @@ -201,3 +201,12 @@ export function isVueViewModel(wat: unknown): boolean { // Not using Object.prototype.toString because in Vue 3 it would read the instance's Symbol(Symbol.toStringTag) property. return !!(typeof wat === 'object' && wat !== null && ((wat as VueViewModel).__isVue || (wat as VueViewModel)._isVue)); } + +/** + * Checks whether the given parameter is a Standard Web API Request instance. + * + * Returns false if Request is not available in the current runtime. + */ +export function isRequest(request: unknown): request is Request { + return typeof Request !== 'undefined' && isInstanceOf(request, Request); +} diff --git a/packages/core/test/lib/logs/exports.test.ts b/packages/core/test/lib/logs/exports.test.ts index acc40ba0c361..c672373df947 100644 --- a/packages/core/test/lib/logs/exports.test.ts +++ b/packages/core/test/lib/logs/exports.test.ts @@ -136,8 +136,53 @@ describe('_INTERNAL_captureLog', () => { const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; expect(logAttributes).toEqual( expect.arrayContaining([ - expect.objectContaining({ key: 'release', value: { stringValue: '1.0.0' } }), - expect.objectContaining({ key: 'environment', value: { stringValue: 'test' } }), + expect.objectContaining({ key: 'sentry.release', value: { stringValue: '1.0.0' } }), + expect.objectContaining({ key: 'sentry.environment', value: { stringValue: 'test' } }), + ]), + ); + }); + + it('includes SDK metadata in log attributes when available', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableLogs: true }, + }); + const client = new TestClient(options); + // Mock getSdkMetadata to return SDK info + vi.spyOn(client, 'getSdkMetadata').mockReturnValue({ + sdk: { + name: 'sentry.javascript.node', + version: '7.0.0', + }, + }); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with SDK metadata' }, client, undefined); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'sentry.sdk.name', value: { stringValue: 'sentry.javascript.node' } }), + expect.objectContaining({ key: 'sentry.sdk.version', value: { stringValue: '7.0.0' } }), + ]), + ); + }); + + it('does not include SDK metadata in log attributes when not available', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableLogs: true }, + }); + const client = new TestClient(options); + // Mock getSdkMetadata to return no SDK info + vi.spyOn(client, 'getSdkMetadata').mockReturnValue({}); + + _INTERNAL_captureLog({ level: 'info', message: 'test log without SDK metadata' }, client, undefined); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'sentry.sdk.name' }), + expect.objectContaining({ key: 'sentry.sdk.version' }), ]), ); }); @@ -206,11 +251,11 @@ describe('_INTERNAL_captureLog', () => { value: { stringValue: 'Hello %s, welcome to %s' }, }), expect.objectContaining({ - key: 'sentry.message.param.0', + key: 'sentry.message.parameter.0', value: { stringValue: 'John' }, }), expect.objectContaining({ - key: 'sentry.message.param.1', + key: 'sentry.message.parameter.1', value: { stringValue: 'Sentry' }, }), ]), diff --git a/packages/core/test/lib/server-runtime-client.test.ts b/packages/core/test/lib/server-runtime-client.test.ts index 87c62b1567d4..4aaf77a06fb2 100644 --- a/packages/core/test/lib/server-runtime-client.test.ts +++ b/packages/core/test/lib/server-runtime-client.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from 'vitest'; import { Scope, createTransport } from '../../src'; import type { ServerRuntimeClientOptions } from '../../src/server-runtime-client'; import { ServerRuntimeClient } from '../../src/server-runtime-client'; -import { _INTERNAL_captureLog } from '../../src/logs/exports'; +import { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer } from '../../src/logs/exports'; const PUBLIC_DSN = 'https://username@domain/123'; @@ -256,8 +256,8 @@ describe('ServerRuntimeClient', () => { _INTERNAL_captureLog({ message: 'test1', level: 'info' }, client); _INTERNAL_captureLog({ message: 'test2', level: 'info' }, client); - // Trigger flush event - client.emit('flush'); + // Trigger flush directly + _INTERNAL_flushLogsBuffer(client); expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); expect(client['_logWeight']).toBe(0); // Weight should be reset after flush diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index 8b312b902258..e5f1092856f1 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -84,7 +84,6 @@ export const buildFeedbackIntegration = ({ email: 'email', name: 'username', }, - _experiments = {}, tags, styleNonce, scriptNonce, @@ -159,8 +158,6 @@ export const buildFeedbackIntegration = ({ onSubmitError, onSubmitSuccess, onFormSubmitted, - - _experiments, }; let _shadow: ShadowRoot | null = null; diff --git a/packages/feedback/src/screenshot/components/Annotations.tsx b/packages/feedback/src/screenshot/components/Annotations.tsx deleted file mode 100644 index eb897b40f166..000000000000 --- a/packages/feedback/src/screenshot/components/Annotations.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import type { VNode, h as hType } from 'preact'; -import type * as Hooks from 'preact/hooks'; -import { DOCUMENT } from '../../constants'; - -interface FactoryParams { - h: typeof hType; -} - -export default function AnnotationsFactory({ - h, // eslint-disable-line @typescript-eslint/no-unused-vars -}: FactoryParams) { - return function Annotations({ - action, - imageBuffer, - annotatingRef, - }: { - action: 'crop' | 'annotate' | ''; - imageBuffer: HTMLCanvasElement; - annotatingRef: Hooks.Ref; - }): VNode { - const onAnnotateStart = (): void => { - if (action !== 'annotate') { - return; - } - - const handleMouseMove = (moveEvent: MouseEvent): void => { - const annotateCanvas = annotatingRef.current; - if (annotateCanvas) { - const rect = annotateCanvas.getBoundingClientRect(); - const x = moveEvent.clientX - rect.x; - const y = moveEvent.clientY - rect.y; - - const ctx = annotateCanvas.getContext('2d'); - if (ctx) { - ctx.lineTo(x, y); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(x, y); - } - } - }; - - const handleMouseUp = (): void => { - const ctx = annotatingRef.current?.getContext('2d'); - if (ctx) { - ctx.beginPath(); - } - - // Add your apply annotation logic here - applyAnnotation(); - - DOCUMENT.removeEventListener('mousemove', handleMouseMove); - DOCUMENT.removeEventListener('mouseup', handleMouseUp); - }; - - DOCUMENT.addEventListener('mousemove', handleMouseMove); - DOCUMENT.addEventListener('mouseup', handleMouseUp); - }; - - const applyAnnotation = (): void => { - // Logic to apply the annotation - const imageCtx = imageBuffer.getContext('2d'); - const annotateCanvas = annotatingRef.current; - if (imageCtx && annotateCanvas) { - imageCtx.drawImage( - annotateCanvas, - 0, - 0, - annotateCanvas.width, - annotateCanvas.height, - 0, - 0, - imageBuffer.width, - imageBuffer.height, - ); - - const annotateCtx = annotateCanvas.getContext('2d'); - if (annotateCtx) { - annotateCtx.clearRect(0, 0, annotateCanvas.width, annotateCanvas.height); - } - } - }; - return ( - - ); - }; -} diff --git a/packages/feedback/src/screenshot/components/Crop.tsx b/packages/feedback/src/screenshot/components/Crop.tsx deleted file mode 100644 index 3b31ee71573c..000000000000 --- a/packages/feedback/src/screenshot/components/Crop.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import type { FeedbackInternalOptions } from '@sentry/core'; -import type { VNode, h as hType } from 'preact'; -import type * as Hooks from 'preact/hooks'; -import { DOCUMENT, WINDOW } from '../../constants'; -import CropCornerFactory from './CropCorner'; - -const CROP_BUTTON_SIZE = 30; -const CROP_BUTTON_BORDER = 3; -const CROP_BUTTON_OFFSET = CROP_BUTTON_SIZE + CROP_BUTTON_BORDER; -const DPI = WINDOW.devicePixelRatio; - -interface Box { - startX: number; - startY: number; - endX: number; - endY: number; -} - -interface Rect { - x: number; - y: number; - height: number; - width: number; -} - -const constructRect = (box: Box): Rect => ({ - x: Math.min(box.startX, box.endX), - y: Math.min(box.startY, box.endY), - width: Math.abs(box.startX - box.endX), - height: Math.abs(box.startY - box.endY), -}); - -const getContainedSize = (img: HTMLCanvasElement): Rect => { - const imgClientHeight = img.clientHeight; - const imgClientWidth = img.clientWidth; - const ratio = img.width / img.height; - let width = imgClientHeight * ratio; - let height = imgClientHeight; - if (width > imgClientWidth) { - width = imgClientWidth; - height = imgClientWidth / ratio; - } - const x = (imgClientWidth - width) / 2; - const y = (imgClientHeight - height) / 2; - return { x: x, y: y, width: width, height: height }; -}; - -interface FactoryParams { - h: typeof hType; - hooks: typeof Hooks; - options: FeedbackInternalOptions; -} - -export default function CropFactory({ - h, - hooks, - options, -}: FactoryParams): (props: { - action: 'crop' | 'annotate' | ''; - imageBuffer: HTMLCanvasElement; - croppingRef: Hooks.Ref; - cropContainerRef: Hooks.Ref; - croppingRect: Box; - setCroppingRect: Hooks.StateUpdater; - resize: () => void; -}) => VNode { - const CropCorner = CropCornerFactory({ h }); - return function Crop({ - action, - imageBuffer, - croppingRef, - cropContainerRef, - croppingRect, - setCroppingRect, - resize, - }: { - action: 'crop' | 'annotate' | ''; - imageBuffer: HTMLCanvasElement; - croppingRef: Hooks.Ref; - cropContainerRef: Hooks.Ref; - croppingRect: Box; - setCroppingRect: Hooks.StateUpdater; - resize: () => void; - }): VNode { - const initialPositionRef = hooks.useRef({ initialX: 0, initialY: 0 }); - - const [isResizing, setIsResizing] = hooks.useState(false); - const [confirmCrop, setConfirmCrop] = hooks.useState(false); - - hooks.useEffect(() => { - const cropper = croppingRef.current; - if (!cropper) { - return; - } - - const ctx = cropper.getContext('2d'); - if (!ctx) { - return; - } - - const imageDimensions = getContainedSize(imageBuffer); - const croppingBox = constructRect(croppingRect); - ctx.clearRect(0, 0, imageDimensions.width, imageDimensions.height); - - if (action !== 'crop') { - return; - } - - // draw gray overlay around the selection - ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; - ctx.fillRect(0, 0, imageDimensions.width, imageDimensions.height); - ctx.clearRect(croppingBox.x, croppingBox.y, croppingBox.width, croppingBox.height); - - // draw selection border - ctx.strokeStyle = '#ffffff'; - ctx.lineWidth = 3; - ctx.strokeRect(croppingBox.x + 1, croppingBox.y + 1, croppingBox.width - 2, croppingBox.height - 2); - ctx.strokeStyle = '#000000'; - ctx.lineWidth = 1; - ctx.strokeRect(croppingBox.x + 3, croppingBox.y + 3, croppingBox.width - 6, croppingBox.height - 6); - }, [croppingRect, action]); - - // Resizing logic - const makeHandleMouseMove = hooks.useCallback((corner: string) => { - return (e: MouseEvent) => { - if (!croppingRef.current) { - return; - } - - const cropCanvas = croppingRef.current; - const cropBoundingRect = cropCanvas.getBoundingClientRect(); - const mouseX = e.clientX - cropBoundingRect.x; - const mouseY = e.clientY - cropBoundingRect.y; - - switch (corner) { - case 'top-left': - setCroppingRect(prev => ({ - ...prev, - startX: Math.min(Math.max(0, mouseX), prev.endX - CROP_BUTTON_OFFSET), - startY: Math.min(Math.max(0, mouseY), prev.endY - CROP_BUTTON_OFFSET), - })); - break; - case 'top-right': - setCroppingRect(prev => ({ - ...prev, - endX: Math.max(Math.min(mouseX, cropCanvas.width / DPI), prev.startX + CROP_BUTTON_OFFSET), - startY: Math.min(Math.max(0, mouseY), prev.endY - CROP_BUTTON_OFFSET), - })); - break; - case 'bottom-left': - setCroppingRect(prev => ({ - ...prev, - startX: Math.min(Math.max(0, mouseX), prev.endX - CROP_BUTTON_OFFSET), - endY: Math.max(Math.min(mouseY, cropCanvas.height / DPI), prev.startY + CROP_BUTTON_OFFSET), - })); - break; - case 'bottom-right': - setCroppingRect(prev => ({ - ...prev, - endX: Math.max(Math.min(mouseX, cropCanvas.width / DPI), prev.startX + CROP_BUTTON_OFFSET), - endY: Math.max(Math.min(mouseY, cropCanvas.height / DPI), prev.startY + CROP_BUTTON_OFFSET), - })); - break; - } - }; - }, []); - - // Dragging logic - const onDragStart = (e: MouseEvent): void => { - if (isResizing) { - return; - } - - initialPositionRef.current = { initialX: e.clientX, initialY: e.clientY }; - - const handleMouseMove = (moveEvent: MouseEvent): void => { - const cropCanvas = croppingRef.current; - if (!cropCanvas) { - return; - } - - const deltaX = moveEvent.clientX - initialPositionRef.current.initialX; - const deltaY = moveEvent.clientY - initialPositionRef.current.initialY; - - setCroppingRect(prev => { - const newStartX = Math.max( - 0, - Math.min(prev.startX + deltaX, cropCanvas.width / DPI - (prev.endX - prev.startX)), - ); - const newStartY = Math.max( - 0, - Math.min(prev.startY + deltaY, cropCanvas.height / DPI - (prev.endY - prev.startY)), - ); - - const newEndX = newStartX + (prev.endX - prev.startX); - const newEndY = newStartY + (prev.endY - prev.startY); - - initialPositionRef.current.initialX = moveEvent.clientX; - initialPositionRef.current.initialY = moveEvent.clientY; - - return { startX: newStartX, startY: newStartY, endX: newEndX, endY: newEndY }; - }); - }; - - const handleMouseUp = (): void => { - DOCUMENT.removeEventListener('mousemove', handleMouseMove); - DOCUMENT.removeEventListener('mouseup', handleMouseUp); - }; - - DOCUMENT.addEventListener('mousemove', handleMouseMove); - DOCUMENT.addEventListener('mouseup', handleMouseUp); - }; - - const onGrabButton = (e: Event, corner: string): void => { - setIsResizing(true); - const handleMouseMove = makeHandleMouseMove(corner); - const handleMouseUp = (): void => { - DOCUMENT.removeEventListener('mousemove', handleMouseMove); - DOCUMENT.removeEventListener('mouseup', handleMouseUp); - setConfirmCrop(true); - setIsResizing(false); - }; - - DOCUMENT.addEventListener('mouseup', handleMouseUp); - DOCUMENT.addEventListener('mousemove', handleMouseMove); - }; - - function applyCrop(): void { - const cutoutCanvas = DOCUMENT.createElement('canvas'); - const imageBox = getContainedSize(imageBuffer); - const croppingBox = constructRect(croppingRect); - cutoutCanvas.width = croppingBox.width * DPI; - cutoutCanvas.height = croppingBox.height * DPI; - - const cutoutCtx = cutoutCanvas.getContext('2d'); - if (cutoutCtx && imageBuffer) { - cutoutCtx.drawImage( - imageBuffer, - (croppingBox.x / imageBox.width) * imageBuffer.width, - (croppingBox.y / imageBox.height) * imageBuffer.height, - (croppingBox.width / imageBox.width) * imageBuffer.width, - (croppingBox.height / imageBox.height) * imageBuffer.height, - 0, - 0, - cutoutCanvas.width, - cutoutCanvas.height, - ); - } - - const ctx = imageBuffer.getContext('2d'); - if (ctx) { - ctx.clearRect(0, 0, imageBuffer.width, imageBuffer.height); - imageBuffer.width = cutoutCanvas.width; - imageBuffer.height = cutoutCanvas.height; - imageBuffer.style.width = `${croppingBox.width}px`; - imageBuffer.style.height = `${croppingBox.height}px`; - ctx.drawImage(cutoutCanvas, 0, 0); - - resize(); - } - } - - return ( -
- - {action === 'crop' && ( -
- - - - -
- )} - {action === 'crop' && ( -
- - -
- )} -
- ); - }; -} diff --git a/packages/feedback/src/screenshot/components/CropCorner.tsx b/packages/feedback/src/screenshot/components/CropCorner.tsx deleted file mode 100644 index de3b6e506e71..000000000000 --- a/packages/feedback/src/screenshot/components/CropCorner.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { VNode, h as hType } from 'preact'; - -interface FactoryParams { - h: typeof hType; -} - -export default function CropCornerFactory({ - h, // eslint-disable-line @typescript-eslint/no-unused-vars -}: FactoryParams) { - return function CropCorner({ - top, - left, - corner, - onGrabButton, - }: { - top: number; - left: number; - corner: string; - onGrabButton: (e: Event, corner: string) => void; - }): VNode { - return ( - - ); - }; -} diff --git a/packages/feedback/src/screenshot/components/CropIcon.tsx b/packages/feedback/src/screenshot/components/CropIcon.tsx deleted file mode 100644 index 091179d86004..000000000000 --- a/packages/feedback/src/screenshot/components/CropIcon.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { VNode, h as hType } from 'preact'; - -interface FactoryParams { - h: typeof hType; -} - -export default function CropIconFactory({ - h, // eslint-disable-line @typescript-eslint/no-unused-vars -}: FactoryParams) { - return function CropIcon(): VNode { - return ( - - - - ); - }; -} diff --git a/packages/feedback/src/screenshot/components/IconClose.tsx b/packages/feedback/src/screenshot/components/IconClose.tsx new file mode 100644 index 000000000000..dea383a61839 --- /dev/null +++ b/packages/feedback/src/screenshot/components/IconClose.tsx @@ -0,0 +1,29 @@ +import type { VNode, h as hType } from 'preact'; + +interface FactoryParams { + h: typeof hType; +} + +export default function IconCloseFactory({ + h, // eslint-disable-line @typescript-eslint/no-unused-vars +}: FactoryParams) { + return function IconClose(): VNode { + return ( + + + + + + + ); + }; +} diff --git a/packages/feedback/src/screenshot/components/PenIcon.tsx b/packages/feedback/src/screenshot/components/PenIcon.tsx deleted file mode 100644 index 75a0faedf480..000000000000 --- a/packages/feedback/src/screenshot/components/PenIcon.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { VNode, h as hType } from 'preact'; - -interface FactoryParams { - h: typeof hType; -} - -export default function PenIconFactory({ - h, // eslint-disable-line @typescript-eslint/no-unused-vars -}: FactoryParams) { - return function PenIcon(): VNode { - return ( - - - - - - ); - }; -} diff --git a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx index 9e8e708ec580..ef76eac7f42a 100644 --- a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx +++ b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx @@ -2,20 +2,32 @@ import type { FeedbackInternalOptions, FeedbackModalIntegration } from '@sentry/ import type { ComponentType, VNode, h as hType } from 'preact'; import { h } from 'preact'; // eslint-disable-line @typescript-eslint/no-unused-vars import type * as Hooks from 'preact/hooks'; -import { WINDOW } from '../../constants'; -import AnnotationsFactory from './Annotations'; -import CropFactory from './Crop'; +import { DOCUMENT, WINDOW } from '../../constants'; +import IconCloseFactory from './IconClose'; import { createScreenshotInputStyles } from './ScreenshotInput.css'; import ToolbarFactory from './Toolbar'; import { useTakeScreenshotFactory } from './useTakeScreenshot'; -const DPI = WINDOW.devicePixelRatio; - interface FactoryParams { h: typeof hType; hooks: typeof Hooks; - imageBuffer: HTMLCanvasElement; + + /** + * A ref to a Canvas Element that serves as our "value" or image output. + */ + outputBuffer: HTMLCanvasElement; + + /** + * A reference to the whole dialog (the parent of this component) so that we + * can show/hide it and take a clean screenshot of the webpage. + */ dialog: ReturnType; + + /** + * The whole options object. + * + * Needed to set nonce and id values for editor specific styles + */ options: FeedbackInternalOptions; } @@ -23,150 +35,329 @@ interface Props { onError: (error: Error) => void; } -interface Box { - startX: number; - startY: number; - endX: number; - endY: number; -} +type MaybeCanvas = HTMLCanvasElement | null; +type Screenshot = { canvas: HTMLCanvasElement; dpi: number }; -interface Rect { +type DrawType = 'highlight' | 'hide' | ''; +interface DrawCommand { + type: DrawType; x: number; y: number; - height: number; - width: number; + h: number; + w: number; +} + +function drawRect(command: DrawCommand, ctx: CanvasRenderingContext2D, color: string): void { + switch (command.type) { + case 'highlight': { + // creates a shadow around + ctx.shadowColor = 'rgba(0, 0, 0, 0.7)'; + ctx.shadowBlur = 50; + + // draws a rectangle first with a shadow + ctx.fillStyle = color; + ctx.fillRect(command.x - 1, command.y - 1, command.w + 2, command.h + 2); + + // cut out the inside of the rectangle + ctx.clearRect(command.x, command.y, command.w, command.h); + + break; + } + case 'hide': + ctx.fillStyle = 'rgb(0, 0, 0)'; + ctx.fillRect(command.x, command.y, command.w, command.h); + + break; + default: + break; + } } -const getContainedSize = (img: HTMLCanvasElement): Rect => { - const imgClientHeight = img.clientHeight; - const imgClientWidth = img.clientWidth; - const ratio = img.width / img.height; - let width = imgClientHeight * ratio; - let height = imgClientHeight; - if (width > imgClientWidth) { - width = imgClientWidth; - height = imgClientWidth / ratio; +function with2dContext( + canvas: MaybeCanvas, + options: CanvasRenderingContext2DSettings, + callback: (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => void, +): void { + if (!canvas) { + return; } - const x = (imgClientWidth - width) / 2; - const y = (imgClientHeight - height) / 2; - return { x: x, y: y, width: width, height: height }; -}; + const ctx = canvas.getContext('2d', options); + if (!ctx) { + return; + } + callback(canvas, ctx); +} + +function paintImage(maybeDest: MaybeCanvas, source: HTMLCanvasElement): void { + with2dContext(maybeDest, { alpha: true }, (destCanvas, destCtx) => { + destCtx.drawImage(source, 0, 0, source.width, source.height, 0, 0, destCanvas.width, destCanvas.height); + }); +} + +// Paint the array of drawCommands into a canvas. +// Assuming this is the canvas foreground, and the background is cleaned. +function paintForeground(maybeCanvas: MaybeCanvas, strokeColor: string, drawCommands: DrawCommand[]): void { + with2dContext(maybeCanvas, { alpha: true }, (canvas, ctx) => { + // If there's anything to draw, then we'll first clear the canvas with + // a transparent grey background + if (drawCommands.length) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + drawCommands.forEach(command => { + drawRect(command, ctx, strokeColor); + }); + }); +} export function ScreenshotEditorFactory({ h, hooks, - imageBuffer, + outputBuffer, dialog, options, }: FactoryParams): ComponentType { const useTakeScreenshot = useTakeScreenshotFactory({ hooks }); const Toolbar = ToolbarFactory({ h }); - const Annotations = AnnotationsFactory({ h }); - const Crop = CropFactory({ h, hooks, options }); - - return function ScreenshotEditor({ onError }: Props): VNode { - const styles = hooks.useMemo(() => ({ __html: createScreenshotInputStyles(options.styleNonce).innerText }), []); - - const canvasContainerRef = hooks.useRef(null); - const cropContainerRef = hooks.useRef(null); - const annotatingRef = hooks.useRef(null); - const croppingRef = hooks.useRef(null); - const [action, setAction] = hooks.useState<'annotate' | 'crop' | ''>('crop'); - const [croppingRect, setCroppingRect] = hooks.useState({ - startX: 0, - startY: 0, - endX: 0, - endY: 0, - }); + const IconClose = IconCloseFactory({ h }); + const editorStyleInnerText = { __html: createScreenshotInputStyles(options.styleNonce).innerText }; - hooks.useEffect(() => { - WINDOW.addEventListener('resize', resize); + const dialogStyle = (dialog.el as HTMLElement).style; + + const ScreenshotEditor = ({ screenshot }: { screenshot: Screenshot }): VNode => { + // Data for rendering: + const [action, setAction] = hooks.useState('highlight'); + const [drawCommands, setDrawCommands] = hooks.useState([]); + + // Refs to our html components: + const measurementRef = hooks.useRef(null); + const backgroundRef = hooks.useRef(null); + const foregroundRef = hooks.useRef(null); + const mouseRef = hooks.useRef(null); + + // The size of our window, relative to the imageSource + const [scaleFactor, setScaleFactor] = hooks.useState(1); + + const strokeColor = hooks.useMemo((): string => { + const sentryFeedback = DOCUMENT.getElementById(options.id); + if (!sentryFeedback) { + return 'white'; + } + const computedStyle = getComputedStyle(sentryFeedback); + return ( + computedStyle.getPropertyValue('--button-primary-background') || + computedStyle.getPropertyValue('--accent-background') + ); + }, [options.id]); + + // The initial resize, to measure the area and set the children to the correct size + hooks.useLayoutEffect(() => { + const handleResize = (): void => { + const measurementDiv = measurementRef.current; + if (!measurementDiv) { + return; + } + with2dContext(screenshot.canvas, { alpha: false }, canvas => { + const scale = Math.min( + measurementDiv.clientWidth / canvas.width, + measurementDiv.clientHeight / canvas.height, + ); + setScaleFactor(scale); + }); + }; + + handleResize(); + WINDOW.addEventListener('resize', handleResize); return () => { - WINDOW.removeEventListener('resize', resize); + WINDOW.removeEventListener('resize', handleResize); }; - }, []); + }, [screenshot]); + + // Set the size of the canvas element to match our screenshot + const setCanvasSize = hooks.useCallback( + (maybeCanvas: MaybeCanvas, scale: number): void => { + with2dContext(maybeCanvas, { alpha: true }, (canvas, ctx) => { + // Must call `scale()` before setting `width` & `height` + ctx.scale(scale, scale); + canvas.width = screenshot.canvas.width; + canvas.height = screenshot.canvas.height; + }); + }, + [screenshot], + ); + + // Draw the screenshot into the background + hooks.useEffect(() => { + setCanvasSize(backgroundRef.current, screenshot.dpi); + paintImage(backgroundRef.current, screenshot.canvas); + }, [screenshot]); + + // Draw the commands into the foreground + hooks.useEffect(() => { + setCanvasSize(foregroundRef.current, screenshot.dpi); + with2dContext(foregroundRef.current, { alpha: true }, (canvas, ctx) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + }); + paintForeground(foregroundRef.current, strokeColor, drawCommands); + }, [drawCommands, strokeColor]); - function resizeCanvas(canvasRef: Hooks.Ref, imageDimensions: Rect): void { - const canvas = canvasRef.current; - if (!canvas) { + // Draw into the output outputBuffer + hooks.useEffect(() => { + setCanvasSize(outputBuffer, screenshot.dpi); + paintImage(outputBuffer, screenshot.canvas); + with2dContext(DOCUMENT.createElement('canvas'), { alpha: true }, (foreground, ctx) => { + ctx.scale(screenshot.dpi, screenshot.dpi); // The scale needs to be set before we set the width/height and paint + foreground.width = screenshot.canvas.width; + foreground.height = screenshot.canvas.height; + paintForeground(foreground, strokeColor, drawCommands); + paintImage(outputBuffer, foreground); + }); + }, [drawCommands, screenshot, strokeColor]); + + const handleMouseDown = (e: MouseEvent): void => { + if (!action || !mouseRef.current) { return; } - canvas.width = imageDimensions.width * DPI; - canvas.height = imageDimensions.height * DPI; - canvas.style.width = `${imageDimensions.width}px`; - canvas.style.height = `${imageDimensions.height}px`; - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.scale(DPI, DPI); - } - } + const boundingRect = mouseRef.current.getBoundingClientRect(); + const startingPoint: DrawCommand = { + type: action, + x: e.offsetX / scaleFactor, + y: e.offsetY / scaleFactor, + w: 0, + h: 0, + }; - function resize(): void { - const imageDimensions = getContainedSize(imageBuffer); + const getDrawCommand = (startingPoint: DrawCommand, e: MouseEvent): DrawCommand => { + const x = (e.clientX - boundingRect.x) / scaleFactor; + const y = (e.clientY - boundingRect.y) / scaleFactor; + return { + type: startingPoint.type, + x: Math.min(startingPoint.x, x), + y: Math.min(startingPoint.y, y), + w: Math.abs(x - startingPoint.x), + h: Math.abs(y - startingPoint.y), + } as DrawCommand; + }; - resizeCanvas(croppingRef, imageDimensions); - resizeCanvas(annotatingRef, imageDimensions); + const handleMouseMove = (e: MouseEvent): void => { + with2dContext(foregroundRef.current, { alpha: true }, (canvas, ctx) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + }); + paintForeground(foregroundRef.current, strokeColor, [...drawCommands, getDrawCommand(startingPoint, e)]); + }; - const cropContainer = cropContainerRef.current; - if (cropContainer) { - cropContainer.style.width = `${imageDimensions.width}px`; - cropContainer.style.height = `${imageDimensions.height}px`; - } + const handleMouseUp = (e: MouseEvent): void => { + const drawCommand = getDrawCommand(startingPoint, e); - setCroppingRect({ startX: 0, startY: 0, endX: imageDimensions.width, endY: imageDimensions.height }); - } + // Prevent just clicking onto the canvas, mouse has to move at least 1 pixel + if (drawCommand.w * scaleFactor >= 1 && drawCommand.h * scaleFactor >= 1) { + setDrawCommands(prev => [...prev, drawCommand]); + } + DOCUMENT.removeEventListener('mousemove', handleMouseMove); + DOCUMENT.removeEventListener('mouseup', handleMouseUp); + }; + + DOCUMENT.addEventListener('mousemove', handleMouseMove); + DOCUMENT.addEventListener('mouseup', handleMouseUp); + }; + + const deleteRect = hooks.useCallback((index: number): hType.JSX.MouseEventHandler => { + return (e: MouseEvent): void => { + e.preventDefault(); + e.stopPropagation(); + setDrawCommands(prev => { + const updatedRects = [...prev]; + updatedRects.splice(index, 1); + return updatedRects; + }); + }; + }, []); + + const dimensions = { + width: `${screenshot.canvas.width * scaleFactor}px`, + height: `${screenshot.canvas.height * scaleFactor}px`, + }; + + const handleStopPropagation = (e: MouseEvent): void => { + e.stopPropagation(); + }; + + return ( +
+