Skip to content

Commit 7b815bf

Browse files
authored
feat(nuxt): Add Sentry Pinia plugin (#14047)
closes #14039 By adding `trackPinia`, the Pinia store is monitored with Sentry. ```js Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, trackPinia: true }); ``` or with custom options: ```js Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, trackPinia: { actionTransformer: action => `Transformed: ${action}`, }, }); ```
1 parent 6e2c0d1 commit 7b815bf

File tree

8 files changed

+195
-6
lines changed

8 files changed

+195
-6
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<script setup lang="ts">
2+
import { ref } from '#imports'
3+
import { useCartStore } from '~~/stores/cart'
4+
5+
const cart = useCartStore()
6+
7+
const itemName = ref('')
8+
9+
function addItemToCart() {
10+
if (!itemName.value) return
11+
cart.addItem(itemName.value)
12+
itemName.value = ''
13+
}
14+
15+
function throwError() {
16+
throw new Error('This is an error')
17+
}
18+
19+
function clearCart() {
20+
if (window.confirm('Are you sure you want to clear the cart?')) {
21+
cart.rawItems = []
22+
}
23+
}
24+
</script>
25+
26+
<template>
27+
<Layout>
28+
<div>
29+
<div style="margin: 1rem 0;">
30+
<PiniaLogo />
31+
</div>
32+
33+
<form @submit.prevent="addItemToCart" data-testid="add-items">
34+
<input id="item-input" type="text" v-model="itemName" />
35+
<button id="item-add">Add</button>
36+
<button id="throw-error" @click="throwError">Throw error</button>
37+
</form>
38+
39+
<form>
40+
<ul data-testid="items">
41+
<li v-for="item in cart.items" :key="item.name">
42+
{{ item.name }} ({{ item.amount }})
43+
<button
44+
@click="cart.removeItem(item.name)"
45+
type="button"
46+
>X</button>
47+
</li>
48+
</ul>
49+
50+
<button
51+
:disabled="!cart.items.length"
52+
@click="clearCart"
53+
type="button"
54+
data-testid="clear"
55+
>Clear the cart</button>
56+
</form>
57+
</div>
58+
</Layout>
59+
</template>
60+
61+
62+
63+
<style scoped>
64+
img {
65+
width: 200px;
66+
}
67+
68+
button,
69+
input {
70+
margin-right: 0.5rem;
71+
margin-bottom: 0.5rem;
72+
}
73+
</style>

dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export default defineNuxtConfig({
44
compatibilityDate: '2024-04-03',
55
imports: { autoImport: false },
66

7-
modules: ['@sentry/nuxt/module'],
7+
modules: ['@pinia/nuxt', '@sentry/nuxt/module'],
88

99
runtimeConfig: {
1010
public: {

dev-packages/e2e-tests/test-applications/nuxt-4/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"test:assert": "pnpm test"
1515
},
1616
"dependencies": {
17+
"@pinia/nuxt": "^0.5.5",
1718
"@sentry/nuxt": "latest || *",
1819
"nuxt": "^3.13.2"
1920
},

dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts

+7
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,11 @@ Sentry.init({
77
tunnel: `http://localhost:3031/`, // proxy server
88
tracesSampleRate: 1.0,
99
trackComponents: true,
10+
trackPinia: {
11+
actionTransformer: action => `Transformed: ${action}`,
12+
stateTransformer: state => ({
13+
transformed: true,
14+
...state,
15+
}),
16+
},
1017
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { acceptHMRUpdate, defineStore } from '#imports';
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: any, item: any) => {
12+
const existingItem = items.find((it: any) => 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,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('/pinia-cart');
6+
7+
await page.locator('#item-input').fill('item');
8+
await page.locator('#item-add').click();
9+
10+
const errorPromise = waitForError('nuxt-4', 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/nuxt/src/common/types.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import type { init as initNode } from '@sentry/node';
22
import type { SentryRollupPluginOptions } from '@sentry/rollup-plugin';
33
import type { SentryVitePluginOptions } from '@sentry/vite-plugin';
4-
import type { init as initVue } from '@sentry/vue';
4+
import type { createSentryPiniaPlugin, init as initVue } from '@sentry/vue';
55

66
// Omitting 'app' as the Nuxt SDK will add the app instance in the client plugin (users do not have to provide this)
7-
export type SentryNuxtClientOptions = Omit<Parameters<typeof initVue>[0] & object, 'app'>;
7+
export type SentryNuxtClientOptions = Omit<Parameters<typeof initVue>[0] & object, 'app'> & {
8+
/**
9+
* Control if an existing Pinia store should be monitored.
10+
* Set this to `true` to track with default options or provide your custom Pinia plugin options.
11+
*
12+
* This only works if "@pinia/nuxt" is added to the `modules` array.
13+
*
14+
* @default false
15+
*/
16+
trackPinia?: true | Parameters<typeof createSentryPiniaPlugin>[0];
17+
};
18+
819
export type SentryNuxtServerOptions = Omit<Parameters<typeof initNode>[0] & object, 'app'>;
920

1021
type SourceMapsOptions = {

packages/nuxt/src/runtime/plugins/sentry.client.ts

+22-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getClient } from '@sentry/core';
2-
import { browserTracingIntegration, vueIntegration } from '@sentry/vue';
2+
import { consoleSandbox } from '@sentry/utils';
3+
import { browserTracingIntegration, createSentryPiniaPlugin, vueIntegration } from '@sentry/vue';
34
import { defineNuxtPlugin } from 'nuxt/app';
45
import { reportNuxtError } from '../utils';
56

@@ -34,18 +35,36 @@ export default defineNuxtPlugin({
3435
name: 'sentry-client-integrations',
3536
dependsOn: ['sentry-client-config'],
3637
async setup(nuxtApp) {
38+
const sentryClient = getClient();
39+
const clientOptions = sentryClient && sentryClient.getOptions();
40+
3741
// This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", in which case everything inside
3842
// will get tree-shaken away
3943
if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) {
40-
const sentryClient = getClient();
41-
4244
if (sentryClient && '$router' in nuxtApp) {
4345
sentryClient.addIntegration(
4446
browserTracingIntegration({ router: nuxtApp.$router as VueRouter, routeLabel: 'path' }),
4547
);
4648
}
4749
}
4850

51+
if (clientOptions && 'trackPinia' in clientOptions && clientOptions.trackPinia) {
52+
if ('$pinia' in nuxtApp) {
53+
(nuxtApp.$pinia as { use: (plugin: unknown) => void }).use(
54+
// `trackPinia` is an object with custom options or `true` (pass `undefined` to use default options)
55+
createSentryPiniaPlugin(clientOptions.trackPinia === true ? undefined : clientOptions.trackPinia),
56+
);
57+
} else {
58+
clientOptions.debug &&
59+
consoleSandbox(() => {
60+
// eslint-disable-next-line no-console
61+
console.warn(
62+
'[Sentry] You set `trackPinia`, but the Pinia module was not found. Make sure to add `"@pinia/nuxt"` to your modules array.',
63+
);
64+
});
65+
}
66+
}
67+
4968
nuxtApp.hook('app:created', vueApp => {
5069
const sentryClient = getClient();
5170

0 commit comments

Comments
 (0)