Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ env:
# renovate: datasource=npm depName=npm
NPM_VERSION: "11"
# renovate: datasource=npm depName=@microbit-foundation/python-editor-v3-microbit registryUrl=https://npm.pkg.github.com
THEME_VERSION: 0.3.0
THEME_VERSION: 0.3.0-analytics.ga4.98
# renovate: datasource=npm depName=@microbit-foundation/website-deploy-aws registryUrl=https://npm.pkg.github.com
DEPLOY_AWS_VERSION: "0.6.0"
# renovate: datasource=npm depName=@microbit-foundation/website-deploy-aws-config registryUrl=https://npm.pkg.github.com
Expand Down
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ its own copies of React and friends, so leave it in place either way.
After bumping the pinned `@microbit/ui` version, follow "Upgrading in an app" in
`../ui/packages/ui/README.md`.

## Analytics

Events go through `Logging` (`src/logging/`) and are documented in
`docs/analytics-events.md`; update the doc when adding or changing an event.
Names are snake_case with flat primitive params. gtag only exists on
Foundation builds (`VITE_FOUNDATION_BUILD`, see `index.html`), so OSS and
local dev log events to the console instead. The private theme package
supplies brand config only, including the `product` analytics slug.
Comment on lines +24 to +29

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Events go through `Logging` (`src/logging/`) and are documented in
`docs/analytics-events.md`; update the doc when adding or changing an event.
Names are snake_case with flat primitive params. gtag only exists on
Foundation builds (`VITE_FOUNDATION_BUILD`, see `index.html`), so OSS and
local dev log events to the console instead. The private theme package
supplies brand config only, including the `product` analytics slug.
Events go through `Logging` (`src/logging/`) and are documented in
`docs/analytics-events.md`; update the doc when adding or changing an event.
Names are snake_case with flat primitive params. gtag only exists on
Foundation builds (`VITE_FOUNDATION_BUILD`, see `index.html`), so OSS and
local dev log events to the console instead.

Remove irrelevant note.


## Commands

- Unit tests: `npm test` (vitest). E2e: run headlessly via
Expand Down
299 changes: 299 additions & 0 deletions docs/analytics-events.md

Large diffs are not rendered by default.

90 changes: 90 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@microbit/ui-patterns": "^0.4.0",
"@sanity/block-content-to-react": "^3.0.0",
"@sanity/image-url": "^1.0.1",
"@sentry/browser": "^10.71.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
Expand Down
18 changes: 15 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import "./App.css";
import { DialogProvider } from "./common/use-dialogs";
import VisualViewPortCSSVariables from "./common/VisualViewportCSSVariables";
import { deployment, useDeployment } from "./deployment";
import { createUSBConnection } from "@microbit/microbit-connection/usb";
import { ConnectionStatusChange } from "@microbit/microbit-connection";
import {
MicrobitUSBConnection,
createUSBConnection,
} from "@microbit/microbit-connection/usb";
import { DeviceContextProvider } from "./device/device-hooks";
import { MockDeviceConnection } from "./device/mock";
import DocumentationProvider from "./documentation/documentation-hooks";
Expand All @@ -21,6 +25,7 @@ import { FileSystemProvider } from "./fs/fs-hooks";
import { createHost } from "./fs/host";
import { fetchMicroPython } from "./micropython/micropython";
import { LanguageServerClientProvider } from "./language-server/language-server-hooks";
import { logDeviceStatusChange } from "./logging/analytics";
import { LoggingProvider } from "./logging/logging-hooks";
import TranslationProvider from "./messages/TranslationProvider";
import ProjectDropTarget from "./project/ProjectDropTarget";
Expand All @@ -38,7 +43,7 @@ const isMockDeviceMode = () =>
);

const logging = deployment.logging;
const device = isMockDeviceMode()
const device: MicrobitUSBConnection = isMockDeviceMode()
? new MockDeviceConnection()
: createUSBConnection({ logging });

Expand All @@ -50,9 +55,16 @@ fs.initializeInBackground();

const App = () => {
useEffect(() => {
logging.event({ type: "boot" });
logging.setUserProperty(
"webusb_available",
"usb" in navigator ? "yes" : "no"
);
const statusListener = (event: ConnectionStatusChange) =>
logDeviceStatusChange(logging, event);
device.addEventListener("status", statusListener);
device.initialize();
return () => {
device.removeEventListener("status", statusListener);
device.dispose();
};
}, []);
Expand Down
2 changes: 1 addition & 1 deletion src/common/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class ErrorBoundary extends React.Component<
}

componentDidCatch(error: any, _errorInfo: ErrorInfo) {
this.context?.error(error);
this.context?.error("Uncaught render error", error);
}

