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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ playwright-report/
test-results/
*.log
.DS_Store
.env
.env.local
.env.*.local
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
18 changes: 18 additions & 0 deletions entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions entrypoints/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,32 @@ 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: ["<all_urls>"],
runAt: "document_idle",
main: () => {
setTelemetrySink(createForwardingSink("selector-extension-content"));

const contextMenu = new ContextMenuTracker();
contextMenu.addContextMenuListener();
const picker = new PickerSession(contextMenu);
const contentMessagingClient = createContentMessagingClient();
const context: ContentContext = { picker, contentMessagingClient };

registerContentHandlers(contentHandlers, context);

reportGlobalErrors(window, isExtensionError);
},
});
46 changes: 46 additions & 0 deletions entrypoints/popup/components/TelemetryToggle.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean | null>(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<HTMLInputElement>) => {
const next = event.target.checked;
setEnabled(next);
void setTelemetryEnabled(next);
};

return (
<label
className={styles.menuToggle}
role="menuitemcheckbox"
aria-checked={enabled}
>
<input
id="telemetry-toggle"
type="checkbox"
checked={enabled}
onChange={onChange}
/>
<span>Share anonymous usage data</span>
</label>
);
}
2 changes: 2 additions & 0 deletions entrypoints/popup/components/WorkspaceSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -48,6 +49,7 @@ export function WorkspaceSwitcher({
<div className={styles.menuIdentity}>
<span className={styles.menuName}>{name}</span>
</div>
<TelemetryToggle />
<button
id="sign-out"
type="button"
Expand Down
42 changes: 41 additions & 1 deletion entrypoints/popup/main.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,47 @@
import { Component, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./ui.module.css";
import { setTelemetrySink, trackException } from "@/lib/telemetry/api";
import { createForwardingSink } from "@/lib/telemetry/forwardingSink";
import { reportGlobalErrors } from "@/lib/telemetry/globalErrors";

// The popup is its own extension window — its error events are all ours (no host
// page), so no filtering is needed. Forward telemetry to the background egress.
setTelemetrySink(createForwardingSink("selector-extension-popup"));
reportGlobalErrors(window);

/** Reports render-time crashes to telemetry and shows a minimal fallback. */
class TelemetryErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };

static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}

componentDidCatch(error: unknown): void {
trackException({ error, properties: { source: "errorBoundary" } });
}

render(): ReactNode {
if (this.state.hasError) {
return (
<div style={{ padding: 16, fontSize: 13 }}>
Something went wrong. Please reopen the popup.
</div>
);
}
return this.props.children;
}
}

const container = document.getElementById("root");
if (!container) throw new Error("Missing #root in popup");
createRoot(container).render(<App />);
createRoot(container).render(
<TelemetryErrorBoundary>
<App />
</TelemetryErrorBoundary>
);
25 changes: 25 additions & 0 deletions entrypoints/popup/ui.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,31 @@ button {
color: var(--danger);
}

.menuToggle {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 7px 8px;
margin-bottom: 4px;
font-size: 12.5px;
font-weight: 600;
text-align: left;
color: var(--fg);
border-bottom: 1px solid var(--border);
cursor: pointer;
}

.menuToggle input {
flex: none;
margin: 0;
cursor: pointer;
}

.menuToggle span {
flex: 1;
}

/* ── usage bar ──────────────────────────────────────────────────────────── */

.usage {
Expand Down
24 changes: 24 additions & 0 deletions lib/agent/agentLoopController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@/lib/messaging";
import { appendSelectorHistory } from "@/lib/state";
import { generalizeArrayXpath } from "@/lib/content/dom/arrayXpath";
import type { BackgroundTelemetry } from "@/lib/telemetry";
import type {
BrowserResultRecord,
FinalSelectorResult,
Expand All @@ -21,6 +22,7 @@ import type {
export interface AgentLoopDeps {
state: SelectorState;
backgroundMessagingClient: BackgroundMessagingClient;
telemetry: BackgroundTelemetry;
}

const MAX_BACKEND_STEPS = 20;
Expand Down Expand Up @@ -66,11 +68,17 @@ export class AgentLoopController {

this.status = "running";
let steps = 0;
const startedAt = Date.now();
try {
while (true) {
if (signal.aborted) return;

if (steps >= MAX_BACKEND_STEPS) {
this.deps.telemetry.trackEvent({
name: "agentLoop.maxStepsExceeded",
measurements: { steps },
operationId: sessionId,
});
await this.settle(sessionId, {
status: "error",
note: `Exceeded max backend steps (${MAX_BACKEND_STEPS}).`,
Expand Down Expand Up @@ -148,12 +156,28 @@ export class AgentLoopController {
return;
}
console.error("[selector-extension] AgentLoop error", error);
this.deps.telemetry.trackException({
error,
operationId: sessionId,
properties: { context: "agentLoop" },
});
await this.settle(sessionId, {
status: "error",
note: error instanceof Error ? error.message : "Unknown loop error",
});
} finally {
this.abortController = null;
this.deps.telemetry.trackEvent({
name: "agentLoop.completed",
properties: {
outcome: signal.aborted
? "aborted"
: this.deps.state.get()?.status ?? "unknown",
mode: this.deps.state.get()?.mode ?? "unknown",
},
measurements: { durationMs: Date.now() - startedAt, steps },
operationId: sessionId,
});
}
}

Expand Down
57 changes: 48 additions & 9 deletions lib/auth/request.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { getApiHeaders, getApiQueryParams } from "./manager";
// Import the api surface directly (not the barrel) so this background-only
// module never pulls in the messaging-backed forwarding sink.
import { trackEvent, trackException } from "@/lib/telemetry/api";
import { scrubUrlToHost } from "@/lib/telemetry/scrub";

/**
* Fetch an Intuned backend REST endpoint with the active auth method applied:
Expand All @@ -23,13 +27,48 @@ export async function fetchIntunedApi(
target.searchParams.set(key, value);
}

return fetch(target.toString(), {
...init,
headers: {
"Content-Type": "application/json",
...init.headers,
...authHeaders,
},
credentials: authHeaders ? "omit" : "include",
});
// Telemetry dimensions: host + pathname only — never the query string (it
// carries the api-key workspace id) or the request body.
const host = scrubUrlToHost(target.toString());
const pathname = target.pathname;
const method = (init.method ?? "GET").toUpperCase();
const startedAt = Date.now();

try {
const res = await fetch(target.toString(), {
...init,
headers: {
"Content-Type": "application/json",
...init.headers,
...authHeaders,
},
credentials: authHeaders ? "omit" : "include",
});
trackEvent({
name: "api.request",
properties: {
host,
pathname,
method,
ok: String(res.ok),
statusCode: String(res.status),
},
measurements: { durationMs: Date.now() - startedAt },
});
return res;
} catch (error) {
// Network-level failure (DNS, offline) — the response never arrived. Skip
// user-initiated cancels (agent-loop abort), which aren't real failures.
const aborted =
init.signal?.aborted ||
(error instanceof DOMException && error.name === "AbortError");
if (!aborted) {
trackException({
error,
properties: { host, pathname, method, context: "api.request" },
measurements: { durationMs: Date.now() - startedAt },
});
}
throw error;
}
}
Loading