Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -1,12 +1,49 @@
import React, { useEffect } from "react";
import { Button, Grid, Loader, Alert } from "@webiny/admin-ui";
import { getMachineId } from "@webiny/telemetry/react.js";
import { Center } from "./Center.js";
import { Container } from "./Container.js";
import type {
ErrorObject,
ISystemInstallerPresenter
} from "~/presentation/installation/presenters/SystemInstaller/abstractions.js";

const INSTALL_FINISH_URL =
process.env.REACT_APP_WEBINY_INSTALL_FINISH_URL || "https://www.webiny.com/install/finish";

/**
* If telemetry is enabled AND the admin is hosted on CloudFront (production
* deployment), route the "Start using Webiny" CTA through the marketing
* site's /install/finish page so the website's anonymous wts_did cookie can
* be aliased to the deployer's machine_id. Falls through to the local
* `finishInstallation` flow otherwise.
*/
const buildInstallFinishHref = (currentUrl: string): string | null => {
if (process.env.REACT_APP_WEBINY_TELEMETRY === "false") {
return null;
}

if (typeof window === "undefined") {
return null;
}
const isCloudFrontHost = window.location.hostname.endsWith(".cloudfront.net");
const allowAlternate = Boolean(process.env.REACT_APP_WEBINY_INSTALL_FINISH_URL);
if (!isCloudFrontHost && !allowAlternate) {
return null;
}

const machineId = getMachineId();
if (!machineId) {
return null;
}

const params = new URLSearchParams({
machine_id: machineId,
return_to: currentUrl
});
return `${INSTALL_FINISH_URL}?${params.toString()}`;
};

