diff --git a/packages/plugins/injection/src/xpack.ts b/packages/plugins/injection/src/xpack.ts index a97d7a24e..951d5df60 100644 --- a/packages/plugins/injection/src/xpack.ts +++ b/packages/plugins/injection/src/xpack.ts @@ -91,10 +91,16 @@ export const getXpackPlugin = // We need to prepare the injections before the build starts. // Otherwise they'll be empty once resolved. - compiler.hooks.beforeRun.tapPromise(PLUGIN_NAME, async () => { + const setupInjections = async () => { // Prepare the injections. await addInjections(log, toInject, contentsToInject, context.buildRoot); - }); + }; + + // For one-time builds (production mode) + compiler.hooks.beforeRun.tapPromise(PLUGIN_NAME, setupInjections); + + // For watch mode / dev server (webpack dev mode) + compiler.hooks.watchRun.tapPromise(PLUGIN_NAME, setupInjections); // Handle the InjectPosition.START and InjectPosition.END. // This is a re-implementation of the BannerPlugin, diff --git a/packages/plugins/rum/src/getSourceCodeContextSnippet.ts b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts new file mode 100644 index 000000000..036b02fe1 --- /dev/null +++ b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts @@ -0,0 +1,24 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { SourceCodeContextOptions } from './types'; + +export const DEFAULT_SOURCE_CODE_CONTEXT_VARIABLE = 'DD_SOURCE_CODE_CONTEXT' as const; + +// The source code context snippet - single injection with function definition and call +// SSR-safe: checks window before accessing, never throws +// +// Unminified version: +// (function(c, n) { +// try { +// if (typeof window === 'undefined') return; +// var w = window, +// m = w[n] = w[n] || {}, +// s = new Error().stack; +// s && (m[s] = c) +// } catch (e) {} +// })(context, variableName); +export const getSourceCodeContextSnippet = (context: SourceCodeContextOptions): string => { + return `(function(c,n){try{if(typeof window==='undefined')return;var w=window,m=w[n]=w[n]||{},s=new Error().stack;s&&(m[s]=c)}catch(e){}})(${JSON.stringify(context)},${JSON.stringify(DEFAULT_SOURCE_CODE_CONTEXT_VARIABLE)});`; +}; diff --git a/packages/plugins/rum/src/index.test.ts b/packages/plugins/rum/src/index.test.ts index fb9992717..8fe18ad6d 100644 --- a/packages/plugins/rum/src/index.test.ts +++ b/packages/plugins/rum/src/index.test.ts @@ -21,12 +21,14 @@ describe('RUM Plugin', () => { const injections = { 'browser-sdk': path.resolve('../plugins/rum/src/rum-browser-sdk.js'), 'sdk-init': injectionValue, + 'source-code-context': + /(?=.*DD_SOURCE_CODE_CONTEXT)(?=.*"service":"checkout")(?=.*"version":"1\.2\.3")/, }; const expectations: { type: string; config: RumOptions; - should: { inject: (keyof typeof injections)[]; throw?: boolean }; + should: { inject: (keyof typeof injections)[] }; }[] = [ { type: 'no sdk', @@ -38,6 +40,16 @@ describe('RUM Plugin', () => { config: { sdk: { applicationId: 'app-id' } }, should: { inject: ['browser-sdk', 'sdk-init'] }, }, + { + type: 'source code context', + config: { + sourceCodeContext: { + service: 'checkout', + version: '1.2.3', + }, + }, + should: { inject: ['source-code-context'] }, + }, ]; describe('getPlugins', () => { const injectMock = jest.fn(); @@ -79,21 +91,13 @@ describe('RUM Plugin', () => { const mockContext = getContextMock(); const pluginConfig = { ...defaultPluginOptions, rum: config }; - const expectResult = expect(() => { - getPlugins(getGetPluginsArg(pluginConfig, mockContext)); - }); - - if (should.throw) { - expectResult.toThrow(); - } else { - expectResult.not.toThrow(); - } + getPlugins(getGetPluginsArg(pluginConfig, mockContext)); expect(mockContext.inject).toHaveBeenCalledTimes(should.inject.length); for (const inject of should.inject) { expect(mockContext.inject).toHaveBeenCalledWith( expect.objectContaining({ - value: injections[inject], + value: expect.stringMatching(injections[inject]), }), ); } diff --git a/packages/plugins/rum/src/index.ts b/packages/plugins/rum/src/index.ts index e15aaa630..3d7a92498 100644 --- a/packages/plugins/rum/src/index.ts +++ b/packages/plugins/rum/src/index.ts @@ -7,6 +7,7 @@ import { InjectPosition } from '@dd/core/types'; import path from 'path'; import { CONFIG_KEY, PLUGIN_NAME } from './constants'; +import { getSourceCodeContextSnippet } from './getSourceCodeContextSnippet'; import { getPrivacyPlugin } from './privacy'; import { getInjectionValue } from './sdk'; import type { RumOptions, RumOptionsWithSdk, RumPublicApi, RumInitConfiguration } from './types'; @@ -36,6 +37,14 @@ export const getPlugins: GetPlugins = ({ options, context }) => { return plugins; } + if (validatedOptions.sourceCodeContext) { + context.inject({ + type: 'code', + position: InjectPosition.BEFORE, + value: getSourceCodeContextSnippet(validatedOptions.sourceCodeContext), + }); + } + // NOTE: These files are built from "@dd/tools/rollupConfig.mjs" and available in the distributed package. if (validatedOptions.sdk) { // Inject the SDK from the CDN. diff --git a/packages/plugins/rum/src/sourceCodeContext.ts b/packages/plugins/rum/src/sourceCodeContext.ts new file mode 100644 index 000000000..fe7c5b9f5 --- /dev/null +++ b/packages/plugins/rum/src/sourceCodeContext.ts @@ -0,0 +1,20 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { Logger } from '@dd/core/types'; + +import type { SourceCodeContextOptions } from './types'; + +export const DEFAULT_SOURCE_CODE_CONTEXT_VARIABLE = 'DD_SOURCE_CODE_CONTEXT' as const; + +// The source code context snippet - single injection with function definition and call +// SSR-safe: checks window before accessing, never throws +// Minified for minimal bundle size impact +export const getSourceCodeContextSnippet = ( + context: SourceCodeContextOptions, + log: Logger, +): string => { + // prettier-ignore + return `(function(c,n){try{if(typeof window==='undefined')return;var w=window,m=w[n]=w[n]||{},s=new Error().stack;s&&(m[s]=c)}catch(e){}})(${JSON.stringify(context)},${JSON.stringify(DEFAULT_SOURCE_CODE_CONTEXT_VARIABLE)});`; +}; diff --git a/packages/plugins/rum/src/types.ts b/packages/plugins/rum/src/types.ts index e331648db..1a8e6bfd6 100644 --- a/packages/plugins/rum/src/types.ts +++ b/packages/plugins/rum/src/types.ts @@ -10,10 +10,16 @@ import type { Assign } from '@dd/core/types'; import type { PrivacyOptions, PrivacyOptionsWithDefaults } from './privacy/types'; +export type SourceCodeContextOptions = { + service: string; + version?: string; +}; + export type RumOptions = { enable?: boolean; sdk?: SDKOptions; privacy?: PrivacyOptions; + sourceCodeContext?: SourceCodeContextOptions; }; export type RumPublicApi = typeof datadogRum; @@ -57,6 +63,7 @@ export type RumOptionsWithDefaults = { enable?: boolean; sdk?: SDKOptionsWithDefaults; privacy?: PrivacyOptionsWithDefaults; + sourceCodeContext?: SourceCodeContextOptions; }; export type RumOptionsWithSdk = Assign; diff --git a/packages/plugins/rum/src/validate.test.ts b/packages/plugins/rum/src/validate.test.ts index 8cd123c0e..0b7d7b594 100644 --- a/packages/plugins/rum/src/validate.test.ts +++ b/packages/plugins/rum/src/validate.test.ts @@ -5,7 +5,7 @@ import { defaultPluginOptions } from '@dd/tests/_jest/helpers/mocks'; import { createFilter } from '@rollup/pluginutils'; -import { validatePrivacyOptions } from './validate'; +import { validatePrivacyOptions, validateSourceCodeContextOptions } from './validate'; describe('Test privacy plugin option exclude regex', () => { let filter: (path: string) => boolean; @@ -34,3 +34,34 @@ describe('Test privacy plugin option exclude regex', () => { expect(filter(path)).toBe(expected); }); }); + +describe('sourceCodeContext validation', () => { + test('should return empty result when not configured', () => { + const pluginOptions = { ...defaultPluginOptions, rum: {} }; + const result = validateSourceCodeContextOptions(pluginOptions); + expect(result.errors).toHaveLength(0); + expect(result.config).toBeUndefined(); + }); + + test('should accept when only service is provided (version optional)', () => { + const pluginOptions = { + ...defaultPluginOptions, + rum: { sourceCodeContext: { service: 'checkout' } }, + }; + const result = validateSourceCodeContextOptions(pluginOptions); + expect(result.errors).toHaveLength(0); + expect(result.config).toEqual(expect.objectContaining({ service: 'checkout' })); + }); + + test('should error when service is missing', () => { + const pluginOptions = { + ...defaultPluginOptions, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rum: { sourceCodeContext: { version: '1.2.3' } as any }, + }; + const result = validateSourceCodeContextOptions(pluginOptions); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"sourceCodeContext.service"')]), + ); + }); +}); diff --git a/packages/plugins/rum/src/validate.ts b/packages/plugins/rum/src/validate.ts index 013280e09..309c02968 100644 --- a/packages/plugins/rum/src/validate.ts +++ b/packages/plugins/rum/src/validate.ts @@ -7,7 +7,12 @@ import chalk from 'chalk'; import { CONFIG_KEY, PLUGIN_NAME } from './constants'; import type { PrivacyOptionsWithDefaults } from './privacy/types'; -import type { RumOptions, RumOptionsWithDefaults, SDKOptionsWithDefaults } from './types'; +import type { + RumOptions, + RumOptionsWithDefaults, + SDKOptionsWithDefaults, + SourceCodeContextOptions, +} from './types'; export const validateOptions = ( options: OptionsWithDefaults, @@ -18,9 +23,11 @@ export const validateOptions = ( // Validate and add defaults sub-options. const sdkResults = validateSDKOptions(options); const privacyResults = validatePrivacyOptions(options); + const sourceCodeContextResults = validateSourceCodeContextOptions(options); errors.push(...sdkResults.errors); errors.push(...privacyResults.errors); + errors.push(...sourceCodeContextResults.errors); // Throw if there are any errors. if (errors.length) { @@ -34,6 +41,7 @@ export const validateOptions = ( ...options[CONFIG_KEY], sdk: undefined, privacy: undefined, + sourceCodeContext: undefined, }; // Fill in the defaults. @@ -55,6 +63,10 @@ export const validateOptions = ( ); } + if (sourceCodeContextResults.config) { + toReturn.sourceCodeContext = sourceCodeContextResults.config; + } + return toReturn; }; @@ -141,3 +153,26 @@ export const validatePrivacyOptions = (options: Options): ToReturn => { + const red = chalk.bold.red; + const validatedOptions: RumOptions = options[CONFIG_KEY] || {}; + const toReturn: ToReturn = { + errors: [], + }; + + if (!validatedOptions.sourceCodeContext) { + return toReturn; + } + + const cfg = validatedOptions.sourceCodeContext as SourceCodeContextOptions; + + if (!cfg?.service || typeof cfg.service !== 'string') { + toReturn.errors.push(`Missing ${red('"sourceCodeContext.service"')}.`); + } + + toReturn.config = cfg; + return toReturn; +}; diff --git a/packages/tests/src/e2e/sourceCodeContext/project/index.html b/packages/tests/src/e2e/sourceCodeContext/project/index.html new file mode 100644 index 000000000..ee08b4763 --- /dev/null +++ b/packages/tests/src/e2e/sourceCodeContext/project/index.html @@ -0,0 +1,19 @@ + + + + + + + Source Code Context Test + + + +

