diff --git a/packages/browser-core/src/domain/error/error.types.ts b/packages/browser-core/src/domain/error/error.types.ts index 90f036b934..6b68c01515 100644 --- a/packages/browser-core/src/domain/error/error.types.ts +++ b/packages/browser-core/src/domain/error/error.types.ts @@ -23,12 +23,14 @@ export interface RawErrorCause { export interface Csp { disposition: 'enforce' | 'report' + featureId?: string } export interface RawError { startClocks: ClocksState message: string type?: string + featureId?: string stack?: string source: ErrorSource originalError?: unknown diff --git a/packages/browser-core/src/domain/report/browser.types.ts b/packages/browser-core/src/domain/report/browser.types.ts index 1434b91b9b..d1637dc429 100644 --- a/packages/browser-core/src/domain/report/browser.types.ts +++ b/packages/browser-core/src/domain/report/browser.types.ts @@ -1,9 +1,9 @@ -export type ReportType = DeprecationReport['type'] | InterventionReport['type'] +export type ReportType = DeprecationReport['type'] | InterventionReport['type'] | DocumentPolicyViolationReport['type'] interface Report { type: ReportType url: string - body: DeprecationReportBody | InterventionReportBody + body: DeprecationReportBody | InterventionReportBody | DocumentPolicyViolationReportBody toJSON(): any } @@ -36,3 +36,16 @@ export interface InterventionReportBody extends ReportBody { columnNumber: number | null sourceFile: string | null } + +export interface DocumentPolicyViolationReport extends Report { + type: 'document-policy-violation' + body: DocumentPolicyViolationReportBody +} +export interface DocumentPolicyViolationReportBody extends ReportBody { + featureId: string + message: string + disposition: 'enforce' | 'report' + lineNumber: null + columnNumber: null + sourceFile: string | null +} diff --git a/packages/browser-core/src/domain/report/reportObservable.spec.ts b/packages/browser-core/src/domain/report/reportObservable.spec.ts index 95da8f37d0..07f7b2f0a3 100644 --- a/packages/browser-core/src/domain/report/reportObservable.spec.ts +++ b/packages/browser-core/src/domain/report/reportObservable.spec.ts @@ -1,5 +1,10 @@ import type { MockCspEventListener, MockReportingObserver } from '../../../test' -import { mockReportingObserver, mockCspEventListener, FAKE_CSP_VIOLATION_EVENT } from '../../../test' +import { + mockReportingObserver, + mockCspEventListener, + FAKE_CSP_VIOLATION_EVENT, + FAKE_DOCUMENT_POLICY_VIOLATION_REPORT, +} from '../../../test' import type { Subscription } from '../../tools/observable' import { ErrorHandling, ErrorSource } from '../error/error.types' import type { RawReportError } from './reportObservable' @@ -74,4 +79,43 @@ describe('report observable', () => { expect(notifyReport).not.toHaveBeenCalled() }) + + it(`should notify ${RawReportType.documentPolicyViolation} reports`, () => { + consoleSubscription = initReportObservable([RawReportType.documentPolicyViolation]).subscribe(notifyReport) + reportingObserver.raiseReport('document-policy-violation') + + expect(notifyReport).toHaveBeenCalledOnceWith( + jasmine.objectContaining({ + message: 'document-policy-violation: Document policy violation: resource compression is required.', + type: 'document-policy-violation', + featureId: 'network-efficiency-guardrails', + csp: { disposition: 'report' }, + }) + ) + }) + + it(`should compute stack for ${RawReportType.documentPolicyViolation}`, () => { + consoleSubscription = initReportObservable([RawReportType.documentPolicyViolation]).subscribe(notifyReport) + reportingObserver.raiseReport('document-policy-violation') + + const [report] = notifyReport.calls.mostRecent().args + + expect(report.stack) + .toEqual(`network-efficiency-guardrails: Document policy violation: resource compression is required. + at @ https://foo.bar/large-uncompressed.js`) + }) + + it(`should notify ${RawReportType.documentPolicyViolation} reports regardless of featureId`, () => { + consoleSubscription = initReportObservable([RawReportType.documentPolicyViolation]).subscribe(notifyReport) + reportingObserver.raiseReport('document-policy-violation', { + body: { ...FAKE_DOCUMENT_POLICY_VIOLATION_REPORT.body, featureId: 'some-other-policy' }, + }) + + expect(notifyReport).toHaveBeenCalledOnceWith( + jasmine.objectContaining({ + type: 'document-policy-violation', + featureId: 'some-other-policy', + }) + ) + }) }) diff --git a/packages/browser-core/src/domain/report/reportObservable.ts b/packages/browser-core/src/domain/report/reportObservable.ts index 21a36fd735..a31d64c4c0 100644 --- a/packages/browser-core/src/domain/report/reportObservable.ts +++ b/packages/browser-core/src/domain/report/reportObservable.ts @@ -6,18 +6,19 @@ import { addEventListener, DOM_EVENT, isEventSupported } from '../../browser/add import { safeTruncate } from '../../tools/utils/stringUtils' import type { RawError } from '../error/error.types' import { ErrorHandling, ErrorSource } from '../error/error.types' -import type { ReportType, InterventionReport, DeprecationReport } from './browser.types' +import type { ReportType, InterventionReport, DeprecationReport, DocumentPolicyViolationReport } from './browser.types' export const RawReportType = { intervention: 'intervention', deprecation: 'deprecation', cspViolation: 'csp_violation', + documentPolicyViolation: 'document-policy-violation', } as const export type RawReportType = (typeof RawReportType)[keyof typeof RawReportType] export type RawReportError = RawError & { - originalError: SecurityPolicyViolationEvent | DeprecationReport | InterventionReport + originalError: SecurityPolicyViolationEvent | DeprecationReport | InterventionReport | DocumentPolicyViolationReport } export function initReportObservable(apis: RawReportType[]) { @@ -27,7 +28,7 @@ export function initReportObservable(apis: RawReportType[]) { observables.push(createCspViolationReportObservable()) } - const reportTypes = apis.filter((api: RawReportType): api is ReportType => api !== RawReportType.cspViolation) + const reportTypes = apis.filter((api): api is ReportType => api !== RawReportType.cspViolation) if (reportTypes.length) { observables.push(createReportObservable(reportTypes)) } @@ -41,8 +42,9 @@ function createReportObservable(reportTypes: ReportType[]) { return } - const handleReports = monitor((reports: Array, _: ReportingObserver) => - reports.forEach((report) => observable.notify(buildRawReportErrorFromReport(report))) + const handleReports = monitor( + (reports: Array, _: ReportingObserver) => + reports.forEach((report) => observable.notify(buildRawReportErrorFromReport(report))) ) as ReportingObserverCallback const observer = new window.ReportingObserver(handleReports, { @@ -72,9 +74,22 @@ function createCspViolationReportObservable() { }) } -function buildRawReportErrorFromReport(report: DeprecationReport | InterventionReport): RawReportError { - const { type, body } = report +function buildRawReportErrorFromReport( + report: DeprecationReport | InterventionReport | DocumentPolicyViolationReport +): RawReportError { + if (report.type === 'document-policy-violation') { + const { featureId, message, disposition, sourceFile } = report.body + return buildRawReportError({ + type: report.type, + featureId, + message: `${report.type}: ${message}`, + originalError: report, + csp: { disposition }, + stack: buildStack(featureId, message, sourceFile, null, null), + }) + } + const { type, body } = report return buildRawReportError({ type: body.id, message: `${type}: ${body.message}`, diff --git a/packages/browser-core/test/emulate/mockReportingObserver.ts b/packages/browser-core/test/emulate/mockReportingObserver.ts index a278ece395..95699933ed 100644 --- a/packages/browser-core/test/emulate/mockReportingObserver.ts +++ b/packages/browser-core/test/emulate/mockReportingObserver.ts @@ -1,4 +1,8 @@ -import type { InterventionReport, ReportType } from '../../src/domain/report/browser.types' +import type { + DocumentPolicyViolationReport, + InterventionReport, + ReportType, +} from '../../src/domain/report/browser.types' import { noop } from '../../src/tools/utils/functionUtils' import { registerCleanupTask } from '../registerCleanupTask' import { createNewEvent } from './createNewEvent' @@ -39,9 +43,13 @@ export function mockReportingObserver() { }) return { - raiseReport(type: ReportType) { + raiseReport(type: ReportType, overrides?: Partial) { if (callbacks[type]) { - callbacks[type].forEach((callback) => callback([{ ...FAKE_REPORT, type }], reportingObserver)) + const report = + type === 'document-policy-violation' + ? { ...FAKE_DOCUMENT_POLICY_VIOLATION_REPORT, ...overrides } + : { ...FAKE_REPORT, type } + callbacks[type].forEach((callback) => callback([report], reportingObserver)) } }, } @@ -85,6 +93,21 @@ export const FAKE_CSP_VIOLATION_EVENT = createNewEvent('securitypolicyviolation' violatedDirective: 'worker-src', }) +export const FAKE_DOCUMENT_POLICY_VIOLATION_REPORT: DocumentPolicyViolationReport = { + type: 'document-policy-violation', + url: 'http://foo.bar', + body: { + featureId: 'network-efficiency-guardrails', + message: 'Document policy violation: resource compression is required.', + disposition: 'report', + lineNumber: null, + columnNumber: null, + sourceFile: 'https://foo.bar/large-uncompressed.js', + toJSON: noop, + }, + toJSON: noop, +} + export const FAKE_REPORT: InterventionReport = { type: 'intervention', url: 'http://foo.bar', diff --git a/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts b/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts index 1ce6d3e5d3..802b19bf04 100644 --- a/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts +++ b/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts @@ -14,6 +14,7 @@ describe('createErrorFieldFromRawError', () => { componentStack: 'at Flex', originalError: new Error('baz'), type: 'qux', + featureId: 'quux-feature', message: 'quux', stack: 'quuz', causes: [ @@ -43,6 +44,7 @@ describe('createErrorFieldFromRawError', () => { expect(createErrorFieldFromRawError(exhaustiveRawError)).toEqual({ message: undefined, kind: 'qux', + feature_id: 'quux-feature', stack: 'quuz', causes: [ { diff --git a/packages/browser-logs/src/domain/createErrorFieldFromRawError.ts b/packages/browser-logs/src/domain/createErrorFieldFromRawError.ts index a098151f95..cc0d80b053 100644 --- a/packages/browser-logs/src/domain/createErrorFieldFromRawError.ts +++ b/packages/browser-logs/src/domain/createErrorFieldFromRawError.ts @@ -14,6 +14,7 @@ export function createErrorFieldFromRawError( return { stack: rawError.stack, kind: rawError.type, + feature_id: rawError.featureId, message: includeMessage ? rawError.message : undefined, causes: rawError.causes, fingerprint: rawError.fingerprint, diff --git a/packages/browser-logs/src/rawLogsEvent.types.ts b/packages/browser-logs/src/rawLogsEvent.types.ts index 8ae09d843e..0bd0dc3b20 100644 --- a/packages/browser-logs/src/rawLogsEvent.types.ts +++ b/packages/browser-logs/src/rawLogsEvent.types.ts @@ -13,6 +13,7 @@ export type RawLogsEvent = interface Error { message?: string kind?: string + feature_id?: string stack?: string fingerprint?: string causes?: RawErrorCause[] diff --git a/packages/browser-rum-core/src/domain/error/errorCollection.ts b/packages/browser-rum-core/src/domain/error/errorCollection.ts index e4bd4c972a..e02498445c 100644 --- a/packages/browser-rum-core/src/domain/error/errorCollection.ts +++ b/packages/browser-rum-core/src/domain/error/errorCollection.ts @@ -70,20 +70,24 @@ export function doStartErrorCollection(lifeCycle: LifeCycle) { function processError(error: RawError): RawRumEventCollectedData { const rawRumEvent: RawRumErrorEvent = { date: error.startClocks.timeStamp, - error: { - id: generateUUID(), - message: error.message, - source: error.source, - stack: error.stack, - handling_stack: error.handlingStack, - component_stack: error.componentStack, - type: error.type, - handling: error.handling, - causes: error.causes, - source_type: 'browser', - fingerprint: error.fingerprint, - csp: error.csp, - }, + error: combine( + { + id: generateUUID(), + message: error.message, + source: error.source, + stack: error.stack, + handling_stack: error.handlingStack, + component_stack: error.componentStack, + type: error.type, + handling: error.handling, + causes: error.causes, + source_type: 'browser' as const, + fingerprint: error.fingerprint, + csp: error.csp, + }, + // TODO: add feature_id to the rum-events-format schema then remove this combine() + error.featureId !== undefined ? { feature_id: error.featureId } : {} + ), type: RumEventType.ERROR, context: error.context, } diff --git a/packages/browser-rum-core/src/domain/error/trackReportError.ts b/packages/browser-rum-core/src/domain/error/trackReportError.ts index afa136e7c1..cf76b23f2d 100644 --- a/packages/browser-rum-core/src/domain/error/trackReportError.ts +++ b/packages/browser-rum-core/src/domain/error/trackReportError.ts @@ -1,9 +1,11 @@ import type { Observable, RawError } from '@datadog/browser-core' import { initReportObservable, RawReportType } from '@datadog/browser-core' export function trackReportError(errorObservable: Observable) { - const subscription = initReportObservable([RawReportType.cspViolation, RawReportType.intervention]).subscribe( - (rawError) => errorObservable.notify(rawError) - ) + const subscription = initReportObservable([ + RawReportType.cspViolation, + RawReportType.intervention, + RawReportType.documentPolicyViolation, + ]).subscribe((rawError) => errorObservable.notify(rawError)) return { stop: () => { diff --git a/test/e2e/lib/framework/serverApps/mock.ts b/test/e2e/lib/framework/serverApps/mock.ts index 148f813600..63bacea6ef 100644 --- a/test/e2e/lib/framework/serverApps/mock.ts +++ b/test/e2e/lib/framework/serverApps/mock.ts @@ -181,10 +181,23 @@ export function createMockServerApp(servers: Servers, setup: string, setupOption if (req.query['js-profiling'] === 'true') { res.header('Document-Policy', 'js-profiling') } + if (req.query['network-efficiency-guardrails'] === 'true') { + res.header('Document-Policy', 'network-efficiency-guardrails') + } res.send(setup) res.end() }) + // Serves an uncompressed JavaScript file large enough to trigger a network-efficiency-guardrails + // policy violation (text resources must be HTTP-compressed). + app.get('/uncompressed-script.js', (_req, res) => { + res.removeHeader('Content-Encoding') + res.header('Content-Type', 'application/javascript') + // Explicitly disable compression for this endpoint so the browser detects a violation + res.header('Cache-Control', 'no-store') + res.send(`// uncompressed script\n${'// padding\n'.repeat(500)}`) + }) + app.get('/no-blob-worker-csp', (_req, res) => { res.header( 'Content-Security-Policy', diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts index 9ffd74279e..294d2e81ad 100644 --- a/test/e2e/playwright.config.ts +++ b/test/e2e/playwright.config.ts @@ -168,10 +168,16 @@ function getProjects() { } function project(name: string, device: string) { + const isChromium = name === 'chromium' return { name, metadata: { sessionName: device, name } satisfies BrowserConfiguration, - use: devices[device], + use: { + ...devices[device], + // Required for experimental APIs (e.g. Network Efficiency Guardrails). + // Only passed to current Chromium — pinned browsers use pinnedProject() and may not support it. + ...(isChromium ? { launchOptions: { args: ['--enable-experimental-web-platform-features'] } } : {}), + }, } } diff --git a/test/e2e/scenario/networkEfficiencyGuardrails.scenario.ts b/test/e2e/scenario/networkEfficiencyGuardrails.scenario.ts new file mode 100644 index 0000000000..db9efb4250 --- /dev/null +++ b/test/e2e/scenario/networkEfficiencyGuardrails.scenario.ts @@ -0,0 +1,98 @@ +import { test, expect } from '@playwright/test' +import { createTest } from '../lib/framework' +import type { BrowserConfiguration } from '../../browsers.conf' + +// Network Efficiency Guardrails is a Document Policy feature currently only available in Edge 146+ +// and in Chromium behind the "Experimental Web Platform features" flag. +// We run these tests only on Chromium. + +test.describe('network efficiency guardrails', () => { + test.beforeEach(({ browserName }) => { + const { version } = test.info().project.metadata as BrowserConfiguration + // Network Efficiency Guardrails requires Chromium 146+. Pinned projects set an explicit + // version; current (unversioned) Chromium is always new enough. + test.skip( + browserName !== 'chromium' || (version !== undefined && Number(version) < 146), + 'Network Efficiency Guardrails requires Chromium 146+' + ) + }) + + test.describe('RUM', () => { + createTest('should collect network-efficiency-guardrails violations as RUM errors') + .withRum() + .withBasePath('/?network-efficiency-guardrails=true') + .run(async ({ page, intakeRegistry, flushEvents, withBrowserLogs }) => { + // Trigger a violation after the SDK has initialized: fetch an uncompressed JS resource. + // The Document-Policy header on the page opts into monitoring, and the lack of + // Content-Encoding on this endpoint triggers a "resource compression" violation. + await page.evaluate(() => fetch('/uncompressed-script.js')) + + await flushEvents() + + const guardrailErrors = intakeRegistry.rumErrorEvents.filter((event) => + event.error.message.startsWith('document-policy-violation:') + ) + + // The SDK bundles themselves (served uncompressed in dev) also trigger violations, + // so we may receive more than one. Assert we got at least one for our resource. + expect(guardrailErrors.length).toBeGreaterThanOrEqual(1) + + const error = guardrailErrors[0].error + expect(error.source).toBe('report') + expect(error.handling).toBe('unhandled') + expect(error.csp?.disposition).toMatch(/enforce|report/) + + // The browser logs a console error for each violation — acknowledge them so the + // framework teardown check doesn't fail. + withBrowserLogs((logs) => { + const errors = logs.filter((log) => log.level === 'error') + expect(errors.length).toBeGreaterThanOrEqual(1) + expect(errors[0].message).toContain('Document policy violation: resource compression is required') + }) + }) + }) + + test.describe('Logs', () => { + createTest('should forward network-efficiency-guardrails violations via forwardReports') + .withLogs({ forwardReports: ['document-policy-violation'] }) + .withBasePath('/?network-efficiency-guardrails=true') + .run(async ({ page, intakeRegistry, flushEvents, withBrowserLogs }) => { + await page.evaluate(() => fetch('/uncompressed-script.js')) + + await flushEvents() + + const guardrailLogs = intakeRegistry.logsEvents.filter((event) => + event.message.startsWith('document-policy-violation:') + ) + + expect(guardrailLogs).toHaveLength(1) + expect(guardrailLogs[0].origin).toBe('report') + expect(guardrailLogs[0].status).toBe('error') + + withBrowserLogs((logs) => { + expect(logs.filter((log) => log.level === 'error')).toHaveLength(1) + expect(logs[0].message).toContain('Document policy violation: resource compression is required') + }) + }) + + createTest('should not forward network-efficiency-guardrails violations when not opted in') + .withLogs({ forwardReports: [] }) + .withBasePath('/?network-efficiency-guardrails=true') + .run(async ({ page, intakeRegistry, flushEvents, withBrowserLogs }) => { + await page.evaluate(() => fetch('/uncompressed-script.js')) + + await flushEvents() + + const guardrailLogs = intakeRegistry.logsEvents.filter((event) => + event.message.startsWith('document-policy-violation:') + ) + + expect(guardrailLogs).toHaveLength(0) + + withBrowserLogs((logs) => { + expect(logs.filter((log) => log.level === 'error')).toHaveLength(1) + expect(logs[0].message).toContain('Document policy violation: resource compression is required') + }) + }) + }) +})