Skip to content
Merged
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
12 changes: 11 additions & 1 deletion Telemetry.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Argent Privacy Notice (Telemetry)

Effective date: 7 July 2026 · Version: 1.01
Effective date: 13 July 2026 · Version: 1.02

This notice is a product-specific supplement to the [Software Mansion Privacy Policy](https://swmansion.com/) (the "Policy") and applies to telemetry collected by Argent, a Software Mansion Software Product. Capitalised terms used but not defined here (including Personal Data, Usage Data, Legitimate Interest, EEA and Software Mansion Software Product) have the meaning given to them in the Policy. Where this notice and the Policy differ in respect of Argent telemetry, this notice prevails.

Expand Down Expand Up @@ -49,6 +49,16 @@ We do not use telemetry for advertising, marketing, profiling, automated decisio
- how long a process ran in the terminal or in CI, and which Argent component emitted the event;
- start and stop of the Argent tool-server, its uptime, the number of tools used, and the reason it stopped.

### Design review (Argent Lens)

When you use the Argent Lens design-review flow (previewing and choosing between proposed UI variants), we collect aggregate, non-content signals about the review interaction:

- that a human opened the review preview for a round, and whether the review was completed or abandoned;
- per-round aggregate counts only: how many elements and variants were proposed, and how many per-element comments, skipped-element comments, and free-form annotations were left — never the comment text, element names, variant code, or file paths;
- whether a round carried a round-wide comment, and whether the optional comment inspector or "reveal off-screen choices" affordances were used;
- how long a review round took, from the first proposal to submission;
- how many times the `argent lens` command was run, and how many coding-agent choices its picker offered (a count only, never the agent names).

### Environment

- Argent version, Node.js version, operating system, processor architecture;
Expand Down
122 changes: 122 additions & 0 deletions packages/telemetry/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,120 @@ export interface ToolserverStopProps extends FailureTelemetryProps {
total_tool_calls: number;
}

// Lens (variant-proposal) events
//
// The agent CALLS `propose_variant` / `await_user_selection`, which the generic
// tool:invoke/complete/fail path already counts — but those calls only prove the
// AGENT acted. They can't tell us whether a HUMAN opened the preview or what they
// decided. These events capture the human side of the funnel: preview loaded →
// round decided OR abandoned. All carry only privacy-safe aggregate counts /
// booleans / durations plus the device `platform` enum — never element names,
Comment thread
latekvo marked this conversation as resolved.
// comment text, variant code, file paths, or raw device identifiers.

// Emitted when a HUMAN renders a proposal round in a VISIBLE preview window,
// driven by an explicit client signal (`POST /preview/opened`) rather than
// inferred from a page load or poll. Fires once per round across all surfaces
// (MCP respawn, reused CLI window, multiple tabs) via a server-side per-round
// dedup, and never from a backgrounded tab (the client gates on
// `document.visibilityState`). The counts are sampled server-side at the moment
// the client reports the round, so they reflect what was staged when the human
// first saw it — consistent across MCP and CLI (both go through the same client
// signal), not a fresh-load vs poll-tick mix.
export interface LensPreviewOpenedProps {
/** Proposal round the human rendered in the preview. */
round: number;
/** Elements with staged proposals when the round was reported (0 for a CLI up-front open). */
element_count: number;
/** Total variants staged across all elements when the round was reported (0 for a CLI up-front open). */
variant_count: number;
/** Whether an `argent lens` CLI session owns the window (vs the MCP path). */
is_cli_session: boolean;
/**
* Device platform the variants target. Omitted whenever the round staged no
* proposals (`element_count === 0`) — including a CLI up-front open — so the
* store's device (which deliberately survives `reset()`) can't attribute a
* zero-count open to a prior flow's device. A TV target is reported as
* `tvos` / `android-tv` once the runtime-kind cache is warm (as with `tool:*`).
*/
platform?: Platform;
}

export interface LensRoundCompletedProps {
round: number;
/** Elements the agent proposed variants for. */
element_count: number;
/** Total variants offered across all elements. */
variant_count: number;
/** Free-form inspector comments the user pinned to on-screen elements. */
annotation_count: number;
/** Proposed elements the user attached a per-element comment to (chosen or skipped). */
element_comment_count: number;
/** Skipped elements the user left a per-element comment on (a "needs changes" signal; chosen-with-comment = element_comment_count - skipped_comment_count). */
skipped_comment_count: number;
/** Whether the user left a round-wide comment. */
has_global_comment: boolean;
/**
* Whether the human opened the element-comment "inspector" (the "Add comment"
* spotlight) at least once during this round — an adoption signal for the
* inspector button that `annotation_count` (a comment-volume proxy) can't
* give: this registers an open even when it produced no saved comment.
*/
inspector_used: boolean;
/**
* Whether the human clicked "Show them" (or its collapsed pill) to reveal
* off-screen variant choices at least once during this round.
*/
offscreen_revealed: boolean;
/** Whether an `argent lens` CLI session owns the window (vs the MCP path). */
is_cli_session: boolean;
/** Whether an `await_user_selection` call was parked to receive this submit. */
had_parked_await: boolean;
/** From the first proposal staged in this round to the submit. */
round_duration_ms: number;
/** Device platform the variants target; omitted when no device was bound. */
platform?: Platform;
}

// The other end of the funnel: a round that had staged proposals but was
// discarded before the human submitted (window closed, `argent lens` exited
// mid-review, or the round was superseded). Fires at most once per abandoned
// round, from the store's single reset() choke point. Drop-off is the metric
// `preview_opened`/`round_completed` alone can't give — this supplies the loss.
export interface LensRoundAbandonedProps {
round: number;
/** Elements that had staged proposals when the round was discarded. */
element_count: number;
/** Total variants staged across those elements. */
variant_count: number;
/** Whether an `await_user_selection` call was parked (i.e. the MCP window was shown) when abandoned. */
had_parked_await: boolean;
/** Whether an `argent lens` CLI session owned the window. */
is_cli_session: boolean;
/** Device platform the variants targeted; omitted when no device was bound. */
platform?: Platform;
}

// Fired ONCE per `argent lens` CLI invocation, on the session begin. The generic
// tool:* path counts the agent's propose_variant/await_user_selection calls
// (which fire many times per session), and lens:preview_opened fires once PER
// ROUND — so neither can count how many times a human ran `argent lens`. This is
// the per-invocation marker: a plain count of these events is the invocation
// total, and distinct telemetry ids over them are the unique-user population for
// the tool. Privacy-safe: only an aggregate count, no PII.
export interface LensCliSessionStartedProps {
/**
* Coding-agent choices offered in the window's picker. In practice `argent lens`
* sends only two values: 0 when no picker is shown (an `--agent` override, a
* remembered-and-still-installed choice, or a single installed agent — the CLI
* resolves the agent itself and posts an empty list), and >= 2 when it forwards
* a real choice for the human to pick. 1 is unreachable from `argent lens` (a
* lone installed agent is auto-selected, not offered), so a 1 in the data
* indicates a hand-crafted POST, not the single-installed-agent case. A
* privacy-safe count, never the agent names.
*/
agent_choice_count: number;
}

// Discriminated union for typed-track()

export interface EventPropertyMap {
Expand All @@ -200,6 +314,10 @@ export interface EventPropertyMap {
"cli:run_fail": CliRunFailProps;
"toolserver:start": ToolserverStartProps;
"toolserver:stop": ToolserverStopProps;
"lens:preview_opened": LensPreviewOpenedProps;
"lens:round_completed": LensRoundCompletedProps;
"lens:round_abandoned": LensRoundAbandonedProps;
"lens:cli_session_started": LensCliSessionStartedProps;
}

export type EventName = keyof EventPropertyMap;
Expand Down Expand Up @@ -229,4 +347,8 @@ export const EVENT_NAMES: readonly EventName[] = [
"cli:run_fail",
"toolserver:start",
"toolserver:stop",
"lens:preview_opened",
"lens:round_completed",
"lens:round_abandoned",
"lens:cli_session_started",
];
33 changes: 33 additions & 0 deletions packages/telemetry/src/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,39 @@ export const ALLOWED: ValidatorMap = {
total_tool_calls: COUNT,
...FAILURE_SIGNAL,
},
"lens:preview_opened": {
round: COUNT,
element_count: COUNT,
variant_count: COUNT,
is_cli_session: bool,
platform: PLATFORM,
},
"lens:round_completed": {
round: COUNT,
element_count: COUNT,
variant_count: COUNT,
annotation_count: COUNT,
element_comment_count: COUNT,
skipped_comment_count: COUNT,
has_global_comment: bool,
inspector_used: bool,
offscreen_revealed: bool,
is_cli_session: bool,
had_parked_await: bool,
round_duration_ms: DURATION_MS,
platform: PLATFORM,
},
"lens:round_abandoned": {
round: COUNT,
element_count: COUNT,
variant_count: COUNT,
had_parked_await: bool,
is_cli_session: bool,
platform: PLATFORM,
},
"lens:cli_session_started": {
agent_choice_count: COUNT,
},
};

/** Strip keys and values that are not allowed for this event. */
Expand Down
56 changes: 56 additions & 0 deletions packages/telemetry/test/sanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,62 @@ describe("sanitize", () => {
});
});

