Skip to content

Commit

Permalink
feat(vue): Add Pinia plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
onurtemizkan committed Oct 2, 2024
1 parent dafd510 commit 1fa274a
Show file tree
Hide file tree
Showing 9 changed files with 265 additions and 56 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
},
"dependencies": {
"@sentry/vue": "latest || *",
"pinia": "^2.2.3",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
Expand Down
6 changes: 6 additions & 0 deletions dev-packages/e2e-tests/test-applications/vue-3/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,5 +25,8 @@ Sentry.init({
trackComponents: ['ComponentMainView', '<ComponentOneView>'],
});

pinia.use(Sentry.createSentryPiniaPlugin());

app.use(pinia);
app.use(router);
app.mount('#app');
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const router = createRouter({
path: '/components',
component: () => import('../views/ComponentMainView.vue'),
},
{
path: '/cart',
component: () => import('../views/CartView.vue'),
},
],
});

Expand Down
43 changes: 43 additions & 0 deletions dev-packages/e2e-tests/test-applications/vue-3/src/stores/cart.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<template>
<Layout>
<div>
<div style="margin: 1rem 0;">
<PiniaLogo />
</div>

<form @submit.prevent="addItemToCart" data-testid="add-items">
<input id="item-input" type="text" v-model="itemName" />
<button id="item-add">Add</button>
<button id="throw-error" @click="throwError">Throw error</button>
</form>

<form>
<ul data-testid="items">
<li v-for="item in cart.items" :key="item.name">
{{ item.name }} ({{ item.amount }})
<button
@click="cart.removeItem(item.name)"
type="button"
>X</button>
</li>
</ul>

<button
:disabled="!cart.items.length"
@click="clearCart"
type="button"
data-testid="clear"
>Clear the cart</button>
</form>
</div>
</Layout>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
import { useCartStore } from '../stores/cart'
export default defineComponent({
setup() {
const cart = useCartStore()
const itemName = ref('')
function addItemToCart() {
if (!itemName.value) return
cart.addItem(itemName.value)
itemName.value = ''
}
function throwError() {
throw new Error('This is an error')
}
function clearCart() {
if (window.confirm('Are you sure you want to clear the cart?')) {
cart.rawItems = []
}
}
// @ts-ignore
window.stores = { cart }
return {
itemName,
addItemToCart,
cart,
throwError,
clearCart,
}
},
})
</script>

<style scoped>
img {
width: 200px;
}
button,
input {
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}
</style>
32 changes: 32 additions & 0 deletions dev-packages/e2e-tests/test-applications/vue-3/tests/pinia.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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('addItem');
expect(actionBreadcrumb?.level).toBe('info');

const stateContext = error.contexts?.state?.state;

expect(stateContext).toBeDefined();
expect(stateContext?.type).toBe('pinia');
expect(stateContext?.value).toEqual({ rawItems: ['item'] });
});
1 change: 1 addition & 0 deletions packages/vue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { browserTracingIntegration } from './browserTracingIntegration';
export { attachErrorHandler } from './errorhandler';
export { createTracingMixins } from './tracing';
export { vueIntegration } from './integration';
export { createSentryPiniaPlugin } from './pinia';
82 changes: 82 additions & 0 deletions packages/vue/src/pinia.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { addBreadcrumb, getClient, getCurrentScope, getGlobalScope } from '@sentry/core';
import { addNonEnumerableProperty } from '@sentry/utils';

// Inline PiniaPlugin type
type PiniaPlugin = (context: {
store: {
$state: unknown;
$onAction: (callback: (context: { name: string; after: (callback: () => void) => void }) => void) => void;
};
}) => void;

type SentryPiniaPluginOptions = {
attachPiniaState?: boolean;
actionTransformer: (action: any) => any;
stateTransformer: (state: any) => any;
};

export const createSentryPiniaPlugin: (options?: SentryPiniaPluginOptions) => PiniaPlugin = (
options: SentryPiniaPluginOptions = {
attachPiniaState: true,
actionTransformer: action => action,
stateTransformer: state => state,
},
) => {
const plugin: PiniaPlugin = ({ store }) => {
options.attachPiniaState &&
getGlobalScope().addEventProcessor((event, hint) => {
try {
hint.attachments = [
...(hint.attachments || []),
{
filename: 'pinia_state.json',
data: JSON.stringify(store.$state),
},
];
} catch (_) {
// empty
}

return event;
});

store.$onAction(context => {
context.after(() => {
const transformedAction = options.actionTransformer(context.name);

if (typeof transformedAction !== 'undefined' && transformedAction !== null) {
addBreadcrumb({
category: 'action',
message: transformedAction,
level: 'info',
});
}

/* Set latest state to scope */
const transformedState = options.stateTransformer(store.$state);
const scope = getCurrentScope();

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 newStateContext = { state: { type: 'pinia', value: transformedState } };

addNonEnumerableProperty(
newStateContext,
'__sentry_override_normalization_depth__',
3 + // 3 layers for `state.value.transformedState
normalizationDepth, // rest for the actual state
);

scope.setContext('state', newStateContext);
} else {
scope.setContext('state', null);
}
});
});
};

return plugin;
};
Loading

0 comments on commit 1fa274a

Please sign in to comment.