Skip to content

Commit 23d8d11

Browse files
committed
feat(vue): Add Pinia plugin
1 parent 35bdc87 commit 23d8d11

File tree

9 files changed

+265
-56
lines changed

9 files changed

+265
-56
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

+6
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,8 @@ Sentry.init({
2225
trackComponents: ['ComponentMainView', '<ComponentOneView>'],
2326
});
2427

28+
pinia.use(Sentry.createSentryPiniaPlugin());
29+
30+
app.use(pinia);
2531
app.use(router);
2632
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,32 @@
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('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({ rawItems: ['item'] });
32+
});

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

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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+
$state: unknown;
8+
$onAction: (callback: (context: { name: string; after: (callback: () => void) => void }) => void) => void;
9+
};
10+
}) => void;
11+
12+
type SentryPiniaPluginOptions = {
13+
attachPiniaState?: boolean;
14+
actionTransformer: (action: any) => any;
15+
stateTransformer: (state: any) => any;
16+
};
17+
18+
export const createSentryPiniaPlugin: (options?: SentryPiniaPluginOptions) => PiniaPlugin = (
19+
options: SentryPiniaPluginOptions = {
20+
attachPiniaState: true,
21+
actionTransformer: action => action,
22+
stateTransformer: state => state,
23+
},
24+
) => {
25+
const plugin: PiniaPlugin = ({ store }) => {
26+
options.attachPiniaState &&
27+
getGlobalScope().addEventProcessor((event, hint) => {
28+
try {
29+
hint.attachments = [
30+
...(hint.attachments || []),
31+
{
32+
filename: 'pinia_state.json',
33+
data: JSON.stringify(store.$state),
34+
},
35+
];
36+
} catch (_) {
37+
// empty
38+
}
39+
40+
return event;
41+
});
42+
43+
store.$onAction(context => {
44+
context.after(() => {
45+
const transformedAction = options.actionTransformer(context.name);
46+
47+
if (typeof transformedAction !== 'undefined' && transformedAction !== null) {
48+
addBreadcrumb({
49+
category: 'action',
50+
message: transformedAction,
51+
level: 'info',
52+
});
53+
}
54+
55+
/* Set latest state to scope */
56+
const transformedState = options.stateTransformer(store.$state);
57+
const scope = getCurrentScope();
58+
59+
if (typeof transformedState !== 'undefined' && transformedState !== null) {
60+
const client = getClient();
61+
const options = client && client.getOptions();
62+
const normalizationDepth = (options && options.normalizeDepth) || 3; // default state normalization depth to 3
63+
64+
const newStateContext = { state: { type: 'pinia', value: transformedState } };
65+
66+
addNonEnumerableProperty(
67+
newStateContext,
68+
'__sentry_override_normalization_depth__',
69+
3 + // 3 layers for `state.value.transformedState
70+
normalizationDepth, // rest for the actual state
71+
);
72+
73+
scope.setContext('state', newStateContext);
74+
} else {
75+
scope.setContext('state', null);
76+
}
77+
});
78+
});
79+
};
80+
81+
return plugin;
82+
};

0 commit comments

Comments
 (0)