describe("lens telemetry events", () => {
it("keeps the round_completed usage flags and drops leaked content", () => {
const out = sanitize("lens:round_completed", {
round: 2,
element_count: 3,
variant_count: 5,
annotation_count: 1,
element_comment_count: 2,
skipped_comment_count: 1,
has_global_comment: true,
inspector_used: true,
offscreen_revealed: false,
is_cli_session: true,
had_parked_await: false,
round_duration_ms: 1234,
platform: "ios",
// Content that must never survive:
element_name: "Checkout button",
comment_text: "make it pop",
});
expect(out).toEqual({
round: 2,
element_count: 3,
variant_count: 5,
annotation_count: 1,
element_comment_count: 2,
skipped_comment_count: 1,
has_global_comment: true,
inspector_used: true,
offscreen_revealed: false,
is_cli_session: true,
had_parked_await: false,
round_duration_ms: 1234,
platform: "ios",
});
});

it("drops a non-boolean inspector_used / offscreen_revealed", () => {
const out = sanitize("lens:round_completed", {
round: 1,
inspector_used: "yes",
offscreen_revealed: 1,
});
// The bool validator rejects non-booleans, so both keys are removed.
expect(out).toEqual({ round: 1 });
});

it("keeps agent_choice_count on cli_session_started and drops extras", () => {
const out = sanitize("lens:cli_session_started", {
agent_choice_count: 2,
agent_names: ["claude", "cursor"], // must be dropped
});
expect(out).toEqual({ agent_choice_count: 2 });
});
});

