Skip to content

Commit 1877473

Browse files
onurtemizkanbillyvg
authored andcommitted
feat(vue): Add Pinia plugin (#13841)
Resolves: #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: getsentry/sentry-docs#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.
1 parent e577eb7 commit 1877473

File tree

10 files changed

+352
-7
lines changed

10 files changed

+352
-7
lines changed

dev-packages/e2e-tests/test-applications/vue-3/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
},
1717
"dependencies": {
1818
"@sentry/vue": "latest || *",
19+
"pinia": "^2.2.3",
1920
"vue": "^3.4.15",
2021
"vue-router": "^4.2.5"
2122
},

dev-packages/e2e-tests/test-applications/vue-3/src/main.ts

+14
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import { createApp } from 'vue';
44
import App from './App.vue';
55
import router from './router';
66

7+
import { createPinia } from 'pinia';
8+
79
import * as Sentry from '@sentry/vue';
810
import { browserTracingIntegration } from '@sentry/vue';
911

1012
const app = createApp(App);
13+
const pinia = createPinia();
1114

1215
Sentry.init({
1316
app,
@@ -22,5 +25,16 @@ Sentry.init({
2225
trackComponents: ['ComponentMainView', '<ComponentOneView>'],
2326
});
2427

28+
pinia.use(
29+
Sentry.createSentryPiniaPlugin({
30+
actionTransformer: action => `Transformed: ${action}`,
31+
stateTransformer: state => ({
32+
transformed: true,
33+
...state,
34+
}),
35+
}),
36+
);
37+
38+
app.use(pinia);
2539
app.use(router);
2640
app.mount('#app');

dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ const router = createRouter({
3434
path: '/components',
3535
component: () => import('../views/ComponentMainView.vue'),
3636
},
37+
{
38+
path: '/cart',
39+
component: () => import('../views/CartView.vue'),
40+
},
3741
],
3842
});
3943

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { acceptHMRUpdate, defineStore } from 'pinia';
2+
3+
export const useCartStore = defineStore({
4+
id: 'cart',
5+
state: () => ({
6+
rawItems: [] as string[],
7+
}),
8+
getters: {
9+
items: (state): Array<{ name: string; amount: number }> =>
10+
state.rawItems.reduce(
11+
(items, item) => {
12+
const existingItem = items.find(it => it.name === item);
13+
14+
if (!existingItem) {
15+
items.push({ name: item, amount: 1 });
16+
} else {
17+
existingItem.amount++;
18+
}
19+
20+
return items;
21+
},
22+
[] as Array<{ name: string; amount: number }>,
23+
),
24+
},
25+
actions: {
26+
addItem(name: string) {
27+
this.rawItems.push(name);
28+
},
29+
30+
removeItem(name: string) {
31+
const i = this.rawItems.lastIndexOf(name);
32+
if (i > -1) this.rawItems.splice(i, 1);
33+
},
34+
35+
throwError() {
36+
throw new Error('error');
37+
},
38+
},
39+
});
40+
41+
if (import.meta.hot) {
42+
import.meta.hot.accept(acceptHMRUpdate(useCartStore, import.meta.hot));
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<template>
2+
<Layout>
3+
<div>
4+
<div style="margin: 1rem 0;">
5+
<PiniaLogo />
6+
</div>
7+
8+
<form @submit.prevent="addItemToCart" data-testid="add-items">
9+
<input id="item-input" type="text" v-model="itemName" />
10+
<button id="item-add">Add</button>
11+
<button id="throw-error" @click="throwError">Throw error</button>
12+
</form>
13+
14+
<form>
15+
<ul data-testid="items">
16+
<li v-for="item in cart.items" :key="item.name">
17+
{{ item.name }} ({{ item.amount }})
18+
<button
19+
@click="cart.removeItem(item.name)"
20+
type="button"
21+
>X</button>
22+
</li>
23+
</ul>
24+
25+
<button
26+
:disabled="!cart.items.length"
27+
@click="clearCart"
28+
type="button"
29+
data-testid="clear"
30+
>Clear the cart</button>
31+
</form>
32+
</div>
33+
</Layout>
34+
</template>
35+
36+
<script lang="ts">
37+
import { defineComponent, ref } from 'vue'
38+
import { useCartStore } from '../stores/cart'
39+
40+
41+
export default defineComponent({
42+
setup() {
43+
const cart = useCartStore()
44+
45+
const itemName = ref('')
46+
47+
function addItemToCart() {
48+
if (!itemName.value) return
49+
cart.addItem(itemName.value)
50+
itemName.value = ''
51+
}
52+
53+
function throwError() {
54+
throw new Error('This is an error')
55+
}
56+
57+
function clearCart() {
58+
if (window.confirm('Are you sure you want to clear the cart?')) {
59+
cart.rawItems = []
60+
}
61+
}
62+
63+
// @ts-ignore
64+
window.stores = { cart }
65+
66+
return {
67+
itemName,
68+
addItemToCart,
69+
cart,
70+
71+
throwError,
72+
clearCart,
73+
}
74+
},
75+
})
76+
</script>
77+
78+
<style scoped>
79+
img {
80+
width: 200px;
81+
}
82+
83+
button,
84+
input {
85+
margin-right: 0.5rem;
86+
margin-bottom: 0.5rem;
87+
}
88+
</style>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError } from '@sentry-internal/test-utils';
3+
4+
test('sends pinia action breadcrumbs and state context', async ({ page }) => {
5+
await page.goto('/cart');
6+
7+
await page.locator('#item-input').fill('item');
8+
await page.locator('#item-add').click();
9+
10+
const errorPromise = waitForError('vue-3', async errorEvent => {
11+
return errorEvent?.exception?.values?.[0].value === 'This is an error';
12+
});
13+
14+
await page.locator('#throw-error').click();
15+
16+
const error = await errorPromise;
17+
18+
expect(error).toBeTruthy();
19+
expect(error.breadcrumbs?.length).toBeGreaterThan(0);
20+
21+
const actionBreadcrumb = error.breadcrumbs?.find(breadcrumb => breadcrumb.category === 'action');
22+
23+
expect(actionBreadcrumb).toBeDefined();
24+
expect(actionBreadcrumb?.message).toBe('Transformed: addItem');
25+
expect(actionBreadcrumb?.level).toBe('info');
26+
27+
const stateContext = error.contexts?.state?.state;
28+
29+
expect(stateContext).toBeDefined();
30+
expect(stateContext?.type).toBe('pinia');
31+
expect(stateContext?.value).toEqual({
32+
transformed: true,
33+
rawItems: ['item'],
34+
});
35+
});

