From 7b815bf481051a546e6078157b87b4c10ccd70d4 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 22 Oct 2024 13:16:53 +0200 Subject: [PATCH] feat(nuxt): Add Sentry Pinia plugin (#14047) closes https://github.com/getsentry/sentry-javascript/issues/14039 By adding `trackPinia`, the Pinia store is monitored with Sentry. ```js Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, trackPinia: true }); ``` or with custom options: ```js Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, trackPinia: { actionTransformer: action => `Transformed: ${action}`, }, }); ``` --- .../nuxt-4/app/pages/pinia-cart.vue | 73 +++++++++++++++++++ .../test-applications/nuxt-4/nuxt.config.ts | 2 +- .../test-applications/nuxt-4/package.json | 1 + .../nuxt-4/sentry.client.config.ts | 7 ++ .../test-applications/nuxt-4/stores/cart.ts | 43 +++++++++++ .../nuxt-4/tests/pinia.test.ts | 35 +++++++++ packages/nuxt/src/common/types.ts | 15 +++- .../nuxt/src/runtime/plugins/sentry.client.ts | 25 ++++++- 8 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/pinia-cart.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/stores/cart.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/pinia.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/pinia-cart.vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/pinia-cart.vue new file mode 100644 index 000000000000..3d210cf459de --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/pinia-cart.vue @@ -0,0 +1,73 @@ + + + + + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index c00ba0d5d9ed..da988a9ee003 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -4,7 +4,7 @@ export default defineNuxtConfig({ compatibilityDate: '2024-04-03', imports: { autoImport: false }, - modules: ['@sentry/nuxt/module'], + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], runtimeConfig: { public: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index db56273a7493..178804768e87 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -14,6 +14,7 @@ "test:assert": "pnpm test" }, "dependencies": { + "@pinia/nuxt": "^0.5.5", "@sentry/nuxt": "latest || *", "nuxt": "^3.13.2" }, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts index 7547bafa6618..b32effbff3b8 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts @@ -7,4 +7,11 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, trackComponents: true, + trackPinia: { + actionTransformer: action => `Transformed: ${action}`, + stateTransformer: state => ({ + transformed: true, + ...state, + }), + }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/stores/cart.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/stores/cart.ts new file mode 100644 index 000000000000..cad52916ac25 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/stores/cart.ts @@ -0,0 +1,43 @@ +import { acceptHMRUpdate, defineStore } from '#imports'; + +export const useCartStore = defineStore({ + id: 'cart', + state: () => ({ + rawItems: [] as string[], + }), + getters: { + items: (state): Array<{ name: string; amount: number }> => + state.rawItems.reduce( + (items: any, item: any) => { + const existingItem = items.find((it: any) => it.name === item); + + if (!existingItem) { + items.push({ name: item, amount: 1 }); + } else { + existingItem.amount++; + } + + return items; + }, + [] as Array<{ name: string; amount: number }>, + ), + }, + actions: { + addItem(name: string) { + this.rawItems.push(name); + }, + + removeItem(name: string) { + const i = this.rawItems.lastIndexOf(name); + if (i > -1) this.rawItems.splice(i, 1); + }, + + throwError() { + throw new Error('error'); + }, + }, +}); + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useCartStore, import.meta.hot)); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/pinia.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/pinia.test.ts new file mode 100644 index 000000000000..44b057a29f15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/pinia.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('sends pinia action breadcrumbs and state context', async ({ page }) => { + await page.goto('/pinia-cart'); + + await page.locator('#item-input').fill('item'); + await page.locator('#item-add').click(); + + const errorPromise = waitForError('nuxt-4', async errorEvent => { + return errorEvent?.exception?.values?.[0].value === 'This is an error'; + }); + + await page.locator('#throw-error').click(); + + const error = await errorPromise; + + expect(error).toBeTruthy(); + expect(error.breadcrumbs?.length).toBeGreaterThan(0); + + const actionBreadcrumb = error.breadcrumbs?.find(breadcrumb => breadcrumb.category === 'action'); + + expect(actionBreadcrumb).toBeDefined(); + expect(actionBreadcrumb?.message).toBe('Transformed: addItem'); + expect(actionBreadcrumb?.level).toBe('info'); + + const stateContext = error.contexts?.state?.state; + + expect(stateContext).toBeDefined(); + expect(stateContext?.type).toBe('pinia'); + expect(stateContext?.value).toEqual({ + transformed: true, + rawItems: ['item'], + }); +}); diff --git a/packages/nuxt/src/common/types.ts b/packages/nuxt/src/common/types.ts index 6ba29752a308..5b714968d3ca 100644 --- a/packages/nuxt/src/common/types.ts +++ b/packages/nuxt/src/common/types.ts @@ -1,10 +1,21 @@ import type { init as initNode } from '@sentry/node'; import type { SentryRollupPluginOptions } from '@sentry/rollup-plugin'; import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; -import type { init as initVue } from '@sentry/vue'; +import type { createSentryPiniaPlugin, init as initVue } from '@sentry/vue'; // Omitting 'app' as the Nuxt SDK will add the app instance in the client plugin (users do not have to provide this) -export type SentryNuxtClientOptions = Omit[0] & object, 'app'>; +export type SentryNuxtClientOptions = Omit[0] & object, 'app'> & { + /** + * Control if an existing Pinia store should be monitored. + * Set this to `true` to track with default options or provide your custom Pinia plugin options. + * + * This only works if "@pinia/nuxt" is added to the `modules` array. + * + * @default false + */ + trackPinia?: true | Parameters[0]; +}; + export type SentryNuxtServerOptions = Omit[0] & object, 'app'>; type SourceMapsOptions = { diff --git a/packages/nuxt/src/runtime/plugins/sentry.client.ts b/packages/nuxt/src/runtime/plugins/sentry.client.ts index b89a2fa87a8d..a8b15b937d53 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.client.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.client.ts @@ -1,5 +1,6 @@ import { getClient } from '@sentry/core'; -import { browserTracingIntegration, vueIntegration } from '@sentry/vue'; +import { consoleSandbox } from '@sentry/utils'; +import { browserTracingIntegration, createSentryPiniaPlugin, vueIntegration } from '@sentry/vue'; import { defineNuxtPlugin } from 'nuxt/app'; import { reportNuxtError } from '../utils'; @@ -34,11 +35,12 @@ export default defineNuxtPlugin({ name: 'sentry-client-integrations', dependsOn: ['sentry-client-config'], async setup(nuxtApp) { + const sentryClient = getClient(); + const clientOptions = sentryClient && sentryClient.getOptions(); + // This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", in which case everything inside // will get tree-shaken away if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { - const sentryClient = getClient(); - if (sentryClient && '$router' in nuxtApp) { sentryClient.addIntegration( browserTracingIntegration({ router: nuxtApp.$router as VueRouter, routeLabel: 'path' }), @@ -46,6 +48,23 @@ export default defineNuxtPlugin({ } } + if (clientOptions && 'trackPinia' in clientOptions && clientOptions.trackPinia) { + if ('$pinia' in nuxtApp) { + (nuxtApp.$pinia as { use: (plugin: unknown) => void }).use( + // `trackPinia` is an object with custom options or `true` (pass `undefined` to use default options) + createSentryPiniaPlugin(clientOptions.trackPinia === true ? undefined : clientOptions.trackPinia), + ); + } else { + clientOptions.debug && + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[Sentry] You set `trackPinia`, but the Pinia module was not found. Make sure to add `"@pinia/nuxt"` to your modules array.', + ); + }); + } + } + nuxtApp.hook('app:created', vueApp => { const sentryClient = getClient();