Skip to content

feat(pinia)!: Include state of all stores in breadcrumb #15312

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

Merged
merged 2 commits into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Sentry.init({
tracesSampleRate: 1.0,
integrations: [
Sentry.piniaIntegration(usePinia(), {
actionTransformer: action => `Transformed: ${action}`,
actionTransformer: action => `${action}.transformed`,
stateTransformer: state => ({
transformed: true,
...state,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ test('sends pinia action breadcrumbs and state context', async ({ page }) => {
expect(error).toBeTruthy();
expect(error.breadcrumbs?.length).toBeGreaterThan(0);

const actionBreadcrumb = error.breadcrumbs?.find(breadcrumb => breadcrumb.category === 'action');
const actionBreadcrumb = error.breadcrumbs?.find(breadcrumb => breadcrumb.category === 'pinia.action');

expect(actionBreadcrumb).toBeDefined();
expect(actionBreadcrumb?.message).toBe('Transformed: addItem');
expect(actionBreadcrumb?.message).toBe('Store: cart | Action: addItem.transformed');
expect(actionBreadcrumb?.level).toBe('info');

const stateContext = error.contexts?.state?.state;
Expand All @@ -30,6 +30,6 @@ test('sends pinia action breadcrumbs and state context', async ({ page }) => {
expect(stateContext?.type).toBe('pinia');
expect(stateContext?.value).toEqual({
transformed: true,
rawItems: ['item'],
cart: { rawItems: ['item'] },
});
});
2 changes: 1 addition & 1 deletion dev-packages/e2e-tests/test-applications/vue-3/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Sentry.init({

pinia.use(
Sentry.createSentryPiniaPlugin({
actionTransformer: action => `Transformed: ${action}`,
actionTransformer: action => `${action}.transformed`,
stateTransformer: state => ({
transformed: true,
...state,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { acceptHMRUpdate, defineStore } from 'pinia';

export const useCartStore = defineStore({
id: 'cart',
export const useCartStore = defineStore('cart', {
state: () => ({
rawItems: [] as string[],
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
state: () => ({ name: 'Counter Store', count: 0 }),
actions: {
increment() {
this.count++;
},
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,50 +29,45 @@
data-testid="clear"
>Clear the cart</button>
</form>

<br/>

<div>
<h3>Counter: {{ $counter.count }}</h3>
<button @click="$counter.increment">+</button>
</div>
</div>
</Layout>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
<script setup lang="ts">
import { ref } from 'vue'
import { useCartStore } from '../stores/cart'
import { useCounterStore } from '@/stores/counter';

const cart = useCartStore()
const $counter = useCounterStore()

export default defineComponent({
setup() {
const cart = useCartStore()

const itemName = ref('')

function addItemToCart() {
if (!itemName.value) return
cart.addItem(itemName.value)
itemName.value = ''
}
const itemName = ref('')

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 = []
}
}
function addItemToCart() {
if (!itemName.value) return
cart.addItem(itemName.value)
itemName.value = ''
}

// @ts-ignore
window.stores = { cart }
function throwError() {
throw new Error('This is an error')
}

return {
itemName,
addItemToCart,
cart,
function clearCart() {
if (window.confirm('Are you sure you want to clear the cart?')) {
cart.rawItems = []
}
}

throwError,
clearCart,
}
},
})
// @ts-ignore
window.stores = { cart }
</script>

<style scoped>
Expand Down
75 changes: 72 additions & 3 deletions dev-packages/e2e-tests/test-applications/vue-3/tests/pinia.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ test('sends pinia action breadcrumbs and state context', async ({ page }) => {
expect(error).toBeTruthy();
expect(error.breadcrumbs?.length).toBeGreaterThan(0);

const actionBreadcrumb = error.breadcrumbs?.find(breadcrumb => breadcrumb.category === 'action');
const actionBreadcrumb = error.breadcrumbs?.find(breadcrumb => breadcrumb.category === 'pinia.action');

expect(actionBreadcrumb).toBeDefined();
expect(actionBreadcrumb?.message).toBe('Transformed: addItem');
expect(actionBreadcrumb?.message).toBe('Store: cart | Action: addItem.transformed');
expect(actionBreadcrumb?.level).toBe('info');

const stateContext = error.contexts?.state?.state;
Expand All @@ -30,6 +30,75 @@ test('sends pinia action breadcrumbs and state context', async ({ page }) => {
expect(stateContext?.type).toBe('pinia');
expect(stateContext?.value).toEqual({
transformed: true,
rawItems: ['item'],
cart: {
rawItems: ['item'],
},
counter: {
count: 0,
name: 'Counter Store',
},
});
});

test('state transformer receives full state object and is stored in state context', async ({ page }) => {
await page.goto('/cart');

await page.locator('#item-input').fill('multiple store test');
await page.locator('#item-add').click();

await page.locator('button:text("+")').click();
await page.locator('button:text("+")').click();
await page.locator('button:text("+")').click();

await page.locator('#item-input').fill('multiple store pinia');
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;

// Verify stateTransformer was called with full state and modified state
const stateContext = error.contexts?.state?.state;

expect(stateContext?.value).toEqual({
transformed: true,
cart: {
rawItems: ['multiple store test', 'multiple store pinia'],
},
counter: {
name: 'Counter Store',
count: 3,
},
});
});

test('different store interaction order maintains full state tracking', async ({ page }) => {
await page.goto('/cart');

await page.locator('button:text("+")').click();

await page.locator('#item-input').fill('order test item');
await page.locator('#item-add').click();

await page.locator('button:text("+")').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;

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

expect(stateContext).toBeDefined();

const stateValue = stateContext?.value;
expect(stateValue.cart.rawItems).toEqual(['order test item']);
expect(stateValue.counter.count).toBe(2);
});
2 changes: 2 additions & 0 deletions docs/migration/v8-to-v9.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,10 +357,12 @@ All exports and APIs of `@sentry/utils` and `@sentry/types` (except for the ones
```

- The option `logErrors` in the `vueIntegration` has been removed. The Sentry Vue error handler will always propagate the error to a user-defined error handler or re-throw the error (which will log the error without modifying).
- The option `stateTransformer` in `createSentryPiniaPlugin()` now receives the full state from all stores as its parameter. The top-level keys of the state object are the store IDs.

### `@sentry/nuxt`

- The `tracingOptions` option in `Sentry.init()` was removed in favor of passing the `vueIntegration()` to `Sentry.init({ integrations: [...] })` and setting `tracingOptions` there.
- The option `stateTransformer` in the `piniaIntegration` now receives the full state from all stores as its parameter. The top-level keys of the state object are the store IDs.

### `@sentry/vue` and `@sentry/nuxt`

Expand Down
34 changes: 24 additions & 10 deletions packages/vue/src/pinia.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { addBreadcrumb, addNonEnumerableProperty, getClient, getCurrentScope, getGlobalScope } from '@sentry/core';
import type { Ref } from 'vue';

// Inline PiniaPlugin type
// Inline Pinia types
type StateTree = Record<string | number | symbol, any>;
type PiniaPlugin = (context: {
store: {
$id: string;
$state: unknown;
$onAction: (callback: (context: { name: string; after: (callback: () => void) => void }) => void) => void;
};
pinia: { state: Ref<Record<string, StateTree>> };
}) => void;

type SentryPiniaPluginOptions = {
attachPiniaState?: boolean;
addBreadcrumbs?: boolean;
actionTransformer?: (action: any) => any;
stateTransformer?: (state: any) => any;
actionTransformer?: (action: string) => any;
stateTransformer?: (state: Record<string, unknown>) => any;
};

export const createSentryPiniaPlugin: (options?: SentryPiniaPluginOptions) => PiniaPlugin = (
Expand All @@ -24,19 +27,29 @@ export const createSentryPiniaPlugin: (options?: SentryPiniaPluginOptions) => Pi
stateTransformer: state => state,
},
) => {
const plugin: PiniaPlugin = ({ store }) => {
const plugin: PiniaPlugin = ({ store, pinia }) => {
const getAllStoreStates = (): Record<string, unknown> => {
const states: Record<string, unknown> = {};

Object.keys(pinia.state.value).forEach(storeId => {
states[storeId] = pinia.state.value[storeId];
});

return states;
};

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`;
const filename = `pinia_state_all_stores_${timestamp}.json`;

hint.attachments = [
...(hint.attachments || []),
{
filename,
data: JSON.stringify(store.$state),
data: JSON.stringify(getAllStoreStates()),
},
];
} catch (_) {
Expand All @@ -58,14 +71,15 @@ export const createSentryPiniaPlugin: (options?: SentryPiniaPluginOptions) => Pi
options.addBreadcrumbs !== false
) {
addBreadcrumb({
category: 'action',
message: transformedActionName,
category: 'pinia.action',
message: `Store: ${store.$id} | Action: ${transformedActionName}`,
level: 'info',
});
}

/* Set latest state to scope */
const transformedState = options.stateTransformer ? options.stateTransformer(store.$state) : store.$state;
/* Set latest state of all stores to scope */
const allStates = getAllStoreStates();
const transformedState = options.stateTransformer ? options.stateTransformer(allStates) : allStates;
const scope = getCurrentScope();
const currentState = scope.getScopeData().contexts.state;

Expand Down
Loading