Source Code Context Test - {{bundler}}

+

Testing source code context injection.

+ + + + + + + diff --git a/packages/tests/src/e2e/sourceCodeContext/project/index.js b/packages/tests/src/e2e/sourceCodeContext/project/index.js new file mode 100644 index 000000000..59ba6761e --- /dev/null +++ b/packages/tests/src/e2e/sourceCodeContext/project/index.js @@ -0,0 +1,31 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* eslint-env browser */ + +const $ = document.querySelector.bind(document); + +// Function to capture and store a stack trace +function captureStackTrace() { + const error = new Error(); + const stack = error.stack; + return stack; +} + +// Store stack trace globally for testing +$('#capture_stack').addEventListener('click', () => { + const stack = captureStackTrace(); + window.capturedStack = stack; + console.log('Stack captured:', stack); +}); + +// Trigger an error for testing +$('#trigger_error').addEventListener('click', () => { + try { + throw new Error('Test error from source code context'); + } catch (e) { + window.caughtError = e; + console.log('Error caught:', e); + } +}); diff --git a/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts b/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts new file mode 100644 index 000000000..1643c07da --- /dev/null +++ b/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts @@ -0,0 +1,164 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* eslint-env browser */ +/* global globalThis */ +import { verifyProjectBuild } from '@dd/tests/_playwright/helpers/buildProject'; +import type { TestOptions } from '@dd/tests/_playwright/testParams'; +import { test } from '@dd/tests/_playwright/testParams'; +import { defaultConfig } from '@dd/tools/plugins'; +import type { Page } from '@playwright/test'; +import path from 'path'; + +// Have a similar experience to Jest. +const { expect, beforeAll, describe } = test; + +const SERVICE_NAME = 'test-micro-frontend'; +const SERVICE_VERSION = '1.2.3'; + +const userFlow = async (url: string, page: Page, bundler: TestOptions['bundler']) => { + // Navigate to our page. + await page.goto(`${url}/index.html?context_bundler=${bundler}`); + await page.waitForSelector('body'); +}; + +describe('Source Code Context', () => { + // Build our fixture project. + beforeAll(async ({ publicDir, bundlers, suiteName }) => { + const source = path.resolve(__dirname, 'project'); + const destination = path.resolve(publicDir, suiteName); + await verifyProjectBuild( + source, + destination, + bundlers, + { + ...defaultConfig, + rum: { + enable: true, + sourceCodeContext: { + service: SERVICE_NAME, + version: SERVICE_VERSION, + }, + }, + }, + { + entry: bundlers.reduce((acc, bundler) => ({ ...acc, [bundler]: './index.js' }), {}), + }, + ); + }); + + test('Should inject DD_SOURCE_CODE_CONTEXT global variable', async ({ + page, + bundler, + browserName, + suiteName, + devServerUrl, + }) => { + const errors: string[] = []; + const testBaseUrl = `${devServerUrl}/${suiteName}`; + + // Listen for errors on the page. + page.on('pageerror', (error) => errors.push(error.message)); + page.on('response', async (response) => { + if (!response.ok()) { + const url = response.request().url(); + const prefix = `[${bundler} ${browserName} ${response.status()}]`; + errors.push(`${prefix} ${url}`); + } + }); + + await userFlow(testBaseUrl, page, bundler); + + // Check that DD_SOURCE_CODE_CONTEXT is defined + const hasContext = await page.evaluate(() => { + return typeof (globalThis as any).DD_SOURCE_CODE_CONTEXT !== 'undefined'; + }); + + expect(hasContext).toBe(true); + expect(errors).toEqual([]); + }); + + test('Should map stack traces to source code context', async ({ + page, + bundler, + browserName, + suiteName, + devServerUrl, + }) => { + const errors: string[] = []; + const testBaseUrl = `${devServerUrl}/${suiteName}`; + + // Listen for errors on the page. + page.on('pageerror', (error) => errors.push(error.message)); + page.on('response', async (response) => { + if (!response.ok()) { + const url = response.request().url(); + const prefix = `[${bundler} ${browserName} ${response.status()}]`; + errors.push(`${prefix} ${url}`); + } + }); + + await userFlow(testBaseUrl, page, bundler); + + const DD_SOURCE_CODE_CONTEXT = await page.evaluate(() => { + return (globalThis as any).DD_SOURCE_CODE_CONTEXT; + }); + + expect(DD_SOURCE_CODE_CONTEXT).toBeDefined(); + + // Get all the stack trace keys from the context + const contextKeys = Object.keys(DD_SOURCE_CODE_CONTEXT); + expect(contextKeys.length).toEqual(1); + + const firstStackKey = contextKeys[0]; + const context = DD_SOURCE_CODE_CONTEXT[firstStackKey]; + + expect(firstStackKey).toContain(`/${bundler}.js`); + expect(context).toBeDefined(); + expect(context.service).toBe(SERVICE_NAME); + expect(context.version).toBe(SERVICE_VERSION); + + expect(errors).toEqual([]); + }); + + test('Should not throw errors', async ({ + page, + bundler, + browserName, + suiteName, + devServerUrl, + }) => { + const errors: string[] = []; + const consoleErrors: string[] = []; + const testBaseUrl = `${devServerUrl}/${suiteName}`; + + // Listen for errors on the page. + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + page.on('response', async (response) => { + if (!response.ok()) { + const url = response.request().url(); + const prefix = `[${bundler} ${browserName} ${response.status()}]`; + errors.push(`${prefix} ${url}`); + } + }); + + // Mock error to confirm the snippet’s try/catch blocks failures + await page.addInitScript(() => { + (globalThis as any).Error = function () { + // eslint-disable-next-line no-throw-literal + throw 'Test error from source code context'; + }; + }); + + await userFlow(testBaseUrl, page, bundler); + + expect(errors).toEqual([]); + expect(consoleErrors.filter((e) => e.includes('DD_SOURCE_CODE_CONTEXT'))).toEqual([]); + }); +});