Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
17 changes: 15 additions & 2 deletions packages/browser-core/src/domain/report/browser.types.ts
Original file line number Diff line number Diff line change
@@ -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
}

Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -74,4 +79,41 @@ 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: '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 <anonymous> @ 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: 'some-other-policy',
})
)
})
})
28 changes: 21 additions & 7 deletions packages/browser-core/src/domain/report/reportObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) {
Expand All @@ -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))
}
Expand All @@ -41,8 +42,9 @@ function createReportObservable(reportTypes: ReportType[]) {
return
}

const handleReports = monitor((reports: Array<DeprecationReport | InterventionReport>, _: ReportingObserver) =>
reports.forEach((report) => observable.notify(buildRawReportErrorFromReport(report)))
const handleReports = monitor(
(reports: Array<DeprecationReport | InterventionReport | DocumentPolicyViolationReport>, _: ReportingObserver) =>
reports.forEach((report) => observable.notify(buildRawReportErrorFromReport(report)))
) as ReportingObserverCallback

const observer = new window.ReportingObserver(handleReports, {
Expand Down Expand Up @@ -72,9 +74,21 @@ 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: 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}`,
Expand Down
29 changes: 26 additions & 3 deletions packages/browser-core/test/emulate/mockReportingObserver.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -39,9 +43,13 @@ export function mockReportingObserver() {
})

return {
raiseReport(type: ReportType) {
raiseReport(type: ReportType, overrides?: Partial<DocumentPolicyViolationReport>) {
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))
}
},
}
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { Observable, RawError } from '@datadog/browser-core'
import { initReportObservable, RawReportType } from '@datadog/browser-core'
export function trackReportError(errorObservable: Observable<RawError>) {
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: () => {
Expand Down
13 changes: 13 additions & 0 deletions test/e2e/lib/framework/serverApps/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 7 additions & 1 deletion test/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] } } : {}),
},
}
}

Expand Down
98 changes: 98 additions & 0 deletions test/e2e/scenario/networkEfficiencyGuardrails.scenario.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
})
Loading