diff --git a/.gitignore b/.gitignore index 38d3fe8..42d8f30 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ playwright-report/ test-results/ *.log .DS_Store +.env +.env.local +.env.*.local diff --git a/README.md b/README.md index 414a8d8..a6a4d3a 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,29 @@ After the first `yarn dev`, load the unpacked extension from `.output/chrome-mv3 - **Browser** — Vitest browser-mode tests that run selector generation against a real DOM and prove each candidate resolves to exactly the expected element set. This is the correctness oracle. Both layers run under `yarn test`. - **E2E** — Playwright against the packaged MV3 extension with a real page, pointer flow, popup, content script, and background worker. Run with `yarn e2e`. +## Telemetry + +The extension reports anonymous diagnostics to Azure Application Insights so we can +spot errors and usage issues in the wild. It is **anonymous** (a random per-install +id — never your email, workspace name, browsed-page URLs, or selector strings) and can +be turned off from the workspace menu in the popup ("Share anonymous usage data"). + +What's collected: exceptions/unhandled rejections, command events with timing, +agent-loop outcomes, and Intuned API request host, path, status, and latency — never +the query string (it carries your workspace id). See [lib/telemetry/](./lib/telemetry). + +The background service worker is the single egress; content and popup forward items +to it over the message protocol. Because an MV3 worker has no DOM and is short-lived, +the SDK pipeline is built from `@microsoft/applicationinsights-core-js` + +`-channel-js` directly (fetch transport, in-memory buffer) — not the Web SDK. + +The Azure connection string is hard-coded in [lib/config.ts](./lib/config.ts) +(`HARDCODED_CONNECTION_STRING`); the write-only ingestion key is safe to embed in the +published bundle. Because it is always present, dev builds report too — while developing, +either use the in-popup opt-out or point telemetry at a throwaway resource by setting the +`config.appInsightsConnectionString` key in `browser.storage.local` (an empty value falls +back to the hard-coded string; clearing it restores the default). + ## Project layout ``` diff --git a/entrypoints/background.ts b/entrypoints/background.ts index f109ef1..e5c7be3 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -13,21 +13,39 @@ import { } from "@/lib/background/contextMenu"; import { createBackgroundMessagingClient } from "@/lib/messaging"; import { SelectorState } from "@/lib/state"; +import { BackgroundTelemetryClient } from "@/lib/telemetry/client"; +import { setTelemetrySink } from "@/lib/telemetry/api"; +import { reportGlobalErrors } from "@/lib/telemetry/globalErrors"; export default defineBackground(() => { const state = new SelectorState(); const messaging = createBackgroundMessagingClient(); + + // Single App Insights egress for the whole extension. Registered as the api.ts + // sink so background-originated trackEvent/trackException (registerHandlers, + // fetchIntunedApi, …) route here; content/popup forward over messaging. init() + // is async — calls before it resolves are safe no-ops. + const telemetry = new BackgroundTelemetryClient(); + setTelemetrySink(telemetry); + void telemetry.init(); + const agentLoopController = new AgentLoopController({ state, backgroundMessagingClient: messaging, + telemetry, }); const context: BackgroundContext = { state, agentLoopController, backgroundMessagingClient: messaging, + telemetry, }; + // Global safety net. This runs at the top of every worker cold start, so any + // otherwise-unobserved error/rejection in the worker is captured. + reportGlobalErrors(self); + void state.hydrate(); registerBackgroundHandlers(backgroundHandlers, context); diff --git a/entrypoints/content.ts b/entrypoints/content.ts index 29b8be4..5a04579 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -6,11 +6,24 @@ import { type ContentContext, } from "@/lib/content"; import { createContentMessagingClient } from "@/lib/messaging"; +import { setTelemetrySink } from "@/lib/telemetry/api"; +import { createForwardingSink } from "@/lib/telemetry/forwardingSink"; +import { reportGlobalErrors } from "@/lib/telemetry/globalErrors"; + +// A content script shares the page's `window`, so its error listeners also fire +// for host-page errors. Gate on the extension origin so we only report our own. +const EXTENSION_ORIGIN = /(?:chrome|moz)-extension:\/\//; +function isExtensionError(error: unknown, filename?: string): boolean { + if (filename && EXTENSION_ORIGIN.test(filename)) return true; + return error instanceof Error && !!error.stack && EXTENSION_ORIGIN.test(error.stack); +} export default defineContentScript({ matches: [""], runAt: "document_idle", main: () => { + setTelemetrySink(createForwardingSink("selector-extension-content")); + const contextMenu = new ContextMenuTracker(); contextMenu.addContextMenuListener(); const picker = new PickerSession(contextMenu); @@ -18,5 +31,7 @@ export default defineContentScript({ const context: ContentContext = { picker, contentMessagingClient }; registerContentHandlers(contentHandlers, context); + + reportGlobalErrors(window, isExtensionError); }, }); diff --git a/entrypoints/popup/components/TelemetryToggle.tsx b/entrypoints/popup/components/TelemetryToggle.tsx new file mode 100644 index 0000000..950cbdb --- /dev/null +++ b/entrypoints/popup/components/TelemetryToggle.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import { getTelemetryEnabled, setTelemetryEnabled } from "@/lib/config"; +import styles from "../ui.module.css"; + +/** + * Opt-out control for anonymous telemetry. Reads/writes the shared config flag + * (browser.storage.local); the background client picks up the change live via a + * storage.onChanged listener. Renders nothing until the current value loads. + */ +export function TelemetryToggle() { + const [enabled, setEnabled] = useState(null); + + useEffect(() => { + let active = true; + void getTelemetryEnabled().then((value) => { + if (active) setEnabled(value); + }); + return () => { + active = false; + }; + }, []); + + if (enabled === null) return null; + + const onChange = (event: React.ChangeEvent) => { + const next = event.target.checked; + setEnabled(next); + void setTelemetryEnabled(next); + }; + + return ( + + ); +} diff --git a/entrypoints/popup/components/WorkspaceSwitcher.tsx b/entrypoints/popup/components/WorkspaceSwitcher.tsx index 0243546..0012163 100644 --- a/entrypoints/popup/components/WorkspaceSwitcher.tsx +++ b/entrypoints/popup/components/WorkspaceSwitcher.tsx @@ -5,6 +5,7 @@ import { ChevronDown, SignOutIcon } from "../icons"; import { useClickOutside } from "../hooks/useClickOutside"; import { useCachedAvatar } from "../hooks/useCachedAvatar"; import { displayName, initials } from "../utils"; +import { TelemetryToggle } from "./TelemetryToggle"; export function WorkspaceSwitcher({ identity, @@ -48,6 +49,7 @@ export function WorkspaceSwitcher({
{name}
+