interface StepProps {
error?: ErrorObject;
isInstalled: boolean;
Expand All @@ -26,6 +63,19 @@ export const FinishSetupStep = ({
installSystem();
}, []);

const handleStartUsing = () => {
if (typeof window !== "undefined") {
const handoff = buildInstallFinishHref(
window.location.origin + window.location.pathname
);
if (handoff) {
window.location.assign(handoff);
return;
}
}
finishInstallation();
};

const subtitle = isInstalled
? "Setup complete! Everything went smooth as a breeze!"
: "We're finalizing installation of Webiny...please wait.";
Expand Down Expand Up @@ -74,7 +124,7 @@ export const FinishSetupStep = ({
variant={"primary"}
size={"lg"}
text={"Start using Webiny"}
onClick={finishInstallation}
onClick={handleStartUsing}
/>
</Grid.Column>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,11 @@ class SystemInstallerPresenterImpl implements Abstraction.Interface {
});
await this.repository.installSystem(installationInput);

// We intentionally do NOT send `projectName` or `organizationName` to
// telemetry — those are user-typed free-text fields that risk
// identifying the user's company. Only the categorical
// `referralSource` is forwarded for funnel attribution.
await this.telemetry.sendEvent("install-wizard-end", {
project: basicInfo.projectName,
organization: basicInfo.organizationName,
referralSource: basicInfo.referralSource
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import fs from "fs-extra";
import path from "path";
import { GetProjectRootPath } from "../../../../services/GetProjectRootPath.js";
Expand Down Expand Up @@ -40,5 +41,14 @@ export class SetupBaseWebinyProject {
}
);
}

// Anonymous per-project identifier used by telemetry to group CLI/admin
// events at the install level. Tracked in git (not in .webiny/) so it
// stays stable across machines collaborating on the same project.
fs.writeJsonSync(
path.join(projectRootFolderPath, "webiny.installation.json"),
{ installationId: randomUUID() },
{ spaces: 2 }
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "~/abstractions/index.js";
import { globalConfig } from "@webiny/global-config";
import { isCI } from "ci-info";
import { readInstallationId } from "../installationId.js";

class SetAdminAppEnvVarsBeforeBuildImpl implements AdminBeforeBuild.Interface {
constructor(
Expand All @@ -25,6 +26,13 @@ class SetAdminAppEnvVarsBeforeBuildImpl implements AdminBeforeBuild.Interface {
process.env.REACT_APP_WCP_PROJECT_ID = projectId;
}

if (!("REACT_APP_WEBINY_INSTALLATION_ID" in process.env)) {
const installationId = readInstallationId();
if (installationId) {
process.env.REACT_APP_WEBINY_INSTALLATION_ID = installationId;
}
}

if (!("REACT_APP_WEBINY_TELEMETRY" in process.env)) {
process.env.REACT_APP_WEBINY_TELEMETRY = String(telemetry);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "~/abstractions/index.js";
import { globalConfig } from "@webiny/global-config";
import { isCI } from "ci-info";
import { readInstallationId } from "../installationId.js";

class SetAdminAppEnvVarsBeforeWatchImpl implements AdminBeforeWatch.Interface {
constructor(
Expand All @@ -25,6 +26,13 @@ class SetAdminAppEnvVarsBeforeWatchImpl implements AdminBeforeWatch.Interface {
process.env.REACT_APP_WCP_PROJECT_ID = projectId;
}

if (!("REACT_APP_WEBINY_INSTALLATION_ID" in process.env)) {
const installationId = readInstallationId();
if (installationId) {
process.env.REACT_APP_WEBINY_INSTALLATION_ID = installationId;
}
}

if (!("REACT_APP_WEBINY_TELEMETRY" in process.env)) {
process.env.REACT_APP_WEBINY_TELEMETRY = String(telemetry);
}
Expand Down
27 changes: 27 additions & 0 deletions packages/project/src/extensions/installationId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";

/**
* Reads the anonymous per-project installation id from
* `<project-root>/webiny.installation.json` (relative to `process.cwd()`).
*
* The file is generated once at `create-webiny-project` time and tracked in
* git, so this id is stable across machines that share the same Webiny
* project. Used by the build step to expose `REACT_APP_WEBINY_INSTALLATION_ID`
* to the admin bundle.
*
* Returns null if the file is missing or unreadable — builds proceed without
* the env var; telemetry events fire without the `installation_id` property.
*/
export function readInstallationId(): string | null {
try {
const path = join(process.cwd(), "webiny.installation.json");
if (!existsSync(path)) {
return null;
}
const data = JSON.parse(readFileSync(path, "utf8"));
return typeof data?.installationId === "string" ? data.installationId : null;
} catch {
return null;
}
}
1 change: 0 additions & 1 deletion packages/telemetry/cli.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export declare interface SendEventParams {
event: string;
user?: string;
version?: string;
properties: Record<string, any>;
}
Expand Down
32 changes: 28 additions & 4 deletions packages/telemetry/cli.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { globalConfig } from "@webiny/global-config";
import { isCI } from "ci-info";
import { WTS } from "wts-client/node.js";
import { WTS } from "@webiny/wts-client/node";
import baseSendEvent from "./sendEvent.js";
import { loadJsonFileSync } from "load-json-file";
import path from "path";

export const sendEvent = async ({ event, user, version, properties }) => {
export const sendEvent = async ({ event, version, properties }) => {
const shouldSend = isEnabled();
if (!shouldSend) {
return;
}

const wts = new WTS();
// The WTS client reads the machine id from `~/.webiny/config` (user.id field)
// via the same path globalConfig writes to. No need to pass user explicitly.
const wts = new WTS({ source: "cli" });

const wcpProperties = {};
const [wcpOrgId, wcpProjectId] = getWcpOrgProjectId();
Expand All @@ -20,15 +22,21 @@ export const sendEvent = async ({ event, user, version, properties }) => {
wcpProperties.wcpProjectId = wcpProjectId;
}

const installationProperties = {};
const installationId = getInstallationId();
if (installationId) {
installationProperties.installation_id = installationId;
}

const packageJsonPath = path.join(import.meta.dirname, "package.json");
const packageJson = loadJsonFileSync(packageJsonPath);

return baseSendEvent({
event,
user: user || globalConfig.get("id"),
properties: {
...properties,
...wcpProperties,
...installationProperties,
version: version || packageJson.version,
ci: isCI,
newUser: Boolean(globalConfig.get("newUser"))
Expand All @@ -46,6 +54,22 @@ const getWcpOrgProjectId = () => {
return [];
};

/**
* Reads the anonymous per-project installation id from
* `<project-root>/webiny.installation.json`. Generated once at
* `create-webiny-project` time and tracked in git so it stays stable across
* machines that share the project. Returns null if the file is missing or
* unreadable — telemetry events still fire, just without the property.
*/
const getInstallationId = () => {
try {
const data = loadJsonFileSync(path.join(process.cwd(), "webiny.installation.json"));
return typeof data?.installationId === "string" ? data.installationId : null;
} catch {
return null;
}
};

export const enable = () => {
globalConfig.set("telemetry", true);
};
Expand Down
4 changes: 2 additions & 2 deletions packages/telemetry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
"license": "MIT",
"dependencies": {
"@webiny/global-config": "0.0.0",
"@webiny/wts-client": "^3.0.1",
"ci-info": "^4.4.0",
"jsesc": "^3.1.0",
"load-json-file": "^7.0.1",
"strip-ansi": "^7.2.0",
"wts-client": "^2.0.0"
"strip-ansi": "^7.2.0"
},
"publishConfig": {
"access": "public",
Expand Down
1 change: 1 addition & 0 deletions packages/telemetry/react.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export declare function sendEvent(ev: string, properties?: Record<string, any>): Promise<any>;
export declare function getMachineId(): string | null;
97 changes: 94 additions & 3 deletions packages/telemetry/react.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,104 @@
import baseSendEvent from "./sendEvent.js";
import { WTS } from "wts-client/admin.js";
import { WTS } from "@webiny/wts-client/web";

const STORAGE_MACHINE_ID = "wts_machine_id";
const STORAGE_PROJECT_ID = "wts_project_id";

let wtsInstance = null;
let projectId = null;

/**
* Resolves the WTS client identity for the admin app.
*
* Priority for `distinct_id` (machine_id):
* 1. URL param `wts_did` on first load. Persisted to localStorage.
* 2. localStorage (subsequent loads).
* 3. `process.env.REACT_APP_WEBINY_TELEMETRY_USER_ID` (build-time fallback,
* set by `SetAdminAppEnvVarsBefore{Build,Watch}` from `~/.webiny/config`).
*
* Priority for `project_id` (installation_id):
* 1. URL param `iid` on first load. Persisted to localStorage.
* 2. localStorage.
* 3. `process.env.REACT_APP_WEBINY_INSTALLATION_ID` (build-time fallback,
* set from `<project>/webiny.installation.json`).
*
* Attached as a super-property on every admin event so PostHog funnels can
* group per-install.
*/
const initWts = () => {
if (wtsInstance) {
return wtsInstance;
}

let distinctId = process.env.REACT_APP_WEBINY_TELEMETRY_USER_ID;
projectId = process.env.REACT_APP_WEBINY_INSTALLATION_ID || null;

if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);

const fromUrl = params.get("wts_did");
if (fromUrl) {
distinctId = fromUrl;
try {
window.localStorage.setItem(STORAGE_MACHINE_ID, fromUrl);
} catch {
// localStorage unavailable; URL value is used for this session only.
}
} else {
try {
distinctId = window.localStorage.getItem(STORAGE_MACHINE_ID) || distinctId;
} catch {
// ignore
}
}

const iidFromUrl = params.get("iid");
if (iidFromUrl) {
projectId = iidFromUrl;
try {
window.localStorage.setItem(STORAGE_PROJECT_ID, iidFromUrl);
} catch {
// ignore
}
} else {
try {
projectId = window.localStorage.getItem(STORAGE_PROJECT_ID) || projectId;
} catch {
// env-var value (set above) is used as fallback.
}
}
}

wtsInstance = new WTS({ source: "admin", distinctId });
return wtsInstance;
};

/**
* Returns the machine_id used by admin events, if known. Used by the
* install/finish CTA to construct the alias handoff URL.
*/
export const getMachineId = () => {
initWts();
if (typeof window !== "undefined") {
try {
const stored = window.localStorage.getItem(STORAGE_MACHINE_ID);
if (stored) {
return stored;
}
} catch {
// ignore
}
}
return process.env.REACT_APP_WEBINY_TELEMETRY_USER_ID || null;
};

export const sendEvent = async (event, properties = {}) => {
const shouldSend = process.env.REACT_APP_WEBINY_TELEMETRY !== "false";
if (!shouldSend) {
return;
}

const wts = new WTS();
const wts = initWts();

const wcpProperties = {};
const [wcpOrgId, wcpProjectId] = getWcpOrgProjectId();
Expand All @@ -18,10 +109,10 @@ export const sendEvent = async (event, properties = {}) => {

return baseSendEvent({
event,
user: process.env.REACT_APP_WEBINY_TELEMETRY_USER_ID,
properties: {
...properties,
...wcpProperties,
...(projectId ? { project_id: projectId } : {}),
version: process.env.REACT_APP_WEBINY_VERSION,
ci: process.env.REACT_APP_IS_CI === "true",
newUser: process.env.REACT_APP_WEBINY_TELEMETRY_NEW_USER === "true"
Expand Down
Loading
Loading