Skip to content

Commit

Permalink
feat(nuxt): Add Sentry Pinia plugin (#14047)
Browse files Browse the repository at this point in the history
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}`,
  },
});
```
  • Loading branch information
s1gr1d authored Oct 22, 2024
1 parent 6e2c0d1 commit 7b815bf
Show file tree
Hide file tree
Showing 8 changed files with 195 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref } from '#imports'
import { useCartStore } from '~~/stores/cart'
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 = []
}
}
</script>

<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>



<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
Expand Up @@ -4,7 +4,7 @@ export default defineNuxtConfig({
compatibilityDate: '2024-04-03',
imports: { autoImport: false },

modules: ['@sentry/nuxt/module'],
modules: ['@pinia/nuxt', '@sentry/nuxt/module'],

runtimeConfig: {
public: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@pinia/nuxt": "^0.5.5",
"@sentry/nuxt": "latest || *",
"nuxt": "^3.13.2"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,11 @@ Sentry.init({
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
trackComponents: true,
trackPinia: {
actionTransformer: action => `Transformed: ${action}`,
stateTransformer: state => ({
transformed: true,
...state,
}),
},
});
43 changes: 43 additions & 0 deletions dev-packages/e2e-tests/test-applications/nuxt-4/stores/cart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { acceptHMRUpdate, defineStore } from '#imports';

export const useCartStore = defineStore({
id: 'cart',
state: () => ({
rawItems: [] as string[],
}),
getters: {
items: (state): Array<{ name: string; amount: number }> =>
state.rawItems.reduce(
(items: any, item: any) => {
const existingItem = items.find((it: any) => 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,35 @@
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('/pinia-cart');

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

const errorPromise = waitForError('nuxt-4', 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('Transformed: addItem');
expect(actionBreadcrumb?.level).toBe('info');

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

expect(stateContext).toBeDefined();
expect(stateContext?.type).toBe('pinia');
expect(stateContext?.value).toEqual({
transformed: true,
rawItems: ['item'],
});
});
15 changes: 13 additions & 2 deletions packages/nuxt/src/common/types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import type { init as initNode } from '@sentry/node';
import type { SentryRollupPluginOptions } from '@sentry/rollup-plugin';
import type { SentryVitePluginOptions } from '@sentry/vite-plugin';
import type { init as initVue } from '@sentry/vue';
import type { createSentryPiniaPlugin, init as initVue } from '@sentry/vue';

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

export type SentryNuxtServerOptions = Omit<Parameters<typeof initNode>[0] & object, 'app'>;

type SourceMapsOptions = {
Expand Down
25 changes: 22 additions & 3 deletions packages/nuxt/src/runtime/plugins/sentry.client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getClient } from '@sentry/core';
import { browserTracingIntegration, vueIntegration } from '@sentry/vue';
import { consoleSandbox } from '@sentry/utils';
import { browserTracingIntegration, createSentryPiniaPlugin, vueIntegration } from '@sentry/vue';
import { defineNuxtPlugin } from 'nuxt/app';
import { reportNuxtError } from '../utils';

Expand Down Expand Up @@ -34,18 +35,36 @@ export default defineNuxtPlugin({
name: 'sentry-client-integrations',
dependsOn: ['sentry-client-config'],
async setup(nuxtApp) {
const sentryClient = getClient();
const clientOptions = sentryClient && sentryClient.getOptions();

// This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", in which case everything inside
// will get tree-shaken away
if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) {
const sentryClient = getClient();

if (sentryClient && '$router' in nuxtApp) {
sentryClient.addIntegration(
browserTracingIntegration({ router: nuxtApp.$router as VueRouter, routeLabel: 'path' }),
);
}
}

if (clientOptions && 'trackPinia' in clientOptions && clientOptions.trackPinia) {
if ('$pinia' in nuxtApp) {
(nuxtApp.$pinia as { use: (plugin: unknown) => void }).use(
// `trackPinia` is an object with custom options or `true` (pass `undefined` to use default options)
createSentryPiniaPlugin(clientOptions.trackPinia === true ? undefined : clientOptions.trackPinia),
);
} else {
clientOptions.debug &&
consoleSandbox(() => {
// eslint-disable-next-line no-console
console.warn(
'[Sentry] You set `trackPinia`, but the Pinia module was not found. Make sure to add `"@pinia/nuxt"` to your modules array.',
);
});
}
}

nuxtApp.hook('app:created', vueApp => {
const sentryClient = getClient();

Expand Down

0 comments on commit 7b815bf

Please sign in to comment.