render() {
Expand Down
2 changes: 1 addition & 1 deletion src/common/use-action-feedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class ActionFeedback {
* @param error the error thrown.
*/
unexpectedError(error: any) {
this.logging.error(error);
this.logging.error("Unexpected error", error);
this.toast({
title: this.intl.formatMessage({ id: "unexpected-error-description" }),
status: "error",
Expand Down
30 changes: 30 additions & 0 deletions src/compliance/stub.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* (c) 2026, Micro:bit Educational Foundation and contributors
*
* SPDX-License-Identifier: MIT
*/
import { ReactNode, createContext } from "react";
import { CookieConsent, DeploymentConfig } from "../deployment";

const stubConsentValue: CookieConsent = {
analytics: false,
functional: true,
};
const stubConsentContext = createContext<CookieConsent | undefined>(
stubConsentValue
);

/**
* Compliance for builds without the shared-assets cookie modal (OSS forks,
* local dev). Consent is immediately "functional only" so features gated on
* having a consent decision, such as the welcome dialog, still work.
*/
export const createStubCompliance = (): DeploymentConfig["compliance"] => ({
ConsentProvider: ({ children }: { children: ReactNode }) => (
<stubConsentContext.Provider value={stubConsentValue}>
{children}
</stubConsentContext.Provider>
),
consentContext: stubConsentContext,
manageCookies: undefined,
});
130 changes: 130 additions & 0 deletions src/compliance/web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* (c) 2026, Micro:bit Educational Foundation and contributors
*
* SPDX-License-Identifier: MIT
*/
import { ReactNode, createContext, useEffect, useState } from "react";
import { CookieConsent, DeploymentConfig } from "../deployment";
import { isStageWithAnalytics } from "../logging/stage";

/**
* Surface of the shared-assets `commonConsent` API
* (https://shared-assets.microbit.org/common/v2/common.js) that we depend
* on. Defined here only to give the compliance code a typed handle on
* `window` — the script itself is the authoritative source of behaviour.
*/
interface CommonConsent {
show: (opts: { userTriggered?: boolean; config: ConsentConfig }) => void;
hide: () => void;
}

interface ConsentConfig {
ga: Record<string, never> | undefined;
custom: Array<{
type: string;
category: string;
name: string;
purpose: string;
}>;
}

type CommonConsentWindow = Window & {
commonConsent?: CommonConsent;
};

/**
* Web compliance backed by the shared-assets `commonConsent` API. Shows
* the cookie modal, listens for `consentchange`, and exposes a
* `manageCookies` callback that re-opens the modal on user request.
* Embedded sites (`window.self !== window.top`) assume the parent
* handles notices so we no-op there.
*/
export const createWebCompliance = (
env: Record<string, string>
): DeploymentConfig["compliance"] => {
const consentContext = createContext<CookieConsent | undefined>(undefined);

const config: ConsentConfig = {
ga: isStageWithAnalytics(env.VITE_STAGE) ? {} : undefined,
custom: [
{
type: "session",
category: "essential",
name: "sessionSettings",
purpose: "Used to disable hints based on your prior actions",
},
{
type: "local",
category: "essential",
name: "release-notice",
purpose:
"Records which version of the first-time-use notice you've seen so we can decide to show or suppress it in future",
},
{
type: "local",
category: "essential",
name: "settings",
purpose:
"Used to store your settings and remember which dialogs you've opted not to be shown in future",
},
],
};

const showConsent = (
{ userTriggered }: { userTriggered: boolean } = { userTriggered: false }
) => {
(window as CommonConsentWindow).commonConsent?.show({
userTriggered,
config,
});
};

const hideConsent = () => {
(window as CommonConsentWindow).commonConsent?.hide();
};

const manageCookies = () => showConsent({ userTriggered: true });

const ConsentProvider = ({ children }: { children: ReactNode }) => {
const [value, setValue] = useState<CookieConsent | undefined>(undefined);
useEffect(() => {
// If we're embedded we assume the embedding site is taking
// responsibility for required notices to avoid nested cookie modals.
if (inIframe()) {
return;
}
const w = window as CommonConsentWindow;
const updateListener = (event: Event) => {
setValue((event as CustomEvent<CookieConsent>).detail);
};
const initListener = () => showConsent();
w.addEventListener("consentchange", updateListener);
if (w.commonConsent) {
showConsent();
} else {
w.addEventListener("consentinit", initListener);
}
return () => {
w.removeEventListener("consentchange", updateListener);
w.removeEventListener("consentinit", initListener);
hideConsent();
};
}, []);

return (
<consentContext.Provider value={value}>
{children}
</consentContext.Provider>
);
};

return { ConsentProvider, consentContext, manageCookies };
};

const inIframe = () => {
try {
return window.self !== window.top;
} catch {
return true;
}
};
Loading