describe("ALLOWED ↔ EVENT_NAMES sync", () => {
it("every declared event name has a sanitizer entry", () => {
for (const name of EVENT_NAMES) {
Expand Down
34 changes: 5 additions & 29 deletions packages/tool-server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ import {
FAILURE_CODES,
type FailureSignal,
type FileInputSpec,
// The tool-server's coarse *device* platform (TV-agnostic). Telemetry's own
// Platform (imported below) is a superset that also carries `tvos`/`android-tv`.
type Platform as DevicePlatform,
type Registry,
type ResolvedFileInput,
} from "@argent/registry";
Expand All @@ -37,8 +34,7 @@ import {
UnsupportedOperationError,
} from "./utils/capability";
import { resolveDevice } from "./utils/device-info";
import { getCachedSimulatorRuntimeKind } from "./utils/ios-devices";
import { getCachedAndroidRuntimeKind } from "./utils/adb";
import { refineTvPlatform } from "./utils/telemetry-platform";
import type { Server as HttpServer } from "node:http";
import {
CHROMIUM_CDP_NAMESPACE,
Expand Down Expand Up @@ -123,30 +119,10 @@ type InvocationMeta = { platform?: TelemetryPlatform } & AiTelemetryProps;
// stored or forwarded.
type HttpFailureMeta = { platform?: TelemetryPlatform } & AiTelemetryProps;

/**
* Split a TV target out of its base mobile platform for telemetry, using only
* the already-memoized runtime kind — never a fresh `simctl`/`adb` probe, since
* this runs on the per-tool-call hot path. A tvOS simulator and an iPhone
* simulator share the same UDID shape (both classify as `ios`); an Android TV
* emulator and a phone share the `emulator-NNNN` serial shape (both `android`).
* The device platform stays coarse (a TV is a `runtimeKind`, not its own device
* platform — capability gating and dispatch are TV-agnostic); we refine it to
* `tvos` / `android-tv` here for reporting only when the cache already knows the
* kind, and leave it coarse otherwise. The first tool call on a device may land
* before the cache is warm and report the base platform; subsequent calls, once
* a describe/interaction path has warmed the runtime-kind cache (the per-platform
* warmers are listed on `getCachedSimulatorRuntimeKind` /
* `getCachedAndroidRuntimeKind`), report the TV variant.
*/
function refineTvPlatform(basePlatform: DevicePlatform, deviceId: string): TelemetryPlatform {
if (basePlatform === "ios" && getCachedSimulatorRuntimeKind(deviceId) === "tv") {
return "tvos";
}
if (basePlatform === "android" && getCachedAndroidRuntimeKind(deviceId) === "tv") {
return "android-tv";
}
return basePlatform;
}
// `refineTvPlatform` — splitting a TV target out of its coarse mobile platform
// for telemetry from the warm runtime-kind cache — now lives in
// ./utils/telemetry-platform so the Lens funnel events can share the exact same
// TV attribution as the per-tool-call path here (a single source of truth).

function inferPlatform(deviceId: string | null): TelemetryPlatform | null {
if (!deviceId) return null;
Expand Down
Loading