// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://resources/cr_elements/cr_tab_box/cr_tab_box.js';
import './attribution_detail_table.js';
import './attribution_internals_table.js';
import type {Origin} from 'chrome://resources/mojo/url/mojom/origin.mojom-webui.js';
import {AggregatableResult} from './aggregatable_result.mojom-webui.js';
import {AttributionSupport} from './attribution.mojom-webui.js';
import type {AttributionDetailTableElement} from './attribution_detail_table.js';
import type {HandlerInterface, NetworkStatus, ObserverInterface, ReportID, ReportStatus, WebUIAggregatableDebugReport, WebUIDebugReport, WebUIOsRegistration, WebUIRegistration, WebUIReport, WebUISource, WebUISourceRegistration, WebUITrigger} from './attribution_internals.mojom-webui.js';
import {Factory, HandlerRemote, ObserverReceiver, WebUISource_Attributability} from './attribution_internals.mojom-webui.js';
import type {AttributionInternalsTableElement, CompareFunc, DataColumn, InitOpts, RenderFunc} from './attribution_internals_table.js';
import {OsRegistrationResult, RegistrationType} from './attribution_reporting.mojom-webui.js';
import {EventLevelResult} from './event_level_result.mojom-webui.js';
import {ProcessAggregatableDebugReportResult} from './process_aggregatable_debug_report_result.mojom-webui.js';
import {SourceType} from './source_type.mojom-webui.js';
import {StoreSourceResult} from './store_source_result.mojom-webui.js';
import {TriggerDataMatching} from './trigger_data_matching.mojom-webui.js';
// If kAttributionAggregatableBudgetPerSource changes, update this value
const BUDGET_PER_SOURCE = 65536;
type Comparable = bigint|number|string|boolean|Date;
function compareDefault<T extends Comparable>(a: T, b: T): number {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
function undefinedFirst<V>(f: CompareFunc<V>): CompareFunc<V|undefined> {
return (a: V|undefined, b: V|undefined): number => {
if (a === undefined && b === undefined) {
return 0;
}
if (a === undefined) {
return -1;
}
if (b === undefined) {
return 1;
}
return f(a, b);
};
}
function compareLexicographic<V>(f: CompareFunc<V>): CompareFunc<V[]> {
return (a: V[], b: V[]): number => {
for (let i = 0; i < a.length && i < b.length; ++i) {
const r = f(a[i]!, b[i]!);
if (r !== 0) {
return r;
}
}
return compareDefault(a.length, b.length);
};
}
function bigintReplacer(_key: string, value: any): any {
return typeof value === 'bigint' ? value.toString() : value;
}
interface Valuable<V> {
readonly compare?: CompareFunc<V>;
readonly render: RenderFunc<V>;
}
function allowingUndefined<V>({render, compare}: Valuable<V>):
Valuable<V|undefined> {
return {
compare: compare ? undefinedFirst(compare) : undefined,
render: (td: HTMLElement, v: V|undefined) => {
if (v !== undefined) {
render(td, v);
}
},
};
}
function valueColumn<T, K extends keyof T>(
label: string, key: K, {render, compare}: Valuable<T[K]>,
defaultSort: boolean = false): DataColumn<T> {
return {
label,
render: (td, data) => render(td, data[key]),
compare: compare ? (a, b) => compare(a[key], b[key]) : undefined,
defaultSort,
};
}
const asDate: Valuable<Date> = {
compare: compareDefault,
render: (td: HTMLElement, v: Date) => {
const time = td.ownerDocument.createElement('time');
time.dateTime = v.toISOString();
time.innerText = v.toLocaleString();
td.replaceChildren(time);
},
};
const numberClass: string = 'number';
const asNumber: Valuable<bigint|number> = {
compare: compareDefault,
render: (td: HTMLElement, v: bigint|number) => {
td.classList.add(numberClass);
td.innerText = v.toString();
},
};
function asCustomNumber<V extends bigint|number>(fmt: (v: V) => string):
Valuable<V> {
return {
compare: compareDefault,
render: (td: HTMLElement, v: V) => {
td.classList.add(numberClass);
td.innerText = fmt(v);
},
};
}
const asStringOrBool: Valuable<string|boolean> = {
compare: compareDefault,
render: (td: HTMLElement, v: string|boolean) => td.innerText = v.toString(),
};
const asCode: Valuable<string> = {
render: (td: HTMLElement, v: string) => {
const code = td.ownerDocument.createElement('code');
code.innerText = v;
const pre = td.ownerDocument.createElement('pre');
pre.append(code);
td.replaceChildren(pre);
},
};
function asList<V>({render, compare}: Valuable<V>): Valuable<V[]> {
return {
compare: compare ? compareLexicographic(compare) : undefined,
render: (td: HTMLElement, vs: V[]) => {
if (vs.length === 0) {
td.replaceChildren();
return;
}
const ul = td.ownerDocument.createElement('ul');
for (const v of vs) {
const li = td.ownerDocument.createElement('li');
render(li, v);
ul.append(li);
}
td.replaceChildren(ul);
},
};
}
function renderUrl(td: HTMLElement, url: string): void {
const a = td.ownerDocument.createElement('a');
a.target = '_blank';
a.href = url;
a.innerText = url;
td.replaceChildren(a);
}
const asUrl: Valuable<string> = {
compare: compareDefault,
render: renderUrl,
};
function isAttributionSuccessDebugReport(url: string): boolean {
return url.includes('/.well-known/attribution-reporting/debug/');
}
interface Source {
id: bigint;
sourceEventId: bigint;
sourceOrigin: string;
destinations: string[];
reportingOrigin: string;
sourceTime: Date;
expiryTime: Date;
triggerSpecs: string;
aggregatableReportWindowTime: Date;
sourceType: string;
filterData: string;
aggregationKeys: string;
debugKey?: bigint;
dedupKeys: bigint[];
priority: bigint;
status: string;
remainingAggregatableAttributionBudget: number;
aggregatableDedupKeys: bigint[];
triggerDataMatching: string;
eventLevelEpsilon: number;
debugCookieSet: boolean;
remainingAggregatableDebugBudget: number;
aggregatableDebugKeyPiece: string;
attributionScopesData: string;
}
function newSource(mojo: WebUISource): Source {
return {
id: mojo.id,
sourceEventId: mojo.sourceEventId,
sourceOrigin: originToText(mojo.sourceOrigin),
destinations:
mojo.destinations.destinations.map(d => originToText(d.siteAsOrigin))
.sort(compareDefault),
reportingOrigin: originToText(mojo.reportingOrigin),
sourceTime: new Date(mojo.sourceTime),
expiryTime: new Date(mojo.expiryTime),
triggerSpecs: mojo.triggerSpecsJson,
aggregatableReportWindowTime: new Date(mojo.aggregatableReportWindowTime),
sourceType: sourceTypeText[mojo.sourceType],
priority: mojo.priority,
filterData: JSON.stringify(mojo.filterData.filterValues, null, ' '),
aggregationKeys: JSON.stringify(mojo.aggregationKeys, bigintReplacer, ' '),
debugKey: mojo.debugKey ?? undefined,
dedupKeys: mojo.dedupKeys.sort(compareDefault),
remainingAggregatableAttributionBudget:
mojo.remainingAggregatableAttributionBudget,
aggregatableDedupKeys: mojo.aggregatableDedupKeys.sort(compareDefault),
triggerDataMatching: triggerDataMatchingText[mojo.triggerDataMatching],
eventLevelEpsilon: mojo.eventLevelEpsilon,
status: attributabilityText[mojo.attributability],
debugCookieSet: mojo.debugCookieSet,
remainingAggregatableDebugBudget: mojo.remainingAggregatableDebugBudget,
aggregatableDebugKeyPiece: mojo.aggregatableDebugKeyPiece,
attributionScopesData: mojo.attributionScopesDataJson,
};
}
function initSourceTable(panel: HTMLElement):
AttributionInternalsTableElement<Source> {
return initPanel(
panel,
[
valueColumn('Source Event ID', 'sourceEventId', asNumber),
valueColumn('Status', 'status', asStringOrBool),
valueColumn('Source Origin', 'sourceOrigin', asUrl),
valueColumn('Destinations', 'destinations', asList(asUrl)),
valueColumn('Reporting Origin', 'reportingOrigin', asUrl),
valueColumn(
'Registration Time', 'sourceTime', asDate, /*defaultSort=*/ true),
valueColumn('Expiry', 'expiryTime', asDate),
valueColumn('Source Type', 'sourceType', asStringOrBool),
valueColumn('Debug Key', 'debugKey', allowingUndefined(asNumber)),
],
{
getId: source => source.id,
isSelectable: true,
},
[
valueColumn('Priority', 'priority', asNumber),
valueColumn('Filter Data', 'filterData', asCode),
valueColumn('Debug Cookie Set', 'debugCookieSet', asStringOrBool),
'Event-Level Fields',
valueColumn('Attribution Scopes Data', 'attributionScopesData', asCode),
valueColumn(
'Epsilon', 'eventLevelEpsilon',
asCustomNumber((v: number) => v.toFixed(3))),
valueColumn(
'Trigger Data Matching', 'triggerDataMatching', asStringOrBool),
valueColumn('Trigger Specs', 'triggerSpecs', asCode),
valueColumn('Dedup Keys', 'dedupKeys', asList(asNumber)),
'Aggregatable Fields',
valueColumn(
'Report Window Time', 'aggregatableReportWindowTime', asDate),
valueColumn(
'Remaining Aggregatable Attribution Budget',
'remainingAggregatableAttributionBudget',
asCustomNumber((v) => `${v} / ${BUDGET_PER_SOURCE}`)),
valueColumn('Aggregation Keys', 'aggregationKeys', asCode),
valueColumn('Dedup Keys', 'aggregatableDedupKeys', asList(asNumber)),
valueColumn(
'Remaining Aggregatable Debug Budget',
'remainingAggregatableDebugBudget',
asCustomNumber((v) => `${v} / ${BUDGET_PER_SOURCE}`)),
valueColumn(
'Aggregatable Debug Key Piece', 'aggregatableDebugKeyPiece',
asStringOrBool),
]);
}
class Registration {
readonly time: Date;
readonly contextOrigin: string;
readonly reportingOrigin: string;
readonly registrationJson: string;
readonly clearedDebugKey?: bigint;
constructor(mojo: WebUIRegistration) {
this.time = new Date(mojo.time);
this.contextOrigin = originToText(mojo.contextOrigin);
this.reportingOrigin = originToText(mojo.reportingOrigin);
this.registrationJson = mojo.registrationJson;
this.clearedDebugKey = mojo.clearedDebugKey ?? undefined;
}
}
function initRegistrationTableModel<T extends Registration>(
panel: HTMLElement, contextOriginTitle: string,
cols: Iterable<DataColumn<T>>): AttributionInternalsTableElement<T> {
return initPanel(
panel,
[
valueColumn('Time', 'time', asDate, /*defaultSort=*/ true),
valueColumn(contextOriginTitle, 'contextOrigin', asUrl),
valueColumn('Reporting Origin', 'reportingOrigin', asUrl),
valueColumn(
'Cleared Debug Key', 'clearedDebugKey',
allowingUndefined(asNumber)),
...cols,
],
{isSelectable: true},
[valueColumn('Registration JSON', 'registrationJson', asCode)]);
}
class Trigger extends Registration {
readonly eventLevelResult: string;
readonly aggregatableResult: string;
constructor(mojo: WebUITrigger) {
super(mojo.registration);
this.eventLevelResult = eventLevelResultText[mojo.eventLevelResult];
this.aggregatableResult = aggregatableResultText[mojo.aggregatableResult];
}
}
function initTriggerTable(panel: HTMLElement):
AttributionInternalsTableElement<Trigger> {
return initRegistrationTableModel(panel, 'Destination', [
valueColumn('Event-Level Result', 'eventLevelResult', asStringOrBool),
valueColumn('Aggregatable Result', 'aggregatableResult', asStringOrBool),
]);
}
class SourceRegistration extends Registration {
readonly type: string;
readonly status: string;
constructor(mojo: WebUISourceRegistration) {
super(mojo.registration);
this.type = sourceTypeText[mojo.type];
this.status = sourceRegistrationStatusText[mojo.status];
}
}
function initSourceRegistrationTable(panel: HTMLElement):
AttributionInternalsTableElement<SourceRegistration> {
return initRegistrationTableModel(panel, 'Source Origin', [
valueColumn('Type', 'type', asStringOrBool),
valueColumn('Status', 'status', asStringOrBool),
]);
}
function isHttpError(code: number): boolean {
return code < 200 || code >= 400;
}
const reportStatusColumn: DataColumn<{status: string, sendFailed: boolean}> = {
label: 'Status',
compare: (a, b) => compareDefault(a.status, b.status),
render: (td, report) => {
td.classList.toggle('send-error', report.sendFailed);
td.innerText = report.status;
},
};
function networkStatusToString(status: NetworkStatus, sentPrefix: string):
[status: string, sendFailed: boolean] {
if (status.httpResponseCode !== undefined) {
return [
`${sentPrefix}HTTP ${status.httpResponseCode}`,
isHttpError(status.httpResponseCode),
];
} else if (status.networkError !== undefined) {
return [`Network error: ${status.networkError}`, true];
} else {
throw new Error('invalid NetworkStatus union');
}
}
class Report {
id: ReportID;
reportBody: string;
reportUrl: string;
triggerTime: Date;
reportTime: Date;
status: string;
sendFailed: boolean;
constructor(mojo: WebUIReport) {
this.id = mojo.id;
this.reportBody = mojo.reportBody;
this.reportUrl = mojo.reportUrl.url;
this.triggerTime = new Date(mojo.triggerTime);
this.reportTime = new Date(mojo.reportTime);
[this.status, this.sendFailed] =
Report.statusToString(mojo.status, 'Sent: ');
}
isPending(): boolean {
return this.status === 'Pending';
}
static statusToString(status: ReportStatus, sentPrefix: string):
[status: string, sendFailed: boolean] {
if (status.networkStatus !== undefined) {
return networkStatusToString(status.networkStatus, sentPrefix);
} else if (status.pending !== undefined) {
return ['Pending', false];
} else if (status.replacedByHigherPriorityReport !== undefined) {
return [
`Replaced by higher-priority report: ${
status.replacedByHigherPriorityReport}`,
false,
];
} else if (status.prohibitedByBrowserPolicy !== undefined) {
return ['Prohibited by browser policy', false];
} else if (status.failedToAssemble !== undefined) {
return ['Dropped due to assembly failure', false];
} else {
throw new Error('invalid ReportStatus union');
}
}
}
class EventLevelReport extends Report {
reportPriority: bigint;
randomizedReport: boolean;
constructor(mojo: WebUIReport) {
super(mojo);
this.reportPriority = mojo.data.eventLevelData!.priority;
this.randomizedReport = !mojo.data.eventLevelData!.attributedTruthfully;
}
}
class AggregatableReport extends Report {
contributions: string;
aggregationCoordinator: string;
isNullReport: boolean;
constructor(mojo: WebUIReport) {
super(mojo);
this.contributions = JSON.stringify(
mojo.data.aggregatableAttributionData!.contributions, bigintReplacer,
' ');
this.aggregationCoordinator =
mojo.data.aggregatableAttributionData!.aggregationCoordinator;
this.isNullReport = mojo.data.aggregatableAttributionData!.isNullReport;
}
}
function initPanel<T>(
panel: HTMLElement, cols: Iterable<DataColumn<T>>, initOpts: InitOpts<T>,
detailCols: Iterable<string|DataColumn<T>>,
onSelectionChange: (data: T|undefined) => void =
() => {}): AttributionInternalsTableElement<T> {
const t = panel.querySelector<AttributionInternalsTableElement<T>>(
'attribution-internals-table')!;
t.init(cols, initOpts);
const d = panel.querySelector<AttributionDetailTableElement<T>>(
'attribution-detail-table')!;
d.init([...cols, ...detailCols]);
t.addEventListener(
'selection-change', (e: CustomEvent<{data: T | undefined}>) => {
onSelectionChange(e.detail.data);
d.update(e.detail.data);
});
d.addEventListener('close', () => t.clearSelection());
return t;
}
function initReportTable<T extends Report>(
panel: HTMLElement, handler: HandlerInterface,
cols: Iterable<DataColumn<T>>): AttributionInternalsTableElement<T> {
const sendReportButton = panel.querySelector('button')!;
const t = initPanel<T>(
panel,
[
reportStatusColumn,
valueColumn('URL', 'reportUrl', asUrl),
valueColumn('Trigger Time', 'triggerTime', asDate),
valueColumn('Report Time', 'reportTime', asDate, /*defaultSort=*/ true),
...cols,
],
{
// Prevent sent/dropped reports from being removed by returning
// undefined.
getId: (report, updated) =>
(report.isPending() || updated) ? report.id.value : undefined,
isSelectable: true,
},
[valueColumn('Body', 'reportBody', asCode)],
(report: T|undefined) => sendReportButton.disabled =
!(report?.isPending()));
sendReportButton.addEventListener(
'click', () => sendReport(t, sendReportButton, handler));
return t;
}
/**
* Sends the selected report.
* Disables the button while the report is still being sent.
* Observer.onReportsChanged and Observer.onSourcesChanged will be called
* automatically as the report is deleted, so there's no need to manually
* refresh the data on completion.
*/
function sendReport<T extends Report>(
t: AttributionInternalsTableElement<T>, sendReportButton: HTMLButtonElement,
handler: HandlerInterface): void {
const id = t.selectedData()?.id;
if (id === undefined) {
return;
}
const previousText = sendReportButton.innerText;
sendReportButton.disabled = true;
sendReportButton.innerText = 'Sending...';
handler.sendReport(id).then(() => {
sendReportButton.innerText = previousText;
});
}
const registrationTypeText: Readonly<Record<RegistrationType, string>> = {
[RegistrationType.kSource]: 'Source',
[RegistrationType.kTrigger]: 'Trigger',
};
const osRegistrationResultText:
Readonly<Record<OsRegistrationResult, string>> = {
[OsRegistrationResult.kPassedToOs]: 'Passed to OS',
[OsRegistrationResult.kInvalidRegistrationUrl]:
'Invalid registration URL',
[OsRegistrationResult.kProhibitedByBrowserPolicy]:
'Prohibited by browser policy',
[OsRegistrationResult.kExcessiveQueueSize]: 'Excessive queue size',
[OsRegistrationResult.kRejectedByOs]: 'Rejected by OS',
};
interface OsRegistration {
time: Date;
registrationUrl: string;
topLevelOrigin: string;
registrationType: string;
debugKeyAllowed: boolean;
debugReporting: boolean;
result: string;
}
function newOsRegistration(mojo: WebUIOsRegistration): OsRegistration {
return {
time: new Date(mojo.time),
registrationUrl: mojo.registrationUrl.url,
topLevelOrigin: originToText(mojo.topLevelOrigin),
debugKeyAllowed: mojo.isDebugKeyAllowed,
debugReporting: mojo.debugReporting,
registrationType: `OS ${registrationTypeText[mojo.type]}`,
result: osRegistrationResultText[mojo.result],
};
}
function initOsRegistrationTable(
t: AttributionInternalsTableElement<OsRegistration>):
AttributionInternalsTableElement<OsRegistration> {
t.init([
valueColumn('Time', 'time', asDate, /*defaultSort=*/ true),
valueColumn('Type', 'registrationType', asStringOrBool),
valueColumn('URL', 'registrationUrl', asUrl),
valueColumn('Top-Level Origin', 'topLevelOrigin', asUrl),
valueColumn('Debug Key Allowed', 'debugKeyAllowed', asStringOrBool),
valueColumn('Debug Reporting', 'debugReporting', asStringOrBool),
valueColumn('Result', 'result', asStringOrBool),
]);
return t;
}
interface DebugReport {
body: string;
url: string;
time: Date;
status: string;
sendFailed: boolean;
}
function verboseDebugReport(mojo: WebUIDebugReport): DebugReport {
const report: DebugReport = {
body: mojo.body,
url: mojo.url.url,
time: new Date(mojo.time),
status: '',
sendFailed: false,
};
[report.status, report.sendFailed] =
networkStatusToString(mojo.status, /*sentPrefix=*/ '');
return report;
}
function attributionSuccessDebugReport(mojo: WebUIReport): DebugReport {
const [status, sendFailed] =
Report.statusToString(mojo.status, /*sentPrefix=*/ '');
return {
body: mojo.reportBody,
url: mojo.reportUrl.url,
time: new Date(mojo.reportTime),
status,
sendFailed,
};
}
const processAggregatableDebugReportResultText:
Readonly<Record<ProcessAggregatableDebugReportResult, string>> = {
[ProcessAggregatableDebugReportResult.kSuccess]: 'Success',
[ProcessAggregatableDebugReportResult.kNoDebugData]: 'No debug data',
[ProcessAggregatableDebugReportResult.kInsufficientBudget]:
'Insufficient budget',
[ProcessAggregatableDebugReportResult.kExcessiveReports]:
'Excessive reports',
[ProcessAggregatableDebugReportResult.kGlobalRateLimitReached]:
'Global rate-limit reached',
[ProcessAggregatableDebugReportResult.kReportingSiteRateLimitReached]:
'Per reporting site rate-limit reached',
[ProcessAggregatableDebugReportResult.kBothRateLimitsReached]:
'Both rate-limits reached',
[ProcessAggregatableDebugReportResult.kInternalError]: 'Internal error',
};
function aggregatableDebugReport(mojo: WebUIAggregatableDebugReport):
DebugReport {
const report: DebugReport = {
body: mojo.body,
url: mojo.url.url,
time: new Date(mojo.time),
status: '',
sendFailed: false,
};
const processStatus =
processAggregatableDebugReportResultText[mojo.processResult];
let sendStatus;
if (mojo.sendResult.networkStatus !== undefined) {
[sendStatus, report.sendFailed] = networkStatusToString(
mojo.sendResult.networkStatus, /*sentPrefix=*/ '');
} else if (mojo.sendResult.assemblyFailed !== undefined) {
sendStatus = 'Assembly failure';
} else {
throw new Error('invalid AggregatableDebugReportStatus union');
}
report.status = `${processStatus}, ${sendStatus}`;
return report;
}
function initDebugReportTable(panel: HTMLElement):
AttributionInternalsTableElement<DebugReport> {
return initPanel(
panel,
[
valueColumn('Time', 'time', asDate, /*defaultSort=*/ true),
valueColumn('URL', 'url', asUrl),
reportStatusColumn,
],
{isSelectable: true}, [
valueColumn('Body', 'body', asCode),
]);
}
// Converts a mojo origin into a user-readable string, omitting default ports.
function originToText(origin: Origin): string {
if (origin.host.length === 0) {
return 'Null';
}
let result = origin.scheme + '://' + origin.host;
if ((origin.scheme === 'https' && origin.port !== 443) ||
(origin.scheme === 'http' && origin.port !== 80)) {
result += ':' + origin.port;
}
return result;
}
const sourceTypeText: Readonly<Record<SourceType, string>> = {
[SourceType.kNavigation]: 'Navigation',
[SourceType.kEvent]: 'Event',
};
const triggerDataMatchingText: Readonly<Record<TriggerDataMatching, string>> = {
[TriggerDataMatching.kModulus]: 'modulus',
[TriggerDataMatching.kExact]: 'exact',
};
const attributabilityText:
Readonly<Record<WebUISource_Attributability, string>> = {
[WebUISource_Attributability.kAttributable]: 'Attributable',
[WebUISource_Attributability.kNoisedNever]:
'Unattributable: noised with no reports',
[WebUISource_Attributability.kNoisedFalsely]:
'Unattributable: noised with fake reports',
[WebUISource_Attributability.kReachedEventLevelAttributionLimit]:
'Attributable: reached event-level attribution limit',
};
const sourceRegistrationStatusText:
Readonly<Record<StoreSourceResult, string>> = {
[StoreSourceResult.kSuccess]: 'Success',
[StoreSourceResult.kSuccessNoised]: 'Success',
[StoreSourceResult.kInternalError]: 'Rejected: internal error',
[StoreSourceResult.kInsufficientSourceCapacity]:
'Rejected: insufficient source capacity',
[StoreSourceResult.kInsufficientUniqueDestinationCapacity]:
'Rejected: insufficient unique destination capacity',
[StoreSourceResult.kExcessiveReportingOrigins]:
'Rejected: excessive reporting origins',
[StoreSourceResult.kProhibitedByBrowserPolicy]:
'Rejected: prohibited by browser policy',
[StoreSourceResult.kDestinationReportingLimitReached]:
'Rejected: destination reporting limit reached',
[StoreSourceResult.kDestinationGlobalLimitReached]:
'Rejected: destination global limit reached',
[StoreSourceResult.kDestinationBothLimitsReached]:
'Rejected: destination both limits reached',
[StoreSourceResult.kExceedsMaxChannelCapacity]:
'Rejected: channel capacity exceeds max allowed',
[StoreSourceResult.kReportingOriginsPerSiteLimitReached]:
'Rejected: reached reporting origins per site limit',
[StoreSourceResult.kExceedsMaxTriggerStateCardinality]:
'Rejected: trigger state cardinality exceeds limit',
[StoreSourceResult.kDestinationPerDayReportingLimitReached]:
'Rejected: destination per day reporting limit reached',
[StoreSourceResult.kExceedsMaxScopesChannelCapacity]:
'Rejected: scopes channel capacity exceeds max allowed',
[StoreSourceResult.kExceedsMaxEventStatesLimit]:
'Rejected: event states exceeds limit',
};
const commonResult = {
success: 'Success: Report stored',
internalError: 'Failure: Internal error',
noMatchingImpressions: 'Failure: No matching sources',
noMatchingSourceFilterData: 'Failure: No matching source filter data',
deduplicated: 'Failure: Deduplicated against an earlier report',
noCapacityForConversionDestination:
'Failure: No report capacity for destination site',
excessiveAttributions: 'Failure: Excessive attributions',
excessiveReportingOrigins: 'Failure: Excessive reporting origins',
reportWindowPassed: 'Failure: Report window has passed',
excessiveReports: 'Failure: Excessive reports',
prohibitedByBrowserPolicy: 'Failure: Prohibited by browser policy',
};
const eventLevelResultText: Readonly<Record<EventLevelResult, string>> = {
[EventLevelResult.kSuccess]: commonResult.success,
[EventLevelResult.kSuccessDroppedLowerPriority]: commonResult.success,
[EventLevelResult.kInternalError]: commonResult.internalError,
[EventLevelResult.kNoMatchingImpressions]: commonResult.noMatchingImpressions,
[EventLevelResult.kNoMatchingSourceFilterData]:
commonResult.noMatchingSourceFilterData,
[EventLevelResult.kNoCapacityForConversionDestination]:
commonResult.noCapacityForConversionDestination,
[EventLevelResult.kExcessiveAttributions]: commonResult.excessiveAttributions,
[EventLevelResult.kExcessiveReportingOrigins]:
commonResult.excessiveReportingOrigins,
[EventLevelResult.kDeduplicated]: commonResult.deduplicated,
[EventLevelResult.kReportWindowNotStarted]:
'Failure: Report window has not started',
[EventLevelResult.kReportWindowPassed]: commonResult.reportWindowPassed,
[EventLevelResult.kPriorityTooLow]: 'Failure: Priority too low',
[EventLevelResult.kNeverAttributedSource]: 'Failure: Noised',
[EventLevelResult.kFalselyAttributedSource]: 'Failure: Noised',
[EventLevelResult.kNotRegistered]: 'Failure: No event-level data present',
[EventLevelResult.kProhibitedByBrowserPolicy]:
commonResult.prohibitedByBrowserPolicy,
[EventLevelResult.kNoMatchingConfigurations]:
'Failure: no matching event-level configurations',
[EventLevelResult.kExcessiveReports]: commonResult.excessiveReports,
[EventLevelResult.kNoMatchingTriggerData]:
'Failure: no matching trigger data',
};
const aggregatableResultText: Readonly<Record<AggregatableResult, string>> = {
[AggregatableResult.kSuccess]: commonResult.success,
[AggregatableResult.kInternalError]: commonResult.internalError,
[AggregatableResult.kNoMatchingImpressions]:
commonResult.noMatchingImpressions,
[AggregatableResult.kNoMatchingSourceFilterData]:
commonResult.noMatchingSourceFilterData,
[AggregatableResult.kNoCapacityForConversionDestination]:
commonResult.noCapacityForConversionDestination,
[AggregatableResult.kExcessiveAttributions]:
commonResult.excessiveAttributions,
[AggregatableResult.kExcessiveReportingOrigins]:
commonResult.excessiveReportingOrigins,
[AggregatableResult.kDeduplicated]: commonResult.deduplicated,
[AggregatableResult.kReportWindowPassed]: commonResult.reportWindowPassed,
[AggregatableResult.kNoHistograms]: 'Failure: No source histograms',
[AggregatableResult.kInsufficientBudget]: 'Failure: Insufficient budget',
[AggregatableResult.kNotRegistered]: 'Failure: No aggregatable data present',
[AggregatableResult.kProhibitedByBrowserPolicy]:
commonResult.prohibitedByBrowserPolicy,
[AggregatableResult.kExcessiveReports]: commonResult.excessiveReports,
};
const attributionSupportText: Readonly<Record<AttributionSupport, string>> = {
[AttributionSupport.kWeb]: 'web',
[AttributionSupport.kWebAndOs]: 'os, web',
[AttributionSupport.kOs]: 'os',
[AttributionSupport.kNone]: '',
[AttributionSupport.kUnset]: 'unset',
};
class AttributionInternals implements ObserverInterface {
private readonly sources: AttributionInternalsTableElement<Source>;
private readonly sourceRegistrations:
AttributionInternalsTableElement<SourceRegistration>;
private readonly triggers: AttributionInternalsTableElement<Trigger>;
private readonly debugReports: AttributionInternalsTableElement<DebugReport>;
private readonly osRegistrations:
AttributionInternalsTableElement<OsRegistration>;
private readonly eventLevelReports:
AttributionInternalsTableElement<EventLevelReport>;
private readonly aggregatableReports:
AttributionInternalsTableElement<AggregatableReport>;
private readonly handler = new HandlerRemote();
constructor() {
this.eventLevelReports = initReportTable<EventLevelReport>(
document.querySelector('#event-level-report-panel')!, this.handler, [
valueColumn('Priority', 'reportPriority', asNumber),
valueColumn('Randomized', 'randomizedReport', asStringOrBool),
]);
this.aggregatableReports = initReportTable<AggregatableReport>(
document.querySelector('#aggregatable-report-panel')!, this.handler, [
valueColumn('Histograms', 'contributions', asCode),
valueColumn(
'Aggregation Coordinator', 'aggregationCoordinator', asUrl),
valueColumn('Null', 'isNullReport', asStringOrBool),
]);
this.sources =
initSourceTable(document.querySelector('#active-source-panel')!);
this.sourceRegistrations = initSourceRegistrationTable(
document.querySelector('#source-registration-panel')!);
this.triggers = initTriggerTable(
document.querySelector('#trigger-registration-panel')!);
this.debugReports =
initDebugReportTable(document.querySelector('#debug-report-panel')!);
this.osRegistrations = initOsRegistrationTable(
document.querySelector('#osRegistrationTable')!);
const tabs = document.querySelectorAll<HTMLElement>('div[slot="tab"]');
const panels = document.querySelectorAll<HTMLElement>('div[slot="panel"]');
for (let i = 0; i < panels.length && i < tabs.length; ++i) {
const tab = tabs[i]!;
panels[i]!.addEventListener(
'rows-change',
e => tab.classList.toggle(
'unread',
!tab.hasAttribute('selected') && e.detail.rowCount > 0));
}
Factory.getRemote().create(
new ObserverReceiver(this).$.bindNewPipeAndPassRemote(),
this.handler.$.bindNewPipeAndPassReceiver());
}
onReportHandled(mojo: WebUIReport): void {
this.addSentOrDroppedReport(mojo);
}
onDebugReportSent(mojo: WebUIDebugReport): void {
this.debugReports.addRow(verboseDebugReport(mojo));
}
onAggregatableDebugReportSent(mojo: WebUIAggregatableDebugReport): void {
this.debugReports.addRow(aggregatableDebugReport(mojo));
}
onSourceHandled(mojo: WebUISourceRegistration): void {
this.sourceRegistrations.addRow(new SourceRegistration(mojo));
}
onTriggerHandled(mojo: WebUITrigger): void {
this.triggers.addRow(new Trigger(mojo));
}
onOsRegistration(mojo: WebUIOsRegistration): void {
this.osRegistrations.addRow(newOsRegistration(mojo));
}
private addSentOrDroppedReport(mojo: WebUIReport): void {
if (isAttributionSuccessDebugReport(mojo.reportUrl.url)) {
this.debugReports.addRow(attributionSuccessDebugReport(mojo));
} else if (mojo.data.eventLevelData !== undefined) {
this.eventLevelReports.addRow(new EventLevelReport(mojo));
} else {
this.aggregatableReports.addRow(new AggregatableReport(mojo));
}
}
/**
* Deletes all data stored by the conversions backend.
* onReportsChanged and onSourcesChanged will be called
* automatically as data is deleted, so there's no need to manually refresh
* the data on completion.
*/
clearStorage(): void {
this.sourceRegistrations.clearRows();
this.triggers.clearRows();
this.eventLevelReports.clearRows(report => !report.isPending());
this.aggregatableReports.clearRows(report => !report.isPending());
this.debugReports.clearRows();
this.osRegistrations.clearRows();
this.handler.clearStorage();
}
onDebugModeChanged(debugMode: boolean): void {
const reportDelaysContent =
document.querySelector<HTMLElement>('#report-delays')!;
const noiseContent = document.querySelector<HTMLElement>('#noise')!;
if (debugMode) {
reportDelaysContent.innerText = 'disabled';
noiseContent.innerText = 'disabled';
} else {
reportDelaysContent.innerText = 'enabled';
noiseContent.innerText = 'enabled';
}
}
refresh(): void {
this.handler.isAttributionReportingEnabled().then((response) => {
const featureStatus =
document.querySelector<HTMLElement>('#feature-status')!;
featureStatus.innerText = response.enabled ? 'enabled' : 'disabled';
const attributionSupport = document.querySelector<HTMLElement>('#attribution-support')!;
attributionSupport.innerText =
attributionSupportText[response.attributionSupport];
});
}
onSourcesChanged(sources: WebUISource[]): void {
this.sources.updateRows(function*() {
for (const source of sources) {
yield newSource(source);
}
}());
}
onReportsChanged(reports: WebUIReport[]): void {
this.eventLevelReports.updateRows(function*() {
for (const report of reports) {
if (report.data.eventLevelData !== undefined) {
yield new EventLevelReport(report);
}
}
}());
this.aggregatableReports.updateRows(function*() {
for (const report of reports) {
if (report.data.aggregatableAttributionData !== undefined) {
yield new AggregatableReport(report);
}
}
}());
}
}
document.addEventListener('DOMContentLoaded', function() {
const tabBox = document.querySelector('cr-tab-box')!;
tabBox.addEventListener('selected-index-change', e => {
const tabs = document.querySelectorAll<HTMLElement>('div[slot="tab"]');
tabs[e.detail]!.classList.remove('unread');
});
const internals = new AttributionInternals();
document.querySelector('#refresh')!.addEventListener(
'click', () => internals.refresh());
document.querySelector('#clear-data')!.addEventListener(
'click', () => internals.clearStorage());
tabBox.hidden = false;
internals.refresh();
});