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 } from '@nestjs/common';
import { EventsService } from './events.service';
import { reportIsolationScopeOnSpan } from './utils';

@Controller('events')
export class EventsController {
Expand All @@ -15,12 +16,15 @@ export class EventsController {
@Get('emit-multiple')
async emitMultipleEvents() {
await this.eventsService.emitMultipleEvents();
reportIsolationScopeOnSpan();

return { message: 'Events emitted' };
}

@Get('test-isolation')
testIsolation() {
reportIsolationScopeOnSpan();

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,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import * as Sentry from '@sentry/nestjs';
import { reportIsolationScopeOnSpan } from '../utils';

@Injectable()
export class TestEventListener {
Expand All @@ -27,6 +28,7 @@ export class TestEventListener {
@OnEvent('multiple.second')
async handleMultipleEvents(payload: any): Promise<void> {
Sentry.setTag(payload.data, true);
reportIsolationScopeOnSpan();
await new Promise(resolve => setTimeout(resolve, 100));
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/nestjs';
import * as http from 'http';

export function makeHttpRequest(url) {
Expand All @@ -24,3 +25,20 @@ export function makeHttpRequest(url) {
.end();
});
}

/**
* Streamed spans carry no scope data, so the specs read what the isolation scope holds from these
* attributes on the enclosing segment span instead.
*/
export function reportIsolationScopeOnSpan() {
const activeSpan = Sentry.getActiveSpan();
if (!activeSpan) {
return;
}

const scopeData = Sentry.getIsolationScope().getScopeData();
Sentry.getRootSpan(activeSpan).setAttributes({
'isolation_scope.tag_keys': Object.keys(scopeData.tags),
'isolation_scope.breadcrumb_messages': scopeData.breadcrumbs.map(breadcrumb => breadcrumb.message ?? ''),
});
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
import type { SerializedStreamedSpan } from '@sentry-internal/test-utils';
import { waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';

const APP_NAME = 'nestjs-distributed-tracing';

function waitForSegmentSpan(name: string): Promise<SerializedStreamedSpan> {
return waitForStreamedSpan(APP_NAME, span => span.is_segment && span.name === name);
}

test('Event emitter', async () => {
const eventErrorPromise = waitForError('nestjs-distributed-tracing', errorEvent => {
const eventErrorPromise = waitForError(APP_NAME, errorEvent => {
return errorEvent.exception.values[0].value === 'Test error from event handler';
});
const successEventTransactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => {
return transactionEvent.transaction === 'event myEvent.pass';
});
const successEventSpanPromise = waitForSegmentSpan('event myEvent.pass');

const eventsUrl = `http://localhost:3050/events/emit`;
await fetch(eventsUrl);

const eventError = await eventErrorPromise;
const successEventTransaction = await successEventTransactionPromise;
const successEventSpan = await successEventSpanPromise;

expect(eventError.exception).toEqual({
values: [
Expand All @@ -29,18 +34,15 @@ test('Event emitter', async () => {
],
});

expect(successEventTransaction.contexts.trace).toEqual({
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
data: {
'sentry.segment.name.source': 'custom',
'sentry.op': 'function',
'sentry.origin': 'auto.event.nestjs',
},
origin: 'auto.event.nestjs',
op: 'function',
expect(successEventSpan).toMatchObject({
is_segment: true,
status: 'ok',
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
attributes: expect.objectContaining({
'sentry.op': { type: 'string', value: 'function' },
'sentry.origin': { type: 'string', value: 'auto.event.nestjs' },
'sentry.segment.name.source': { type: 'string', value: 'custom' },
}),
});
});

Expand All @@ -52,44 +54,47 @@ test('Event handler breadcrumbs do not leak into subsequent HTTP requests', asyn
// Wait for at least one setInterval tick to fire and add the breadcrumb
await new Promise(resolve => setTimeout(resolve, 3000));

const transactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => {
return transactionEvent.transaction === 'GET /events/test-isolation';
});
const segmentSpanPromise = waitForSegmentSpan('GET /events/test-isolation');

await fetch('http://localhost:3050/events/test-isolation');

const transaction = await transactionPromise;
const segmentSpan = await segmentSpanPromise;

const leakedBreadcrumb = (transaction.breadcrumbs || []).find(
(b: any) => b.message === 'leaked-breadcrumb-from-event-handler',
// Streamed spans carry no breadcrumbs, so the route reports its isolation scope as an attribute
expect(segmentSpan.attributes['isolation_scope.breadcrumb_messages']?.value).not.toContain(
'leaked-breadcrumb-from-event-handler',
);
expect(leakedBreadcrumb).toBeUndefined();
});

test('Multiple OnEvent decorators', async () => {
const firstTxPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => {
return transactionEvent.transaction === 'event multiple.first|multiple.second';
});
const secondTxPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => {
return transactionEvent.transaction === 'event multiple.first|multiple.second';
});
const rootPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => {
return transactionEvent.transaction === 'GET /events/emit-multiple';
// Both handler invocations produce a segment span of the same name in traces of their own, so
// they are accumulated rather than awaited one by one - two `waitFor` calls would both resolve
// with whichever span arrives first.
const streamedSpans: SerializedStreamedSpan[] = [];
void waitForStreamedSpans(APP_NAME, spans => {
streamedSpans.push(...spans);
return false;
});

const rootSpanPromise = waitForSegmentSpan('GET /events/emit-multiple');

const eventsUrl = `http://localhost:3050/events/emit-multiple`;
await fetch(eventsUrl);

const firstTx = await firstTxPromise;
const secondTx = await secondTxPromise;
const rootTx = await rootPromise;
const rootSpan = await rootSpanPromise;

expect(firstTx).toBeDefined();
expect(secondTx).toBeDefined();
const findHandlerSpans = () =>
streamedSpans.filter(span => span.is_segment && span.name === 'event multiple.first|multiple.second');
await expect.poll(() => findHandlerSpans().length).toBe(2);

// Streamed spans carry no scope tags, so the app reports its isolation scope as an attribute.
// The tags belong to the event handlers' isolation scopes, not to the root HTTP request's.
const handlerTagKeys = findHandlerSpans().flatMap(
span => (span.attributes['isolation_scope.tag_keys']?.value as string[]) ?? [],
);
expect(handlerTagKeys).toEqual(expect.arrayContaining(['test-first', 'test-second']));

// Tags should be on the event handler transactions, not the root HTTP transaction
expect(firstTx.tags?.['test-first'] || firstTx.tags?.['test-second']).toBe(true);
expect(secondTx.tags?.['test-first'] || secondTx.tags?.['test-second']).toBe(true);
expect(rootTx.tags?.['test-first']).toBeUndefined();
expect(rootTx.tags?.['test-second']).toBeUndefined();
const rootTagKeys = rootSpan.attributes['isolation_scope.tag_keys']?.value as string[];
expect(rootTagKeys).not.toContain('test-first');
expect(rootTagKeys).not.toContain('test-second');
});
Loading
Loading