-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(vue): Add Pinia plugin #13841
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(vue): Add Pinia plugin #13841
Changes from 1 commit
1fa274a
c9de1b6
cf1b89f
c8482b9
c7cfa72
ea7ba22
4a78c04
f244aba
a4c3098
02afb2b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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> |
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'] }); | ||
}); |
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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would add an option to turn off creating breadcrumbs. From experience, some apps have a lot of state updates and breadcrumbs might be very spammy. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated: ea7ba22 |
||
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; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we add pinia as a peer dependency as well with major v2?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, added
pinia
as an optional peer dependency. 👍