packages/vue/package.json

+7-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,13 @@
4545
"@sentry/utils": "8.34.0"
4646
},
4747
"peerDependencies": {
48-
"vue": "2.x || 3.x"
48+
"vue": "2.x || 3.x",
49+
"pinia": "2.x"
50+
},
51+
"peerDependenciesMeta": {
52+
"pinia": {
53+
"optional": true
54+
}
4955
},
5056
"devDependencies": {
5157
"vue": "~3.2.41"

packages/vue/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { browserTracingIntegration } from './browserTracingIntegration';
55
export { attachErrorHandler } from './errorhandler';
66
export { createTracingMixins } from './tracing';
77
export { vueIntegration } from './integration';
8+
export { createSentryPiniaPlugin } from './pinia';

packages/vue/src/pinia.ts

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { addBreadcrumb, getClient, getCurrentScope, getGlobalScope } from '@sentry/core';
2+
import { addNonEnumerableProperty } from '@sentry/utils';
3+
4+
// Inline PiniaPlugin type
5+
type PiniaPlugin = (context: {
6+
store: {
7+
$id: string;
8+
$state: unknown;
9+
$onAction: (callback: (context: { name: string; after: (callback: () => void) => void }) => void) => void;
10+
};
11+
}) => void;
12+
13+
type SentryPiniaPluginOptions = {
14+
attachPiniaState?: boolean;
15+
addBreadcrumbs?: boolean;
16+
actionTransformer?: (action: any) => any;
17+
stateTransformer?: (state: any) => any;
18+
};
19+
20+
export const createSentryPiniaPlugin: (options?: SentryPiniaPluginOptions) => PiniaPlugin = (
21+
options: SentryPiniaPluginOptions = {
22+
attachPiniaState: true,
23+
addBreadcrumbs: true,
24+
actionTransformer: action => action,
25+
stateTransformer: state => state,
26+
},
27+
) => {
28+
const plugin: PiniaPlugin = ({ store }) => {
29+
options.attachPiniaState !== false &&
30+
getGlobalScope().addEventProcessor((event, hint) => {
31+
try {
32+
// Get current timestamp in hh:mm:ss
33+
const timestamp = new Date().toTimeString().split(' ')[0];
34+
const filename = `pinia_state_${store.$id}_${timestamp}.json`;
35+
36+
hint.attachments = [
37+
...(hint.attachments || []),
38+
{
39+
filename,
40+
data: JSON.stringify(store.$state),
41+
},
42+
];
43+
} catch (_) {
44+
// empty
45+
}
46+
47+
return event;
48+
});
49+
50+
store.$onAction(context => {
51+
context.after(() => {
52+
const transformedActionName = options.actionTransformer
53+
? options.actionTransformer(context.name)
54+
: context.name;
55+
56+
if (
57+
typeof transformedActionName !== 'undefined' &&
58+
transformedActionName !== null &&
59+
options.addBreadcrumbs !== false
60+
) {
61+
addBreadcrumb({
62+
category: 'action',
63+
message: transformedActionName,
64+
level: 'info',
65+
});
66+
}
67+
68+
/* Set latest state to scope */
69+
const transformedState = options.stateTransformer ? options.stateTransformer(store.$state) : store.$state;
70+
const scope = getCurrentScope();
71+
const currentState = scope.getScopeData().contexts.state;
72+
73+
if (typeof transformedState !== 'undefined' && transformedState !== null) {
74+
const client = getClient();
75+
const options = client && client.getOptions();
76+
const normalizationDepth = (options && options.normalizeDepth) || 3; // default state normalization depth to 3
77+
const piniaStateContext = { type: 'pinia', value: transformedState };
78+
79+
const newState = {
80+
...(currentState || {}),
81+
state: piniaStateContext,
82+
};
83+
84+
addNonEnumerableProperty(
85+
newState,
86+
'__sentry_override_normalization_depth__',
87+
3 + // 3 layers for `state.value.transformedState
88+
normalizationDepth, // rest for the actual state
89+
);
90+
91+
scope.setContext('state', newState);
92+
} else {
93+
scope.setContext('state', {
94+
...(currentState || {}),
95+
state: { type: 'pinia', value: 'undefined' },
96+
});
97+
}
98+
});
99+
});
100+
};
101+
102+
return plugin;
103+
};

0 commit comments

Comments
 (0)