From 4ebac947ec23ff0d188b239dba173d7fe13c8ed7 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 5 Aug 2024 09:31:01 +0200 Subject: [PATCH] feat(solidstart): Add sentry `onBeforeResponse` middleware to enable distributed tracing (#13221) Works by adding the Sentry middlware to your `src/middleware.ts` file: ```typescript import { sentryBeforeResponseMiddleware } from '@sentry/solidstart/middleware'; import { createMiddleware } from '@solidjs/start/middleware'; export default createMiddleware({ onBeforeResponse: [ sentryBeforeResponseMiddleware(), // Add your other middleware handlers after `sentryBeforeResponseMiddleware` ], }); ``` And specifying `./src/middleware.ts` in `app.config.ts` Closes: https://github.com/getsentry/sentry-javascript/issues/12551 Co-authored-by: Lukas Stracke --- packages/solidstart/README.md | 34 +++++++- packages/solidstart/package.json | 17 +++- packages/solidstart/rollup.npm.config.mjs | 1 + packages/solidstart/src/middleware.ts | 61 ++++++++++++++ packages/solidstart/test/middleware.test.ts | 82 +++++++++++++++++++ ...es.json => tsconfig.subexports-types.json} | 1 + packages/solidstart/tsconfig.types.json | 3 +- 7 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 packages/solidstart/src/middleware.ts create mode 100644 packages/solidstart/test/middleware.test.ts rename packages/solidstart/{tsconfig.solidrouter-types.json => tsconfig.subexports-types.json} (95%) diff --git a/packages/solidstart/README.md b/packages/solidstart/README.md index e27e73447f2d..61aa3b2793da 100644 --- a/packages/solidstart/README.md +++ b/packages/solidstart/README.md @@ -46,10 +46,12 @@ Initialize the SDK in `entry-client.jsx` ```jsx import * as Sentry from '@sentry/solidstart'; +import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter'; import { mount, StartClient } from '@solidjs/start/client'; Sentry.init({ dsn: '__PUBLIC_DSN__', + integrations: [solidRouterBrowserTracingIntegration()], tracesSampleRate: 1.0, // Capture 100% of the transactions }); @@ -69,7 +71,37 @@ Sentry.init({ }); ``` -### 4. Run your application +### 4. Server instrumentation + +Complete the setup by adding the Sentry middlware to your `src/middleware.ts` file: + +```typescript +import { sentryBeforeResponseMiddleware } from '@sentry/solidstart/middleware'; +import { createMiddleware } from '@solidjs/start/middleware'; + +export default createMiddleware({ + onBeforeResponse: [ + sentryBeforeResponseMiddleware(), + // Add your other middleware handlers after `sentryBeforeResponseMiddleware` + ], +}); +``` + +And don't forget to specify `./src/middleware.ts` in your `app.config.ts`: + +```typescript +import { defineConfig } from '@solidjs/start/config'; + +export default defineConfig({ + // ... + middleware: './src/middleware.ts', +}); +``` + +The Sentry middleware enhances the data collected by Sentry on the server side by enabling distributed tracing between +the client and server. + +### 5. Run your application Then run your app diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index 7a6e1849b589..785cef7fc94e 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -39,6 +39,17 @@ "require": "./build/cjs/index.server.js" } }, + "./middleware": { + "types": "./middleware.d.ts", + "import": { + "types": "./middleware.d.ts", + "default": "./build/esm/middleware.js" + }, + "require": { + "types": "./middleware.d.ts", + "default": "./build/cjs/middleware.js" + } + }, "./solidrouter": { "types": "./solidrouter.d.ts", "browser": { @@ -87,15 +98,15 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.mjs", - "build:types": "run-s build:types:core build:types:solidrouter", + "build:types": "run-s build:types:core build:types:subexports", "build:types:core": "tsc -p tsconfig.types.json", - "build:types:solidrouter": "tsc -p tsconfig.solidrouter-types.json", + "build:types:subexports": "tsc -p tsconfig.subexports-types.json", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", "build:types:watch": "tsc -p tsconfig.types.json --watch", "build:tarball": "npm pack", - "circularDepCheck": "madge --circular src/index.client.ts && madge --circular src/index.server.ts && madge --circular src/index.types.ts && madge --circular src/solidrouter.client.ts && madge --circular src/solidrouter.server.ts && madge --circular src/solidrouter.ts", + "circularDepCheck": "madge --circular src/index.client.ts && madge --circular src/index.server.ts && madge --circular src/index.types.ts && madge --circular src/solidrouter.client.ts && madge --circular src/solidrouter.server.ts && madge --circular src/solidrouter.ts && madge --circular src/middleware.ts", "clean": "rimraf build coverage sentry-solidstart-*.tgz ./*.d.ts ./*.d.ts.map ./client ./server", "fix": "eslint . --format stylish --fix", "lint": "eslint . --format stylish", diff --git a/packages/solidstart/rollup.npm.config.mjs b/packages/solidstart/rollup.npm.config.mjs index b0087a93c6fe..8e91d0371a27 100644 --- a/packages/solidstart/rollup.npm.config.mjs +++ b/packages/solidstart/rollup.npm.config.mjs @@ -12,6 +12,7 @@ export default makeNPMConfigVariants( 'src/solidrouter.server.ts', 'src/client/solidrouter.ts', 'src/server/solidrouter.ts', + 'src/middleware.ts', ], // prevent this internal code from ending up in our built package (this doesn't happen automatially because // the name doesn't match an SDK dependency) diff --git a/packages/solidstart/src/middleware.ts b/packages/solidstart/src/middleware.ts new file mode 100644 index 000000000000..0113cce8f988 --- /dev/null +++ b/packages/solidstart/src/middleware.ts @@ -0,0 +1,61 @@ +import { getTraceData } from '@sentry/core'; +import { addNonEnumerableProperty } from '@sentry/utils'; +import type { ResponseMiddleware } from '@solidjs/start/middleware'; +import type { FetchEvent } from '@solidjs/start/server'; + +export type ResponseMiddlewareResponse = Parameters[1] & { + __sentry_wrapped__?: boolean; +}; + +function addMetaTagToHead(html: string): string { + const { 'sentry-trace': sentryTrace, baggage } = getTraceData(); + + if (!sentryTrace) { + return html; + } + + const metaTags = [``]; + + if (baggage) { + metaTags.push(``); + } + + const content = `\n${metaTags.join('\n')}\n`; + return html.replace('', content); +} + +/** + * Returns an `onBeforeResponse` solid start middleware handler that adds tracing data as + * tags to a page on pageload to enable distributed tracing. + */ +export function sentryBeforeResponseMiddleware() { + return async function onBeforeResponse(event: FetchEvent, response: ResponseMiddlewareResponse) { + if (!response.body || response.__sentry_wrapped__) { + return; + } + + // Ensure we don't double-wrap, in case a user has added the middleware twice + // e.g. once manually, once via the wizard + addNonEnumerableProperty(response, '__sentry_wrapped__', true); + + const contentType = event.response.headers.get('content-type'); + const isPageloadRequest = contentType && contentType.startsWith('text/html'); + + if (!isPageloadRequest) { + return; + } + + const body = response.body as NodeJS.ReadableStream; + const decoder = new TextDecoder(); + response.body = new ReadableStream({ + start: async controller => { + for await (const chunk of body) { + const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); + const modifiedHtml = addMetaTagToHead(html); + controller.enqueue(new TextEncoder().encode(modifiedHtml)); + } + controller.close(); + }, + }); + }; +} diff --git a/packages/solidstart/test/middleware.test.ts b/packages/solidstart/test/middleware.test.ts new file mode 100644 index 000000000000..888a0fbc702d --- /dev/null +++ b/packages/solidstart/test/middleware.test.ts @@ -0,0 +1,82 @@ +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, it, vi } from 'vitest'; +import { sentryBeforeResponseMiddleware } from '../src/middleware'; +import type { ResponseMiddlewareResponse } from '../src/middleware'; + +describe('middleware', () => { + describe('sentryBeforeResponseMiddleware', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '123', + baggage: 'abc', + }); + + const mockFetchEvent = { + request: {}, + locals: {}, + response: { + // mocks a pageload + headers: new Headers([['content-type', 'text/html']]), + }, + nativeEvent: {}, + }; + + let mockMiddlewareHTMLResponse: ResponseMiddlewareResponse; + let mockMiddlewareHTMLNoHeadResponse: ResponseMiddlewareResponse; + let mockMiddlewareJSONResponse: ResponseMiddlewareResponse; + + beforeEach(() => { + // h3 doesn't pass a proper Response object to the middleware + mockMiddlewareHTMLResponse = { + body: new Response('').body, + }; + mockMiddlewareHTMLNoHeadResponse = { + body: new Response('Hello World').body, + }; + mockMiddlewareJSONResponse = { + body: new Response('{"prefecture": "Kagoshima"}').body, + }; + }); + + it('injects tracing meta tags into the response body', async () => { + const onBeforeResponse = sentryBeforeResponseMiddleware(); + onBeforeResponse(mockFetchEvent, mockMiddlewareHTMLResponse); + + // for testing convenience, we pass the body back into a proper response + // mockMiddlewareHTMLResponse has been modified by our middleware + const html = await new Response(mockMiddlewareHTMLResponse.body).text(); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('does not add meta tags if there is no head tag', async () => { + const onBeforeResponse = sentryBeforeResponseMiddleware(); + onBeforeResponse(mockFetchEvent, mockMiddlewareHTMLNoHeadResponse); + + const html = await new Response(mockMiddlewareHTMLNoHeadResponse.body).text(); + expect(html).toEqual('Hello World'); + }); + + it('does not add tracing meta tags twice into the same response', async () => { + const onBeforeResponse1 = sentryBeforeResponseMiddleware(); + onBeforeResponse1(mockFetchEvent, mockMiddlewareHTMLResponse); + + const onBeforeResponse2 = sentryBeforeResponseMiddleware(); + onBeforeResponse2(mockFetchEvent, mockMiddlewareHTMLResponse); + + const html = await new Response(mockMiddlewareHTMLResponse.body).text(); + expect(html.match(//g)).toHaveLength(1); + expect(html.match(//g)).toHaveLength(1); + }); + + it('does not modify a non-HTML response', async () => { + const onBeforeResponse = sentryBeforeResponseMiddleware(); + onBeforeResponse({ ...mockFetchEvent, response: { headers: new Headers() } }, mockMiddlewareJSONResponse); + + const json = await new Response(mockMiddlewareJSONResponse.body).json(); + expect(json).toEqual({ + prefecture: 'Kagoshima', + }); + }); + }); +}); diff --git a/packages/solidstart/tsconfig.solidrouter-types.json b/packages/solidstart/tsconfig.subexports-types.json similarity index 95% rename from packages/solidstart/tsconfig.solidrouter-types.json rename to packages/solidstart/tsconfig.subexports-types.json index f800d830c511..1c9daec11314 100644 --- a/packages/solidstart/tsconfig.solidrouter-types.json +++ b/packages/solidstart/tsconfig.subexports-types.json @@ -15,6 +15,7 @@ "src/solidrouter.server.ts", "src/server/solidrouter.ts", "src/solidrouter.ts", + "src/middleware.ts", ], // Without this, we cannot output into the root dir "exclude": [] diff --git a/packages/solidstart/tsconfig.types.json b/packages/solidstart/tsconfig.types.json index f7cc8c3d1610..bf2ca092abc1 100644 --- a/packages/solidstart/tsconfig.types.json +++ b/packages/solidstart/tsconfig.types.json @@ -14,6 +14,7 @@ "src/client/solidrouter.ts", "src/solidrouter.server.ts", "src/server/solidrouter.ts", - "src/solidrouter.ts" + "src/solidrouter.ts", + "src/middleware.ts", ] }