diff --git a/CHANGELOG.md b/CHANGELOG.md index a231f2e7e854..e04de0cd43bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.80.0 + +- feat(astro): Add distributed tracing via `` tags (#9483) +- feat(node): Capture internal server errors in trpc middleware (#9482) +- feat(remix): Export a type to use for `MetaFunction` parameters (#9493) +- fix(astro): Mark SDK package as Astro-external (#9509) +- ref(nextjs): Don't initialize Server SDK during build (#9503) + ## 7.79.0 - feat(tracing): Add span `origin` to trace context (#9472) diff --git a/packages/astro/README.md b/packages/astro/README.md index df68adfa1037..c3321f5b311f 100644 --- a/packages/astro/README.md +++ b/packages/astro/README.md @@ -64,8 +64,8 @@ import { sequence } from "astro:middleware"; import * as Sentry from "@sentry/astro"; export const onRequest = sequence( - Sentry.sentryMiddleware(), - // Add your other handlers after sentryMiddleware + Sentry.handleRequest(), + // Add your other handlers after Sentry.handleRequest() ); ``` diff --git a/packages/astro/package.json b/packages/astro/package.json index d440be51bb9e..531b99bc372e 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -74,5 +74,8 @@ }, "volta": { "extends": "../../package.json" + }, + "astro": { + "external": true } } diff --git a/packages/astro/src/server/meta.ts b/packages/astro/src/server/meta.ts new file mode 100644 index 000000000000..7257cf01db51 --- /dev/null +++ b/packages/astro/src/server/meta.ts @@ -0,0 +1,78 @@ +import { getDynamicSamplingContextFromClient } from '@sentry/core'; +import type { Hub, Span } from '@sentry/types'; +import { + dynamicSamplingContextToSentryBaggageHeader, + generateSentryTraceHeader, + logger, + TRACEPARENT_REGEXP, +} from '@sentry/utils'; + +/** + * Extracts the tracing data from the current span or from the client's scope + * (via transaction or propagation context) and renders the data to tags. + * + * This function creates two serialized tags: + * - `` + * - `` + * + * TODO: Extract this later on and export it from the Core or Node SDK + * + * @param span the currently active span + * @param client the SDK's client + * + * @returns an object with the two serialized tags + */ +export function getTracingMetaTags(span: Span | undefined, hub: Hub): { sentryTrace: string; baggage?: string } { + const scope = hub.getScope(); + const client = hub.getClient(); + const { dsc, sampled, traceId } = scope.getPropagationContext(); + const transaction = span?.transaction; + + const sentryTrace = span ? span.toTraceparent() : generateSentryTraceHeader(traceId, undefined, sampled); + + const dynamicSamplingContext = transaction + ? transaction.getDynamicSamplingContext() + : dsc + ? dsc + : client + ? getDynamicSamplingContextFromClient(traceId, client, scope) + : undefined; + + const baggage = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext); + + const isValidSentryTraceHeader = TRACEPARENT_REGEXP.test(sentryTrace); + if (!isValidSentryTraceHeader) { + logger.warn('Invalid sentry-trace data. Returning empty tag'); + } + + const validBaggage = isValidBaggageString(baggage); + if (!validBaggage) { + logger.warn('Invalid baggage data. Returning empty tag'); + } + + return { + sentryTrace: ``, + baggage: baggage && ``, + }; +} + +/** + * Tests string against baggage spec as defined in: + * + * - W3C Baggage grammar: https://www.w3.org/TR/baggage/#definition + * - RFC7230 token definition: https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 + * + * exported for testing + */ +export function isValidBaggageString(baggage?: string): boolean { + if (!baggage || !baggage.length) { + return false; + } + const keyRegex = "[-!#$%&'*+.^_`|~A-Za-z0-9]+"; + const valueRegex = '[!#-+-./0-9:<=>?@A-Z\\[\\]a-z{-}]+'; + const spaces = '\\s*'; + const baggageRegex = new RegExp( + `^${keyRegex}${spaces}=${spaces}${valueRegex}(${spaces},${spaces}${keyRegex}${spaces}=${spaces}${valueRegex})*$`, + ); + return baggageRegex.test(baggage); +} diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index ff4ac1d44c78..c04618cad33f 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -1,13 +1,19 @@ -import { captureException, configureScope, startSpan } from '@sentry/node'; +import { captureException, configureScope, getCurrentHub, startSpan } from '@sentry/node'; +import type { Hub, Span } from '@sentry/types'; import { addExceptionMechanism, objectify, stripUrlQueryAndFragment, tracingContextFromHeaders } from '@sentry/utils'; import type { APIContext, MiddlewareResponseHandler } from 'astro'; +import { getTracingMetaTags } from './meta'; + 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 + * Important: Only enable this option if your Astro app is configured for (hybrid) SSR + * via the `output: 'server' | 'hybrid'` option in your `astro.config.mjs` file. + * Otherwise, Astro will throw an error when starting the server. + * + * Only set this to `true` if you're fine with collecting potentially personally identifiable information (PII). * * @default false (recommended) */ @@ -15,6 +21,7 @@ type MiddlewareOptions = { /** * 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) @@ -93,11 +100,42 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseH }, }, async span => { - const res = await next(); - if (span && res.status) { - span.setHttpStatus(res.status); + const originalResponse = await next(); + + if (span && originalResponse.status) { + span.setHttpStatus(originalResponse.status); + } + + const hub = getCurrentHub(); + const client = hub.getClient(); + const contentType = originalResponse.headers.get('content-type'); + + const isPageloadRequest = contentType && contentType.startsWith('text/html'); + if (!isPageloadRequest || !client) { + return originalResponse; } - return res; + + // Type case necessary b/c the body's ReadableStream type doesn't include + // the async iterator that is actually available in Node + // We later on use the async iterator to read the body chunks + // see https://github.com/microsoft/TypeScript/issues/39051 + const originalBody = originalResponse.body as NodeJS.ReadableStream | null; + if (!originalBody) { + return originalResponse; + } + + const newResponseStream = new ReadableStream({ + start: async controller => { + for await (const chunk of originalBody) { + const html = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk); + const modifiedHtml = addMetaTagToHead(html, hub, span); + controller.enqueue(new TextEncoder().encode(modifiedHtml)); + } + controller.close(); + }, + }); + + return new Response(newResponseStream, originalResponse); }, ); return res; @@ -109,6 +147,20 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseH }; }; +/** + * This function optimistically assumes that the HTML coming in chunks will not be split + * within the tag. If this still happens, we simply won't replace anything. + */ +function addMetaTagToHead(htmlChunk: string, hub: Hub, span?: Span): string { + if (typeof htmlChunk !== 'string') { + return htmlChunk; + } + + const { sentryTrace, baggage } = getTracingMetaTags(span, hub); + const content = `\n${sentryTrace}\n${baggage}\n`; + return htmlChunk.replace('', content); +} + /** * Interpolates the route from the URL and the passed params. * Best we can do to get a route name instead of a raw URL. diff --git a/packages/astro/test/server/meta.test.ts b/packages/astro/test/server/meta.test.ts new file mode 100644 index 000000000000..6298f5f2a20b --- /dev/null +++ b/packages/astro/test/server/meta.test.ts @@ -0,0 +1,178 @@ +import * as SentryCore from '@sentry/core'; +import { vi } from 'vitest'; + +import { getTracingMetaTags, isValidBaggageString } from '../../src/server/meta'; + +const mockedSpan = { + toTraceparent: () => '12345678901234567890123456789012-1234567890123456-1', + transaction: { + getDynamicSamplingContext: () => ({ + environment: 'production', + }), + }, +}; + +const mockedHub = { + getScope: () => ({ + getPropagationContext: () => ({ + traceId: '123', + }), + }), + getClient: () => ({}), +}; + +describe('getTracingMetaTags', () => { + it('returns the tracing tags from the span, if it is provided', () => { + { + // @ts-expect-error - only passing a partial span object + const tags = getTracingMetaTags(mockedSpan, mockedHub); + + expect(tags).toEqual({ + sentryTrace: '', + baggage: '', + }); + } + }); + + it('returns propagationContext DSC data if no span is available', () => { + const tags = getTracingMetaTags(undefined, { + ...mockedHub, + // @ts-expect-error - only passing a partial scope object + getScope: () => ({ + getPropagationContext: () => ({ + traceId: '12345678901234567890123456789012', + sampled: true, + spanId: '1234567890123456', + dsc: { + environment: 'staging', + public_key: 'key', + trace_id: '12345678901234567890123456789012', + }, + }), + }), + }); + + expect(tags).toEqual({ + sentryTrace: expect.stringMatching( + //, + ), + baggage: + '', + }); + }); + + it('returns only the `sentry-trace` tag if no DSC is available', () => { + vi.spyOn(SentryCore, 'getDynamicSamplingContextFromClient').mockReturnValueOnce({ + trace_id: '', + public_key: undefined, + }); + + const tags = getTracingMetaTags( + // @ts-expect-error - only passing a partial span object + { + toTraceparent: () => '12345678901234567890123456789012-1234567890123456-1', + transaction: undefined, + }, + mockedHub, + ); + + expect(tags).toEqual({ + sentryTrace: '', + }); + }); + + it('returns only the `sentry-trace` tag if no DSC is available', () => { + vi.spyOn(SentryCore, 'getDynamicSamplingContextFromClient').mockReturnValueOnce({ + trace_id: '', + public_key: undefined, + }); + + const tags = getTracingMetaTags( + // @ts-expect-error - only passing a partial span object + { + toTraceparent: () => '12345678901234567890123456789012-1234567890123456-1', + transaction: undefined, + }, + { + ...mockedHub, + getClient: () => undefined, + }, + ); + + expect(tags).toEqual({ + sentryTrace: '', + }); + }); +}); + +describe('isValidBaggageString', () => { + it.each([ + 'sentry-environment=production', + 'sentry-environment=staging,sentry-public_key=key,sentry-trace_id=abc', + // @ is allowed in values + 'sentry-release=project@1.0.0', + // spaces are allowed around the delimiters + 'sentry-environment=staging , sentry-public_key=key ,sentry-release=myproject@1.0.0', + 'sentry-environment=staging , thirdparty=value ,sentry-release=myproject@1.0.0', + // these characters are explicitly allowed for keys in the baggage spec: + "!#$%&'*+-.^_`|~1234567890abcxyzABCXYZ=true", + // special characters in values are fine (except for ",;\ - see other test) + 'key=(value)', + 'key=[{(value)}]', + 'key=some$value', + 'key=more#value', + 'key=max&value', + 'key=max:value', + 'key=x=value', + ])('returns true if the baggage string is valid (%s)', baggageString => { + expect(isValidBaggageString(baggageString)).toBe(true); + }); + + it.each([ + // baggage spec doesn't permit leading spaces + ' sentry-environment=production,sentry-publickey=key,sentry-trace_id=abc', + // no spaces in keys or values + 'sentry-public key=key', + 'sentry-publickey=my key', + // no delimiters ("(),/:;<=>?@[\]{}") in keys + 'asdf(x=value', + 'asdf)x=value', + 'asdf,x=value', + 'asdf/x=value', + 'asdf:x=value', + 'asdf;x=value', + 'asdfx=value', + 'asdf?x=value', + 'asdf@x=value', + 'asdf[x=value', + 'asdf]x=value', + 'asdf\\x=value', + 'asdf{x=value', + 'asdf}x=value', + // no ,;\" in values + 'key=va,lue', + 'key=va;lue', + 'key=va\\lue', + 'key=va"lue"', + // baggage headers can have properties but we currently don't support them + 'sentry-environment=production;prop1=foo;prop2=bar,nextkey=value', + // no fishy stuff + 'absolutely not a valid baggage string', + 'val"/>', + 'something"/>', + '', + '/>', + '" onblur="alert("xss")', + ])('returns false if the baggage string is invalid (%s)', baggageString => { + expect(isValidBaggageString(baggageString)).toBe(false); + }); + + it('returns false if the baggage string is empty', () => { + expect(isValidBaggageString('')).toBe(false); + }); + + it('returns false if the baggage string is empty', () => { + expect(isValidBaggageString(undefined)).toBe(false); + }); +}); diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index af42348a94b9..058982f06cc6 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -4,8 +4,16 @@ import { vi } from 'vitest'; import { handleRequest, interpolateRouteFromUrlAndParams } from '../../src/server/middleware'; +vi.mock('../../src/server/meta', () => ({ + getTracingMetaTags: () => ({ + sentryTrace: '', + baggage: '', + }), +})); + describe('sentryMiddleware', () => { const startSpanSpy = vi.spyOn(SentryNode, 'startSpan'); + const nextResult = Promise.resolve(new Response(null, { status: 200, headers: new Headers() })); afterEach(() => { vi.clearAllMocks(); @@ -24,7 +32,6 @@ describe('sentryMiddleware', () => { id: '123', }, }; - const nextResult = Promise.resolve({ status: 200 }); const next = vi.fn(() => nextResult); // @ts-expect-error, a partial ctx object is fine here @@ -109,7 +116,7 @@ describe('sentryMiddleware', () => { params: {}, url: new URL('https://myDomain.io/users/'), }; - const next = vi.fn(); + const next = vi.fn(() => nextResult); // @ts-expect-error, a partial ctx object is fine here await middleware(ctx, next); @@ -159,7 +166,7 @@ describe('sentryMiddleware', () => { params: {}, url: new URL('https://myDomain.io/users/'), }; - const next = vi.fn(); + const next = vi.fn(() => nextResult); // @ts-expect-error, a partial ctx object is fine here await middleware(ctx, next); @@ -178,6 +185,95 @@ describe('sentryMiddleware', () => { expect.any(Function), // the `next` function ); }); + + it('injects tracing tags into the HTML of a pageload response', async () => { + vi.spyOn(SentryNode, 'getCurrentHub').mockImplementation(() => ({ + // @ts-expect-error this is fine + getClient: () => ({}), + })); + + const middleware = handleRequest(); + + const ctx = { + request: { + method: 'GET', + url: '/users', + headers: new Headers(), + }, + params: {}, + url: new URL('https://myDomain.io/users/'), + }; + const next = vi.fn(() => + Promise.resolve( + new Response('', { + 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'); + + const html = await resultFromNext?.text(); + + expect(html).toContain(' { + const middleware = handleRequest(); + + const ctx = { + request: { + method: 'GET', + url: '/users', + headers: new Headers(), + }, + params: {}, + url: new URL('https://myDomain.io/users/'), + }; + + const originalResponse = new Response('{"foo": "bar"}', { + headers: new Headers({ 'content-type': 'application/json' }), + }); + const next = vi.fn(() => Promise.resolve(originalResponse)); + + // @ts-expect-error, a partial ctx object is fine here + const resultFromNext = await middleware(ctx, next); + + expect(resultFromNext).toBe(originalResponse); + }); + + it("no-ops if there's no tag in the response", async () => { + const middleware = handleRequest(); + + const ctx = { + request: { + method: 'GET', + url: '/users', + headers: new Headers(), + }, + params: {}, + url: new URL('https://myDomain.io/users/'), + }; + + const originalHtml = '

no head

'; + const originalResponse = new Response(originalHtml, { + headers: new Headers({ 'content-type': 'text/html' }), + }); + const next = vi.fn(() => Promise.resolve(originalResponse)); + + // @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'); + + const html = await resultFromNext?.text(); + + expect(html).toBe(originalHtml); + }); }); describe('interpolateRouteFromUrlAndParams', () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a912475e6c41..d2b856a7cb37 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export type { OfflineStore, OfflineTransportOptions } from './transports/offline export type { ServerRuntimeClientOptions } from './server-runtime-client'; export * from './tracing'; +export { createEventEnvelope } from './envelope'; export { addBreadcrumb, captureCheckIn, diff --git a/packages/e2e-tests/test-applications/create-next-app/playwright.config.ts b/packages/e2e-tests/test-applications/create-next-app/playwright.config.ts index 734bda58c10f..7c84f56d178f 100644 --- a/packages/e2e-tests/test-applications/create-next-app/playwright.config.ts +++ b/packages/e2e-tests/test-applications/create-next-app/playwright.config.ts @@ -20,7 +20,7 @@ const port = 3030; const config: PlaywrightTestConfig = { testDir: './tests', /* Maximum time one test can run for. */ - timeout: 60 * 1000, + timeout: 150_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/packages/e2e-tests/test-applications/create-next-app/tests/behaviour-client.test.ts b/packages/e2e-tests/test-applications/create-next-app/tests/behaviour-client.test.ts index d0ede66cf32f..54d52d32172b 100644 --- a/packages/e2e-tests/test-applications/create-next-app/tests/behaviour-client.test.ts +++ b/packages/e2e-tests/test-applications/create-next-app/tests/behaviour-client.test.ts @@ -4,7 +4,7 @@ import axios, { AxiosError } from 'axios'; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; test('Sends a client-side exception to Sentry', async ({ page }) => { await page.goto('/'); diff --git a/packages/e2e-tests/test-applications/create-next-app/tests/behaviour-server.test.ts b/packages/e2e-tests/test-applications/create-next-app/tests/behaviour-server.test.ts index 3b3d74af1885..a864701afa9b 100644 --- a/packages/e2e-tests/test-applications/create-next-app/tests/behaviour-server.test.ts +++ b/packages/e2e-tests/test-applications/create-next-app/tests/behaviour-server.test.ts @@ -4,7 +4,7 @@ import axios, { AxiosError } from 'axios'; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; test('Sends a server-side exception to Sentry', async ({ baseURL }) => { const { data } = await axios.get(`${baseURL}/api/error`); diff --git a/packages/e2e-tests/test-applications/create-remix-app-v2/app/root.tsx b/packages/e2e-tests/test-applications/create-remix-app-v2/app/root.tsx index d4b2c07516eb..cf8d185866a9 100644 --- a/packages/e2e-tests/test-applications/create-remix-app-v2/app/root.tsx +++ b/packages/e2e-tests/test-applications/create-remix-app-v2/app/root.tsx @@ -1,5 +1,5 @@ import { cssBundleHref } from '@remix-run/css-bundle'; -import { json, LinksFunction } from '@remix-run/node'; +import { json, LinksFunction, MetaFunction } from '@remix-run/node'; import { Links, LiveReload, @@ -11,6 +11,7 @@ import { useRouteError, } from '@remix-run/react'; import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix'; +import type { SentryMetaArgs } from '@sentry/remix'; export const links: LinksFunction = () => [...(cssBundleHref ? [{ rel: 'stylesheet', href: cssBundleHref }] : [])]; @@ -22,6 +23,22 @@ export const loader = () => { }); }; +export const meta = ({ data }: SentryMetaArgs>) => { + return [ + { + env: data.ENV, + }, + { + name: 'sentry-trace', + content: data.sentryTrace, + }, + { + name: 'baggage', + content: data.sentryBaggage, + }, + ]; +}; + export function ErrorBoundary() { const error = useRouteError(); const eventId = captureRemixErrorBoundaryError(error); diff --git a/packages/e2e-tests/test-applications/create-remix-app-v2/playwright.config.ts b/packages/e2e-tests/test-applications/create-remix-app-v2/playwright.config.ts index 33f00235771b..79efcbc22c1a 100644 --- a/packages/e2e-tests/test-applications/create-remix-app-v2/playwright.config.ts +++ b/packages/e2e-tests/test-applications/create-remix-app-v2/playwright.config.ts @@ -9,7 +9,7 @@ const port = 3030; const config: PlaywrightTestConfig = { testDir: './tests', /* Maximum time one test can run for. */ - timeout: 60 * 1000, + timeout: 150_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/packages/e2e-tests/test-applications/create-remix-app-v2/tests/behaviour-client.test.ts b/packages/e2e-tests/test-applications/create-remix-app-v2/tests/behaviour-client.test.ts index e07481075f5a..3b39c9a4ca0b 100644 --- a/packages/e2e-tests/test-applications/create-remix-app-v2/tests/behaviour-client.test.ts +++ b/packages/e2e-tests/test-applications/create-remix-app-v2/tests/behaviour-client.test.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import axios, { AxiosError } from 'axios'; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; @@ -206,3 +206,31 @@ test('Sends a client-side ErrorBoundary exception to Sentry', async ({ page }) = ) .toBe(200); }); + +test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => { + await page.goto('/'); + + const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { + state: 'attached', + }); + const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { + state: 'attached', + }); + + expect(sentryTraceMetaTag).toBeTruthy(); + expect(baggageMetaTag).toBeTruthy(); +}); + +test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => { + await page.goto('/user/123'); + + const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { + state: 'attached', + }); + const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { + state: 'attached', + }); + + expect(sentryTraceMetaTag).toBeTruthy(); + expect(baggageMetaTag).toBeTruthy(); +}); diff --git a/packages/e2e-tests/test-applications/create-remix-app/app/root.tsx b/packages/e2e-tests/test-applications/create-remix-app/app/root.tsx index d4b2c07516eb..a015aba78f6c 100644 --- a/packages/e2e-tests/test-applications/create-remix-app/app/root.tsx +++ b/packages/e2e-tests/test-applications/create-remix-app/app/root.tsx @@ -1,5 +1,5 @@ import { cssBundleHref } from '@remix-run/css-bundle'; -import { json, LinksFunction } from '@remix-run/node'; +import { json, LinksFunction, MetaFunction } from '@remix-run/node'; import { Links, LiveReload, @@ -22,6 +22,19 @@ export const loader = () => { }); }; +export const meta: MetaFunction = ({ data }) => { + return [ + { + name: 'sentry-trace', + content: data.sentryTrace, + }, + { + name: 'baggage', + content: data.sentryBaggage, + }, + ]; +}; + export function ErrorBoundary() { const error = useRouteError(); const eventId = captureRemixErrorBoundaryError(error); diff --git a/packages/e2e-tests/test-applications/create-remix-app/playwright.config.ts b/packages/e2e-tests/test-applications/create-remix-app/playwright.config.ts index b35659cecdb5..785ca43321a4 100644 --- a/packages/e2e-tests/test-applications/create-remix-app/playwright.config.ts +++ b/packages/e2e-tests/test-applications/create-remix-app/playwright.config.ts @@ -9,7 +9,7 @@ const port = 3030; const config: PlaywrightTestConfig = { testDir: './tests', /* Maximum time one test can run for. */ - timeout: 60 * 1000, + timeout: 150_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/packages/e2e-tests/test-applications/create-remix-app/tests/behaviour-client.test.ts b/packages/e2e-tests/test-applications/create-remix-app/tests/behaviour-client.test.ts index e07481075f5a..3b39c9a4ca0b 100644 --- a/packages/e2e-tests/test-applications/create-remix-app/tests/behaviour-client.test.ts +++ b/packages/e2e-tests/test-applications/create-remix-app/tests/behaviour-client.test.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import axios, { AxiosError } from 'axios'; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; @@ -206,3 +206,31 @@ test('Sends a client-side ErrorBoundary exception to Sentry', async ({ page }) = ) .toBe(200); }); + +test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => { + await page.goto('/'); + + const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { + state: 'attached', + }); + const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { + state: 'attached', + }); + + expect(sentryTraceMetaTag).toBeTruthy(); + expect(baggageMetaTag).toBeTruthy(); +}); + +test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => { + await page.goto('/user/123'); + + const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { + state: 'attached', + }); + const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { + state: 'attached', + }); + + expect(sentryTraceMetaTag).toBeTruthy(); + expect(baggageMetaTag).toBeTruthy(); +}); diff --git a/packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts b/packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts index df41adb615c8..51f272fa514a 100644 --- a/packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts +++ b/packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts @@ -5,7 +5,7 @@ import path from 'path'; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; test( 'Find symbolicated event on sentry', diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.ts index 391b5eac610e..ab3c40a21471 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.ts @@ -21,7 +21,7 @@ const eventProxyPort = 3031; const config: PlaywrightTestConfig = { testDir: './tests', /* Maximum time one test can run for. */ - timeout: 60 * 1000, + timeout: 150_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/exceptions.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/exceptions.test.ts index d31e869fd8b3..5383a43c34a9 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/exceptions.test.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/exceptions.test.ts @@ -5,7 +5,7 @@ import axios, { AxiosError } from 'axios'; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; test('Sends a client-side exception to Sentry', async ({ page }) => { await page.goto('/'); diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts index 8413d623eb5c..b57538e3b90c 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts @@ -5,7 +5,7 @@ import axios, { AxiosError } from 'axios'; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; test('Sends a pageload transaction', async ({ page }) => { const pageloadTransactionEventPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => { diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/playwright.config.ts b/packages/e2e-tests/test-applications/node-experimental-fastify-app/playwright.config.ts index f39997dc76e8..8565f228f271 100644 --- a/packages/e2e-tests/test-applications/node-experimental-fastify-app/playwright.config.ts +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/playwright.config.ts @@ -10,7 +10,7 @@ const eventProxyPort = 3031; const config: PlaywrightTestConfig = { testDir: './tests', /* Maximum time one test can run for. */ - timeout: 60 * 1000, + timeout: 150_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/errors.test.ts b/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/errors.test.ts index 4656ba23e7de..70a0a9c660e0 100644 --- a/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/errors.test.ts +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/errors.test.ts @@ -4,7 +4,7 @@ import axios, { AxiosError } from 'axios'; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; test('Sends exception to Sentry', async ({ baseURL }) => { const { data } = await axios.get(`${baseURL}/test-error`); diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/propagation.test.ts b/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/propagation.test.ts index d069fd299682..b05228b21311 100644 --- a/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/propagation.test.ts +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/propagation.test.ts @@ -6,7 +6,7 @@ import { waitForTransaction } from '../event-proxy-server'; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const inboundTransactionPromise = waitForTransaction('node-experimental-fastify-app', transactionEvent => { diff --git a/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/transactions.test.ts b/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/transactions.test.ts index b9241d5a2569..b89297c1689d 100644 --- a/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/transactions.test.ts +++ b/packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/transactions.test.ts @@ -5,7 +5,7 @@ import axios, { AxiosError } from 'axios'; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; test('Sends an API route transaction', async ({ baseURL }) => { const pageloadTransactionEventPromise = waitForTransaction('node-experimental-fastify-app', transactionEvent => { diff --git a/packages/e2e-tests/test-applications/node-express-app/playwright.config.ts b/packages/e2e-tests/test-applications/node-express-app/playwright.config.ts index d3fbb6971415..58192fc98bb6 100644 --- a/packages/e2e-tests/test-applications/node-express-app/playwright.config.ts +++ b/packages/e2e-tests/test-applications/node-express-app/playwright.config.ts @@ -15,7 +15,7 @@ const expressPort = 3030; const config: PlaywrightTestConfig = { testDir: './tests', /* Maximum time one test can run for. */ - timeout: 60 * 1000, + timeout: 150_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/packages/e2e-tests/test-applications/node-express-app/tests/server.test.ts b/packages/e2e-tests/test-applications/node-express-app/tests/server.test.ts index 4429c2d46edd..2443fcf55f66 100644 --- a/packages/e2e-tests/test-applications/node-express-app/tests/server.test.ts +++ b/packages/e2e-tests/test-applications/node-express-app/tests/server.test.ts @@ -5,7 +5,7 @@ import { waitForError } from '../event-proxy-server'; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; test('Sends exception to Sentry', async ({ baseURL }) => { const { data } = await axios.get(`${baseURL}/test-error`); diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts b/packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts index c68482378d7a..5f93f826ebf0 100644 --- a/packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts +++ b/packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts @@ -7,7 +7,7 @@ import { devices } from '@playwright/test'; const config: PlaywrightTestConfig = { testDir: './tests', /* Maximum time one test can run for. */ - timeout: 60 * 1000, + timeout: 150_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts b/packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts index 43d8eeec8f35..5397dfa8eb64 100644 --- a/packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts +++ b/packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; import axios, { AxiosError } from 'axios'; import { ReplayRecordingData } from './fixtures/ReplayRecordingData'; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; diff --git a/packages/e2e-tests/test-applications/react-router-6-use-routes/playwright.config.ts b/packages/e2e-tests/test-applications/react-router-6-use-routes/playwright.config.ts index c68482378d7a..5f93f826ebf0 100644 --- a/packages/e2e-tests/test-applications/react-router-6-use-routes/playwright.config.ts +++ b/packages/e2e-tests/test-applications/react-router-6-use-routes/playwright.config.ts @@ -7,7 +7,7 @@ import { devices } from '@playwright/test'; const config: PlaywrightTestConfig = { testDir: './tests', /* Maximum time one test can run for. */ - timeout: 60 * 1000, + timeout: 150_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/packages/e2e-tests/test-applications/react-router-6-use-routes/tests/behaviour-test.spec.ts b/packages/e2e-tests/test-applications/react-router-6-use-routes/tests/behaviour-test.spec.ts index 2bda1adcbf9c..4800c9e7cb0e 100644 --- a/packages/e2e-tests/test-applications/react-router-6-use-routes/tests/behaviour-test.spec.ts +++ b/packages/e2e-tests/test-applications/react-router-6-use-routes/tests/behaviour-test.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; import axios, { AxiosError } from 'axios'; import { ReplayRecordingData } from './fixtures/ReplayRecordingData'; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; diff --git a/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/playwright.config.ts b/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/playwright.config.ts index c68482378d7a..5f93f826ebf0 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/playwright.config.ts +++ b/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/playwright.config.ts @@ -7,7 +7,7 @@ import { devices } from '@playwright/test'; const config: PlaywrightTestConfig = { testDir: './tests', /* Maximum time one test can run for. */ - timeout: 60 * 1000, + timeout: 150_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/tests/behaviour-test.spec.ts b/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/tests/behaviour-test.spec.ts index 8ff0e686feb4..632d2bfe9d55 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/tests/behaviour-test.spec.ts +++ b/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/tests/behaviour-test.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import axios, { AxiosError } from 'axios'; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/playwright.config.ts b/packages/e2e-tests/test-applications/standard-frontend-react/playwright.config.ts index c68482378d7a..5f93f826ebf0 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react/playwright.config.ts +++ b/packages/e2e-tests/test-applications/standard-frontend-react/playwright.config.ts @@ -7,7 +7,7 @@ import { devices } from '@playwright/test'; const config: PlaywrightTestConfig = { testDir: './tests', /* Maximum time one test can run for. */ - timeout: 60 * 1000, + timeout: 150_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/tests/behaviour-test.spec.ts b/packages/e2e-tests/test-applications/standard-frontend-react/tests/behaviour-test.spec.ts index 2bda1adcbf9c..4800c9e7cb0e 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react/tests/behaviour-test.spec.ts +++ b/packages/e2e-tests/test-applications/standard-frontend-react/tests/behaviour-test.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; import axios, { AxiosError } from 'axios'; import { ReplayRecordingData } from './fixtures/ReplayRecordingData'; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; diff --git a/packages/e2e-tests/test-applications/sveltekit/playwright.config.ts b/packages/e2e-tests/test-applications/sveltekit/playwright.config.ts index e38177e51a90..bfa29df7d549 100644 --- a/packages/e2e-tests/test-applications/sveltekit/playwright.config.ts +++ b/packages/e2e-tests/test-applications/sveltekit/playwright.config.ts @@ -15,7 +15,7 @@ const port = 3030; const config: PlaywrightTestConfig = { testDir: './test', /* Maximum time one test can run for. */ - timeout: 60 * 1000, + timeout: 150_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/packages/e2e-tests/test-applications/sveltekit/test/transaction.test.ts b/packages/e2e-tests/test-applications/sveltekit/test/transaction.test.ts index edc36cb5ca9c..2a069a79b22f 100644 --- a/packages/e2e-tests/test-applications/sveltekit/test/transaction.test.ts +++ b/packages/e2e-tests/test-applications/sveltekit/test/transaction.test.ts @@ -6,7 +6,7 @@ import axios, { AxiosError } from 'axios'; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; -const EVENT_POLLING_TIMEOUT = 30_000; +const EVENT_POLLING_TIMEOUT = 90_000; test('Sends a pageload transaction', async ({ page }) => { const pageloadTransactionEventPromise = waitForTransaction('sveltekit', (transactionEvent: any) => { diff --git a/packages/feedback/src/index.ts b/packages/feedback/src/index.ts index 31efcfca2386..57b6075dfca5 100644 --- a/packages/feedback/src/index.ts +++ b/packages/feedback/src/index.ts @@ -1,2 +1,2 @@ -export { sendFeedbackRequest } from './util/sendFeedbackRequest'; +export { sendFeedback } from './sendFeedback'; export { Feedback } from './integration'; diff --git a/packages/feedback/src/types/index.ts b/packages/feedback/src/types/index.ts index e73868169b16..6269e8a697a8 100644 --- a/packages/feedback/src/types/index.ts +++ b/packages/feedback/src/types/index.ts @@ -1,26 +1,10 @@ -import type { Event, Primitive } from '@sentry/types'; +import type { Primitive } from '@sentry/types'; import type { ActorComponent } from '../widget/Actor'; import type { DialogComponent } from '../widget/Dialog'; export type SentryTags = { [key: string]: Primitive } | undefined; -/** - * NOTE: These types are still considered Beta and subject to change. - * @hidden - */ -export interface FeedbackEvent extends Event { - feedback: { - message: string; - url: string; - contact_email?: string; - name?: string; - replay_id?: string; - }; - // TODO: Add this event type to Event - // type: 'feedback_event'; -} - export interface SendFeedbackData { feedback: { message: string; diff --git a/packages/feedback/src/util/handleFeedbackSubmit.ts b/packages/feedback/src/util/handleFeedbackSubmit.ts index 8388300a5a4c..6e7b7c014de4 100644 --- a/packages/feedback/src/util/handleFeedbackSubmit.ts +++ b/packages/feedback/src/util/handleFeedbackSubmit.ts @@ -1,18 +1,22 @@ +import type { TransportMakeRequestResponse } from '@sentry/types'; +import { logger } from '@sentry/utils'; + import { sendFeedback } from '../sendFeedback'; import type { FeedbackFormData, SendFeedbackOptions } from '../types'; import type { DialogComponent } from '../widget/Dialog'; /** - * Calls `sendFeedback` to send feedback, handles UI behavior of dialog. + * Handles UI behavior of dialog when feedback is submitted, calls + * `sendFeedback` to send feedback. */ export async function handleFeedbackSubmit( dialog: DialogComponent | null, feedback: FeedbackFormData, options?: SendFeedbackOptions, -): Promise { +): Promise { if (!dialog) { // Not sure when this would happen - return false; + return; } const showFetchError = (): void => { @@ -22,21 +26,15 @@ export async function handleFeedbackSubmit( dialog.showError('There was a problem submitting feedback, please wait and try again.'); }; + dialog.hideError(); + try { - dialog.hideError(); const resp = await sendFeedback(feedback, options); - if (!resp) { - // Errored... re-enable submit button - showFetchError(); - return false; - } - // Success! return resp; - } catch { - // Errored... re-enable submit button + } catch (err) { + __DEBUG_BUILD__ && logger.error(err); showFetchError(); - return false; } } diff --git a/packages/feedback/src/util/prepareFeedbackEvent.ts b/packages/feedback/src/util/prepareFeedbackEvent.ts index 6d32506d3be4..9b1b0f9c6e8b 100644 --- a/packages/feedback/src/util/prepareFeedbackEvent.ts +++ b/packages/feedback/src/util/prepareFeedbackEvent.ts @@ -1,8 +1,6 @@ import type { Scope } from '@sentry/core'; import { prepareEvent } from '@sentry/core'; -import type { Client } from '@sentry/types'; - -import type { FeedbackEvent } from '../types'; +import type { Client, FeedbackEvent } from '@sentry/types'; interface PrepareFeedbackEventParams { client: Client; @@ -17,7 +15,7 @@ export async function prepareFeedbackEvent({ scope, event, }: PrepareFeedbackEventParams): Promise { - const eventHint = { integrations: undefined }; + const eventHint = {}; if (client.emit) { client.emit('preprocessEvent', event, eventHint); } @@ -25,12 +23,14 @@ export async function prepareFeedbackEvent({ const preparedEvent = (await prepareEvent( client.getOptions(), event, - { integrations: undefined }, + eventHint, scope, + client, )) as FeedbackEvent | null; - // If e.g. a global event processor returned null - if (!preparedEvent) { + if (preparedEvent === null) { + // Taken from baseclient's `_processEvent` method, where this is handled for errors/transactions + client.recordDroppedEvent('event_processor', 'feedback', event); return null; } @@ -39,14 +39,5 @@ export async function prepareFeedbackEvent({ // we need to do this manually. preparedEvent.platform = preparedEvent.platform || 'javascript'; - // extract the SDK name because `client._prepareEvent` doesn't add it to the event - const metadata = client.getSdkMetadata && client.getSdkMetadata(); - const { name, version } = (metadata && metadata.sdk) || {}; - - preparedEvent.sdk = { - ...preparedEvent.sdk, - name: name || 'sentry.javascript.unknown', - version: version || '0.0.0', - }; return preparedEvent; } diff --git a/packages/feedback/src/util/sendFeedbackRequest.ts b/packages/feedback/src/util/sendFeedbackRequest.ts index 626457d6122b..45a3bd493de9 100644 --- a/packages/feedback/src/util/sendFeedbackRequest.ts +++ b/packages/feedback/src/util/sendFeedbackRequest.ts @@ -1,39 +1,36 @@ -import { getCurrentHub } from '@sentry/core'; -import { dsnToString } from '@sentry/utils'; +import { createEventEnvelope, getCurrentHub } from '@sentry/core'; +import type { FeedbackEvent, TransportMakeRequestResponse } from '@sentry/types'; import type { SendFeedbackData } from '../types'; import { prepareFeedbackEvent } from './prepareFeedbackEvent'; /** - * Send feedback using `fetch()` + * Send feedback using transport */ export async function sendFeedbackRequest({ feedback: { message, email, name, replay_id, url }, -}: SendFeedbackData): Promise { +}: SendFeedbackData): Promise { const hub = getCurrentHub(); - - if (!hub) { - return null; - } - const client = hub.getClient(); const scope = hub.getScope(); const transport = client && client.getTransport(); const dsn = client && client.getDsn(); if (!client || !transport || !dsn) { - return null; + return; } - const baseEvent = { - feedback: { - contact_email: email, - name, - message, - replay_id, - url, + const baseEvent: FeedbackEvent = { + contexts: { + feedback: { + contact_email: email, + name, + message, + replay_id, + url, + }, }, - // type: 'feedback_event', + type: 'feedback', }; const feedbackEvent = await prepareFeedbackEvent({ @@ -42,72 +39,80 @@ export async function sendFeedbackRequest({ event: baseEvent, }); - if (!feedbackEvent) { - // Taken from baseclient's `_processEvent` method, where this is handled for errors/transactions - // client.recordDroppedEvent('event_processor', 'feedback', baseEvent); - return null; + if (feedbackEvent === null) { + return; } /* For reference, the fully built event looks something like this: { - "data": { - "dist": "abc123", + "type": "feedback", + "event_id": "d2132d31b39445f1938d7e21b6bf0ec4", + "timestamp": 1597977777.6189718, + "dist": "1.12", + "platform": "javascript", "environment": "production", - "feedback": { - "contact_email": "colton.allen@sentry.io", - "message": "I really like this user-feedback feature!", - "replay_id": "ec3b4dc8b79f417596f7a1aa4fcca5d2", - "url": "https://docs.sentry.io/platforms/javascript/" + "release": 42, + "tags": {"transaction": "/organizations/:orgId/performance/:eventSlug/"}, + "sdk": {"name": "name", "version": "version"}, + "user": { + "id": "123", + "username": "user", + "email": "user@site.com", + "ip_address": "192.168.11.12", }, - "id": "1ffe0775ac0f4417aed9de36d9f6f8dc", - "platform": "javascript", - "release": "version@1.3", "request": { - "headers": { - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36" - } - }, - "sdk": { - "name": "sentry.javascript.react", - "version": "6.18.1" + "url": None, + "headers": { + "user-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15" + }, }, - "tags": { - "key": "value" + "contexts": { + "feedback": { + "message": "test message", + "contact_email": "test@example.com", + "type": "feedback", + }, + "trace": { + "trace_id": "4C79F60C11214EB38604F4AE0781BFB2", + "span_id": "FA90FDEAD5F74052", + "type": "trace", + }, + "replay": { + "replay_id": "e2d42047b1c5431c8cba85ee2a8ab25d", + }, }, - "timestamp": "2023-08-31T14:10:34.954048", - "user": { - "email": "username@example.com", - "id": "123", - "ip_address": "127.0.0.1", - "name": "user", - "username": "user2270129" - } } - } */ - // Prevent this data (which, if it exists, was used in earlier steps in the processing pipeline) from being sent to - // sentry. (Note: Our use of this property comes and goes with whatever we might be debugging, whatever hacks we may - // have temporarily added, etc. Even if we don't happen to be using it at some point in the future, let's not get rid - // of this `delete`, lest we miss putting it back in the next time the property is in use.) - delete feedbackEvent.sdkProcessingMetadata; + const envelope = createEventEnvelope(feedbackEvent, dsn, client.getOptions()._metadata, client.getOptions().tunnel); + + let response: void | TransportMakeRequestResponse; try { - const path = 'https://sentry.io/api/0/feedback/'; - const response = await fetch(path, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `DSN ${dsnToString(dsn)}`, - }, - body: JSON.stringify(feedbackEvent), - }); - if (!response.ok) { - return null; + response = await transport.send(envelope); + } catch (err) { + const error = new Error('Unable to send Feedback'); + + try { + // In case browsers don't allow this property to be writable + // @ts-expect-error This needs lib es2022 and newer + error.cause = err; + } catch { + // nothing to do } + throw error; + } + + // TODO (v8): we can remove this guard once transport.send's type signature doesn't include void anymore + if (!response) { return response; - } catch (err) { - return null; } + + // Require valid status codes, otherwise can assume feedback was not sent successfully + if (typeof response.statusCode === 'number' && (response.statusCode < 200 || response.statusCode >= 300)) { + throw new Error('Unable to send Feedback'); + } + + return response; } diff --git a/packages/feedback/test/unit/util/prepareFeedbackEvent.test.ts b/packages/feedback/test/unit/util/prepareFeedbackEvent.test.ts index cee0d99f0b5c..c5a2c37390fb 100644 --- a/packages/feedback/test/unit/util/prepareFeedbackEvent.test.ts +++ b/packages/feedback/test/unit/util/prepareFeedbackEvent.test.ts @@ -1,8 +1,7 @@ import type { Hub, Scope } from '@sentry/core'; import { getCurrentHub } from '@sentry/core'; -import type { Client } from '@sentry/types'; +import type { Client, FeedbackEvent } from '@sentry/types'; -import type { FeedbackEvent } from '../../../src/types'; import { prepareFeedbackEvent } from '../../../src/util/prepareFeedbackEvent'; import { getDefaultClientOptions, TestClient } from '../../utils/TestClient'; @@ -18,15 +17,6 @@ describe('Unit | util | prepareFeedbackEvent', () => { client = hub.getClient()!; scope = hub.getScope()!; - - jest.spyOn(client, 'getSdkMetadata').mockImplementation(() => { - return { - sdk: { - name: 'sentry.javascript.testSdk', - version: '1.0.0', - }, - }; - }); }); afterEach(() => { @@ -41,13 +31,14 @@ describe('Unit | util | prepareFeedbackEvent', () => { const event: FeedbackEvent = { timestamp: 1670837008.634, event_id: 'feedback-ID', - feedback: { - contact_email: 'test@test.com', - message: 'looks great!', - replay_id: replayId, - url: 'https://sentry.io/', - }, + type: 'feedback', contexts: { + feedback: { + contact_email: 'test@test.com', + message: 'looks great!', + replay_id: replayId, + url: 'https://sentry.io/', + }, replay: { error_sample_rate: 1.0, session_sample_rate: 0.1, @@ -57,31 +48,26 @@ describe('Unit | util | prepareFeedbackEvent', () => { const feedbackEvent = await prepareFeedbackEvent({ scope, client, event }); - expect(client.getSdkMetadata).toHaveBeenCalledTimes(1); - expect(feedbackEvent).toEqual({ timestamp: 1670837008.634, event_id: 'feedback-ID', - feedback: { - contact_email: 'test@test.com', - message: 'looks great!', - replay_id: replayId, - url: 'https://sentry.io/', - }, platform: 'javascript', environment: 'production', contexts: { + feedback: { + contact_email: 'test@test.com', + message: 'looks great!', + replay_id: replayId, + url: 'https://sentry.io/', + }, replay: { error_sample_rate: 1.0, session_sample_rate: 0.1, }, }, - sdk: { - name: 'sentry.javascript.testSdk', - version: '1.0.0', - }, sdkProcessingMetadata: expect.any(Object), breadcrumbs: undefined, + type: 'feedback', }); }); }); diff --git a/packages/feedback/test/widget/createWidget.test.ts b/packages/feedback/test/widget/createWidget.test.ts index 1a18b5b93605..1c39de8f26c1 100644 --- a/packages/feedback/test/widget/createWidget.test.ts +++ b/packages/feedback/test/widget/createWidget.test.ts @@ -177,7 +177,7 @@ describe('createWidget', () => { }); (sendFeedbackRequest as jest.Mock).mockImplementation(() => { - return false; + throw new Error('Unable to send feedback'); }); widget.actor?.el?.dispatchEvent(new Event('click')); diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index a4d7a96b94fd..42fecd1d39ff 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -1,4 +1,4 @@ -import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core'; +import { captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core'; import { addExceptionMechanism, tracingContextFromHeaders } from '@sentry/utils'; import { isRedirectNavigationError } from './nextNavigationErrorUtils'; @@ -13,8 +13,6 @@ export function wrapRouteHandlerWithSentry any>( routeHandler: F, context: RouteHandlerContext, ): (...args: Parameters) => ReturnType extends Promise ? ReturnType : Promise> { - addTracingExtensions(); - const { method, parameterizedRoute, baggageHeader, sentryTraceHeader } = context; return new Proxy(routeHandler, { diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index 360b169c7b37..6efa50d2b804 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -1,11 +1,4 @@ -import { - addTracingExtensions, - captureException, - flush, - getCurrentHub, - runWithAsyncContext, - startTransaction, -} from '@sentry/core'; +import { captureException, flush, getCurrentHub, runWithAsyncContext, startTransaction } from '@sentry/core'; import { addExceptionMechanism, tracingContextFromHeaders } from '@sentry/utils'; import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; @@ -19,8 +12,6 @@ export function wrapServerComponentWithSentry any> appDirComponent: F, context: ServerComponentContext, ): F { - addTracingExtensions(); - const { componentRoute, componentType } = context; // Even though users may define server components as async functions, for the client bundles diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 07c6de85e92c..7c66d8f1fb7f 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,10 +1,12 @@ -import { SDK_VERSION } from '@sentry/core'; +import { addTracingExtensions, SDK_VERSION } from '@sentry/core'; import { RewriteFrames } from '@sentry/integrations'; import type { SdkMetadata } from '@sentry/types'; import { addOrUpdateIntegration, escapeStringForRegex, GLOBAL_OBJ } from '@sentry/utils'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { init as vercelEdgeInit } from '@sentry/vercel-edge'; +import { isBuild } from '../common/utils/isBuild'; + export type EdgeOptions = VercelEdgeOptions; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { @@ -13,6 +15,12 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { /** Inits the Sentry NextJS SDK on the Edge Runtime. */ export function init(options: VercelEdgeOptions = {}): void { + addTracingExtensions(); + + if (isBuild()) { + return; + } + const opts = { _metadata: {} as SdkMetadata, ...options, diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index cfbe245ca936..822f2619d127 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -1,3 +1,4 @@ +import { addTracingExtensions } from '@sentry/core'; import { RewriteFrames } from '@sentry/integrations'; import type { NodeOptions } from '@sentry/node'; import { configureScope, getCurrentHub, init as nodeInit, Integrations } from '@sentry/node'; @@ -63,6 +64,12 @@ const IS_VERCEL = !!process.env.VERCEL; /** Inits the Sentry NextJS SDK on node. */ export function init(options: NodeOptions): void { + addTracingExtensions(); + + if (isBuild()) { + return; + } + const opts = { environment: process.env.SENTRY_ENVIRONMENT || getVercelEnv(false) || process.env.NODE_ENV, ...options, diff --git a/packages/node/src/handlers.ts b/packages/node/src/handlers.ts index 868cf7d5a6b2..f080b00275dc 100644 --- a/packages/node/src/handlers.ts +++ b/packages/node/src/handlers.ts @@ -338,7 +338,7 @@ interface TrpcMiddlewareArguments { * e.g. Express Request Handlers or Next.js SDK. */ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { - return function ({ path, type, next, rawInput }: TrpcMiddlewareArguments): T { + return async function ({ path, type, next, rawInput }: TrpcMiddlewareArguments): Promise { const hub = getCurrentHub(); const clientOptions = hub.getClient()?.getOptions(); const sentryTransaction = hub.getScope().getTransaction(); @@ -358,7 +358,36 @@ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { sentryTransaction.setContext('trpc', trpcContext); } - return next(); + function captureError(e: unknown): void { + captureException(e, scope => { + scope.addEventProcessor(event => { + addExceptionMechanism(event, { + handled: false, + }); + return event; + }); + + return scope; + }); + } + + try { + return await next(); + } catch (e: unknown) { + if (typeof e === 'object' && e) { + if ('code' in e) { + // Is likely TRPCError - we only want to capture internal server errors + if (e.code === 'INTERNAL_SERVER_ERROR') { + captureError(e); + } + } else { + // Is likely random error that bubbles up + captureError(e); + } + } + + throw e; + } }; } diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts index 42034876d269..7686a08782c8 100644 --- a/packages/remix/src/index.server.ts +++ b/packages/remix/src/index.server.ts @@ -62,6 +62,8 @@ export { remixRouterInstrumentation, withSentry } from './client/performance'; export { captureRemixErrorBoundaryError } from './client/errors'; export { wrapExpressCreateRequestHandler } from './utils/serverAdapters/express'; +export type { SentryMetaArgs } from './utils/types'; + function sdkAlreadyInitialized(): boolean { const hub = getCurrentHub(); return !!hub.getClient(); diff --git a/packages/remix/src/utils/types.ts b/packages/remix/src/utils/types.ts new file mode 100644 index 000000000000..9634678a05ce --- /dev/null +++ b/packages/remix/src/utils/types.ts @@ -0,0 +1,6 @@ +export type SentryMetaArgs any> = Parameters[0] & { + data: { + sentryTrace: string; + sentryBaggage: string; + }; +}; diff --git a/packages/types/src/datacategory.ts b/packages/types/src/datacategory.ts index 84340cffc4f1..ca2acd29e235 100644 --- a/packages/types/src/datacategory.ts +++ b/packages/types/src/datacategory.ts @@ -24,5 +24,7 @@ export type DataCategory = | 'profile' // Check-in event (monitor) | 'monitor' + // Feedback type event (v2) + | 'feedback' // Unknown data category | 'unknown'; diff --git a/packages/types/src/envelope.ts b/packages/types/src/envelope.ts index 051de2a07960..d479b9f8a4e9 100644 --- a/packages/types/src/envelope.ts +++ b/packages/types/src/envelope.ts @@ -2,6 +2,7 @@ import type { SerializedCheckIn } from './checkin'; import type { ClientReport } from './clientreport'; import type { DsnComponents } from './dsn'; import type { Event } from './event'; +import type { FeedbackEvent } from './feedback'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; import type { SerializedSession, Session, SessionAggregates } from './session'; @@ -26,6 +27,7 @@ export type DynamicSamplingContext = { export type EnvelopeItemType = | 'client_report' | 'user_report' + | 'feedback' | 'session' | 'sessions' | 'transaction' @@ -57,7 +59,7 @@ type BaseEnvelope = [ ]; type EventItemHeaders = { - type: 'event' | 'transaction' | 'profile'; + type: 'event' | 'transaction' | 'profile' | 'feedback'; }; type AttachmentItemHeaders = { type: 'attachment'; @@ -67,6 +69,7 @@ type AttachmentItemHeaders = { attachment_type?: string; }; type UserFeedbackItemHeaders = { type: 'user_report' }; +type FeedbackItemHeaders = { type: 'feedback' }; type SessionItemHeaders = { type: 'session' }; type SessionAggregatesItemHeaders = { type: 'sessions' }; type ClientReportItemHeaders = { type: 'client_report' }; @@ -87,6 +90,7 @@ export type CheckInItem = BaseEnvelopeItem; type ReplayRecordingItem = BaseEnvelopeItem; export type StatsdItem = BaseEnvelopeItem; +export type FeedbackItem = BaseEnvelopeItem; export type EventEnvelopeHeaders = { event_id: string; sent_at: string; trace?: DynamicSamplingContext }; type SessionEnvelopeHeaders = { sent_at: string }; @@ -95,7 +99,10 @@ type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders; type ReplayEnvelopeHeaders = BaseEnvelopeHeaders; type StatsdEnvelopeHeaders = BaseEnvelopeHeaders; -export type EventEnvelope = BaseEnvelope; +export type EventEnvelope = BaseEnvelope< + EventEnvelopeHeaders, + EventItem | AttachmentItem | UserFeedbackItem | FeedbackItem +>; export type SessionEnvelope = BaseEnvelope; export type ClientReportEnvelope = BaseEnvelope; export type ReplayEnvelope = [ReplayEnvelopeHeaders, [ReplayEventItem, ReplayRecordingItem]]; diff --git a/packages/types/src/event.ts b/packages/types/src/event.ts index 61783ef9361b..55207f89337d 100644 --- a/packages/types/src/event.ts +++ b/packages/types/src/event.ts @@ -61,7 +61,7 @@ export interface Event { * Note that `ErrorEvent`s do not have a type (hence its undefined), * while all other events are required to have one. */ -export type EventType = 'transaction' | 'profile' | 'replay_event' | undefined; +export type EventType = 'transaction' | 'profile' | 'replay_event' | 'feedback' | undefined; export interface ErrorEvent extends Event { type: undefined; diff --git a/packages/types/src/feedback.ts b/packages/types/src/feedback.ts new file mode 100644 index 000000000000..360064cda833 --- /dev/null +++ b/packages/types/src/feedback.ts @@ -0,0 +1,20 @@ +import type { Event } from './event'; + +export interface FeedbackContext extends Record { + message: string; + contact_email?: string; + name?: string; + replay_id?: string; + url?: string; +} + +/** + * NOTE: These types are still considered Alpha and subject to change. + * @hidden + */ +export interface FeedbackEvent extends Event { + type: 'feedback'; + contexts: Event['contexts'] & { + feedback: FeedbackContext; + }; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index a8a9531a6490..27068f8080f8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -36,6 +36,7 @@ export type { EventEnvelopeHeaders, EventItem, ReplayEnvelope, + FeedbackItem, SessionEnvelope, SessionItem, UserFeedbackItem, @@ -69,6 +70,7 @@ export type { Profile, } from './profiling'; export type { ReplayEvent, ReplayRecordingData, ReplayRecordingMode } from './replay'; +export type { FeedbackEvent } from './feedback'; export type { QueryParams, Request, SanitizedRequestData } from './request'; export type { Runtime } from './runtime'; export type { CaptureContext, Scope, ScopeContext } from './scope'; diff --git a/packages/utils/src/envelope.ts b/packages/utils/src/envelope.ts index 54772c6ae6fc..c716ab282dfe 100644 --- a/packages/utils/src/envelope.ts +++ b/packages/utils/src/envelope.ts @@ -208,6 +208,7 @@ const ITEM_TYPE_TO_DATA_CATEGORY_MAP: Record = { replay_event: 'replay', replay_recording: 'replay', check_in: 'monitor', + feedback: 'feedback', // TODO: This is a temporary workaround until we have a proper data category for metrics statsd: 'unknown', };