diff --git a/backend/__tests__/unit/routes/stats.byo-step.test.js b/backend/__tests__/unit/routes/stats.byo-step.test.js index 153ab258f..85be6ae3f 100644 --- a/backend/__tests__/unit/routes/stats.byo-step.test.js +++ b/backend/__tests__/unit/routes/stats.byo-step.test.js @@ -2,6 +2,8 @@ const request = require('supertest'); const express = require('express'); const mockMarkByoCommandCopied = jest.fn(); +const mockMarkByoCopyType = jest.fn(); +const mockMarkByoSetup = jest.fn(); const mockMarkSurface = jest.fn(); const mockInsertOne = jest.fn().mockResolvedValue({ acknowledged: true }); const mockPassThrough = () => (_req, _res, next) => next(); @@ -19,6 +21,8 @@ jest.mock('../../../middleware/auth', () => (req, _res, next) => { }); jest.mock('../../../services/onboardingMarks', () => ({ markByoCommandCopied: (...args) => mockMarkByoCommandCopied(...args), + markByoCopyType: (...args) => mockMarkByoCopyType(...args), + markByoSetup: (...args) => mockMarkByoSetup(...args), markSurface: (...args) => mockMarkSurface(...args), })); jest.mock('express-rate-limit', () => ({ @@ -94,6 +98,29 @@ describe('POST /api/stats/byo-step', () => { expect(mockInsertOne).toHaveBeenCalledWith({ userId: 'human-1', step, at: expect.any(Date) }); }); + it('a copy step passes the copy type and the setup the page showed to the marks', async () => { + const res = await request(app).post('/api/stats/byo-step') + .send({ step: 'mcp-command-copied', copyType: 'mcp-claude', setupOs: 'mac', listenerAvailable: true }); + + expect(res.status).toBe(200); + expect(mockMarkByoCopyType).toHaveBeenCalledWith('human-1', 'mcp-claude'); + expect(mockMarkByoSetup).toHaveBeenCalledWith('human-1', { setupOs: 'mac', listenerAvailable: true }); + // The event row is unchanged: the copy type and setup stay out of telemetry. + expect(mockInsertOne).toHaveBeenCalledWith(expect.objectContaining({ + step: 'mcp-command-copied', + commandType: 'mcp', + })); + expect(JSON.stringify(mockInsertOne.mock.calls[0][0])).not.toContain('mcp-claude'); + }); + + it('a non-copy step records the setup but never a copy type', async () => { + await request(app).post('/api/stats/byo-step') + .send({ step: 'seat-created', copyType: 'token', setupOs: 'windows', listenerAvailable: false }); + + expect(mockMarkByoCopyType).not.toHaveBeenCalled(); + expect(mockMarkByoSetup).toHaveBeenCalledWith('human-1', { setupOs: 'windows', listenerAvailable: false }); + }); + it('other steps set no surface mark', async () => { await request(app).post('/api/stats/byo-step').send({ step: 'page-viewed' }); expect(mockMarkSurface).not.toHaveBeenCalled(); @@ -106,6 +133,13 @@ describe('POST /api/stats/byo-step', () => { expect(mockInsertOne).not.toHaveBeenCalled(); }); + it('a copy type that does not match its step writes no per-type mark', async () => { + await request(app).post('/api/stats/byo-step').send({ step: 'token-copied', copyType: 'listen' }); + await request(app).post('/api/stats/byo-step').send({ step: 'cli-command-copied', copyType: 'mcp-claude' }); + + expect(mockMarkByoCopyType).not.toHaveBeenCalled(); + }); + it('rejects unknown steps without writing a mark or telemetry event', async () => { const res = await request(app) .post('/api/stats/byo-step') diff --git a/backend/__tests__/unit/services/onboardingMarks.test.js b/backend/__tests__/unit/services/onboardingMarks.test.js index 3b6331ad3..342ef125a 100644 --- a/backend/__tests__/unit/services/onboardingMarks.test.js +++ b/backend/__tests__/unit/services/onboardingMarks.test.js @@ -12,6 +12,7 @@ const { markPodOpened, markStartPicked, markByoOpened, markByoCommandCopied, markByoConnectionAttempt, markWelcomeHostedStart, markSurface, markByoDaemonRegistered, resetOnboardingMarksForTest, + markByoCopyType, markByoSetup, } = require('../../../services/onboardingMarks'); const flush = () => new Promise((r) => setImmediate(r)); @@ -260,6 +261,55 @@ describe('BYO setup marks', () => { }); }); +describe('markByoCopyType and markByoSetup (TASK-714)', () => { + test('each command type gets its own write-once mark, so a later listen copy after MCP leaves a trace', () => { + markByoCopyType('u1', 'mcp-claude'); + markByoCopyType('u1', 'listen'); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: 'u1', isBot: { $ne: true }, 'onboarding.byoMcpClaudeCopiedAt': { $exists: false } }, + { $set: { 'onboarding.byoMcpClaudeCopiedAt': expect.any(Date) } }, + ); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: 'u1', isBot: { $ne: true }, 'onboarding.byoListenCopiedAt': { $exists: false } }, + { $set: { 'onboarding.byoListenCopiedAt': expect.any(Date) } }, + ); + }); + + test('each type is written once per person, however often it is copied', () => { + markByoCopyType('u1', 'daemon'); + markByoCopyType('u1', 'daemon'); + markByoCopyType('u1', 'daemon'); + expect(mockUpdateOne).toHaveBeenCalledTimes(1); + }); + + test('refuses an unknown copy type, so the page cannot write an arbitrary field', () => { + markByoCopyType('u1', 'constructor'); + markByoCopyType('u1', 'listen; drop'); + markByoCopyType('u1', undefined); + markByoCopyType('', 'token'); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); + + test('writes the setup OS and listener eligibility once, and nothing else', () => { + markByoSetup('u1', { setupOs: 'mac', listenerAvailable: true }); + markByoSetup('u1', { setupOs: 'windows', listenerAvailable: false }); + expect(mockUpdateOne).toHaveBeenCalledTimes(1); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: 'u1', isBot: { $ne: true }, 'onboarding.byoListenerEligible': { $exists: false } }, + { $set: { 'onboarding.byoSetupOs': 'mac', 'onboarding.byoListenerEligible': true } }, + ); + expect(JSON.stringify(mockUpdateOne.mock.calls)).not.toMatch(/token|command|pod/i); + }); + + test('a missing or malformed setup writes nothing', () => { + markByoSetup('u1', { setupOs: 'mac' }); + markByoSetup('u1', { setupOs: 'plan9', listenerAvailable: true }); + markByoSetup('u1', { setupOs: 'linux', listenerAvailable: 'yes' }); + markByoSetup('u1', undefined); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); +}); + describe('markByoDaemonRegistered (TASK-661)', () => { test('sets byoDaemonRegisteredAt write-once in the filter, never for bots', () => { markByoDaemonRegistered('u1'); diff --git a/backend/models/User.ts b/backend/models/User.ts index bc27dcb5d..8da1c1edf 100644 --- a/backend/models/User.ts +++ b/backend/models/User.ts @@ -222,6 +222,15 @@ export interface IUser extends Document { /** First copy of each setup command, kept per kind (TASK-721). */ byoMcpCopiedAt?: Date; byoCliCopiedAt?: Date; + /** First copy of each command type, each its own write-once mark (TASK-714). */ + byoListenCopiedAt?: Date; + byoMcpClaudeCopiedAt?: Date; + byoMcpCursorCopiedAt?: Date; + byoDaemonCopiedAt?: Date; + byoTokenCopiedAt?: Date; + /** The OS the result page set up for, and whether the listener could run there. */ + byoSetupOs?: 'mac' | 'linux' | 'windows' | 'other'; + byoListenerEligible?: boolean; /** First authenticated BYO runtime request, never overwritten. */ byoFirstConnectionAttemptAt?: Date; /** First successful `commonly daemon register`, never overwritten (TASK-661). */ @@ -481,6 +490,13 @@ const userSchema = new Schema({ byoCommandType: { type: String, enum: ['mcp', 'cli'] }, byoMcpCopiedAt: { type: Date }, byoCliCopiedAt: { type: Date }, + byoListenCopiedAt: { type: Date }, + byoMcpClaudeCopiedAt: { type: Date }, + byoMcpCursorCopiedAt: { type: Date }, + byoDaemonCopiedAt: { type: Date }, + byoTokenCopiedAt: { type: Date }, + byoSetupOs: { type: String, enum: ['mac', 'linux', 'windows', 'other'] }, + byoListenerEligible: { type: Boolean }, byoFirstConnectionAttemptAt: { type: Date }, byoDaemonRegisteredAt: { type: Date }, inviteCardShownAt: { type: Date }, diff --git a/backend/routes/stats.ts b/backend/routes/stats.ts index 192b18895..0326ce348 100644 --- a/backend/routes/stats.ts +++ b/backend/routes/stats.ts @@ -78,7 +78,7 @@ router.get('/public', statsPublicLimit, async (_req: unknown, res: { json: (d: u // eslint-disable-next-line global-require const auth = require('../middleware/auth'); // eslint-disable-next-line global-require -const { markByoCommandCopied, markSurface } = require('../services/onboardingMarks'); +const { markByoCommandCopied, markByoCopyType, markByoSetup, markSurface } = require('../services/onboardingMarks'); // eslint-disable-next-line global-require const { rateLimit } = require('express-rate-limit'); // eslint-disable-next-line global-require @@ -91,6 +91,14 @@ const BYO_STEPS = new Set([ 'token-copied', 'listen-confirmed', 'listen-timeout', 'hosted-offer-shown', 'hosted-offer-clicked', 'invite-card-shown', 'invite-card-dismissed', 'byo-offer-shown', ]); +// Steps that copy a setup command, and the copy types each may carry. A step +// and its type must agree, so "token-copied" can never set the listen mark. +const COPY_TYPES_BY_STEP: Record = { + 'mcp-command-copied': ['mcp-claude', 'mcp-cursor'], + 'cli-command-copied': ['listen'], + 'daemon-command-copied': ['daemon'], + 'token-copied': ['token'], +}; // Onboarding surfaces: each step also sets its write-once onboarding mark. const SURFACE_STEPS: Record = { 'invite-card-shown': 'inviteCardShownAt', @@ -114,6 +122,13 @@ router.post('/byo-step', rateLimit({ // the copied command, which contains a runtime token. markByoCommandCopied(req.userId, step === 'mcp-command-copied' ? 'mcp' : 'cli'); } + const allowedCopyTypes = COPY_TYPES_BY_STEP[step]; + if (allowedCopyTypes && allowedCopyTypes.includes(req.body?.copyType)) { + markByoCopyType(req.userId, req.body.copyType); + } + // The setup the result page showed. The service validates both fields and + // writes nothing for a missing or malformed value. + markByoSetup(req.userId, { setupOs: req.body?.setupOs, listenerAvailable: req.body?.listenerAvailable }); if (SURFACE_STEPS[step]) markSurface(req.userId, SURFACE_STEPS[step]); await recordByoEvent(req.userId, step as any, { ...(step === 'mcp-command-copied' ? { commandType: 'mcp' } : {}), diff --git a/backend/services/onboardingMarks.ts b/backend/services/onboardingMarks.ts index 432e36d8a..8410e231f 100644 --- a/backend/services/onboardingMarks.ts +++ b/backend/services/onboardingMarks.ts @@ -192,6 +192,73 @@ export function markByoCommandCopied(userId: unknown, commandType: 'mcp' | 'cli' } } +/** + * The first copy of each setup command, by its own type (TASK-714). + * + * `listen` is the listener command; `mcp-claude` and `mcp-cursor` are the two + * MCP snippets; `daemon` and `token` are the daemon and token copies. Each + * gets its own write-once field, so a later listen copy after an MCP copy + * leaves a trace. The existing first-copy marks above are unchanged. + */ +export const BYO_COPY_TYPES = ['listen', 'mcp-claude', 'mcp-cursor', 'daemon', 'token'] as const; +export type ByoCopyType = typeof BYO_COPY_TYPES[number]; +const BYO_COPY_FIELD: Record = { + listen: 'onboarding.byoListenCopiedAt', + 'mcp-claude': 'onboarding.byoMcpClaudeCopiedAt', + 'mcp-cursor': 'onboarding.byoMcpCursorCopiedAt', + daemon: 'onboarding.byoDaemonCopiedAt', + token: 'onboarding.byoTokenCopiedAt', +}; +const byoCopyTypeMarked = new Set(); + +export function markByoCopyType(userId: unknown, copyType: unknown): void { + const id = String(userId || ''); + if (!id || !(BYO_COPY_TYPES as readonly unknown[]).includes(copyType)) return; + const type = copyType as ByoCopyType; + const key = `${id}:${type}`; + if (byoCopyTypeMarked.has(key)) return; + byoCopyTypeMarked.add(key); + const field = BYO_COPY_FIELD[type]; + try { + User.updateOne( + { _id: id, isBot: { $ne: true }, [field]: { $exists: false } }, + { $set: { [field]: new Date() } }, + ).catch(() => { + byoCopyTypeMarked.delete(key); + }); + } catch { + // Telemetry fails open: it must never break the copy flow. + byoCopyTypeMarked.delete(key); + } +} + +/** + * The OS the result page set up for and whether its listener could run there, + * written once per person (TASK-714). Booleans and the fixed OS enum only: no + * command, token, or pod is stored. A missing or malformed value writes nothing. + */ +const byoSetupMarked = new Set(); + +type ByoSetupInput = { setupOs?: unknown; listenerAvailable?: unknown } | null | undefined; +export function markByoSetup(userId: unknown, setup: ByoSetupInput): void { + const id = String(userId || ''); + const setupOs = parseByoOpenedOs(setup?.setupOs); + const listenerEligible = setup?.listenerAvailable; + if (!id || !setupOs || typeof listenerEligible !== 'boolean' || byoSetupMarked.has(id)) return; + byoSetupMarked.add(id); + try { + User.updateOne( + { _id: id, isBot: { $ne: true }, 'onboarding.byoListenerEligible': { $exists: false } }, + { $set: { 'onboarding.byoSetupOs': setupOs, 'onboarding.byoListenerEligible': listenerEligible } }, + ).catch(() => { + byoSetupMarked.delete(id); + }); + } catch { + // Telemetry fails open: it must never break the copy flow. + byoSetupMarked.delete(id); + } +} + /** * Record the first authenticated request from a BYO/local runtime. * @@ -287,6 +354,8 @@ export function resetOnboardingMarksForTest(): void { byoOpenedMarked.clear(); byoCommandCopiedMarked.clear(); byoCommandKindMarked.clear(); + byoCopyTypeMarked.clear(); + byoSetupMarked.clear(); byoConnectionAttemptMarked.clear(); daemonRegisteredMarked.clear(); surfaceMarked.clear(); diff --git a/frontend/src/v2/__tests__/V2AgentBYOCopyTelemetry.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOCopyTelemetry.test.tsx index 10315d65f..0b0353849 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOCopyTelemetry.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOCopyTelemetry.test.tsx @@ -99,3 +99,57 @@ test('a failed daemon copy posts no copy step', async () => { expect(stepsPosted()).not.toContain('daemon-command-copied'); }); + +// TASK-714: the copy says which command it was and what the page showed, so +// the server can tell "the listener was possible" from "it was not". +const withUserAgent = (ua: string) => { + Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: ua }); +}; +const MAC_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36'; +const WINDOWS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36'; + +// Issue a seat so the result page renders (the same path the listen-snippet +// suite drives), then return the page's own setup telemetry. +const issueSeatAndReadSetup = async () => { + axios.post + .mockResolvedValueOnce({ data: { ok: true } }) // /api/registry/install + .mockResolvedValueOnce({ data: { token: 'cm_agent_test_token' } }); // runtime-tokens + renderPage(); + await waitFor(() => expect(screen.getByText('Workspace (chat)')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('button', { name: /get my connect command/i })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: /get my connect command/i })); + await screen.findByText(/your connect command for/i); + return recordByoStep.mock.calls.find(([, step]) => step === 'seat-created'); +}; + +test.each([ + ['mac', MAC_UA, true], + ['windows', WINDOWS_UA, false], +])('the %s result page records the setup it shows (listener %s)', async (os, ua, listenerAvailable) => { + const original = window.navigator.userAgent; + withUserAgent(ua); + try { + const call = await issueSeatAndReadSetup(); + expect(call?.[2]).toEqual({ setupOs: os, listenerAvailable }); + } finally { + withUserAgent(original); + } +}); + +test('a Mac daemon copy posts its command type with the setup the page showed', async () => { + const original = window.navigator.userAgent; + withUserAgent(MAC_UA); + try { + renderPage(); + const block = await screen.findByTestId('byo-add-computer'); + await act(async () => { + fireEvent.click(block.querySelector('button.v2-byo__copy')); + }); + + const call = recordByoStep.mock.calls.find(([, step]) => step === 'daemon-command-copied'); + expect(call?.[2]).toEqual({ setupOs: 'mac', listenerAvailable: true, copyType: 'daemon' }); + expect(JSON.stringify(call)).not.toMatch(/commonly daemon|cm_agent/); + } finally { + withUserAgent(original); + } +}); diff --git a/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx index ca87d134b..ca372334e 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx @@ -91,7 +91,7 @@ test('exit intent offers immediately; manual copy dismisses permanently', async test('button copy suppresses the offer beyond the Copied label', async () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockResolvedValue(undefined) } }); await issue(); await act(async () => fireEvent.click(screen.getByTestId('byo-listen').querySelector('button.v2-byo__copy'))); await tick(60000); expect(offer()).not.toBeInTheDocument(); - expect(axios.post).toHaveBeenCalledWith('/api/stats/byo-step', { step: 'cli-command-copied' }, expect.anything()); + expect(axios.post).toHaveBeenCalledWith('/api/stats/byo-step', expect.objectContaining({ step: 'cli-command-copied', copyType: 'listen' }), expect.anything()); }); test('hosting errors preserve the command and a retryable offer', async () => { await issue(); axios.post.mockImplementation((url) => url === '/api/registry/install' ? Promise.reject({ response: { data: { code: 'hosted_cap_reached', cap: 1 } } }) : Promise.resolve({ data: {} })); @@ -102,7 +102,7 @@ test('page entry is recorded once under StrictMode; command rendering is separat render(); await screen.findByTestId('byo-mode-byo'); fireEvent.click(screen.getByTestId('byo-mode-byo')); await waitFor(() => expect(screen.getByRole('button', { name: /get my connect command/i })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: /get my connect command/i })); await screen.findByText(/your connect command for/i); - expect(axios.post.mock.calls.filter(([url]) => url === '/api/stats/byo-step').map(([, body]) => body)).toEqual([{ step: 'page-viewed' }, { step: 'hosted-offer-shown' }, { step: 'seat-created' }]); + expect(axios.post.mock.calls.filter(([url]) => url === '/api/stats/byo-step').map(([, body]) => body)).toEqual([{ step: 'page-viewed' }, { step: 'hosted-offer-shown' }, { step: 'seat-created', setupOs: 'mac', listenerAvailable: true }]); }); test('a failed clipboard write leaves the fallback available', async () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockRejectedValue(new Error('Denied')) } }); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 7b694dc4a..1e832083d 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -19,7 +19,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; -import { recordByoStep, type ByoStep } from '../utils/byoTelemetry'; +import { recordByoStep, type ByoCopyType, type ByoStep } from '../utils/byoTelemetry'; import getApiBaseUrl from '../../utils/apiBaseUrl'; import { daemonSetupCommand } from '../utils/daemonSetupCommand'; import { defaultMachine, machineDisplayName } from '../utils/machineLabel'; @@ -989,8 +989,10 @@ const V2AgentBYO: React.FC = () => { // BYO funnel telemetry: 7 of 7 recent seats died between token issuance and // the first authenticated call, and the server cannot see whether the user // even copied a command. Fire-and-forget; the flow never waits on it. - const byoStep = (step: ByoStep) => { - recordByoStep(api, step); + // Every step carries the setup the page is showing (OS and listener + // eligibility) so the server can tell "listener was possible" from "not". + const byoStep = (step: ByoStep, copyType?: ByoCopyType) => { + recordByoStep(api, step, { setupOs, listenerAvailable, ...(copyType ? { copyType } : {}) }); }; useEffect(() => { @@ -1020,8 +1022,9 @@ const V2AgentBYO: React.FC = () => { setShowHostedOffer(false); if (!issued) return; // This event means the issued command rendered, distinct from page entry. - recordByoStep(api, 'seat-created'); - }, [api, issued]); + // It also carries the setup the result page shows (TASK-714). + recordByoStep(api, 'seat-created', { setupOs, listenerAvailable }); + }, [api, issued, setupOs, listenerAvailable]); const rememberHostedOffer = () => { if (!offerSessionKey) return; @@ -1068,10 +1071,11 @@ const V2AgentBYO: React.FC = () => { else if (key === 'listen') markCopiedKind('listen'); setCopied(key); setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500); - if (key === 'claude' || key === 'cursor') byoStep('mcp-command-copied'); - else if (key === 'listen') byoStep('cli-command-copied'); - else if (key === 'daemon-setup') byoStep('daemon-command-copied'); - else if (key === 'tok') byoStep('token-copied'); + if (key === 'claude') byoStep('mcp-command-copied', 'mcp-claude'); + else if (key === 'cursor') byoStep('mcp-command-copied', 'mcp-cursor'); + else if (key === 'listen') byoStep('cli-command-copied', 'listen'); + else if (key === 'daemon-setup') byoStep('daemon-command-copied', 'daemon'); + else if (key === 'tok') byoStep('token-copied', 'token'); } catch { // Clipboard may be unavailable (non-HTTPS, sandbox); user can select manually. } diff --git a/frontend/src/v2/utils/byoTelemetry.ts b/frontend/src/v2/utils/byoTelemetry.ts index 66d074b7b..e811980cb 100644 --- a/frontend/src/v2/utils/byoTelemetry.ts +++ b/frontend/src/v2/utils/byoTelemetry.ts @@ -5,9 +5,21 @@ export type ByoStep = 'page-viewed' | 'seat-created' | 'mcp-command-copied' | 'c | 'hosted-offer-shown' | 'hosted-offer-clicked' | 'invite-card-shown' | 'invite-card-dismissed' | 'byo-offer-shown'; +// Which setup command was copied. Fixed names only; the server keeps the first +// copy of each (TASK-714), so "listen after MCP" stays visible. +export type ByoCopyType = 'listen' | 'mcp-claude' | 'mcp-cursor' | 'daemon' | 'token'; + +// What the result page showed: the OS it set up for and whether the listener +// could run there. Booleans and fixed names only, never the command or token. +export interface ByoSetupContext { + setupOs: 'mac' | 'linux' | 'windows' | 'other'; + listenerAvailable: boolean; + copyType?: ByoCopyType; +} + // Fixed event names only. Commands and credentials must never enter telemetry. -export const recordByoStep = (api: V2ApiClient, step: ByoStep): void => { - void api.post('/api/stats/byo-step', { step }).catch(() => {}); +export const recordByoStep = (api: V2ApiClient, step: ByoStep, context?: ByoSetupContext): void => { + void api.post('/api/stats/byo-step', { step, ...context }).catch(() => {}); }; // Surface steps fire on every mount; the server mark is write-once, so one