Skip to content
Draft
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
@@ -1,5 +1,6 @@
import { Controller, Get, Param } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { getActiveSpan, getIsolationScope, getRootSpan } from '@sentry/nestjs';
import { Queue } from 'bullmq';

@Controller()
Expand All @@ -14,8 +15,18 @@ export class AppController {

@Get('check-isolation')
checkIsolation() {
// This endpoint is called after the processor adds a breadcrumb.
// The test verifies that breadcrumbs from the processor do NOT leak here.
// This endpoint is called after the processor adds a breadcrumb. Streamed spans carry no
// breadcrumbs, so the tests read from this attribute which of the processor's breadcrumbs
// leaked into this request's isolation scope.
const activeSpan = getActiveSpan();
if (activeSpan) {
const breadcrumbs = getIsolationScope().getScopeData().breadcrumbs;
getRootSpan(activeSpan).setAttribute(
'isolation_scope.leaked_breadcrumbs',
breadcrumbs.map(breadcrumb => breadcrumb.message ?? '').filter(message => message.startsWith('leaked-')),
);
}

return { message: 'ok' };
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as Sentry from '@sentry/nestjs';

Sentry.init({
traceLifecycle: 'static',
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';

const APP_NAME = 'nestjs-bullmq';

function waitForProcessSpan(): Promise<unknown> {
return waitForStreamedSpan(APP_NAME, span => span.is_segment && getSpanOp(span) === 'queue.process');
}

/**
* The `/check-isolation` route reports the leaked breadcrumbs it can see as a span attribute,
* because streamed spans carry no breadcrumbs of their own.
*/
async function getLeakedBreadcrumbs(baseURL: string): Promise<unknown> {
const segmentSpanPromise = waitForStreamedSpan(APP_NAME, span => {
return span.is_segment && span.name === 'GET /check-isolation';
});

await fetch(`${baseURL}/check-isolation`);

return (await segmentSpanPromise).attributes['isolation_scope.leaked_breadcrumbs']?.value;
}

test('Sends exception to Sentry on error in @Processor process method', async ({ baseURL }) => {
const errorEventPromise = waitForError('nestjs-bullmq', event => {
const errorEventPromise = waitForError(APP_NAME, event => {
return (
!event.type &&
event.exception?.values?.[0]?.value === 'Test error from BullMQ processor' &&
Expand All @@ -22,43 +42,31 @@ test('Sends exception to Sentry on error in @Processor process method', async ({
});
});

test('Creates a transaction for successful job processing', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => {
return transactionEvent.contexts?.trace?.op === 'queue.process';
test('Creates a segment span for successful job processing', async ({ baseURL }) => {
const spanPromise = waitForStreamedSpan(APP_NAME, span => {
return span.is_segment && getSpanOp(span) === 'queue.process';
});

// Enqueue a job that will succeed
await fetch(`${baseURL}/enqueue/success`);

const transaction = await transactionPromise;
const span = await spanPromise;

expect(transaction.transaction).toBe('test-queue process');
expect(transaction.contexts?.trace?.op).toBe('queue.process');
expect(transaction.contexts?.trace?.origin).toBe('auto.queue.nestjs.bullmq');
// Streamed messaging spans are named `<operation> <destination>`, the other way around from the
// transaction name.
expect(span.name).toBe('process test-queue');
expect(span.attributes['sentry.origin']).toEqual({ value: 'auto.queue.nestjs.bullmq', type: 'string' });
});

test('BullMQ processor breadcrumbs do not leak into subsequent HTTP requests', async ({ baseURL }) => {
const processTransactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => {
return transactionEvent.contexts?.trace?.op === 'queue.process';
});
const processSpanPromise = waitForProcessSpan();

// Enqueue a job that adds a breadcrumb during processing
await fetch(`${baseURL}/enqueue/breadcrumb-test`);

await processTransactionPromise;

const transactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => {
return transactionEvent.transaction === 'GET /check-isolation';
});
await processSpanPromise;

await fetch(`${baseURL}/check-isolation`);

const transaction = await transactionPromise;

const leakedBreadcrumb = (transaction.breadcrumbs || []).find(
(b: any) => b.message === 'leaked-breadcrumb-from-bullmq-processor',
);
expect(leakedBreadcrumb).toBeUndefined();
expect(await getLeakedBreadcrumbs(baseURL!)).not.toContain('leaked-breadcrumb-from-bullmq-processor');
});

// TODO: @OnWorkerEvent('completed') handlers run outside the isolation scope created by process().
Expand All @@ -67,84 +75,45 @@ test('BullMQ processor breadcrumbs do not leak into subsequent HTTP requests', a
test('BullMQ @OnWorkerEvent completed lifecycle breadcrumbs currently leak into subsequent HTTP requests', async ({
baseURL,
}) => {
const processTransactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => {
return transactionEvent.contexts?.trace?.op === 'queue.process';
});
const processSpanPromise = waitForProcessSpan();

// Enqueue a job (the completed event fires right after the job is processed)
await fetch(`${baseURL}/enqueue/lifecycle-breadcrumb-test`);

await processTransactionPromise;

const transactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => {
return transactionEvent.transaction === 'GET /check-isolation';
});

await fetch(`${baseURL}/check-isolation`);

const transaction = await transactionPromise;
await processSpanPromise;

const leakedBreadcrumb = (transaction.breadcrumbs || []).find(
(b: any) => b.message === 'leaked-breadcrumb-from-lifecycle-event',
);
// This SHOULD be toBeUndefined() once lifecycle event isolation is implemented.
expect(leakedBreadcrumb).toBeDefined();
// This SHOULD be not.toContain() once lifecycle event isolation is implemented.
expect(await getLeakedBreadcrumbs(baseURL!)).toContain('leaked-breadcrumb-from-lifecycle-event');
});

// TODO: @OnWorkerEvent('active') handlers run outside the isolation scope created by process().
// Breadcrumbs set there leak into the default isolation scope and appear on subsequent HTTP requests.
test('BullMQ @OnWorkerEvent active lifecycle breadcrumbs currently leak into subsequent HTTP requests', async ({
baseURL,
}) => {
const processTransactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => {
return transactionEvent.contexts?.trace?.op === 'queue.process';
});
const processSpanPromise = waitForProcessSpan();

await fetch(`${baseURL}/enqueue/lifecycle-active-breadcrumb-test`);

await processTransactionPromise;

const transactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => {
return transactionEvent.transaction === 'GET /check-isolation';
});

await fetch(`${baseURL}/check-isolation`);
await processSpanPromise;

const transaction = await transactionPromise;

const leakedBreadcrumb = (transaction.breadcrumbs || []).find(
(b: any) => b.message === 'leaked-breadcrumb-from-active-event',
);
// This SHOULD be toBeUndefined() once lifecycle event isolation is implemented.
expect(leakedBreadcrumb).toBeDefined();
// This SHOULD be not.toContain() once lifecycle event isolation is implemented.
expect(await getLeakedBreadcrumbs(baseURL!)).toContain('leaked-breadcrumb-from-active-event');
});

// TODO: @OnWorkerEvent('failed') handlers run outside the isolation scope created by process().
// Breadcrumbs set there leak into the default isolation scope and appear on subsequent HTTP requests.
test('BullMQ @OnWorkerEvent failed lifecycle breadcrumbs currently leak into subsequent HTTP requests', async ({
baseURL,
}) => {
const processTransactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => {
return transactionEvent.contexts?.trace?.op === 'queue.process';
});
const processSpanPromise = waitForProcessSpan();

await fetch(`${baseURL}/enqueue/lifecycle-failed-breadcrumb-test`);

await processTransactionPromise;

const transactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => {
return transactionEvent.transaction === 'GET /check-isolation';
});

await fetch(`${baseURL}/check-isolation`);

const transaction = await transactionPromise;
await processSpanPromise;

const leakedBreadcrumb = (transaction.breadcrumbs || []).find(
(b: any) => b.message === 'leaked-breadcrumb-from-failed-event',
);
// This SHOULD be toBeUndefined() once lifecycle event isolation is implemented.
expect(leakedBreadcrumb).toBeDefined();
// This SHOULD be not.toContain() once lifecycle event isolation is implemented.
expect(await getLeakedBreadcrumbs(baseURL!)).toContain('leaked-breadcrumb-from-failed-event');
});

// The 'progress' event does NOT leak breadcrumbs — unlike 'active', 'completed', and 'failed',
Expand All @@ -153,24 +122,11 @@ test('BullMQ @OnWorkerEvent failed lifecycle breadcrumbs currently leak into sub
test('BullMQ @OnWorkerEvent progress lifecycle breadcrumbs do not leak into subsequent HTTP requests', async ({
baseURL,
}) => {
const processTransactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => {
return transactionEvent.contexts?.trace?.op === 'queue.process';
});
const processSpanPromise = waitForProcessSpan();

await fetch(`${baseURL}/enqueue/lifecycle-progress-breadcrumb-test`);

await processTransactionPromise;

const transactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => {
return transactionEvent.transaction === 'GET /check-isolation';
});

await fetch(`${baseURL}/check-isolation`);

const transaction = await transactionPromise;
await processSpanPromise;

const leakedBreadcrumb = (transaction.breadcrumbs || []).find(
(b: any) => b.message === 'leaked-breadcrumb-from-progress-event',
);
expect(leakedBreadcrumb).toBeUndefined();
expect(await getLeakedBreadcrumbs(baseURL!)).not.toContain('leaked-breadcrumb-from-progress-event');
});
Loading