diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6007f02c..113ffec8e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 45578fc37..a3cd593f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. + ## Commands - Unit tests: `npm test` (vitest). E2e: run headlessly via diff --git a/docs/analytics-events.md b/docs/analytics-events.md new file mode 100644 index 000000000..06f9a4b2f --- /dev/null +++ b/docs/analytics-events.md @@ -0,0 +1,299 @@ +# Analytics events + +Analytics events emitted by the app. The catalogue is aligned with +ml-trainer's `docs/analytics-events.md` so the two apps can share GA4 custom +definitions where the concept matches; the "Removed / migrated events" table +at the end maps the previous UA-shaped events to their replacements. + +## Overview + +- The web build emits via gtag. GA4 Enhanced Measurement auto-collects + `page_view` (including `pushState` navigation between documentation pages), + `session_start`, `first_visit`, `user_engagement`, etc. Those are not + redocumented here. +- gtag is only present when `shared-assets/common.js` is loaded, which + `index.html` does only for Foundation builds (`VITE_FOUNDATION_BUILD`), and + the script itself is hostname-gated to `*.microbit.org`. Consent is owned by + the shared-assets cookie modal, which only offers the GA opt-in on + PRODUCTION / STAGING. OSS forks and local dev therefore send nothing: events + fall through to the console via the Sentry-breadcrumb fallback. +- Backend code: `src/logging/logger.ts` (param building, Sentry, product + injection) and `src/logging/sink.ts` (gtag). Shared device-event + vocabulary and helpers are in `src/logging/analytics.ts`. +- Names are snake_case, ≤40 chars. Param values are primitives (string + ≤100 chars, number, or boolean). These are Firebase's rules; the editor has + no native build today but the catalogue is kept compatible so a future one + could share it. +- Every event automatically carries a **`product`** param (`python-editor`), + injected by the logger from `BrandConfig.product`. It's not listed on + individual event tables. Lets dashboards split traffic by product when + sibling apps share a GA4 property. +- Numeric params (`files`, `lines`, `storage_used`, `errors`, `modules`, + `duration_ms`, `count`) are sent raw and should be registered as GA4 **custom + metrics**, not dimensions. The UA-era bucketing (`0-5`, `51-100`, …) is gone; + bucket in the reporting layer if needed. +- Param names are deliberately generic so one GA4 custom definition serves + both apps: `surface` is "which part of the UI" (ml-trainer: home / projects + / toolbar; here: the sidebar tab), `id` is "the content item this event is + about" (the event name says what kind), `state` is the resulting state of a + toggle, `count` is "how many things this event touched", `is_default` is + "still the unedited starter". Prefer reusing one of these over adding a + product-specific name. + +## User properties + +Set once on app boot. Auto-attach to every subsequent event for the same user, +available as user-scoped breakdowns in GA4. + +| Name | Values | Set when | Notes | +| ------------------ | ------------ | -------- | -------------------------------------------------------------- | +| `webusb_available` | `yes` / `no` | App boot | From `"usb" in navigator`. Same name and values as ml-trainer. | + +Unlike ml-trainer there is no `webbluetooth_available`: the editor doesn't use +Bluetooth. + +## Device events + +Same event family and params as ml-trainer so a cross-product device dashboard +works with one set of custom definitions. The editor has no connection state +machine, so the step set is small and there are no `from` / `via` params. + +Every `device_*` event carries: + +- **`task`** — `connect` (the user pressed Connect, or a flash needed a + connection first) or `download` (the user pressed Send to micro:bit and a + flash was attempted). `connect` is deliberately not ml-trainer's + `data_connection`: there it means live sensor data for recording, here it + means serial / REPL plus fast flashing. +- **`transport`** — always `web_usb`. A new value alongside ml-trainer's + `web_bluetooth` / `native_bluetooth` / `radio`; describes the user's setup, + consistent with how ml-trainer uses it on `download` events. + +Browsers without WebUSB can't use the connect flow at all. We emit no +`device_*` events for that cohort; the `webusb_available` user property +captures the segment. + +### `device_step` + +| Param | Values | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `task` | `connect` / `download` | +| `step` | `connect_help` (the connect help dialog was shown), `connecting` (WebUSB chooser / connect in progress), `flashing` (flash started) | +| `transport` | `web_usb` | + +### `device_success` + +| Param | Values | +| -------------- | --------------------------------------------------------------------------------------------------------------- | +| `task` | `connect` / `download` | +| `transport` | `web_usb` | +| `duration_ms` | int — wall-clock flash time (download task only). Revives the dead `WebUSB-time` event as a raw metric. | +| `files` | int — files in the project (download task only) | +| `lines` | int — line count of `main.py` (download task only) | +| `is_default` | boolean — `main.py` is still the unedited starter program (download task only) | +| `storage_used` | int — bytes of micro:bit filesystem used (download task only) | +| `errors` | int — diagnostics the language server currently reports (download task only) | +| `modules` | int — files carrying our module metadata header, i.e. modules added from Reference / Ideas (download task only) | + +The project stats params are the same set as `project_save`, so "what does a +typical program look like when it reaches a micro:bit" is one query. + +### `device_failure` + +| Param | Values | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `task` | `connect` / `download` | +| `at_step` | `connecting` / `flashing` | +| `code` | `DeviceError` code: `no-device-selected`, `device-disconnected`, `firmware-update-required`, `device-in-use`, `timeout`, `connection-error`, `unsupported`; `flash-data` for a hex build failure; `unknown` otherwise | +| `transport` | `web_usb` | + +### `device_exit` + +User pressed Cancel on the connect help dialog. + +| Param | Values | +| ----------- | ---------------------- | +| `task` | `connect` / `download` | +| `at_step` | `connect_help` | +| `reason` | `close` | +| `transport` | `web_usb` | + +### `device_disconnect` + +| Param | Values | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `reason` | `user` (Disconnect button) / `unknown` (status went from connected to disconnected or unauthorised otherwise, e.g. unplugged) | +| `transport` | `web_usb` | + +## Project events + +### `project_save` + +User saved from the project Save menu. Both variants prompt for a project name +first. + +| Param | Values | +| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `format` | `hex` (Save hex: whole project plus MicroPython) / `py` (Save Python script: `main.py` only) | +| `files`, `lines`, `is_default`, `storage_used`, `errors`, `modules` | As on `device_success`. | + +No `destination` param: the editor only downloads. ml-trainer's +`destination: download | share` distinguishes its native share sheet. + +### `project_import` + +User brought files in. Fires once per drop / picker selection, before the +files are parsed, so it counts attempts. + +| Param | Values | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `source` | `drop` / `file_picker` (same values as ml-trainer) | +| `format` | `hex` (replaces the project) / `py` (Python file added to the project) / `other` (any other single file) / `multiple` (more than one file) | + +### `project_reset` + +User chose Reset project, replacing everything with the starter program. Fires +when the menu action is chosen, before the confirm dialog. No params. + +### `project_rename` + +User set the project name, from the header or the name-your-project prompt on +save. No params. + +### `idea_open` + +User opened an idea into the editor. + +| Param | Values | +| ----- | -------------------------- | +| `id` | The idea slug, e.g. `dice` | + +## File events + +Per-file actions from the Project (files) area. + +| Event | Params | When fired | +| ------------- | ------ | ------------------------------------------------------- | +| `file_create` | — | User created a new file | +| `file_delete` | — | User chose delete on a file (before the confirm dialog) | +| `file_save` | — | User downloaded a single file from the files list | + +## Documentation events + +The Reference / Ideas / API sidebar. Page views for each documentation page are +already auto-collected via `page_view` because navigation uses `pushState`; +these events add the _how_. + +### `docs_navigate` + +Fires on programmatic navigation into a documentation page. Browser +back/forward and typed URLs don't fire it (they are `page_view` only). + +| Param | Values | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `via` | `user` (clicked in the sidebar), `search` (picked a search result), `code` (from a code hover/help link), `simulator` (from the simulator) | +| `surface` | `reference` / `ideas` / `api` | +| `id` | The page slug, e.g. `buttons`; absent when navigating to a tab root | + +### `docs_search` + +Fires once per search session: when results first appear after typing, not +per keystroke (300ms debounce) and not again until results are cleared. No +params. Search terms are deliberately not sent. + +## Code snippet events + +Moving code from the documentation into the editor. Drag and copy are the +starts; drop and paste are the completions, so drop ÷ drag is the drag-and-drop +success rate. + +| Event | Params | When fired | +| ------------ | --------------- | --------------------------------------------------------------------------- | +| `code_drag` | `surface`, `id` | User started dragging a snippet from the sidebar | +| `code_drop` | `surface`, `id` | The snippet landed in the editor | +| `code_copy` | `surface`, `id` | User used the snippet's copy button | +| `code_paste` | `surface`, `id` | A copied snippet was pasted into the editor (via our own clipboard context) | + +- `surface` — `reference` / `ideas` / `api`. +- `id` — the documentation slug for Reference / Ideas, or the fully qualified + name for API (e.g. `microbit.display.scroll`). Bounded cardinality, but + large; expect `(other)` in standard reports and use Explorations. + +## Editor events + +The CodeMirror code editor. + +| Event | Params | When fired | +| --------------------- | ---------------------- | ----------------------------------------------------------------------------------------- | +| `editor_paste` | `count:int` | Text pasted from outside the app (not a snippet paste). `count` is the pasted line count. | +| `editor_undo` | — | Undo via the toolbar (keyboard undo is not tracked) | +| `editor_redo` | — | Redo via the toolbar | +| `editor_zoom` | `direction: in \| out` | Font size changed via the zoom buttons | +| `editor_autocomplete` | — | User accepted an autocomplete suggestion | + +## Serial events + +The serial / REPL panel. + +| Event | Params | When fired | +| ------------------ | --------------------------- | ------------------------------------------------------------------------------------- | +| `serial_toggle` | `state: expand \| collapse` | User expanded or collapsed the serial area | +| `serial_help` | — | User opened the serial hints and tips | +| `serial_interrupt` | — | User sent Ctrl-C | +| `serial_reset` | — | User sent Ctrl-D | +| `serial_traceback` | — | A MicroPython traceback arrived from the device (first one per run; cleared on reset) | + +## Simulator events + +| Event | Params | When fired | +| -------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `sim_start` | — | User ran the program in the simulator | +| `sim_stop` | — | User pressed stop (stops caused by code changes are not counted) | +| `sim_reset` | — | User pressed reset | +| `sim_audio` | `state: mute \| unmute` | User toggled simulator sound | +| `sim_sensor` | `id` | First time in the session the user changed a given input, e.g. `accelerometerX`, `gesture`, `compassHeading`, `pin0`, `pinLogo`, `temperature`, `lightLevel`, `soundLevel`, `buttonA`, `buttonB`, `radio_input`. Once per sensor per page load. | +| `sim_log_save` | — | User downloaded the simulated data log as CSV | + +## Layout events + +| Event | Params | When fired | +| ---------------- | ---------------------- | -------------------------------------- | +| `sidebar_toggle` | `state: open \| close` | User collapsed or expanded the sidebar | + +## Removed / migrated events + +Migration notes from the previous UA-shaped events. Every old event was sent +with `event_category: "Python Editor V3"`, the message as `event_label`, and +`value` (default 1); none of that survives. Listed for grep-ability when +reading old dashboards. + +| Old name | Status | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `boot` → `WebUSB-available` | Dropped. Replaced by the `webusb_available` user property. `session_start` auto-fires. | +| `connect` | Replaced by `device_step` (`task: connect`) and `device_success` / `device_failure` / `device_exit`. | +| `disconnect` | Renamed `device_disconnect`; widened with `reason` to also capture unexpected drops. | +| `flash` (+ fan-outs `files`, `fs-used`, `lines`, `lines-value`, `code-errors`, `magic-modules`) | Replaced by `device_step` (`step: flashing`) and `device_success` (`task: download`) with raw numeric params. | +| `save` (+ the same fan-outs) | Renamed `project_save` with `format: hex` and raw numeric params. | +| `save-main-file` | Folded into `project_save` with `format: py`. | +| `save-file` | Renamed `file_save`. | +| `create-file` / `delete-file` | Renamed `file_create` / `file_delete`. | +| `set-project-name` | Renamed `project_rename`. | +| `reset-project` | Renamed `project_reset`. | +| `idea-open` | Renamed `idea_open`; slug moved from label to the `id` param. | +| `drop-load` / `file-upload` → `load` with label `-` | Consolidated to `project_import` with `source` and `format` params. | +| `documentation-user` / `-search` / `-from-code` / `-from-simulator` with label `tab-slug` | Consolidated to `docs_navigate` with `via`, `surface`, `id` params. | +| `search` | Renamed `docs_search`. | +| `code-drag` / `code-drop` / `code-copy` / `code-paste` with label `-` or `api-` | Same names in snake_case; label split into `surface` and `id` params. | +| `paste` (value = line count) | Renamed `editor_paste`; line count moved to the `count` param. | +| `undo` / `redo` | Renamed `editor_undo` / `editor_redo`. | +| `zoom-in` / `zoom-out` | Consolidated to `editor_zoom` with `direction`. | +| `autocomplete-accept` | Renamed `editor_autocomplete`. | +| `sidebar-toggle` with label `open` / `close` | Renamed `sidebar_toggle`; label moved to the `state` param. | +| `serial-expand` / `serial-collapse` | Consolidated to `serial_toggle` with `state`. | +| `serial-info` | Renamed `serial_help`. | +| `serial-interrupt` / `serial-reset` / `serial-traceback` | Same names in snake_case. | +| `sim-user-start` / `sim-user-stopped` / `sim-user-reset` | Renamed `sim_start` / `sim_stop` / `sim_reset`. | +| `sim-user-mute` / `sim-user-unmute` | Consolidated to `sim_audio` with `state`. | +| `sim-user-` | Consolidated to `sim_sensor` with the `id` param. | +| `sim-user-data-log-saved` | Renamed `sim_log_save`. | +| `WebUSB-time` | Was already dead (the emitter went with the connection-library extraction in Feb 2025). Revived as `duration_ms` on `device_success`. | diff --git a/package-lock.json b/package-lock.json index 49301cde2..5838d2c41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,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", @@ -4535,6 +4536,95 @@ "node": ">=10.0.0" } }, + "node_modules/@sentry/browser": { + "version": "10.71.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.71.0.tgz", + "integrity": "sha512-fTE9tUDoggJSFv8cQ+h1UA9onovOoUNJWu8Var1MXmUVuVG/W3WqzJFVyKArZMj95lizZkq8aiS63SURvrBsFQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.71.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.71.0", + "@sentry/feedback": "10.71.0", + "@sentry/replay": "10.71.0", + "@sentry/replay-canvas": "10.71.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils": { + "version": "10.71.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.71.0.tgz", + "integrity": "sha512-Djhb+RdEwSdYTlDaMzc2uRCA+bYDIFsFCtZzKEtB9UR7lJNV/bJ0k3el3ifaVGTRELO8WBll/X0elty1Dk5tTw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.71.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "10.71.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.71.0.tgz", + "integrity": "sha512-OIjT7rzcWJjUC6r3eBT3Td1j0afDBMkbbx9jTocSD+ZSfc25eEU7hoIPS0WvfeIOTIN3y8bfQnXavwMReaNVHQ==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/feedback": { + "version": "10.71.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.71.0.tgz", + "integrity": "sha512-4+zMAmn1DcbYBtFE0pWt6zo8vWoBHTuP/UhtCMTCoGB4sVYvmwRxv9Hc9OpRHSzE9kSl8beD0zKFxuoVHizKQQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.71.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay": { + "version": "10.71.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.71.0.tgz", + "integrity": "sha512-ytEgVg7isvavQL6hgsgYeuSCkcA3EyOKm9lMLQOZTLkiUCtFT/ItWwGNhzS46vQ6wsTv3Uc0Y1JlcJHSFKe/Kg==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.71.0", + "@sentry/core": "10.71.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas": { + "version": "10.71.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.71.0.tgz", + "integrity": "sha512-YTqesxBh9arExI49LrTm4Y2/xPX40q2GWi0gWRw6lNfYtyNz7/ZfHi8/1N6JEc8dldTslqFhII6ZFecUyU9iaA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.71.0", + "@sentry/replay": "10.71.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.52", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", diff --git a/package.json b/package.json index e46e02e8a..7af6e5771 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index f88690cbd..e4691f06c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; @@ -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"; @@ -38,7 +43,7 @@ const isMockDeviceMode = () => ); const logging = deployment.logging; -const device = isMockDeviceMode() +const device: MicrobitUSBConnection = isMockDeviceMode() ? new MockDeviceConnection() : createUSBConnection({ logging }); @@ -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(); }; }, []); diff --git a/src/common/ErrorBoundary.tsx b/src/common/ErrorBoundary.tsx index afc097f46..f927ecc7b 100644 --- a/src/common/ErrorBoundary.tsx +++ b/src/common/ErrorBoundary.tsx @@ -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() { diff --git a/src/common/use-action-feedback.tsx b/src/common/use-action-feedback.tsx index 32722f5c9..990905baa 100644 --- a/src/common/use-action-feedback.tsx +++ b/src/common/use-action-feedback.tsx @@ -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", diff --git a/src/compliance/stub.tsx b/src/compliance/stub.tsx new file mode 100644 index 000000000..190075909 --- /dev/null +++ b/src/compliance/stub.tsx @@ -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( + 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 }) => ( + + {children} + + ), + consentContext: stubConsentContext, + manageCookies: undefined, +}); diff --git a/src/compliance/web.tsx b/src/compliance/web.tsx new file mode 100644 index 000000000..46c518c72 --- /dev/null +++ b/src/compliance/web.tsx @@ -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 | 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 +): DeploymentConfig["compliance"] => { + const consentContext = createContext(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(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).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 ( + + {children} + + ); + }; + + return { ConsentProvider, consentContext, manageCookies }; +}; + +const inIframe = () => { + try { + return window.self !== window.top; + } catch { + return true; + } +}; diff --git a/src/deployment/default/index.tsx b/src/deployment/default/index.tsx index 0defae214..550713d65 100644 --- a/src/deployment/default/index.tsx +++ b/src/deployment/default/index.tsx @@ -3,31 +3,12 @@ * * SPDX-License-Identifier: MIT */ -import { ReactNode, createContext } from "react"; -import { CookieConsent, DeploymentConfigFactory } from ".."; -import { ConsoleLogging } from "./logging"; +import { BrandConfigFactory } from ".."; -const stubConsentValue: CookieConsent = { - analytics: false, - functional: true, -}; -const stubConsentContext = createContext( - stubConsentValue -); - -const defaultDeploymentFactory: DeploymentConfigFactory = () => ({ +const defaultBrandFactory: BrandConfigFactory = () => ({ + product: "python-editor", // This isn't ideal as it's the branded version. You can just remove the field to remove the welcome dialog. welcomeVideoYouTubeId: "mREwMW69qKc", - logging: new ConsoleLogging(), - compliance: { - ConsentProvider: ({ children }: { children: ReactNode }) => ( - - {children} - - ), - consentContext: stubConsentContext, - manageCookies: undefined, - }, }); -export default defaultDeploymentFactory; +export default defaultBrandFactory; diff --git a/src/deployment/default/logging.ts b/src/deployment/default/logging.ts index b71a88029..8c7853e89 100644 --- a/src/deployment/default/logging.ts +++ b/src/deployment/default/logging.ts @@ -5,14 +5,22 @@ */ import { Event, Logging } from "../../logging/logging"; +/** + * Console-only logging for tests and the NullLoggingProvider. The app + * itself always uses Logger (see src/deployment/index.ts), which also + * falls back to the console when no analytics or Sentry are configured. + */ export class ConsoleLogging implements Logging { event(event: Event): void { console.log(event); } - error(e: any): void { - console.error(e); + error(message: string, e: unknown): void { + console.error(message, e); } log(e: any): void { console.log(e); } + setUserProperty(name: string, value: string): void { + console.log("[ConsoleLogging] setUserProperty:", name, value); + } } diff --git a/src/deployment/index.ts b/src/deployment/index.ts index f889029bf..46f0f9d96 100644 --- a/src/deployment/index.ts +++ b/src/deployment/index.ts @@ -3,27 +3,50 @@ * * SPDX-License-Identifier: MIT */ -import { ReactNode, useContext } from "react"; +import React, { ReactNode, useContext } from "react"; +import { createStubCompliance } from "../compliance/stub"; +import { createWebCompliance } from "../compliance/web"; +import { Logger } from "../logging/logger"; import { Logging } from "../logging/logging"; - -export type DeploymentConfigFactory = ( - env: Record -) => DeploymentConfig; +import { WebSink } from "../logging/sink"; // This is configured via a vite alias, defaulting to ./default import { default as df } from "theme-package"; -const deploymentFactory: DeploymentConfigFactory = df; -export const deployment = deploymentFactory(import.meta.env); + +/** + * Brand-and-content config supplied by the (optionally private) theme + * package. No analytics opinions live here — the OSS deployment loader + * picks the logger and compliance backend based on build config and env. + */ +export interface BrandConfig { + /** + * Stable analytics identifier slug for this product. Attached as + * the `product` param on every event the logger emits, so dashboards + * can split traffic by product when multiple sibling apps share a + * GA4 property. + */ + product: string; + welcomeVideoYouTubeId?: string; + squareLogo?: ReactNode; + horizontalLogo?: ReactNode; + + supportLink?: string; + guideLink?: string; + userGuideLink?: string; + accessibilityLink?: string; + termsOfUseLink?: string; + privacyPolicyLink?: string; + translationLink?: string; +} + +export type BrandConfigFactory = (env: Record) => BrandConfig; export interface CookieConsent { analytics: boolean; functional: boolean; } -export interface DeploymentConfig { - welcomeVideoYouTubeId?: string; - squareLogo?: ReactNode; - horizontalLogo?: ReactNode; +export interface DeploymentConfig extends BrandConfig { compliance: { /** * A provider that will be used to wrap the app UI. @@ -39,22 +62,37 @@ export interface DeploymentConfig { */ manageCookies: (() => void) | undefined; }; - - supportLink?: string; - guideLink?: string; - userGuideLink?: string; - accessibilityLink?: string; - termsOfUseLink?: string; - privacyPolicyLink?: string; - translationLink?: string; - logging: Logging; } +const brandFactory: BrandConfigFactory = df; + +const createLogging = (env: Record, product: string): Logging => + // WebSink is silent without gtag and Logger falls back to console + // breadcrumbs without a Sentry DSN, so this is safe for every build. + new Logger(new WebSink(), env, product); + +const createCompliance = ( + env: Record +): DeploymentConfig["compliance"] => + // The same flag index.html uses to decide whether to load the + // shared-assets script that provides the cookie modal (and gtag). + env.VITE_FOUNDATION_BUILD === "true" + ? createWebCompliance(env) + : createStubCompliance(); + +export const deployment: DeploymentConfig = (() => { + const env = import.meta.env as unknown as Record; + const brand = brandFactory(env); + return { + ...brand, + logging: createLogging(env, brand.product), + compliance: createCompliance(env), + }; +})(); + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix -- hook-shaped by design and used as one throughout -export const useDeployment = (): DeploymentConfig => { - return deployment; -}; +export const useDeployment = (): DeploymentConfig => deployment; export const useCookieConsent = (): CookieConsent | undefined => { const { compliance } = useDeployment(); diff --git a/src/device/device-hooks.tsx b/src/device/device-hooks.tsx index facf5726d..13a8b67ba 100644 --- a/src/device/device-hooks.tsx +++ b/src/device/device-hooks.tsx @@ -192,7 +192,7 @@ export const useDeviceTraceback = () => { setRuntimeError((current) => { if (!current && latest) { logging.event({ - type: "serial-traceback", + type: "serial_traceback", }); } return latest; diff --git a/src/device/simulator.ts b/src/device/simulator.ts index fb205b145..20d5cfa41 100644 --- a/src/device/simulator.ts +++ b/src/device/simulator.ts @@ -186,7 +186,7 @@ export class SimulatorDeviceConnection case "request_flash": { this.dispatchEvent("requestflash"); this.logging.event({ - type: "sim-user-start", + type: "sim_start", }); break; } @@ -244,7 +244,7 @@ export class SimulatorDeviceConnection } case "internal_error": { const error = event.data.error; - this.logging.error(error); + this.logging.error("Simulator internal error", error); break; } default: { @@ -264,7 +264,8 @@ export class SimulatorDeviceConnection private logSensor(sensorId: string): void { if (!this.sensorsLogged[sensorId]) { this.logging.event({ - type: `sim-user-${sensorId}`, + type: "sim_sensor", + detail: { id: sensorId }, }); this.sensorsLogged[sensorId] = true; } @@ -365,21 +366,23 @@ export class SimulatorDeviceConnection this.postMessage("reset", {}); this.notifyResetComms(); this.logging.event({ - type: "sim-user-reset", + type: "sim_reset", }); }; mute = async (): Promise => { this.postMessage("mute", {}); this.logging.event({ - type: "sim-user-mute", + type: "sim_audio", + detail: { state: "mute" }, }); }; unmute = async (): Promise => { this.postMessage("unmute", {}); this.logging.event({ - type: "sim-user-unmute", + type: "sim_audio", + detail: { state: "unmute" }, }); }; diff --git a/src/documentation/api/ApiDocumentation.tsx b/src/documentation/api/ApiDocumentation.tsx index ef6db311f..4bb5ba7e3 100644 --- a/src/documentation/api/ApiDocumentation.tsx +++ b/src/documentation/api/ApiDocumentation.tsx @@ -30,7 +30,7 @@ export const ApiDocumentation = ({ docs }: ApiDocumentationProps) => { const [anchor, setAnchor] = useRouterTabSlug("api"); const handleNavigate = useCallback( (id: string | undefined) => { - setAnchor(id ? { id } : undefined, "documentation-user"); + setAnchor(id ? { id } : undefined, "user"); }, [setAnchor] ); diff --git a/src/documentation/api/ApiNode.tsx b/src/documentation/api/ApiNode.tsx index 76b2405d2..dfa3901a9 100644 --- a/src/documentation/api/ApiNode.tsx +++ b/src/documentation/api/ApiNode.tsx @@ -415,7 +415,8 @@ const getDragPasteData = (fullName: string, kind: string): PasteContext => { code, codeWithImports: full, type: kind === "function" ? "call" : "example", - id: `api-${fullName}`, + tab: "api", + id: fullName, }; }; @@ -443,14 +444,14 @@ const DraggableSignature = ({ docs, css: cssProp, }: DraggableSignatureProps) => { - const { fullName, kind, name, id } = docs; + const { fullName, kind, name } = docs; const logging = useLogging(); const dragImage = useCodeDragImage(); const handleDragStart = useCallback( (event: React.DragEvent) => { logging.event({ - type: "code-drag", - message: `api-${id}`, + type: "code_drag", + detail: { surface: "api", id: fullName }, }); dndDebug("dragstart"); event.dataTransfer.dropEffect = "copy"; @@ -461,7 +462,7 @@ const DraggableSignature = ({ event.dataTransfer.setDragImage(dragImage.current, 0, 0); } }, - [fullName, kind, id, dragImage, logging] + [fullName, kind, dragImage, logging] ); const handleDragEnd = useCallback((_event: React.DragEvent) => { @@ -477,8 +478,8 @@ const DraggableSignature = ({ const { onCopy } = useClipboard(code); const handleCopyCode = useCallback(async () => { onCopy(); - await actions?.copyCode(code, codeWithImports, type, id); - }, [actions, code, codeWithImports, onCopy, type, id]); + await actions?.copyCode(code, codeWithImports, type, fullName, "api"); + }, [actions, code, codeWithImports, onCopy, type, fullName]); const hotKeysRef = useHotkeys(keyboardShortcuts.copyCode, handleCopyCode, { preventDefault: true, }); diff --git a/src/documentation/common/CodeEmbed.tsx b/src/documentation/common/CodeEmbed.tsx index 9f72e87b1..105f3eb25 100644 --- a/src/documentation/common/CodeEmbed.tsx +++ b/src/documentation/common/CodeEmbed.tsx @@ -118,7 +118,8 @@ const CodeEmbed = ({ code, codeWithImports, "example", - `${toolkitType}-${parentSlug}` + parentSlug, + toolkitType ); }, [actions, code, codeWithImports, onCopy, parentSlug, toolkitType]); const projectActions = useProjectActions(); @@ -269,15 +270,16 @@ const Code = React.forwardRef( const handleDragStart = useCallback( (event: React.DragEvent) => { logging.event({ - type: "code-drag", - message: `${toolkitType}-${parentSlug}`, + type: "code_drag", + detail: { surface: toolkitType, id: parentSlug }, }); dndDebug("dragstart"); event.dataTransfer.dropEffect = "copy"; setDragContext({ code: full, type: "example", - id: `${toolkitType}-${parentSlug}`, + tab: toolkitType, + id: parentSlug, }); event.dataTransfer.setData(pythonSnippetMediaType, full); if (dragImage.current) { diff --git a/src/documentation/common/DocumentationContent.tsx b/src/documentation/common/DocumentationContent.tsx index 75fba63e6..1846ac01c 100644 --- a/src/documentation/common/DocumentationContent.tsx +++ b/src/documentation/common/DocumentationContent.tsx @@ -104,7 +104,7 @@ const DocumentationInternalLinkMark = ( id: props.mark.slug.current, }, }, - "documentation-user" + "user" ); }} > diff --git a/src/documentation/documentation-hooks.tsx b/src/documentation/documentation-hooks.tsx index 03071dd83..d1c0bde6a 100644 --- a/src/documentation/documentation-hooks.tsx +++ b/src/documentation/documentation-hooks.tsx @@ -47,7 +47,7 @@ const useContent = ( setState({ status: "ok", content, languageId }); } } catch (e) { - logging.error(e); + logging.error("Failed to load documentation", e); if (!ignore) { setState({ status: "error", diff --git a/src/documentation/ideas/IdeasDocumentation.tsx b/src/documentation/ideas/IdeasDocumentation.tsx index 6502f6fca..7a6db029e 100644 --- a/src/documentation/ideas/IdeasDocumentation.tsx +++ b/src/documentation/ideas/IdeasDocumentation.tsx @@ -42,7 +42,7 @@ const IdeasDocumentation = ({ ideas }: IdeasDocumentationProps) => { const ideaId = anchor?.id; const handleNavigate = useCallback( (ideaId: string | undefined) => { - setAnchor(ideaId ? { id: ideaId } : undefined, "documentation-user"); + setAnchor(ideaId ? { id: ideaId } : undefined, "user"); }, [setAnchor] ); diff --git a/src/documentation/reference/ReferenceDocumentation.tsx b/src/documentation/reference/ReferenceDocumentation.tsx index a45face59..62d515de9 100644 --- a/src/documentation/reference/ReferenceDocumentation.tsx +++ b/src/documentation/reference/ReferenceDocumentation.tsx @@ -35,10 +35,7 @@ const ReferenceToolkit = ({ toolkit }: ReferenceDocumentationProps) => { const topicOrEntryId = anchor?.id.split("/")[0]; const handleNavigate = useCallback( (topicOrEntryId: string | undefined) => { - setAnchor( - topicOrEntryId ? { id: topicOrEntryId } : undefined, - "documentation-user" - ); + setAnchor(topicOrEntryId ? { id: topicOrEntryId } : undefined, "user"); }, [setAnchor] ); diff --git a/src/documentation/search/search-hooks.tsx b/src/documentation/search/search-hooks.tsx index 0fc140ed1..fed25d84d 100644 --- a/src/documentation/search/search-hooks.tsx +++ b/src/documentation/search/search-hooks.tsx @@ -73,7 +73,7 @@ const SearchProvider = ({ children }: { children: ReactNode }) => { if (!isUnmounted()) { setResults((prevResults) => { if (!prevResults) { - logging.event({ type: "search" }); + logging.event({ type: "docs_search" }); } return results; }); diff --git a/src/editor/ZoomControls.tsx b/src/editor/ZoomControls.tsx index 9501f18fb..3441945d8 100644 --- a/src/editor/ZoomControls.tsx +++ b/src/editor/ZoomControls.tsx @@ -32,14 +32,14 @@ const ZoomControls = ({ size, css: cssProp }: ZoomControlsProps) => { ...settings, fontSize: Math.min(maximumFontSize, settings.fontSize + fontSizeStep), }); - logging.event({ type: "zoom-in" }); + logging.event({ type: "editor_zoom", detail: { direction: "in" } }); }, [setSettings, settings, logging]); const handleZoomOut = useCallback(() => { setSettings({ ...settings, fontSize: Math.max(minimumFontSize, settings.fontSize - fontSizeStep), }); - logging.event({ type: "zoom-out" }); + logging.event({ type: "editor_zoom", detail: { direction: "out" } }); }, [setSettings, settings, logging]); const intl = useIntl(); return ( diff --git a/src/editor/active-editor-hooks.tsx b/src/editor/active-editor-hooks.tsx index 8e59644ab..83f42e844 100644 --- a/src/editor/active-editor-hooks.tsx +++ b/src/editor/active-editor-hooks.tsx @@ -35,16 +35,18 @@ export class EditorActions { code: string, codeWithImports: string, type: CodeInsertType, - id?: string + id?: string, + tab?: string ): Promise => { this.logging.event({ - type: "code-copy", - message: id, + type: "code_copy", + detail: { surface: tab, id }, }); copyCodeSnippet({ code, codeWithImports, type, + tab, id, }); this.actionFeedback.success({ @@ -53,14 +55,14 @@ export class EditorActions { }; undo = (): void => { this.logging.event({ - type: "undo", + type: "editor_undo", }); undo(this.view); this.view.focus(); }; redo = (): void => { this.logging.event({ - type: "redo", + type: "editor_redo", }); redo(this.view); this.view.focus(); diff --git a/src/editor/codemirror/CodeMirror.tsx b/src/editor/codemirror/CodeMirror.tsx index f6b95ad74..b3c6c2e3c 100644 --- a/src/editor/codemirror/CodeMirror.tsx +++ b/src/editor/codemirror/CodeMirror.tsx @@ -268,7 +268,7 @@ const CodeMirror = ({ tab, slug: { id }, }, - "documentation-from-code" + "code" ); const view = viewRef.current!; // Put the focus back in the text editor so the docs are immediately useful. @@ -306,8 +306,8 @@ const logPastedLineCount = (logging: Logging, update: ViewUpdate) => { new TextEncoder().encode(inserted.toString().trim()) ); logging.event({ - type: "paste", - value: lineCount, + type: "editor_paste", + detail: { count: lineCount }, }); } ) diff --git a/src/editor/codemirror/copypaste.ts b/src/editor/codemirror/copypaste.ts index d1cf91f74..d6f6bd823 100644 --- a/src/editor/codemirror/copypaste.ts +++ b/src/editor/codemirror/copypaste.ts @@ -13,6 +13,10 @@ export interface PasteContext { code: string; codeWithImports: string; type: CodeInsertType; + /** + * Analytics identity of the snippet, as for DragContext. + */ + tab?: string; id?: string; } @@ -47,8 +51,8 @@ const copyPasteHandlers = () => [ } event.preventDefault(); deployment.logging.event({ - type: "code-paste", - message: pasteContext.id, + type: "code_paste", + detail: { surface: pasteContext.tab, id: pasteContext.id }, }); const line = view.state.doc.lineAt(view.state.selection.ranges[0].from); diff --git a/src/editor/codemirror/dnd.ts b/src/editor/codemirror/dnd.ts index de893048f..e7b7de3fd 100644 --- a/src/editor/codemirror/dnd.ts +++ b/src/editor/codemirror/dnd.ts @@ -51,6 +51,11 @@ export type CodeInsertType = export interface DragContext { code: string; type: CodeInsertType; + /** + * Analytics identity of the snippet: the documentation tab it came from + * and its slug / API name. See code_drag / code_drop. + */ + tab?: string; id?: string; } @@ -183,8 +188,8 @@ const dndHandlers = ({ sessionSettings, setSessionSettings }: DragTracker) => { return; } deployment.logging.event({ - type: "code-drop", - message: dragContext.id, + type: "code_drop", + detail: { surface: dragContext.tab, id: dragContext.id }, }); if (!sessionSettings.dragDropSuccess) { setSessionSettings({ diff --git a/src/editor/codemirror/language-server/autocompletion.test.ts b/src/editor/codemirror/language-server/autocompletion.test.ts index 4d8910f6a..c544ebaf3 100644 --- a/src/editor/codemirror/language-server/autocompletion.test.ts +++ b/src/editor/codemirror/language-server/autocompletion.test.ts @@ -30,6 +30,7 @@ const createLogging = (): Logging => ({ event: vi.fn(), error: vi.fn(), log: vi.fn(), + setUserProperty: vi.fn(), }); const createClient = ( @@ -233,7 +234,7 @@ describe("createCompletionSource", () => { expect(state.doc.toString()).toEqual("button_a"); expect(state.selection.main.from).toEqual("button_a".length); expect(logging.event).toHaveBeenCalledWith({ - type: "autocomplete-accept", + type: "editor_autocomplete", }); }); diff --git a/src/editor/codemirror/language-server/autocompletion.ts b/src/editor/codemirror/language-server/autocompletion.ts index 14a9e6d66..dd12edc80 100644 --- a/src/editor/codemirror/language-server/autocompletion.ts +++ b/src/editor/codemirror/language-server/autocompletion.ts @@ -108,7 +108,7 @@ export const createCompletionSource = // In practice we don't get textEdit fields back from Pyright so the label is used. label: item.label, apply: (view, completion, from, to) => { - logging.event({ type: "autocomplete-accept" }); + logging.event({ type: "editor_autocomplete" }); const insert = item.label; const transactions: TransactionSpec[] = [ { diff --git a/src/fs/fs.test.ts b/src/fs/fs.test.ts index 1ad7e97e1..baa8cc299 100644 --- a/src/fs/fs.test.ts +++ b/src/fs/fs.test.ts @@ -228,7 +228,8 @@ describe("Filesystem", () => { it("gives useful stats", async () => { expect(await ufs.statistics()).toEqual({ files: 1, - lines: undefined, // signifies initial program + lines: expect.any(Number), + defaultMain: true, storageUsed: 256, magicModules: 0, }); @@ -246,6 +247,7 @@ describe("Filesystem", () => { expect(await ufs.statistics()).toEqual({ files: 3, lines: 3, + defaultMain: false, storageUsed: 896, magicModules: 1, }); diff --git a/src/fs/fs.ts b/src/fs/fs.ts index c381b39c2..c032246fb 100644 --- a/src/fs/fs.ts +++ b/src/fs/fs.ts @@ -46,10 +46,12 @@ export enum VersionAction { export interface Statistics { /** * The number of lines in main.py. - * - * Undefined when it is unchanged from the default program. */ - lines: number | undefined; + lines: number; + /** + * Whether main.py is unchanged from the default program. + */ + defaultMain: boolean; /** * File count. */ @@ -415,12 +417,10 @@ export class FileSystem extends TypedEventTarget { return { files: files.length, storageUsed: fs.getStorageUsed(), - lines: - this.cachedInitialProject && - this.cachedInitialProject.files[MAIN_FILE] === - fromByteArray(currentMainFile) - ? undefined - : lineNumFromUint8Array(currentMainFile), + lines: lineNumFromUint8Array(currentMainFile), + defaultMain: + this.cachedInitialProject?.files[MAIN_FILE] === + fromByteArray(currentMainFile), magicModules: numMagicModules, }; } diff --git a/src/fs/host.ts b/src/fs/host.ts index 0ad04cb1b..37480617f 100644 --- a/src/fs/host.ts +++ b/src/fs/host.ts @@ -165,7 +165,8 @@ const getControllerHost = (logging: Logging): Window | undefined => { return window.parent; } logging.error( - "Cannot detect valid host controller despite controller URL parameter." + "Cannot detect valid host controller despite controller URL parameter.", + new Error("No parent window") ); } }; diff --git a/src/fs/storage.test.ts b/src/fs/storage.test.ts index 9b5b7f1fe..afbb52d1c 100644 --- a/src/fs/storage.test.ts +++ b/src/fs/storage.test.ts @@ -129,7 +129,9 @@ describe("SplitStrategyStorage", () => { // After encoding this is big enough to hit the 5MB limit. Note that Safari is half this. await split.write("test2.py", new Uint8Array(3_800_000)); - expect(log.errors[0]).toEqual("Abandoning secondary storage due to error"); + expect(log.errors[0].message).toEqual( + "Abandoning secondary storage due to error" + ); expect(await session.ls()).toEqual([]); expect(await memory.ls()).toEqual(["test1.py", "test2.py"]); diff --git a/src/fs/storage.ts b/src/fs/storage.ts index 7cc25d06b..90ed81333 100644 --- a/src/fs/storage.ts +++ b/src/fs/storage.ts @@ -269,12 +269,13 @@ export class SplitStrategyStorage implements FSStorage { await this.secondary.clear(); } catch (e2) { // Not much we can do. - this.log.error("Failed to clear secondary storage in error scenario"); - this.log.error(e2); + this.log.error( + "Failed to clear secondary storage in error scenario", + e2 + ); } // Avoid all future errors this session. - this.log.error("Abandoning secondary storage due to error"); - this.log.error(e1); + this.log.error("Abandoning secondary storage due to error", e1); this.secondary = undefined; } } diff --git a/src/logging/analytics.test.ts b/src/logging/analytics.test.ts new file mode 100644 index 000000000..124afcd7b --- /dev/null +++ b/src/logging/analytics.test.ts @@ -0,0 +1,98 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { ConnectionStatus, DeviceError } from "@microbit/microbit-connection"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + deviceFailureCode, + importFormat, + logDeviceStatusChange, + markUserDisconnect, +} from "./analytics"; +import { MockLogging } from "./mock"; + +const file = (name: string) => new File([""], name); + +describe("importFormat", () => { + it("classifies by extension, case-insensitively", () => { + expect(importFormat([file("a.hex")])).toEqual("hex"); + expect(importFormat([file("A.HEX")])).toEqual("hex"); + expect(importFormat([file("main.py")])).toEqual("py"); + expect(importFormat([file("data.csv")])).toEqual("other"); + expect(importFormat([file("noextension")])).toEqual("other"); + }); + + it("reports multiple files regardless of type", () => { + expect(importFormat([file("a.py"), file("b.py")])).toEqual("multiple"); + }); +}); + +describe("deviceFailureCode", () => { + it("uses the DeviceError code", () => { + expect( + deviceFailureCode(new DeviceError({ code: "device-in-use" })) + ).toEqual("device-in-use"); + }); + + it("falls back to unknown for other errors", () => { + expect(deviceFailureCode(new Error("boom"))).toEqual("unknown"); + expect(deviceFailureCode(undefined)).toEqual("unknown"); + }); +}); + +describe("logDeviceStatusChange", () => { + let logging: MockLogging; + beforeEach(() => { + logging = new MockLogging(); + markUserDisconnect(false); + }); + + it("reports an unexpected drop from Connected", () => { + logDeviceStatusChange(logging, { + previousStatus: ConnectionStatus.Connected, + status: ConnectionStatus.Disconnected, + }); + expect(logging.events).toEqual([ + { + type: "device_disconnect", + detail: { reason: "unknown", transport: "web_usb" }, + }, + ]); + }); + + it("treats loss of authorization from Connected as a drop", () => { + logDeviceStatusChange(logging, { + previousStatus: ConnectionStatus.Connected, + status: ConnectionStatus.NoAuthorizedDevice, + }); + expect(logging.events).toHaveLength(1); + }); + + it("ignores transitions that are not from Connected", () => { + logDeviceStatusChange(logging, { + previousStatus: ConnectionStatus.Connecting, + status: ConnectionStatus.Disconnected, + }); + logDeviceStatusChange(logging, { + previousStatus: ConnectionStatus.Connected, + status: ConnectionStatus.Paused, + }); + expect(logging.events).toEqual([]); + }); + + it("suppresses the status change that follows a user disconnect, once", () => { + markUserDisconnect(true); + logDeviceStatusChange(logging, { + previousStatus: ConnectionStatus.Connected, + status: ConnectionStatus.Disconnected, + }); + expect(logging.events).toEqual([]); + logDeviceStatusChange(logging, { + previousStatus: ConnectionStatus.Connected, + status: ConnectionStatus.Disconnected, + }); + expect(logging.events).toHaveLength(1); + }); +}); diff --git a/src/logging/analytics.ts b/src/logging/analytics.ts new file mode 100644 index 000000000..64f35018d --- /dev/null +++ b/src/logging/analytics.ts @@ -0,0 +1,92 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + ConnectionStatus, + ConnectionStatusChange, + DeviceError, +} from "@microbit/microbit-connection"; +import { Logging } from "./logging"; + +/** + * Shared vocabulary for the device_* events. See docs/analytics-events.md. + * + * `transport` describes the user's hardware setup. The editor only + * connects over WebUSB, so it's a constant here; ml-trainer emits + * `web_bluetooth` / `native_bluetooth` / `radio` for the same param. + */ +export const transport = "web_usb"; + +/** + * Which user goal the device event belongs to: `connect` for the Connect + * button (serial / REPL plus fast flashing) or `download` for Send to + * micro:bit, including any connection it had to establish first. + */ +export type AnalyticsTask = "connect" | "download"; + +export type ImportFormat = "hex" | "py" | "other" | "multiple"; + +/** + * The `format` param for project_import. Extension-based because the + * event counts attempts and fires before the files are parsed. + */ +export const importFormat = (files: File[]): ImportFormat => { + if (files.length > 1) { + return "multiple"; + } + const parts = files[0].name.toLowerCase().split("."); + const extension = parts.length > 1 ? parts[parts.length - 1] : ""; + switch (extension) { + case "hex": + return "hex"; + case "py": + return "py"; + default: + return "other"; + } +}; + +/** + * The `code` param for device_failure. + */ +export const deviceFailureCode = (e: unknown): string => + e instanceof DeviceError ? e.code : "unknown"; + +// Set by the user-initiated disconnect action so the status listener +// below doesn't also report the resulting status change as an +// unexpected disconnect. +let userDisconnectPending = false; + +export const markUserDisconnect = (pending: boolean): void => { + userDisconnectPending = pending; +}; + +/** + * Emit device_disconnect with reason `unknown` when a connected device + * goes away other than via the Disconnect button (unplugged, reset, + * browser revoked access). Wire to the device's "status" event. + */ +export const logDeviceStatusChange = ( + logging: Logging, + change: ConnectionStatusChange +): void => { + if (change.previousStatus !== ConnectionStatus.Connected) { + return; + } + if ( + change.status !== ConnectionStatus.Disconnected && + change.status !== ConnectionStatus.NoAuthorizedDevice + ) { + return; + } + const user = userDisconnectPending; + userDisconnectPending = false; + if (!user) { + logging.event({ + type: "device_disconnect", + detail: { reason: "unknown", transport }, + }); + } +}; diff --git a/src/logging/logger.test.ts b/src/logging/logger.test.ts new file mode 100644 index 000000000..6de3b507f --- /dev/null +++ b/src/logging/logger.test.ts @@ -0,0 +1,85 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Logger } from "./logger"; +import { AnalyticsParams, AnalyticsSink } from "./sink"; + +class RecordingSink implements AnalyticsSink { + events: Array<{ name: string; params: AnalyticsParams }> = []; + userProperties: Record = {}; + event(name: string, params: AnalyticsParams): void { + this.events.push({ name, params }); + } + setUserProperty(name: string, value: string): void { + this.userProperties[name] = value; + } +} + +describe("Logger", () => { + let sink: RecordingSink; + let logger: Logger; + + beforeEach(() => { + // No Sentry DSN: breadcrumbs fall back to console.log. + vi.spyOn(console, "log").mockImplementation(() => {}); + sink = new RecordingSink(); + logger = new Logger(sink, {}, "python-editor"); + }); + + it("flattens primitive detail fields into params and injects product", () => { + logger.event({ + type: "project_save", + detail: { format: "hex", files: 3, is_default: false }, + }); + expect(sink.events).toEqual([ + { + name: "project_save", + params: { + format: "hex", + files: 3, + is_default: false, + product: "python-editor", + }, + }, + ]); + }); + + it("drops non-primitive and undefined detail values", () => { + logger.event({ + type: "docs_navigate", + detail: { via: "user", surface: "reference", id: undefined, nested: {} }, + }); + expect(sink.events[0].params).toEqual({ + via: "user", + surface: "reference", + product: "python-editor", + }); + }); + + it("lets top-level message and value win over detail keys", () => { + logger.event({ + type: "x", + message: "top", + value: 2, + detail: { message: "detail", value: 1 }, + }); + expect(sink.events[0].params).toEqual({ + message: "top", + value: 2, + product: "python-editor", + }); + }); + + it("sends events with no detail as product only", () => { + logger.event({ type: "docs_search" }); + expect(sink.events[0].params).toEqual({ product: "python-editor" }); + }); + + it("passes user properties to the sink", () => { + logger.setUserProperty("webusb_available", "yes"); + expect(sink.userProperties).toEqual({ webusb_available: "yes" }); + }); +}); diff --git a/src/logging/logger.ts b/src/logging/logger.ts new file mode 100644 index 000000000..353fa692d --- /dev/null +++ b/src/logging/logger.ts @@ -0,0 +1,84 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Event, Logging } from "./logging"; +import { initSentry, reportBreadcrumb, reportError } from "./sentry"; +import { AnalyticsParams, AnalyticsSink } from "./sink"; + +/** + * Standard Logging implementation: handles the cross-cutting concerns + * (Sentry init / error capture / event breadcrumbs, param building, + * product injection, console.log) and delegates the analytics SDK calls + * to an AnalyticsSink. + * + * Events are sent in GA4-native shape (name + flat params) — call-site + * `detail` fields become event params directly. See + * docs/analytics-events.md for the catalogue. + */ +export class Logger implements Logging { + private sentryDsn: string | undefined; + + constructor( + private sink: AnalyticsSink, + env: Record, + private product: string + ) { + this.sentryDsn = initSentry(env); + } + + event(event: Event): void { + this.sink.event(event.type, this.buildParams(event)); + reportBreadcrumb(this.sentryDsn, "Event", { + type: event.type, + message: event.message, + value: event.value, + detail: event.detail as unknown, + }); + } + + setUserProperty(name: string, value: string): void { + this.sink.setUserProperty(name, value); + } + + error(message: string, e: unknown): void { + reportError(this.sentryDsn, message, e); + } + + log(v: unknown): void { + console.log(v); + } + + private buildParams(event: Event): AnalyticsParams { + const params: AnalyticsParams = {}; + if ( + event.detail !== undefined && + typeof event.detail === "object" && + event.detail !== null + ) { + for (const [k, v] of Object.entries( + event.detail as Record + )) { + if (isPrimitive(v)) { + params[k] = v; + } + } + } + // Top-level fields set last so they can't be shadowed by detail + // keys of the same name. + if (event.message !== undefined) { + params.message = event.message; + } + if (event.value !== undefined) { + params.value = event.value; + } + // Product is injected here so it lands on every event without + // call sites having to pass it. See BrandConfig.product. + params.product = this.product; + return params; + } +} + +const isPrimitive = (v: unknown): v is string | number | boolean => + typeof v === "string" || typeof v === "number" || typeof v === "boolean"; diff --git a/src/logging/logging.ts b/src/logging/logging.ts index fe7c8ca4f..c208c29ca 100644 --- a/src/logging/logging.ts +++ b/src/logging/logging.ts @@ -12,6 +12,12 @@ export interface Event { export interface Logging { event(event: Event): void; - error(e: any): void; + error(message: string, e: unknown): void; log(e: any): void; + /** + * Set a GA4 user property — auto-attaches to every subsequent event + * for the same user. Set early (e.g. on app boot) so events fired + * after are queryable by it. + */ + setUserProperty(name: string, value: string): void; } diff --git a/src/logging/mock.ts b/src/logging/mock.ts index 16213809a..2bae8d35f 100644 --- a/src/logging/mock.ts +++ b/src/logging/mock.ts @@ -7,16 +7,20 @@ import { Event, Logging } from "./logging"; export class MockLogging implements Logging { events: Event[] = []; - errors: any[] = []; + errors: Array<{ message: string; e: unknown }> = []; logs: any[] = []; + userProperties: Record = {}; event(event: Event): void { this.events.push(event); } - error(e: any): void { - this.errors.push(e); + error(message: string, e: unknown): void { + this.errors.push({ message, e }); } log(e: any): void { this.logs.push(e); } + setUserProperty(name: string, value: string): void { + this.userProperties[name] = value; + } } diff --git a/src/logging/sentry.ts b/src/logging/sentry.ts new file mode 100644 index 000000000..615a0091a --- /dev/null +++ b/src/logging/sentry.ts @@ -0,0 +1,86 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + addBreadcrumb as sentryAddBreadcrumb, + captureException as sentryCaptureException, + init as sentryInit, +} from "@sentry/browser"; + +/** + * Initialise Sentry from build-time env. Returns the configured DSN if + * Sentry was set up, or undefined if disabled — callers use that to + * short-circuit reporting paths. + */ +export const initSentry = (env: Record): string | undefined => { + const version = env.VITE_VERSION || "unknown"; + const stage = env.VITE_STAGE || "unknown"; + // Disable Sentry for the REVIEW stage even if the env var is set. + const dsn = stage === "REVIEW" ? undefined : env.VITE_SENTRY_DSN; + if (!dsn) { + return undefined; + } + try { + sentryInit({ + dsn, + release: `python-editor-v${version}`, + environment: stage, + ignoreErrors: [ + // Low consequence and a big chunk of quota. + "ResizeObserver loop completed with undelivered notifications", + ], + }); + } catch (e) { + console.error(e); + } + return dsn; +}; + +/** + * Report an error to Sentry (if configured) and the console. + */ +export const reportError = ( + dsn: string | undefined, + message: string, + e: unknown +): void => { + console.error(message, e); + if (!dsn) { + return; + } + try { + sentryAddBreadcrumb({ + message, + type: "error-message", + level: "error", + }); + sentryCaptureException(e); + } catch (err) { + console.error(err); + } +}; + +/** + * Add a breadcrumb to Sentry, or console-log it as a fallback when + * Sentry isn't configured. Used to record analytics events as context + * so they appear in the timeline of any captured exception. + */ +export const reportBreadcrumb = ( + dsn: string | undefined, + category: string, + data: object | string +): void => { + if (dsn) { + sentryAddBreadcrumb({ + category, + message: typeof data === "string" ? data : undefined, + data: typeof data === "object" ? data : undefined, + level: "info", + }); + } else { + // Avoid double-logging via console + Sentry breadcrumbs when Sentry is on. + console.log(category, JSON.stringify(data)); + } +}; diff --git a/src/logging/sink.ts b/src/logging/sink.ts new file mode 100644 index 000000000..144312161 --- /dev/null +++ b/src/logging/sink.ts @@ -0,0 +1,48 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ + +export type AnalyticsParams = Record; + +/** + * Output target for analytics events. The Logger owns the cross-cutting + * concerns (Sentry, param building, breadcrumbs); the sink owns whatever + * is specific to the analytics SDK in use. Same shape as ml-trainer's + * sink so a native (Firebase) sink could be dropped in later. + */ +export interface AnalyticsSink { + event(name: string, params: AnalyticsParams): void; + setUserProperty(name: string, value: string): void; +} + +type GtagFn = { + (command: "event", eventName: string, params: AnalyticsParams): void; + ( + command: "set", + target: "user_properties", + values: Record + ): void; +}; + +const gtag = () => (window as Window & { gtag?: GtagFn }).gtag; + +/** + * Web sink: emits events through gtag when shared-assets/common.js has + * set it up. index.html only loads that script for Foundation builds + * (VITE_FOUNDATION_BUILD) and the script itself is hostname-gated to + * `*.microbit.org`, so OSS forks and local dev take the silent no-gtag + * path. Consent is owned by the shared-assets cookie modal, see + * src/compliance/web.tsx. GA4 Enhanced Measurement auto-collects + * page_view, including the router's pushState navigation. + */ +export class WebSink implements AnalyticsSink { + event(name: string, params: AnalyticsParams): void { + gtag()?.("event", name, params); + } + + setUserProperty(name: string, value: string): void { + gtag()?.("set", "user_properties", { [name]: value }); + } +} diff --git a/src/logging/stage.ts b/src/logging/stage.ts new file mode 100644 index 000000000..248f8f6bd --- /dev/null +++ b/src/logging/stage.ts @@ -0,0 +1,15 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ + +/** + * Whether the current build stage routes analytics to a real backend. + * + * Limited to PRODUCTION and STAGING so REVIEW and local dev don't + * pollute the live property with developer / preview traffic. Used by + * the web cookie modal config to omit the GA opt-in section. + */ +export const isStageWithAnalytics = (stage: string | undefined): boolean => + stage === "PRODUCTION" || stage === "STAGING"; diff --git a/src/project/project-actions.tsx b/src/project/project-actions.tsx index edfda84b8..958a5f69f 100644 --- a/src/project/project-actions.tsx +++ b/src/project/project-actions.tsx @@ -38,6 +38,13 @@ import { PythonProject, } from "../fs/initial-project"; import { LanguageServerClient } from "../language-server/client"; +import { + AnalyticsTask, + deviceFailureCode, + importFormat, + markUserDisconnect, + transport, +} from "../logging/analytics"; import { Logging } from "../logging/logging"; import { SessionSettings } from "../settings/session-settings"; import { Settings } from "../settings/settings"; @@ -93,6 +100,18 @@ export enum ConnectionAction { DISCONNECT = "DISCONNECT", } +const analyticsTask = (userAction: ConnectionAction): AnalyticsTask => + userAction === ConnectionAction.FLASH ? "download" : "connect"; + +const statsParams = (stats: ProjectStatistics) => ({ + files: stats.files, + lines: stats.lines, + is_default: stats.defaultMain, + storage_used: stats.storageUsed, + errors: stats.errorCount, + modules: stats.magicModules, +}); + /** * Key actions. * @@ -131,17 +150,14 @@ export class ProjectActions { userAction: ConnectionAction, finalFocusRef: FinalFocusRef ): Promise => { - this.logging.event({ - type: "connect", - }); - const availability = await this.device.checkAvailability(); if (availability !== "available") { this.webusbNotSupportedError(finalFocusRef); return false; } - if (await this.showConnectHelp(forceConnectHelp, finalFocusRef)) { + const task = analyticsTask(userAction); + if (await this.showConnectHelp(forceConnectHelp, task, finalFocusRef)) { return this.connectInternal(userAction, finalFocusRef); } }; @@ -154,6 +170,7 @@ export class ProjectActions { */ private async showConnectHelp( force: boolean, + task: AnalyticsTask, finalFocusRef: FinalFocusRef ): Promise { const showConnectHelpSetting = this.settings.values.showConnectHelp; @@ -164,6 +181,10 @@ export class ProjectActions { ) { return true; } + this.logging.event({ + type: "device_step", + detail: { task, step: "connect_help", transport }, + }); const choice = await this.dialogs.show((callback) => ( { this.logging.event({ - type: "disconnect", + type: "device_disconnect", + detail: { reason: "user", transport }, }); - + // Only expect a Connected -> Disconnected status change if we were + // actually connected, otherwise the flag would linger. + markUserDisconnect(this.device.status === ConnectionStatus.Connected); try { await this.device.disconnect(); } catch (e) { + markUserDisconnect(false); this.handleWebUSBError(e, ConnectionAction.DISCONNECT, finalFocusRef); } }; @@ -264,14 +314,16 @@ export class ProjectActions { files: File[], type: LoadType = "file-upload" ): Promise => { - this.logging.event({ - type, - detail: files, - }); - if (files.length === 0) { throw new Error("Expected to be called with at least one file"); } + this.logging.event({ + type: "project_import", + detail: { + source: type === "drop-load" ? "drop" : "file_picker", + format: importFormat(files), + }, + }); // Avoid lingering messages related to the previous project. // Also makes e2e testing easier. @@ -389,8 +441,8 @@ export class ProjectActions { openIdea = async (slug: string | undefined, code: string, title: string) => { this.logging.event({ - type: "idea-open", - message: slug, + type: "idea_open", + detail: { id: slug }, }); const pythonProject: PythonProject = { files: projectFilesToBase64({ @@ -414,7 +466,7 @@ export class ProjectActions { reset = async () => { this.logging.event({ - type: "reset-project", + type: "project_reset", }); const confirmPrompt = this.intl.formatMessage({ id: "confirm-replace-reset", @@ -501,11 +553,6 @@ export class ProjectActions { throw new Error("Device connection doesn't support flash"); } - this.logging.event({ - type: "flash", - detail: await this.projectStats(), - }); - if ( this.device.status === ConnectionStatus.NoAuthorizedDevice || this.device.status === ConnectionStatus.Disconnected @@ -520,6 +567,13 @@ export class ProjectActions { } } + const task: AnalyticsTask = "download"; + const stats = await this.projectStats(); + this.logging.event({ + type: "device_step", + detail: { task, step: "flashing", transport }, + }); + const flashStart = Date.now(); const flashingCode = this.intl.formatMessage({ id: "flashing-code" }); try { const firstFlashNotice = ( @@ -544,7 +598,26 @@ export class ProjectActions { partial: true, progress, }); + this.logging.event({ + type: "device_success", + detail: { + task, + transport, + duration_ms: Date.now() - flashStart, + ...statsParams(stats), + }, + }); } catch (e) { + this.logging.event({ + type: "device_failure", + detail: { + task, + at_step: "flashing", + code: + e instanceof FlashDataError ? "flash-data" : deviceFailureCode(e), + transport, + }, + }); if (e instanceof FlashDataError) { this.actionFeedback.expectedError({ title: this.intl.formatMessage({ id: "failed-to-build-hex" }), @@ -567,8 +640,8 @@ export class ProjectActions { saveViaWebUsbNotSupported?: boolean ) => { this.logging.event({ - type: "save", - detail: await this.projectStats(), + type: "project_save", + detail: { format: "hex", ...statsParams(await this.projectStats()) }, }); if (!(await this.ensureProjectName(finalFocusRef))) { @@ -605,7 +678,7 @@ export class ProjectActions { */ saveFile = async (filename: string) => { this.logging.event({ - type: "save-file", + type: "file_save", }); try { @@ -627,7 +700,8 @@ export class ProjectActions { */ saveMainFile = async (finalFocusRef: React.RefObject) => { this.logging.event({ - type: "save-main-file", + type: "project_save", + detail: { format: "py", ...statsParams(await this.projectStats()) }, }); if (!(await this.ensureProjectName(finalFocusRef))) { @@ -689,7 +763,7 @@ export class ProjectActions { if (filenameWithoutExtension) { this.logging.event({ - type: "create-file", + type: "file_create", }); try { const filename = ensurePythonExtension(filenameWithoutExtension); @@ -714,7 +788,7 @@ export class ProjectActions { */ deleteFile = async (filename: string) => { this.logging.event({ - type: "delete-file", + type: "file_delete", }); try { @@ -792,7 +866,7 @@ export class ProjectActions { */ setProjectName = async (name: string) => { this.logging.event({ - type: "set-project-name", + type: "project_rename", }); return this.fs.setProjectName(name); diff --git a/src/router-hooks.tsx b/src/router-hooks.tsx index 88aad796d..f9e7d1dc2 100644 --- a/src/router-hooks.tsx +++ b/src/router-hooks.tsx @@ -42,11 +42,10 @@ export interface RouterState { focus?: boolean; } -type NavigationSource = - | "documentation-user" - | "documentation-search" - | "documentation-from-code" - | "documentation-from-simulator"; +/** + * How the user reached a documentation page, for the docs_navigate event. + */ +export type NavigationSource = "user" | "search" | "code" | "simulator"; type RouterContextValue = [ RouterState, @@ -112,11 +111,9 @@ export const RouterProvider = ({ children }: { children: ReactNode }) => { const navigate = useCallback( (newState: RouterState, source?: NavigationSource) => { if (source) { - const parts = [newState.tab, newState.slug?.id]; - const message = parts.filter((x): x is string => !!x).join("-"); logging.event({ - type: source, - message, + type: "docs_navigate", + detail: { via: source, surface: newState.tab, id: newState.slug?.id }, }); } const url = toUrl(newState); diff --git a/src/serial/SerialBar.tsx b/src/serial/SerialBar.tsx index ea63ae8d4..75f957620 100644 --- a/src/serial/SerialBar.tsx +++ b/src/serial/SerialBar.tsx @@ -47,7 +47,8 @@ const SerialBar = ({ const logging = useLogging(); const handleExpandCollapseClick = useCallback(() => { logging.event({ - type: compact ? "serial-expand" : "serial-collapse", + type: "serial_toggle", + detail: { state: compact ? "expand" : "collapse" }, }); onSizeChange(compact ? "open" : "compact"); }, [compact, onSizeChange, logging]); @@ -56,7 +57,7 @@ const SerialBar = ({ const traceback = useDeviceTraceback(); const syncStatus = useSyncStatus(); const handleShowHintsAndTips = useCallback(() => { - logging.event({ type: "serial-info" }); + logging.event({ type: "serial_help" }); setHelpOpen(true); }, [logging]); const menuButtonRef = useRef(null); diff --git a/src/serial/serial-actions.ts b/src/serial/serial-actions.ts index c3b6a0e61..d16918d9f 100644 --- a/src/serial/serial-actions.ts +++ b/src/serial/serial-actions.ts @@ -19,11 +19,11 @@ export class SerialActions { ) {} interrupt = (): void => { - this.logging.event({ type: "serial-interrupt" }); + this.logging.event({ type: "serial_interrupt" }); this.sendCommand("\x03"); }; reset = (): void => { - this.logging.event({ type: "serial-reset" }); + this.logging.event({ type: "serial_reset" }); this.sendCommand("\x04"); }; diff --git a/src/simulator/DataLoggingModule.tsx b/src/simulator/DataLoggingModule.tsx index dc2398d89..ef68d3271 100644 --- a/src/simulator/DataLoggingModule.tsx +++ b/src/simulator/DataLoggingModule.tsx @@ -55,7 +55,7 @@ const DataLoggingModule = ({ }); saveAs(blob, "simulated-log-data.csv"); logging.event({ - type: "sim-user-data-log-saved", + type: "sim_log_save", }); }, [logging, untruncatedDataLog]); if (minimised) { diff --git a/src/simulator/SimulatorActionBar.tsx b/src/simulator/SimulatorActionBar.tsx index 902a2b994..17af16b2e 100644 --- a/src/simulator/SimulatorActionBar.tsx +++ b/src/simulator/SimulatorActionBar.tsx @@ -53,7 +53,7 @@ const SimulatorActionBar = ({ onRunningChange(RunningStatus.STOPPED); if (source === "user") { logging.event({ - type: "sim-user-stopped", + type: "sim_stop", }); } }, diff --git a/src/simulator/SimulatorModules.tsx b/src/simulator/SimulatorModules.tsx index a05ffb67c..c775b7dad 100644 --- a/src/simulator/SimulatorModules.tsx +++ b/src/simulator/SimulatorModules.tsx @@ -175,7 +175,7 @@ const CollapsibleModule = ({ slug: { id: references[id] }, focus: true, }, - "documentation-from-simulator" + "simulator" ); }, [id, setRouterState]); const module = ( diff --git a/src/workbench/SideBarHeader.tsx b/src/workbench/SideBarHeader.tsx index c8e878729..42752c788 100644 --- a/src/workbench/SideBarHeader.tsx +++ b/src/workbench/SideBarHeader.tsx @@ -65,8 +65,8 @@ const SideBarHeader = ({ const handleCollapseBtnClick = useCallback(() => { logging.event({ - type: "sidebar-toggle", - message: !sidebarShown ? "open" : "close", + type: "sidebar_toggle", + detail: { state: !sidebarShown ? "open" : "close" }, }); onSidebarToggled(); }, [logging, onSidebarToggled, sidebarShown]); @@ -111,7 +111,7 @@ const SideBarHeader = ({ handleModalClosed(); // Create new RouterState object to enforce navigation when clicking the same entry twice. const routerState: RouterState = JSON.parse(JSON.stringify(navigation)); - setRouterState(routerState, "documentation-search"); + setRouterState(routerState, "search"); }, [setViewedResults, viewedResults, setRouterState, handleModalClosed] );