From ecf84e0c8ff95f5800efcdcf80ee552964b69175 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 15 Oct 2024 11:44:41 +0300 Subject: [PATCH] feat(vue): Add Pinia plugin (#13841) Resolves: https://github.com/getsentry/sentry-javascript/issues/13279 Depends on: #13840 [Sample Event](https://sentry-sdks.sentry.io/issues/5939879614/?project=5429219&query=is%3Aunresolved%20issue.priority%3A%5Bhigh%2C%20medium%5D&referrer=issue-stream&sort=date&statsPeriod=1h&stream_index=0) Docs PR: https://github.com/getsentry/sentry-docs/pull/11516 Adds a Pinia plugin with a feature set similar to the Redux integration. - Attaches Pinia state as an attachment to the event (`true` by default) - Provides `actionTransformer` and `stateTransformer` to the user for potentially required PII modifications. - Adds breadcrumbs for Pinia actions - Assigns Pinia state to event contexts. --- .../test-applications/vue-3/package.json | 1 + .../test-applications/vue-3/src/main.ts | 14 +++ .../vue-3/src/router/index.ts | 4 + .../vue-3/src/stores/cart.ts | 43 ++++++++ .../vue-3/src/views/CartView.vue | 88 +++++++++++++++ .../vue-3/tests/pinia.test.ts | 35 ++++++ packages/vue/package.json | 8 +- packages/vue/src/index.ts | 1 + packages/vue/src/pinia.ts | 103 ++++++++++++++++++ yarn.lock | 62 ++++++++++- 10 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/vue-3/src/stores/cart.ts create mode 100644 dev-packages/e2e-tests/test-applications/vue-3/src/views/CartView.vue create mode 100644 dev-packages/e2e-tests/test-applications/vue-3/tests/pinia.test.ts create mode 100644 packages/vue/src/pinia.ts diff --git a/dev-packages/e2e-tests/test-applications/vue-3/package.json b/dev-packages/e2e-tests/test-applications/vue-3/package.json index 6b837910fc02..aab0c6f3b744 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@sentry/vue": "latest || *", + "pinia": "^2.2.3", "vue": "^3.4.15", "vue-router": "^4.2.5" }, diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts b/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts index 13064ce04080..b940023b3153 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts @@ -4,10 +4,13 @@ import { createApp } from 'vue'; import App from './App.vue'; import router from './router'; +import { createPinia } from 'pinia'; + import * as Sentry from '@sentry/vue'; import { browserTracingIntegration } from '@sentry/vue'; const app = createApp(App); +const pinia = createPinia(); Sentry.init({ app, @@ -22,5 +25,16 @@ Sentry.init({ trackComponents: ['ComponentMainView', ''], }); +pinia.use( + Sentry.createSentryPiniaPlugin({ + actionTransformer: action => `Transformed: ${action}`, + stateTransformer: state => ({ + transformed: true, + ...state, + }), + }), +); + +app.use(pinia); app.use(router); app.mount('#app'); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts index 3a05e4f1055a..c81a662c61e2 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts @@ -34,6 +34,10 @@ const router = createRouter({ path: '/components', component: () => import('../views/ComponentMainView.vue'), }, + { + path: '/cart', + component: () => import('../views/CartView.vue'), + }, ], }); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/stores/cart.ts b/dev-packages/e2e-tests/test-applications/vue-3/src/stores/cart.ts new file mode 100644 index 000000000000..7786c7f27cd9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/stores/cart.ts @@ -0,0 +1,43 @@ +import { acceptHMRUpdate, defineStore } from 'pinia'; + +export const useCartStore = defineStore({ + id: 'cart', + state: () => ({ + rawItems: [] as string[], + }), + getters: { + items: (state): Array<{ name: string; amount: number }> => + state.rawItems.reduce( + (items, item) => { + const existingItem = items.find(it => 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/vue-3/src/views/CartView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/CartView.vue new file mode 100644 index 000000000000..ba7037e68bfe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/CartView.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/pinia.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/pinia.test.ts new file mode 100644 index 000000000000..5699ebc24b7c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/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('/cart'); + + await page.locator('#item-input').fill('item'); + await page.locator('#item-add').click(); + + const errorPromise = waitForError('vue-3', 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/vue/package.json b/packages/vue/package.json index 1090d0d4292e..7d1978551baf 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -45,7 +45,13 @@ "@sentry/utils": "8.34.0" }, "peerDependencies": { - "vue": "2.x || 3.x" + "vue": "2.x || 3.x", + "pinia": "2.x" + }, + "peerDependenciesMeta": { + "pinia": { + "optional": true + } }, "devDependencies": { "vue": "~3.2.41" diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 110b80d270a9..096b7a2144e5 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -5,3 +5,4 @@ export { browserTracingIntegration } from './browserTracingIntegration'; export { attachErrorHandler } from './errorhandler'; export { createTracingMixins } from './tracing'; export { vueIntegration } from './integration'; +export { createSentryPiniaPlugin } from './pinia'; diff --git a/packages/vue/src/pinia.ts b/packages/vue/src/pinia.ts new file mode 100644 index 000000000000..a21273a7d54b --- /dev/null +++ b/packages/vue/src/pinia.ts @@ -0,0 +1,103 @@ +import { addBreadcrumb, getClient, getCurrentScope, getGlobalScope } from '@sentry/core'; +import { addNonEnumerableProperty } from '@sentry/utils'; + +// Inline PiniaPlugin type +type PiniaPlugin = (context: { + store: { + $id: string; + $state: unknown; + $onAction: (callback: (context: { name: string; after: (callback: () => void) => void }) => void) => void; + }; +}) => void; + +type SentryPiniaPluginOptions = { + attachPiniaState?: boolean; + addBreadcrumbs?: boolean; + actionTransformer?: (action: any) => any; + stateTransformer?: (state: any) => any; +}; + +export const createSentryPiniaPlugin: (options?: SentryPiniaPluginOptions) => PiniaPlugin = ( + options: SentryPiniaPluginOptions = { + attachPiniaState: true, + addBreadcrumbs: true, + actionTransformer: action => action, + stateTransformer: state => state, + }, +) => { + const plugin: PiniaPlugin = ({ store }) => { + options.attachPiniaState !== false && + getGlobalScope().addEventProcessor((event, hint) => { + try { + // Get current timestamp in hh:mm:ss + const timestamp = new Date().toTimeString().split(' ')[0]; + const filename = `pinia_state_${store.$id}_${timestamp}.json`; + + hint.attachments = [ + ...(hint.attachments || []), + { + filename, + data: JSON.stringify(store.$state), + }, + ]; + } catch (_) { + // empty + } + + return event; + }); + + store.$onAction(context => { + context.after(() => { + const transformedActionName = options.actionTransformer + ? options.actionTransformer(context.name) + : context.name; + + if ( + typeof transformedActionName !== 'undefined' && + transformedActionName !== null && + options.addBreadcrumbs !== false + ) { + addBreadcrumb({ + category: 'action', + message: transformedActionName, + level: 'info', + }); + } + + /* Set latest state to scope */ + const transformedState = options.stateTransformer ? options.stateTransformer(store.$state) : store.$state; + const scope = getCurrentScope(); + const currentState = scope.getScopeData().contexts.state; + + if (typeof transformedState !== 'undefined' && transformedState !== null) { + const client = getClient(); + const options = client && client.getOptions(); + const normalizationDepth = (options && options.normalizeDepth) || 3; // default state normalization depth to 3 + const piniaStateContext = { type: 'pinia', value: transformedState }; + + const newState = { + ...(currentState || {}), + state: piniaStateContext, + }; + + addNonEnumerableProperty( + newState, + '__sentry_override_normalization_depth__', + 3 + // 3 layers for `state.value.transformedState + normalizationDepth, // rest for the actual state + ); + + scope.setContext('state', newState); + } else { + scope.setContext('state', { + ...(currentState || {}), + state: { type: 'pinia', value: 'undefined' }, + }); + } + }); + }); + }; + + return plugin; +}; diff --git a/yarn.lock b/yarn.lock index 2a31856c98dd..db64fa00fc6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9826,7 +9826,17 @@ dependencies: "@types/unist" "*" -"@types/history-4@npm:@types/history@4.7.8", "@types/history-5@npm:@types/history@4.7.8", "@types/history@*": +"@types/history-4@npm:@types/history@4.7.8": + version "4.7.8" + resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" + integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== + +"@types/history-5@npm:@types/history@4.7.8": + version "4.7.8" + resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" + integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== + +"@types/history@*": version "4.7.8" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== @@ -10154,7 +10164,15 @@ "@types/history" "^3" "@types/react" "*" -"@types/react-router-4@npm:@types/react-router@5.1.14", "@types/react-router-5@npm:@types/react-router@5.1.14": +"@types/react-router-4@npm:@types/react-router@5.1.14": + version "5.1.14" + resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.14.tgz#e0442f4eb4c446541ad7435d44a97f8fe6df40da" + integrity sha512-LAJpqYUaCTMT2anZheoidiIymt8MuX286zoVFPM3DVb23aQBH0mAkFvzpd4LKqiolV8bBtZWT5Qp7hClCNDENw== + dependencies: + "@types/history" "*" + "@types/react" "*" + +"@types/react-router-5@npm:@types/react-router@5.1.14": version "5.1.14" resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.14.tgz#e0442f4eb4c446541ad7435d44a97f8fe6df40da" integrity sha512-LAJpqYUaCTMT2anZheoidiIymt8MuX286zoVFPM3DVb23aQBH0mAkFvzpd4LKqiolV8bBtZWT5Qp7hClCNDENw== @@ -28626,7 +28644,7 @@ react-is@^18.0.0: dependencies: "@remix-run/router" "1.0.2" -"react-router-6@npm:react-router@6.3.0", react-router@6.3.0: +"react-router-6@npm:react-router@6.3.0": version "6.3.0" resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== @@ -28641,6 +28659,13 @@ react-router-dom@^6.2.2: history "^5.2.0" react-router "6.3.0" +react-router@6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" + integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== + dependencies: + history "^5.2.0" + react@^18.0.0: version "18.0.0" resolved "https://registry.yarnpkg.com/react/-/react-18.0.0.tgz#b468736d1f4a5891f38585ba8e8fb29f91c3cb96" @@ -31089,7 +31114,16 @@ string-template@~0.2.1: resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0= -"string-width-cjs@npm:string-width@^4.2.0", string-width@4.2.3, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@4.2.3, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -31201,7 +31235,14 @@ stringify-object@^3.2.1: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -34218,7 +34259,16 @@ wrangler@^3.67.1: optionalDependencies: fsevents "~2.3.2" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@7.0.0, wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@7.0.0, wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==