Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/playground/blueprints/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export { runBlueprintV2 } from './lib/v2/run-blueprint-v2';
export type { BlueprintMessage } from './lib/v2/run-blueprint-v2';
export {
compileBlueprintV2,
createBlueprintV2ExecutionPlan,
resolveBlueprintV2WordPressSource,
} from './lib/v2/compile';
export type {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { bootSiteClient } from './boot-site-client';
import reducer, { sitesSlice, type SiteInfo } from './slice-sites';
import type { PlaygroundReduxState } from './store';
import { logBlueprintEvents } from '../../tracking';

vi.mock('@wp-playground/client', () => ({
startPlaygroundWeb: vi.fn(),
Expand Down Expand Up @@ -285,6 +286,7 @@ describe('bootSiteClient', () => {

expect(startPlaygroundWeb).toHaveBeenCalledWith(
expect.objectContaining({
onBlueprintValidated: logBlueprintEvents,
wordpressInstallMode: 'install-from-existing-files-if-needed',
mounts: [
expect.objectContaining({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { addClientInfo, updateClientInfo } from './slice-clients';
import { logBlueprintEvents, logTrackingEvent } from '../../tracking';
import {
type Blueprint,
type BlueprintDeclaration,
BlueprintFilesystemRequiredError,
InvalidBlueprintError,
isBlueprintBundle,
Expand Down Expand Up @@ -245,7 +244,7 @@ export function bootSiteClient(
playgroundClient;
},
// Log Blueprint events
onBlueprintValidated: logSupportedBlueprintEvents,
onBlueprintValidated: logBlueprintEvents,
mounts,
wordpressInstallMode,
corsProxy: corsProxyUrl,
Expand Down Expand Up @@ -530,13 +529,6 @@ function waitForPendingOpfsSiteRemovalRetry(
});
}

function logSupportedBlueprintEvents(blueprint: BlueprintDeclaration) {
if ('version' in blueprint && blueprint.version === 2) {
return;
}
return logBlueprintEvents(blueprint as any);
}

/**
* Copies files created during a saved site's first boot from MEMFS into OPFS.
*
Expand Down
120 changes: 119 additions & 1 deletion packages/playground/website/src/lib/tracking.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// @vitest-environment jsdom

import type { BlueprintV1 } from '@wp-playground/blueprints';
import type {
BlueprintBundle,
BlueprintV1,
BlueprintV2Declaration,
} from '@wp-playground/blueprints';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { logBlueprintEvents } from './tracking';

Expand Down Expand Up @@ -74,4 +78,118 @@ describe('logBlueprintEvents', () => {
theme: 'resource:git:directory',
});
});

it('logs v2 operations without exposing data-reference details', async () => {
Comment thread
adamziel marked this conversation as resolved.
const gtag = vi.fn();
window.gtag = gtag;
const blueprint = {
version: 2,
constants: { WP_DEBUG: true },
siteOptions: { blogname: 'Private site name' },
plugins: [
'gutenberg@latest',
{
source: 'https://private.example/plugin.zip?token=secret',
},
],
themes: [
{
gitRepository: 'https://private.example/theme.git',
ref: 'private-branch',
},
],
activeTheme: 'twentytwentyfive',
muPlugins: [
{
filename: 'private-mu-plugin.php',
content: '<?php // secret code',
},
],
media: ['./private-image.png'],
siteLanguage: 'pl_PL',
additionalStepsAfterExecution: [
{
step: 'installPlugin',
source: './private-plugin.zip',
},
{
step: 'installTheme',
source: {
directoryName: 'private-theme',
files: {},
},
},
{
step: 'runPHP',
code: {
filename: 'private-code.php',
content: '<?php // another secret',
},
},
],
} satisfies BlueprintV2Declaration;

await logBlueprintEvents(blueprint);

expect(gtag).toHaveBeenCalledWith('event', 'step', {
step: 'defineWpConfigConsts',
});
expect(gtag).toHaveBeenCalledWith('event', 'step', {
step: 'installMuPlugin',
});
expect(gtag).toHaveBeenCalledWith('event', 'step', {
step: 'runPHP',
});
expect(gtag).toHaveBeenCalledWith('event', 'installPlugin', {
resource: 'wordpress.org/plugins',
plugin: 'gutenberg@latest',
});
expect(gtag).toHaveBeenCalledWith('event', 'installPlugin', {
resource: 'url',
plugin: 'resource:url',
});
expect(gtag).toHaveBeenCalledWith('event', 'installPlugin', {
resource: 'bundled',
plugin: 'resource:bundled',
});
expect(gtag).toHaveBeenCalledWith('event', 'installTheme', {
resource: 'git:directory',
theme: 'resource:git:directory',
});
expect(gtag).toHaveBeenCalledWith('event', 'installTheme', {
resource: 'literal:directory',
theme: 'resource:literal:directory',
});

const reportedData = JSON.stringify(gtag.mock.calls);
expect(reportedData).not.toContain('private.example');
expect(reportedData).not.toContain('private-plugin.zip');
expect(reportedData).not.toContain('private-theme');
expect(reportedData).not.toContain('secret code');
});

it('reads v2 events from Blueprint bundles', async () => {
const gtag = vi.fn();
window.gtag = gtag;
const bundle = {
read: vi.fn(async () => ({
text: async () =>
JSON.stringify({
version: 2,
additionalStepsAfterExecution: [
{
step: 'mkdir',
path: 'wp-content/uploads',
},
],
}),
})),
} as unknown as BlueprintBundle;

await logBlueprintEvents(bundle);

expect(gtag).toHaveBeenCalledWith('event', 'step', {
step: 'mkdir',
});
});
});
169 changes: 152 additions & 17 deletions packages/playground/website/src/lib/tracking.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { BlueprintV1 } from '@wp-playground/blueprints';
import {
getBlueprintDeclaration,
BlueprintReflection,
type Blueprint,
type BlueprintV1Declaration,
type BlueprintV2Declaration,
createBlueprintV2ExecutionPlan,
isStepDefinition,
} from '@wp-playground/blueprints';
import { logger } from '@php-wasm/logger';
Expand Down Expand Up @@ -44,21 +47,35 @@ export const logTrackingEvent = (
};

/**
* Log Blueprint events
* @param blueprint The Blueprint
* Reports declared Blueprint operations without reporting their arguments.
*
* Each operation sends only its name; options such as code and passwords are
* not reported. Plugin and theme installs also send a WordPress.org slug when
* one exists. Every other source is reduced to its resource type so URLs,
* paths, inline data, and Git details never enter analytics.
*
* @param blueprint The Blueprint declaration or bundle to inspect.
*/
export const logBlueprintEvents = async (blueprint: BlueprintV1) => {
/**
* Log the names of provided Blueprint steps.
* Only the names (e.g. "runPhp" or "login") are logged. Step options like
* code, password, URLs are never sent anywhere.
*
* For installPlugin and installTheme, the plugin/theme slug is logged.
* When there is no slug, the prefixed resource type is logged instead.
*/
const blueprintDeclaration = await getBlueprintDeclaration(blueprint);
if (blueprintDeclaration.steps) {
for (const step of blueprintDeclaration.steps) {
export const logBlueprintEvents = async (blueprint: Blueprint) => {
const blueprintDeclaration = (
await BlueprintReflection.create(blueprint)
).getDeclaration();
if (isBlueprintV2Declaration(blueprintDeclaration)) {
logBlueprintV2Events(blueprintDeclaration);
return;
}
logBlueprintV1Events(blueprintDeclaration);
};

/**
* Reports executable v1 steps and safe identifiers for asset installations.
*
* Non-step entries are ignored so analytics only reflects executable step
* definitions.
*/
function logBlueprintV1Events(blueprint: BlueprintV1Declaration) {
if (blueprint.steps) {
for (const step of blueprint.steps) {
if (!isStepDefinition(step)) {
continue;
}
Expand All @@ -80,7 +97,125 @@ export const logBlueprintEvents = async (blueprint: BlueprintV1) => {
}
}
}
};
}

/**
* Reports v2 operations from the ordered plan used by the TypeScript runner.
*
* Reading the execution plan keeps analytics aligned with operations implied
* by top-level v2 fields as well as explicitly declared steps.
*/
function logBlueprintV2Events(blueprint: BlueprintV2Declaration) {
for (const item of createBlueprintV2ExecutionPlan(blueprint)) {
const step = item.type === 'runStep' ? item.step.step : item.type;
logTrackingEvent('step', { step });
if (item.type === 'installPlugin') {
logBlueprintV2AssetEvent('plugin', item.plugin);
} else if (item.type === 'installTheme') {
logBlueprintV2AssetEvent('theme', item.theme);
} else if (
item.type === 'runStep' &&
item.step.step === 'installPlugin'
) {
logBlueprintV2AssetEvent('plugin', item.step);
} else if (
item.type === 'runStep' &&
item.step.step === 'installTheme'
) {
logBlueprintV2AssetEvent('theme', item.step);
}
Comment thread
adamziel marked this conversation as resolved.
}
}

type BlueprintV2Asset =
| NonNullable<BlueprintV2Declaration['plugins']>[number]
| NonNullable<BlueprintV2Declaration['themes']>[number]
| NonNullable<BlueprintV2Declaration['activeTheme']>
| Extract<
NonNullable<
BlueprintV2Declaration['additionalStepsAfterExecution']
>[number],
{ step: 'installPlugin' | 'installTheme' }
>;

/**
* Reports an asset installation without exposing its data-reference contents.
*
* Only WordPress.org string sources are reported verbatim. URLs, bundled
* paths, inline content, and Git references are represented by resource type.
*/
function logBlueprintV2AssetEvent(
type: 'plugin' | 'theme',
asset: BlueprintV2Asset
) {
const source = getBlueprintV2AssetSource(asset);
const resource = getBlueprintV2ResourceType(source, type);
const identifier =
typeof source === 'string' && resource === `wordpress.org/${type}s`
? source
: `resource:${resource}`;
Comment thread
adamziel marked this conversation as resolved.
logTrackingEvent(type === 'plugin' ? 'installPlugin' : 'installTheme', {
resource,
[type]: identifier,
});
}

/**
* Returns the data reference from an asset's shorthand or `{ source }` form.
*/
function getBlueprintV2AssetSource(asset: BlueprintV2Asset): unknown {
if (asset && typeof asset === 'object' && 'source' in asset) {
return asset.source;
}
return asset;
}

/**
* Maps a v2 source shape to the v1 resource name used by existing analytics.
*
* Unknown shapes stay anonymous rather than falling back to serialized source
* data.
*/
function getBlueprintV2ResourceType(source: unknown, type: 'plugin' | 'theme') {
if (typeof source === 'string') {
if (isHttpUrl(source)) {
return 'url';
}
if (source.startsWith('./') || source.startsWith('/')) {
return 'bundled';
}
return `wordpress.org/${type}s`;
}
Comment thread
adamziel marked this conversation as resolved.
if (source && typeof source === 'object') {
if ('filename' in source) {
return 'literal';
}
if ('directoryName' in source) {
return 'literal:directory';
}
if ('gitRepository' in source) {
return 'git:directory';
}
}
return 'unknown';
}

/** Indicates whether a source is an absolute HTTP(S) URL. */
function isHttpUrl(value: string) {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}

/** Narrows a reflected declaration using v2's required version discriminator. */
function isBlueprintV2Declaration(
blueprint: BlueprintV1Declaration | BlueprintV2Declaration
): blueprint is BlueprintV2Declaration {
return (blueprint as { version?: unknown }).version === 2;
}

function getResourceIdentifier(resource: { resource: string; slug?: string }) {
if (resource.slug) {
Expand Down
Loading