diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js new file mode 100644 index 000000000000..230e9ee1fb9e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js @@ -0,0 +1,26 @@ +import * as Sentry from '@sentry/browser'; +import { browserProfilingIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [browserProfilingIntegration()], + tracesSampleRate: 1, + profilesSampleRate: 1, +}); + +function fibonacci(n) { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, forceTransaction: true }, async span => { + fibonacci(30); + + // Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled + await new Promise(resolve => setTimeout(resolve, 21)); + span.end(); +}); diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts new file mode 100644 index 000000000000..35f4e17bec0a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts @@ -0,0 +1,117 @@ +import { expect } from '@playwright/test'; +import type { Event, Profile } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + properEnvelopeRequestParser, + shouldSkipTracingTest, + waitForTransactionRequestOnUrl, +} from '../../../utils/helpers'; + +sentryTest( + 'does not send profile envelope when document-policy is not set', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const req = await waitForTransactionRequestOnUrl(page, url); + const transactionEvent = properEnvelopeRequestParser(req, 0); + const profileEvent = properEnvelopeRequestParser(req, 1); + + expect(transactionEvent).toBeDefined(); + expect(profileEvent).toBeUndefined(); + }, +); + +sentryTest('sends profile envelope in legacy mode', async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + + const req = await waitForTransactionRequestOnUrl(page, url); + const profileEvent = properEnvelopeRequestParser(req, 1); + expect(profileEvent).toBeDefined(); + + const profile = profileEvent.profile; + expect(profileEvent.profile).toBeDefined(); + + expect(profile.samples).toBeDefined(); + expect(profile.stacks).toBeDefined(); + expect(profile.frames).toBeDefined(); + expect(profile.thread_metadata).toBeDefined(); + + // Samples + expect(profile.samples.length).toBeGreaterThanOrEqual(2); + for (const sample of profile.samples) { + expect(typeof sample.elapsed_since_start_ns).toBe('string'); + expect(sample.elapsed_since_start_ns).toMatch(/^\d+$/); // Numeric string + expect(parseInt(sample.elapsed_since_start_ns, 10)).toBeGreaterThanOrEqual(0); + + expect(typeof sample.stack_id).toBe('number'); + expect(sample.stack_id).toBeGreaterThanOrEqual(0); + expect(sample.thread_id).toBe('0'); // Should be main thread + } + + // Stacks + expect(profile.stacks.length).toBeGreaterThan(0); + for (const stack of profile.stacks) { + expect(Array.isArray(stack)).toBe(true); + for (const frameIndex of stack) { + expect(typeof frameIndex).toBe('number'); + expect(frameIndex).toBeGreaterThanOrEqual(0); + expect(frameIndex).toBeLessThan(profile.frames.length); + } + } + + // Frames + expect(profile.frames.length).toBeGreaterThan(0); + for (const frame of profile.frames) { + expect(frame).toHaveProperty('function'); + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + + expect(typeof frame.function).toBe('string'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } + + const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // Function names are minified in minified bundles + expect(functionNames.length).toBeGreaterThan(0); + expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings + } else { + expect(functionNames).toEqual( + expect.arrayContaining([ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startProfileForSpan', + 'startJSSelfProfile', + ]), + ); + } + + expect(profile.thread_metadata).toHaveProperty('0'); + expect(profile.thread_metadata['0']).toHaveProperty('name'); + expect(profile.thread_metadata['0'].name).toBe('main'); + + // Test that profile duration makes sense (should be > 20ms based on test setup) + const startTime = parseInt(profile.samples[0].elapsed_since_start_ns, 10); + const endTime = parseInt(profile.samples[profile.samples.length - 1].elapsed_since_start_ns, 10); + const durationNs = endTime - startTime; + const durationMs = durationNs / 1_000_000; // Convert ns to ms + + // Should be at least 20ms based on our setTimeout(21) in the test + expect(durationMs).toBeGreaterThan(20); +}); diff --git a/dev-packages/browser-integration-tests/utils/fixtures.ts b/dev-packages/browser-integration-tests/utils/fixtures.ts index adebce59edef..7cedc1e4001a 100644 --- a/dev-packages/browser-integration-tests/utils/fixtures.ts +++ b/dev-packages/browser-integration-tests/utils/fixtures.ts @@ -35,6 +35,7 @@ export type TestFixtures = { skipRouteHandler?: boolean; skipDsnRouteHandler?: boolean; handleLazyLoadedFeedback?: boolean; + responseHeaders?: Record; }) => Promise; forceFlushReplay: () => Promise; enableConsole: () => void; @@ -59,7 +60,13 @@ const sentryTest = base.extend({ getLocalTestUrl: ({ page }, use) => { return use( - async ({ testDir, skipRouteHandler = false, skipDsnRouteHandler = false, handleLazyLoadedFeedback = false }) => { + async ({ + testDir, + skipRouteHandler = false, + skipDsnRouteHandler = false, + handleLazyLoadedFeedback = false, + responseHeaders = {}, + }) => { const pagePath = `${TEST_HOST}/index.html`; const tmpDir = path.join(testDir, 'dist', crypto.randomUUID()); @@ -86,7 +93,9 @@ const sentryTest = base.extend({ const file = route.request().url().split('/').pop(); const filePath = path.resolve(tmpDir, `./${file}`); - return fs.existsSync(filePath) ? route.fulfill({ path: filePath }) : route.continue(); + return fs.existsSync(filePath) + ? route.fulfill({ path: filePath, headers: responseHeaders }) + : route.continue(); }); if (handleLazyLoadedFeedback) { diff --git a/dev-packages/browser-integration-tests/utils/generatePlugin.ts b/dev-packages/browser-integration-tests/utils/generatePlugin.ts index ff25097ccf50..bd505473f9b7 100644 --- a/dev-packages/browser-integration-tests/utils/generatePlugin.ts +++ b/dev-packages/browser-integration-tests/utils/generatePlugin.ts @@ -36,6 +36,7 @@ const IMPORTED_INTEGRATION_CDN_BUNDLE_PATHS: Record = { feedbackIntegration: 'feedback', moduleMetadataIntegration: 'modulemetadata', graphqlClientIntegration: 'graphqlclient', + browserProfilingIntegration: 'browserprofiling', // technically, this is not an integration, but let's add it anyway for simplicity makeMultiplexedTransport: 'multiplexedtransport', }; diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 5a9d8a351449..42657ab14731 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -29,6 +29,7 @@ export const envelopeParser = (request: Request | null): unknown[] => { }); }; +// Rather use the `properEnvelopeRequestParser`, as the `envelopeParser` does not follow the envelope spec. export const envelopeRequestParser = (request: Request | null, envelopeIndex = 2): T => { return envelopeParser(request)[envelopeIndex] as T; }; @@ -79,8 +80,12 @@ function getEventAndTraceHeader(envelope: EventEnvelope): EventAndTraceHeader { return [event, trace]; } -export const properEnvelopeRequestParser = (request: Request | null, envelopeIndex = 1): T => { - return properEnvelopeParser(request)[0]?.[envelopeIndex] as T; +export const properEnvelopeRequestParser = ( + request: Request | null, + envelopeItemIndex: number, + envelopeIndex = 1, // 1 is usually the payload of the envelope (0 is the header) +): T => { + return properEnvelopeParser(request)[envelopeItemIndex]?.[envelopeIndex] as T; }; export const properFullEnvelopeRequestParser = (request: Request | null): T => {