From da1d321db00cd4de05ba39fd9e338feff6fb7e51 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Tue, 5 May 2026 15:00:06 +0700 Subject: [PATCH 01/87] feat: [ENG-2611] add analytics field to GlobalConfig Adds a typed analytics: boolean field to the GlobalConfig domain entity with safe migration for legacy configs (defaults to false when absent to preserve the opt-in promise across upgrades). Widens toJson() return to a typed GlobalConfigJson shape. Type guard rejects non-boolean values when the analytics key is structurally present. Tests moved to test/unit/server/core/domain/entities/global-config.test.ts and extended with the seven scenarios required by the ticket. --- .../core/domain/entities/global-config.ts | 39 ++++++++++++- .../domain/entities/global-config.test.ts | 58 ++++++++++++++++++- 2 files changed, 92 insertions(+), 5 deletions(-) rename test/unit/{ => server}/core/domain/entities/global-config.test.ts (63%) diff --git a/src/server/core/domain/entities/global-config.ts b/src/server/core/domain/entities/global-config.ts index 527fdc2f5..7f161ebf2 100644 --- a/src/server/core/domain/entities/global-config.ts +++ b/src/server/core/domain/entities/global-config.ts @@ -4,14 +4,32 @@ import {GLOBAL_CONFIG_VERSION} from '../../../constants.js' * Parameters for creating a GlobalConfig instance. */ export interface GlobalConfigParams { + readonly analytics: boolean + readonly deviceId: string + readonly version: string +} + +/** + * Serialized JSON shape for GlobalConfig. + */ +export type GlobalConfigJson = { + readonly analytics: boolean + readonly deviceId: string + readonly version: string +} + +type GlobalConfigJsonInput = { + readonly analytics?: boolean readonly deviceId: string readonly version: string } /** * Type guard for GlobalConfig JSON validation. + * `analytics` is optional on input (legacy configs predate the field); when + * present it must be a boolean. */ -const isGlobalConfigJson = (json: unknown): json is GlobalConfigParams => { +const isGlobalConfigJson = (json: unknown): json is GlobalConfigJsonInput => { if (typeof json !== 'object' || json === null || json === undefined) return false const obj = json as Record @@ -24,6 +42,10 @@ const isGlobalConfigJson = (json: unknown): json is GlobalConfigParams => { return false } + if ('analytics' in obj && typeof obj.analytics !== 'boolean') { + return false + } + return true } @@ -32,16 +54,19 @@ const isGlobalConfigJson = (json: unknown): json is GlobalConfigParams => { * Contains device-level settings that persist across all projects. */ export class GlobalConfig { + public readonly analytics: boolean public readonly deviceId: string public readonly version: string private constructor(params: GlobalConfigParams) { this.deviceId = params.deviceId this.version = params.version + this.analytics = params.analytics } /** * Creates a new GlobalConfig with the given device ID and current version. + * Analytics defaults to `false` (opt-in). * * @param deviceId - The unique device identifier (UUID v4) * @returns A new GlobalConfig instance @@ -53,6 +78,7 @@ export class GlobalConfig { } return new GlobalConfig({ + analytics: false, deviceId, version: GLOBAL_CONFIG_VERSION, }) @@ -61,6 +87,8 @@ export class GlobalConfig { /** * Deserializes config from JSON format. * Returns undefined for invalid JSON structure (graceful failure). + * Missing `analytics` defaults to `false` to preserve the opt-in promise + * across upgrades from pre-analytics builds. * * @param json - The JSON object to deserialize * @returns GlobalConfig instance or undefined if invalid @@ -70,14 +98,19 @@ export class GlobalConfig { return undefined } - return new GlobalConfig(json) + return new GlobalConfig({ + analytics: json.analytics ?? false, + deviceId: json.deviceId, + version: json.version, + }) } /** * Serializes the config to JSON format. */ - public toJson(): Record { + public toJson(): GlobalConfigJson { return { + analytics: this.analytics, deviceId: this.deviceId, version: this.version, } diff --git a/test/unit/core/domain/entities/global-config.test.ts b/test/unit/server/core/domain/entities/global-config.test.ts similarity index 63% rename from test/unit/core/domain/entities/global-config.test.ts rename to test/unit/server/core/domain/entities/global-config.test.ts index dff998339..fa4981f01 100644 --- a/test/unit/core/domain/entities/global-config.test.ts +++ b/test/unit/server/core/domain/entities/global-config.test.ts @@ -1,7 +1,7 @@ import {expect} from 'chai' -import {GLOBAL_CONFIG_VERSION} from '../../../../../src/server/constants.js' -import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' +import {GLOBAL_CONFIG_VERSION} from '../../../../../../src/server/constants.js' +import {GlobalConfig} from '../../../../../../src/server/core/domain/entities/global-config.js' describe('GlobalConfig', () => { const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' @@ -117,6 +117,7 @@ describe('GlobalConfig', () => { const json = config.toJson() expect(json).to.deep.equal({ + analytics: false, deviceId: validDeviceId, version: GLOBAL_CONFIG_VERSION, }) @@ -130,6 +131,59 @@ describe('GlobalConfig', () => { expect(restored).to.not.be.undefined expect(restored?.deviceId).to.equal(original.deviceId) expect(restored?.version).to.equal(original.version) + expect(restored?.analytics).to.equal(original.analytics) + }) + }) + + describe('analytics field (ENG-2611)', () => { + it('should default analytics to false when absent (legacy upgrade)', () => { + const config = GlobalConfig.fromJson({deviceId: validDeviceId, version: '0.0.1'}) + + expect(config).to.not.be.undefined + expect(config?.analytics).to.equal(false) + }) + + it('should preserve analytics: true when explicitly set', () => { + const config = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) + + expect(config).to.not.be.undefined + expect(config?.analytics).to.equal(true) + }) + + it('should preserve analytics: false when explicitly set', () => { + const config = GlobalConfig.fromJson({analytics: false, deviceId: validDeviceId, version: '0.0.1'}) + + expect(config).to.not.be.undefined + expect(config?.analytics).to.equal(false) + }) + + it('should reject non-boolean analytics value', () => { + const config = GlobalConfig.fromJson({analytics: 'yes', deviceId: validDeviceId, version: '0.0.1'}) + + expect(config).to.be.undefined + }) + + it('should round-trip analytics: true through toJson/fromJson', () => { + const fromTrue = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) + const restoredTrue = GlobalConfig.fromJson(fromTrue!.toJson()) + expect(restoredTrue?.analytics).to.equal(true) + + const fromFalse = GlobalConfig.fromJson({analytics: false, deviceId: validDeviceId, version: '0.0.1'}) + const restoredFalse = GlobalConfig.fromJson(fromFalse!.toJson()) + expect(restoredFalse?.analytics).to.equal(false) + }) + + it('should default analytics to false on create()', () => { + const config = GlobalConfig.create(validDeviceId) + + expect(config.analytics).to.equal(false) + }) + + it('should include analytics: false explicitly in toJson() of default-created instance', () => { + const config = GlobalConfig.create(validDeviceId) + const json = config.toJson() + + expect(json).to.have.property('analytics', false) }) }) From 21c1b2a21c6d4757c2a37ce1215943506ee08349 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Tue, 5 May 2026 16:42:24 +0700 Subject: [PATCH 02/87] feat: [ENG-2612] add brv analytics status command Adds the read-only brv analytics status oclif command, which prints whether CLI analytics is enabled or disabled. Output is "Analytics: enabled" or "Analytics: disabled" by default; --format json emits the shape {"analytics": "enabled" | "disabled"} via the repo's standard writeJsonResponse envelope. Routing follows login / logout / locations: oclif uses withDaemonRetry to emit a globalConfig:get transport event (auto-spawns the daemon). The daemon-side GlobalConfigHandler re-reads the on-disk config every call (no in-memory cache); on a fresh install it seeds a stable deviceId via GlobalConfig.create(randomUUID()) so device identity is available from the first read. Help text describes what telemetry is collected and links to a placeholder privacy policy URL pending M1.5. --- src/oclif/commands/analytics/status.ts | 59 ++++++ src/server/infra/process/feature-handlers.ts | 7 + .../handlers/global-config-handler.ts | 54 ++++++ src/server/infra/transport/handlers/index.ts | 2 + .../transport/events/global-config-events.ts | 9 + src/shared/transport/events/index.ts | 3 + test/commands/analytics/status.test.ts | 183 ++++++++++++++++++ 7 files changed, 317 insertions(+) create mode 100644 src/oclif/commands/analytics/status.ts create mode 100644 src/server/infra/transport/handlers/global-config-handler.ts create mode 100644 src/shared/transport/events/global-config-events.ts create mode 100644 test/commands/analytics/status.test.ts diff --git a/src/oclif/commands/analytics/status.ts b/src/oclif/commands/analytics/status.ts new file mode 100644 index 000000000..652b97d13 --- /dev/null +++ b/src/oclif/commands/analytics/status.ts @@ -0,0 +1,59 @@ +import {Command, Flags} from '@oclif/core' + +import { + GlobalConfigEvents, + type GlobalConfigGetResponse, +} from '../../../shared/transport/events/global-config-events.js' +import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' +import {writeJsonResponse} from '../../lib/json-response.js' + +const COMMAND_ID = 'analytics:status' + +export default class Status extends Command { + public static description = `Show whether ByteRover CLI analytics is enabled or disabled. + +Analytics is opt-in (default: off). When enabled, ByteRover collects anonymous +usage telemetry (event names, CLI version, OS, Node version, environment) to +improve the product. No content of your queries, files, or memory is collected. + +Privacy policy: https://byterover.dev/privacy (placeholder until M1.5) +Toggle: brv analytics enable | brv analytics disable` + public static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --format json'] + public static flags = { + format: Flags.string({ + char: 'f', + default: 'text', + description: 'Output format', + options: ['text', 'json'], + }), + } + + protected async fetchAnalyticsEnabled(options?: DaemonClientOptions): Promise { + return withDaemonRetry(async (client) => { + const response = await client.requestWithAck(GlobalConfigEvents.GET) + return response.analytics + }, options) + } + + public async run(): Promise { + const {flags} = await this.parse(Status) + const isJson = flags.format === 'json' + + try { + const enabled = await this.fetchAnalyticsEnabled({projectPath: process.cwd()}) + const label = enabled ? 'enabled' : 'disabled' + + if (isJson) { + writeJsonResponse({command: COMMAND_ID, data: {analytics: label}, success: true}) + } else { + this.log(`Analytics: ${label}`) + } + } catch (error) { + if (isJson) { + writeJsonResponse({command: COMMAND_ID, data: {error: formatConnectionError(error)}, success: false}) + } else { + this.log(formatConnectionError(error)) + } + } + } +} diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 355f33e80..b63bbd7ca 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -44,6 +44,7 @@ import {createHubKeychainStore} from '../hub/hub-keychain-store.js' import {HubRegistryConfigStore} from '../hub/hub-registry-config-store.js' import {HttpSpaceService} from '../space/http-space-service.js' import {FileCurateLogStore} from '../storage/file-curate-log-store.js' +import {FileGlobalConfigStore} from '../storage/file-global-config-store.js' import {FileReviewBackupStore} from '../storage/file-review-backup-store.js' import {createTokenStore} from '../storage/token-store.js' import {HttpTeamService} from '../team/http-team-service.js' @@ -53,6 +54,7 @@ import { ConfigHandler, ConnectorsHandler, ContextTreeHandler, + GlobalConfigHandler, HubHandler, InitHandler, LocationsHandler, @@ -120,6 +122,11 @@ export async function setupFeatureHandlers({ // Global handlers (no project context needed) new ConfigHandler({transport}).setup() + new GlobalConfigHandler({ + globalConfigStore: new FileGlobalConfigStore(), + transport, + }).setup() + new AuthHandler({ authService: new OAuthService(authConfig), authStateStore, diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts new file mode 100644 index 000000000..bc5e2a4b0 --- /dev/null +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -0,0 +1,54 @@ +import {randomUUID} from 'node:crypto' + +import type {IGlobalConfigStore} from '../../../core/interfaces/storage/i-global-config-store.js' +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import { + GlobalConfigEvents, + type GlobalConfigGetResponse, +} from '../../../../shared/transport/events/global-config-events.js' +import {GlobalConfig} from '../../../core/domain/entities/global-config.js' + +export interface GlobalConfigHandlerDeps { + globalConfigStore: IGlobalConfigStore + transport: ITransportServer +} + +/** + * Handles globalConfig:get event. + * Re-reads the file every call (no in-memory cache) so the daemon always + * reflects the latest on-disk state, including writes from sibling commands. + * If no config exists yet, seeds a fresh one with a stable deviceId. + */ +export class GlobalConfigHandler { + private readonly globalConfigStore: IGlobalConfigStore + private readonly transport: ITransportServer + + constructor(deps: GlobalConfigHandlerDeps) { + this.globalConfigStore = deps.globalConfigStore + this.transport = deps.transport + } + + setup(): void { + this.transport.onRequest(GlobalConfigEvents.GET, async () => this.read()) + } + + private async read(): Promise { + const existing = await this.globalConfigStore.read() + if (existing) { + return { + analytics: existing.analytics, + deviceId: existing.deviceId, + version: existing.version, + } + } + + const seeded = GlobalConfig.create(randomUUID()) + await this.globalConfigStore.write(seeded) + return { + analytics: seeded.analytics, + deviceId: seeded.deviceId, + version: seeded.version, + } + } +} diff --git a/src/server/infra/transport/handlers/index.ts b/src/server/infra/transport/handlers/index.ts index c5fdf33fa..4d48214b0 100644 --- a/src/server/infra/transport/handlers/index.ts +++ b/src/server/infra/transport/handlers/index.ts @@ -6,6 +6,8 @@ export {ConnectorsHandler} from './connectors-handler.js' export type {ConnectorsHandlerDeps} from './connectors-handler.js' export {ContextTreeHandler} from './context-tree-handler.js' export type {ContextTreeHandlerDeps} from './context-tree-handler.js' +export {GlobalConfigHandler} from './global-config-handler.js' +export type {GlobalConfigHandlerDeps} from './global-config-handler.js' export type {ProjectBroadcaster, ProjectPathResolver} from './handler-types.js' export {resolveRequiredProjectPath} from './handler-types.js' export {HubHandler} from './hub-handler.js' diff --git a/src/shared/transport/events/global-config-events.ts b/src/shared/transport/events/global-config-events.ts new file mode 100644 index 000000000..07e108843 --- /dev/null +++ b/src/shared/transport/events/global-config-events.ts @@ -0,0 +1,9 @@ +export const GlobalConfigEvents = { + GET: 'globalConfig:get', +} as const + +export interface GlobalConfigGetResponse { + readonly analytics: boolean + readonly deviceId: string + readonly version: string +} diff --git a/src/shared/transport/events/index.ts b/src/shared/transport/events/index.ts index 45d22eea8..ad60cafc4 100644 --- a/src/shared/transport/events/index.ts +++ b/src/shared/transport/events/index.ts @@ -8,6 +8,7 @@ export * from './client-events.js' export * from './config-events.js' export * from './connector-events.js' export * from './context-tree-events.js' +export * from './global-config-events.js' export * from './hub-events.js' export * from './init-events.js' export * from './llm-events.js' @@ -34,6 +35,7 @@ import {ClientEvents} from './client-events.js' import {ConfigEvents} from './config-events.js' import {ConnectorEvents} from './connector-events.js' import {ContextTreeEvents} from './context-tree-events.js' +import {GlobalConfigEvents} from './global-config-events.js' import {HubEvents} from './hub-events.js' import {InitEvents} from './init-events.js' import {LlmEvents} from './llm-events.js' @@ -64,6 +66,7 @@ export const AllEventGroups = [ ConfigEvents, ConnectorEvents, ContextTreeEvents, + GlobalConfigEvents, HubEvents, InitEvents, LlmEvents, diff --git a/test/commands/analytics/status.test.ts b/test/commands/analytics/status.test.ts new file mode 100644 index 000000000..c99fb43a6 --- /dev/null +++ b/test/commands/analytics/status.test.ts @@ -0,0 +1,183 @@ +import type {ConnectionResult, ITransportClient} from '@campfirein/brv-transport-client' +import type {Config} from '@oclif/core' + +import {NoInstanceRunningError} from '@campfirein/brv-transport-client' +import {Config as OclifConfig} from '@oclif/core' +import {expect} from 'chai' +import sinon, {restore, stub} from 'sinon' + +import type {DaemonClientOptions} from '../../../src/oclif/lib/daemon-client.js' + +import Status from '../../../src/oclif/commands/analytics/status.js' +import {GlobalConfigEvents} from '../../../src/shared/transport/events/global-config-events.js' + +class TestableStatusCommand extends Status { + private readonly mockConnector: () => Promise + + constructor(mockConnector: () => Promise, config: Config, argv: string[] = []) { + super(argv, config) + this.mockConnector = mockConnector + } + + protected override async fetchAnalyticsEnabled(options?: DaemonClientOptions): Promise { + return super.fetchAnalyticsEnabled({ + maxRetries: 1, + retryDelayMs: 0, + transportConnector: this.mockConnector, + ...options, + }) + } +} + +describe('analytics status command', () => { + let config: Config + let loggedMessages: string[] + let mockClient: sinon.SinonStubbedInstance + let mockConnector: sinon.SinonStub<[], Promise> + + before(async () => { + config = await OclifConfig.load(import.meta.url) + }) + + beforeEach(() => { + loggedMessages = [] + + mockClient = { + connect: stub().resolves(), + disconnect: stub().resolves(), + getClientId: stub().returns('test-client-id'), + getDaemonVersion: stub(), + getState: stub().returns('connected'), + isConnected: stub().resolves(true), + joinRoom: stub().resolves(), + leaveRoom: stub().resolves(), + on: stub().returns(() => {}), + once: stub(), + onStateChange: stub().returns(() => {}), + request: stub() as unknown as ITransportClient['request'], + requestWithAck: stub().resolves({analytics: false, deviceId: 'test-device', version: '1.0.0'}), + } as unknown as sinon.SinonStubbedInstance + + mockConnector = stub<[], Promise>().resolves({ + client: mockClient as unknown as ITransportClient, + projectRoot: '/test/project', + }) + }) + + afterEach(() => { + restore() + }) + + function createCommand(argv: string[] = []): TestableStatusCommand { + const command = new TestableStatusCommand(mockConnector, config, argv) + stub(command, 'log').callsFake((msg?: string) => { + if (msg) loggedMessages.push(msg) + }) + return command + } + + function mockAnalyticsResponse(analytics: boolean): void { + ;(mockClient.requestWithAck as sinon.SinonStub).resolves({ + analytics, + deviceId: 'test-device', + version: '1.0.0', + }) + } + + describe('text output', () => { + it('should print "Analytics: disabled" for a fresh (analytics:false) config', async () => { + mockAnalyticsResponse(false) + + await createCommand().run() + + expect(loggedMessages.some((m) => m.includes('Analytics: disabled'))).to.be.true + }) + + it('should print "Analytics: enabled" when underlying config has analytics: true', async () => { + mockAnalyticsResponse(true) + + await createCommand().run() + + expect(loggedMessages.some((m) => m.includes('Analytics: enabled'))).to.be.true + }) + }) + + describe('JSON output', () => { + it('should emit {"analytics": "disabled"} shape when disabled', async () => { + mockAnalyticsResponse(false) + + let captured = '' + const writeStub = stub(process.stdout, 'write').callsFake((chunk) => { + captured += chunk + return true + }) + + try { + await new TestableStatusCommand(mockConnector, config, ['--format', 'json']).run() + } finally { + writeStub.restore() + } + + const parsed = JSON.parse(captured) as {data: {analytics: string}; success: boolean} + expect(parsed.success).to.be.true + expect(parsed.data.analytics).to.equal('disabled') + }) + + it('should emit {"analytics": "enabled"} shape when enabled', async () => { + mockAnalyticsResponse(true) + + let captured = '' + const writeStub = stub(process.stdout, 'write').callsFake((chunk) => { + captured += chunk + return true + }) + + try { + await new TestableStatusCommand(mockConnector, config, ['--format', 'json']).run() + } finally { + writeStub.restore() + } + + const parsed = JSON.parse(captured) as {data: {analytics: string}; success: boolean} + expect(parsed.success).to.be.true + expect(parsed.data.analytics).to.equal('enabled') + }) + + it('should output success: false on connection error', async () => { + mockConnector.rejects(new NoInstanceRunningError()) + + let captured = '' + const writeStub = stub(process.stdout, 'write').callsFake((chunk) => { + captured += chunk + return true + }) + + try { + await new TestableStatusCommand(mockConnector, config, ['--format', 'json']).run() + } finally { + writeStub.restore() + } + + const parsed = JSON.parse(captured) as {success: boolean} + expect(parsed.success).to.be.false + }) + }) + + describe('transport contract', () => { + it('should issue exactly one read against GlobalConfigEvents.GET', async () => { + mockAnalyticsResponse(false) + + await createCommand().run() + + const requestStub = mockClient.requestWithAck as sinon.SinonStub + expect(requestStub.callCount).to.equal(1) + expect(requestStub.firstCall.args[0]).to.equal(GlobalConfigEvents.GET) + }) + }) + + describe('help text', () => { + it('should declare a description string and not throw on construction', () => { + expect(Status.description).to.be.a('string').and.not.be.empty + }) + }) +}) From ee1daaea236ab8f254c1085f68d09cb63b0b090b Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Tue, 5 May 2026 17:30:41 +0700 Subject: [PATCH 03/87] feat: [ENG-2613] add brv analytics enable / disable commands Adds two oclif commands that toggle the analytics flag in GlobalConfig: brv analytics enable and brv analytics disable. Output is "Analytics enabled" / "Analytics disabled" on a state change, or "Analytics already enabled" / "Analytics already disabled" when the flag was already in the target state (idempotent, exits 0). Routing matches M1.2: oclif uses withDaemonRetry to emit the new globalConfig:setAnalytics transport event. The daemon-side GlobalConfigHandler gains a SET_ANALYTICS listener that reads the current config (or seeds a fresh one with a stable deviceId if absent), applies the new value via GlobalConfig.fromJson round-trip (the entity is immutable with a private constructor), and writes back. The idempotent path skips the file write entirely when previous state matches the requested value, so a disable on a fresh install does not create a stub config file. M1.3 ships without the disclosure prompt - the enable command flips the bit immediately. M1.4 (ENG-2618) follows with the disclosure UX wrapper. Reviewers should expect that follow-up. Adds 13 new tests covering all 7 ticket Test plan scenarios: 4 deterministic command-level tests for enable/disable success and idempotent paths, 1 transport-contract assertion per command, plus 3 handler-level integration tests for SET-then-GET state coherence and concurrent-write last-writer-wins semantics. --- src/oclif/commands/analytics/disable.ts | 34 +++++ src/oclif/commands/analytics/enable.ts | 38 +++++ .../handlers/global-config-handler.ts | 28 +++- .../transport/events/global-config-events.ts | 10 ++ test/commands/analytics/disable.test.ts | 136 ++++++++++++++++++ test/commands/analytics/enable.test.ts | 136 ++++++++++++++++++ test/integration/analytics-toggle.test.ts | 105 ++++++++++++++ 7 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 src/oclif/commands/analytics/disable.ts create mode 100644 src/oclif/commands/analytics/enable.ts create mode 100644 test/commands/analytics/disable.test.ts create mode 100644 test/commands/analytics/enable.test.ts create mode 100644 test/integration/analytics-toggle.test.ts diff --git a/src/oclif/commands/analytics/disable.ts b/src/oclif/commands/analytics/disable.ts new file mode 100644 index 000000000..35f2add22 --- /dev/null +++ b/src/oclif/commands/analytics/disable.ts @@ -0,0 +1,34 @@ +import {Command} from '@oclif/core' + +import { + GlobalConfigEvents, + type GlobalConfigSetAnalyticsResponse, +} from '../../../shared/transport/events/global-config-events.js' +import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' + +export default class Disable extends Command { + public static description = `Disable ByteRover CLI analytics. + +Stops anonymous usage telemetry. Re-enable any time with: brv analytics enable` + public static examples = ['<%= config.bin %> <%= command.id %>'] + + public async run(): Promise { + try { + const response = await this.setAnalytics(false, {projectPath: process.cwd()}) + this.log(response.previous === response.current ? 'Analytics already disabled' : 'Analytics disabled') + } catch (error) { + this.log(formatConnectionError(error)) + } + } + + protected async setAnalytics( + analytics: boolean, + options?: DaemonClientOptions, + ): Promise { + return withDaemonRetry( + async (client) => + client.requestWithAck(GlobalConfigEvents.SET_ANALYTICS, {analytics}), + options, + ) + } +} diff --git a/src/oclif/commands/analytics/enable.ts b/src/oclif/commands/analytics/enable.ts new file mode 100644 index 000000000..31b808ddc --- /dev/null +++ b/src/oclif/commands/analytics/enable.ts @@ -0,0 +1,38 @@ +import {Command} from '@oclif/core' + +import { + GlobalConfigEvents, + type GlobalConfigSetAnalyticsResponse, +} from '../../../shared/transport/events/global-config-events.js' +import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' + +export default class Enable extends Command { + public static description = `Enable ByteRover CLI analytics. + +Anonymous usage telemetry will be collected to improve the product. +No content of your queries, files, or memory is collected. + +Privacy policy: https://byterover.dev/privacy (placeholder until M1.5) +Disable any time with: brv analytics disable` + public static examples = ['<%= config.bin %> <%= command.id %>'] + + public async run(): Promise { + try { + const response = await this.setAnalytics(true, {projectPath: process.cwd()}) + this.log(response.previous === response.current ? 'Analytics already enabled' : 'Analytics enabled') + } catch (error) { + this.log(formatConnectionError(error)) + } + } + + protected async setAnalytics( + analytics: boolean, + options?: DaemonClientOptions, + ): Promise { + return withDaemonRetry( + async (client) => + client.requestWithAck(GlobalConfigEvents.SET_ANALYTICS, {analytics}), + options, + ) + } +} diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts index bc5e2a4b0..bc547784c 100644 --- a/src/server/infra/transport/handlers/global-config-handler.ts +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -6,6 +6,8 @@ import type {ITransportServer} from '../../../core/interfaces/transport/i-transp import { GlobalConfigEvents, type GlobalConfigGetResponse, + type GlobalConfigSetAnalyticsRequest, + type GlobalConfigSetAnalyticsResponse, } from '../../../../shared/transport/events/global-config-events.js' import {GlobalConfig} from '../../../core/domain/entities/global-config.js' @@ -15,10 +17,12 @@ export interface GlobalConfigHandlerDeps { } /** - * Handles globalConfig:get event. + * Handles globalConfig:get and globalConfig:setAnalytics events. * Re-reads the file every call (no in-memory cache) so the daemon always * reflects the latest on-disk state, including writes from sibling commands. * If no config exists yet, seeds a fresh one with a stable deviceId. + * SET_ANALYTICS is idempotent: if the requested state matches current state, + * the file is not rewritten. */ export class GlobalConfigHandler { private readonly globalConfigStore: IGlobalConfigStore @@ -31,6 +35,10 @@ export class GlobalConfigHandler { setup(): void { this.transport.onRequest(GlobalConfigEvents.GET, async () => this.read()) + this.transport.onRequest( + GlobalConfigEvents.SET_ANALYTICS, + async (data) => this.setAnalytics(data.analytics), + ) } private async read(): Promise { @@ -51,4 +59,22 @@ export class GlobalConfigHandler { version: seeded.version, } } + + private async setAnalytics(analytics: boolean): Promise { + const existing = await this.globalConfigStore.read() + const current = existing ?? GlobalConfig.create(randomUUID()) + const previous = current.analytics + + if (previous === analytics) { + return {current: previous, previous} + } + + const updated = GlobalConfig.fromJson({...current.toJson(), analytics}) + if (!updated) { + throw new Error('Failed to construct updated GlobalConfig') + } + + await this.globalConfigStore.write(updated) + return {current: updated.analytics, previous} + } } diff --git a/src/shared/transport/events/global-config-events.ts b/src/shared/transport/events/global-config-events.ts index 07e108843..a0f5c95e5 100644 --- a/src/shared/transport/events/global-config-events.ts +++ b/src/shared/transport/events/global-config-events.ts @@ -1,5 +1,6 @@ export const GlobalConfigEvents = { GET: 'globalConfig:get', + SET_ANALYTICS: 'globalConfig:setAnalytics', } as const export interface GlobalConfigGetResponse { @@ -7,3 +8,12 @@ export interface GlobalConfigGetResponse { readonly deviceId: string readonly version: string } + +export interface GlobalConfigSetAnalyticsRequest { + readonly analytics: boolean +} + +export interface GlobalConfigSetAnalyticsResponse { + readonly current: boolean + readonly previous: boolean +} diff --git a/test/commands/analytics/disable.test.ts b/test/commands/analytics/disable.test.ts new file mode 100644 index 000000000..594dcbc05 --- /dev/null +++ b/test/commands/analytics/disable.test.ts @@ -0,0 +1,136 @@ +import type {ConnectionResult, ITransportClient} from '@campfirein/brv-transport-client' +import type {Config} from '@oclif/core' + +import {NoInstanceRunningError} from '@campfirein/brv-transport-client' +import {Config as OclifConfig} from '@oclif/core' +import {expect} from 'chai' +import sinon, {restore, stub} from 'sinon' + +import type {DaemonClientOptions} from '../../../src/oclif/lib/daemon-client.js' +import type {GlobalConfigSetAnalyticsResponse} from '../../../src/shared/transport/events/global-config-events.js' + +import Disable from '../../../src/oclif/commands/analytics/disable.js' +import {GlobalConfigEvents} from '../../../src/shared/transport/events/global-config-events.js' + +class TestableDisableCommand extends Disable { + private readonly mockConnector: () => Promise + + constructor(mockConnector: () => Promise, config: Config, argv: string[] = []) { + super(argv, config) + this.mockConnector = mockConnector + } + + protected override async setAnalytics( + analytics: boolean, + options?: DaemonClientOptions, + ): Promise { + return super.setAnalytics(analytics, { + maxRetries: 1, + retryDelayMs: 0, + transportConnector: this.mockConnector, + ...options, + }) + } +} + +describe('analytics disable command', () => { + let config: Config + let loggedMessages: string[] + let mockClient: sinon.SinonStubbedInstance + let mockConnector: sinon.SinonStub<[], Promise> + + before(async () => { + config = await OclifConfig.load(import.meta.url) + }) + + beforeEach(() => { + loggedMessages = [] + + mockClient = { + connect: stub().resolves(), + disconnect: stub().resolves(), + getClientId: stub().returns('test-client-id'), + getDaemonVersion: stub(), + getState: stub().returns('connected'), + isConnected: stub().resolves(true), + joinRoom: stub().resolves(), + leaveRoom: stub().resolves(), + on: stub().returns(() => {}), + once: stub(), + onStateChange: stub().returns(() => {}), + request: stub() as unknown as ITransportClient['request'], + requestWithAck: stub().resolves({current: false, previous: true}), + } as unknown as sinon.SinonStubbedInstance + + mockConnector = stub<[], Promise>().resolves({ + client: mockClient as unknown as ITransportClient, + projectRoot: '/test/project', + }) + }) + + afterEach(() => { + restore() + }) + + function createCommand(argv: string[] = []): TestableDisableCommand { + const command = new TestableDisableCommand(mockConnector, config, argv) + stub(command, 'log').callsFake((msg?: string) => { + if (msg) loggedMessages.push(msg) + }) + return command + } + + function mockSetAnalyticsResponse(response: GlobalConfigSetAnalyticsResponse): void { + ;(mockClient.requestWithAck as sinon.SinonStub).resolves(response) + } + + describe('toggle from enabled to disabled', () => { + it('should print "Analytics disabled" when previous was true', async () => { + mockSetAnalyticsResponse({current: false, previous: true}) + + await createCommand().run() + + expect(loggedMessages.some((m) => m.includes('Analytics disabled'))).to.be.true + expect(loggedMessages.some((m) => m.includes('already'))).to.be.false + }) + }) + + describe('idempotent (already disabled)', () => { + it('should print "Analytics already disabled" when previous equals current', async () => { + mockSetAnalyticsResponse({current: false, previous: false}) + + await createCommand().run() + + expect(loggedMessages.some((m) => m.includes('Analytics already disabled'))).to.be.true + }) + }) + + describe('connection error', () => { + it('should print formatted connection error when daemon unavailable', async () => { + mockConnector.rejects(new NoInstanceRunningError()) + + await createCommand().run() + + expect(loggedMessages.some((m) => m.includes('Daemon failed to start automatically'))).to.be.true + }) + }) + + describe('transport contract', () => { + it('should issue exactly one SET_ANALYTICS request with {analytics: false}', async () => { + mockSetAnalyticsResponse({current: false, previous: true}) + + await createCommand().run() + + const requestStub = mockClient.requestWithAck as sinon.SinonStub + expect(requestStub.callCount).to.equal(1) + expect(requestStub.firstCall.args[0]).to.equal(GlobalConfigEvents.SET_ANALYTICS) + expect(requestStub.firstCall.args[1]).to.deep.equal({analytics: false}) + }) + }) + + describe('help text', () => { + it('should declare a description string', () => { + expect(Disable.description).to.be.a('string').and.not.be.empty + }) + }) +}) diff --git a/test/commands/analytics/enable.test.ts b/test/commands/analytics/enable.test.ts new file mode 100644 index 000000000..e3dd1188d --- /dev/null +++ b/test/commands/analytics/enable.test.ts @@ -0,0 +1,136 @@ +import type {ConnectionResult, ITransportClient} from '@campfirein/brv-transport-client' +import type {Config} from '@oclif/core' + +import {NoInstanceRunningError} from '@campfirein/brv-transport-client' +import {Config as OclifConfig} from '@oclif/core' +import {expect} from 'chai' +import sinon, {restore, stub} from 'sinon' + +import type {DaemonClientOptions} from '../../../src/oclif/lib/daemon-client.js' +import type {GlobalConfigSetAnalyticsResponse} from '../../../src/shared/transport/events/global-config-events.js' + +import Enable from '../../../src/oclif/commands/analytics/enable.js' +import {GlobalConfigEvents} from '../../../src/shared/transport/events/global-config-events.js' + +class TestableEnableCommand extends Enable { + private readonly mockConnector: () => Promise + + constructor(mockConnector: () => Promise, config: Config, argv: string[] = []) { + super(argv, config) + this.mockConnector = mockConnector + } + + protected override async setAnalytics( + analytics: boolean, + options?: DaemonClientOptions, + ): Promise { + return super.setAnalytics(analytics, { + maxRetries: 1, + retryDelayMs: 0, + transportConnector: this.mockConnector, + ...options, + }) + } +} + +describe('analytics enable command', () => { + let config: Config + let loggedMessages: string[] + let mockClient: sinon.SinonStubbedInstance + let mockConnector: sinon.SinonStub<[], Promise> + + before(async () => { + config = await OclifConfig.load(import.meta.url) + }) + + beforeEach(() => { + loggedMessages = [] + + mockClient = { + connect: stub().resolves(), + disconnect: stub().resolves(), + getClientId: stub().returns('test-client-id'), + getDaemonVersion: stub(), + getState: stub().returns('connected'), + isConnected: stub().resolves(true), + joinRoom: stub().resolves(), + leaveRoom: stub().resolves(), + on: stub().returns(() => {}), + once: stub(), + onStateChange: stub().returns(() => {}), + request: stub() as unknown as ITransportClient['request'], + requestWithAck: stub().resolves({current: true, previous: false}), + } as unknown as sinon.SinonStubbedInstance + + mockConnector = stub<[], Promise>().resolves({ + client: mockClient as unknown as ITransportClient, + projectRoot: '/test/project', + }) + }) + + afterEach(() => { + restore() + }) + + function createCommand(argv: string[] = []): TestableEnableCommand { + const command = new TestableEnableCommand(mockConnector, config, argv) + stub(command, 'log').callsFake((msg?: string) => { + if (msg) loggedMessages.push(msg) + }) + return command + } + + function mockSetAnalyticsResponse(response: GlobalConfigSetAnalyticsResponse): void { + ;(mockClient.requestWithAck as sinon.SinonStub).resolves(response) + } + + describe('toggle from disabled to enabled', () => { + it('should print "Analytics enabled" when previous was false', async () => { + mockSetAnalyticsResponse({current: true, previous: false}) + + await createCommand().run() + + expect(loggedMessages.some((m) => m.includes('Analytics enabled'))).to.be.true + expect(loggedMessages.some((m) => m.includes('already'))).to.be.false + }) + }) + + describe('idempotent (already enabled)', () => { + it('should print "Analytics already enabled" when previous equals current', async () => { + mockSetAnalyticsResponse({current: true, previous: true}) + + await createCommand().run() + + expect(loggedMessages.some((m) => m.includes('Analytics already enabled'))).to.be.true + }) + }) + + describe('connection error', () => { + it('should print formatted connection error when daemon unavailable', async () => { + mockConnector.rejects(new NoInstanceRunningError()) + + await createCommand().run() + + expect(loggedMessages.some((m) => m.includes('Daemon failed to start automatically'))).to.be.true + }) + }) + + describe('transport contract', () => { + it('should issue exactly one SET_ANALYTICS request with {analytics: true}', async () => { + mockSetAnalyticsResponse({current: true, previous: false}) + + await createCommand().run() + + const requestStub = mockClient.requestWithAck as sinon.SinonStub + expect(requestStub.callCount).to.equal(1) + expect(requestStub.firstCall.args[0]).to.equal(GlobalConfigEvents.SET_ANALYTICS) + expect(requestStub.firstCall.args[1]).to.deep.equal({analytics: true}) + }) + }) + + describe('help text', () => { + it('should declare a description string', () => { + expect(Enable.description).to.be.a('string').and.not.be.empty + }) + }) +}) diff --git a/test/integration/analytics-toggle.test.ts b/test/integration/analytics-toggle.test.ts new file mode 100644 index 000000000..eb82e99b6 --- /dev/null +++ b/test/integration/analytics-toggle.test.ts @@ -0,0 +1,105 @@ +import {expect} from 'chai' +import {existsSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {FileGlobalConfigStore} from '../../src/server/infra/storage/file-global-config-store.js' +import {GlobalConfigHandler} from '../../src/server/infra/transport/handlers/global-config-handler.js' +import { + GlobalConfigEvents, + type GlobalConfigGetResponse, + type GlobalConfigSetAnalyticsRequest, + type GlobalConfigSetAnalyticsResponse, +} from '../../src/shared/transport/events/global-config-events.js' +import {createMockTransportServer, type MockTransportServer} from '../helpers/mock-factories.js' + +type GetHandler = (data: undefined, clientId: string) => Promise +type SetHandler = ( + data: GlobalConfigSetAnalyticsRequest, + clientId: string, +) => Promise + +describe('analytics toggle integration (handler level)', () => { + let testDir: string + let testConfigPath: string + let store: FileGlobalConfigStore + let transport: MockTransportServer + let getHandler: GetHandler + let setHandler: SetHandler + + beforeEach(() => { + testDir = join(tmpdir(), `test-analytics-toggle-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`) + testConfigPath = join(testDir, 'config.json') + + store = new FileGlobalConfigStore({ + getConfigDir: () => testDir, + getConfigPath: () => testConfigPath, + }) + transport = createMockTransportServer() + + new GlobalConfigHandler({globalConfigStore: store, transport}).setup() + + const getRaw = transport._handlers.get(GlobalConfigEvents.GET) + const setRaw = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + expect(getRaw, 'GET handler must be registered').to.exist + expect(setRaw, 'SET_ANALYTICS handler must be registered').to.exist + getHandler = getRaw as unknown as GetHandler + setHandler = setRaw as unknown as SetHandler + }) + + afterEach(async () => { + if (existsSync(testDir)) { + await rm(testDir, {force: true, recursive: true}) + } + }) + + describe('after enable, status reflects enabled (ticket scenario 5)', () => { + it('should observe analytics: true via GET after a successful SET_ANALYTICS true', async () => { + const setResponse = await setHandler({analytics: true}, 'client-1') + expect(setResponse.previous).to.equal(false) + expect(setResponse.current).to.equal(true) + + const getResponse = await getHandler(undefined, 'client-1') + expect(getResponse.analytics).to.equal(true) + }) + }) + + describe('after disable, status reflects disabled (ticket scenario 6)', () => { + it('should observe analytics: false via GET after enable then disable', async () => { + await setHandler({analytics: true}, 'client-1') + + const disableResponse = await setHandler({analytics: false}, 'client-1') + expect(disableResponse.previous).to.equal(true) + expect(disableResponse.current).to.equal(false) + + const getResponse = await getHandler(undefined, 'client-1') + expect(getResponse.analytics).to.equal(false) + }) + }) + + describe('concurrent SET_ANALYTICS race (ticket scenario 7)', () => { + it('should produce a coherent final state with last-writer-wins semantics under parallel writes', async () => { + const [responseA, responseB] = await Promise.all([ + setHandler({analytics: true}, 'client-A'), + setHandler({analytics: false}, 'client-B'), + ]) + + // Both calls completed without throwing. + expect(responseA.current).to.be.a('boolean') + expect(responseB.current).to.be.a('boolean') + + // Final on-disk state matches whichever request finished writing last. + // The file store is atomic per writeFile; the handler's read-mutate-write + // sequence interleaves with the event loop, so the survivor is one of + // the two requested values, never corrupted. + const finalState = await getHandler(undefined, 'client-readout') + expect([true, false]).to.include(finalState.analytics) + + // deviceId must remain a non-empty UUID (the seed step stays stable + // across the race; even if both branches generated UUIDs, the file + // ends up with exactly one of them). + expect(finalState.deviceId).to.be.a('string').and.not.be.empty + }) + }) +}) From 482933ce873b40991a0210130cd4f29db54eb79c Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 6 May 2026 08:58:02 +0700 Subject: [PATCH 04/87] feat: [ENG-2618] add disclosure UX wrapper to brv analytics enable Wraps M1.3's bit-flip path so brv analytics enable shows a disclosure that the user must accept before the flag flips. CI / non-interactive use is supported via --yes. Flow: the command first reads current state via globalConfig:get. If analytics is already enabled, it prints "Analytics already enabled" and exits 0 with no prompt and no write. Otherwise it loads the disclosure markdown, prints it, and either accepts implicitly (--yes), prompts via @inquirer/prompts confirm (interactive TTY), or refuses with a clear error (non-TTY without --yes). On accept, the existing globalConfig:setAnalytics path flips the bit; on reject, "Analytics not enabled" prints and the command exits 0 without writing. Disclosure copy lives in src/server/templates/sections/analytics-disclosure.md with lorem ipsum bodies per hoang's request in the Linear ticket. Section headers (what / surfaces / where / cross-device alias / how to disable / privacy policy) are load-bearing for tests and remain stable; PM and legal will replace bodies before the M1 release. Privacy policy URL lives in src/shared/constants/privacy.ts as a placeholder pending the M1.5 docs page; reviewers should update it once the canonical URL is finalized. A TODO(M2) marker is preserved in enable.ts so that when IAnalyticsClient lands the first event sent after enable will be analytics_enabled itself (industry practice). Adds 8 tests covering all 7 ticket scenarios: interactive accept, interactive reject, --yes bypass, already-enabled short-circuit, non-TTY refusal with non-zero exit, disclosure section coverage, and privacy URL constant shape. PM/legal sign-off and the milestone disclosure-file link are manual steps to record at PR / milestone-update time. --- src/oclif/commands/analytics/enable.ts | 95 ++++++++- .../sections/analytics-disclosure.md | 35 ++++ src/shared/constants/privacy.ts | 6 + test/commands/analytics/enable.test.ts | 188 +++++++++++++++--- 4 files changed, 286 insertions(+), 38 deletions(-) create mode 100644 src/server/templates/sections/analytics-disclosure.md create mode 100644 src/shared/constants/privacy.ts diff --git a/src/oclif/commands/analytics/enable.ts b/src/oclif/commands/analytics/enable.ts index 31b808ddc..1b0803e3e 100644 --- a/src/oclif/commands/analytics/enable.ts +++ b/src/oclif/commands/analytics/enable.ts @@ -1,28 +1,95 @@ -import {Command} from '@oclif/core' +import {confirm} from '@inquirer/prompts' +import {Command, Flags} from '@oclif/core' +import {readFile} from 'node:fs/promises' +import {dirname, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' +import {PRIVACY_POLICY_URL} from '../../../shared/constants/privacy.js' import { GlobalConfigEvents, + type GlobalConfigGetResponse, type GlobalConfigSetAnalyticsResponse, } from '../../../shared/transport/events/global-config-events.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' +const here = dirname(fileURLToPath(import.meta.url)) +const DISCLOSURE_PATH = resolve(here, '../../../server/templates/sections/analytics-disclosure.md') + export default class Enable extends Command { public static description = `Enable ByteRover CLI analytics. Anonymous usage telemetry will be collected to improve the product. No content of your queries, files, or memory is collected. -Privacy policy: https://byterover.dev/privacy (placeholder until M1.5) +Privacy policy: ${PRIVACY_POLICY_URL} (placeholder until M1.5) Disable any time with: brv analytics disable` - public static examples = ['<%= config.bin %> <%= command.id %>'] + public static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --yes', + ] + public static flags = { + yes: Flags.boolean({ + char: 'y', + default: false, + description: 'Skip the disclosure prompt (CI / non-interactive)', + }), + } + + protected async confirmDisclosure(): Promise { + return confirm({default: false, message: 'Enable analytics with the terms above?'}) + } + + protected async getCurrentAnalytics(options?: DaemonClientOptions): Promise { + return withDaemonRetry(async (client) => { + const response = await client.requestWithAck(GlobalConfigEvents.GET) + return response.analytics + }, options) + } + + protected isInteractive(): boolean { + return process.stdin.isTTY === true && process.stdout.isTTY === true + } + + protected async loadDisclosure(): Promise { + return readFile(DISCLOSURE_PATH, 'utf8') + } public async run(): Promise { + const {flags} = await this.parse(Enable) + + let alreadyEnabled: boolean + try { + alreadyEnabled = await this.getCurrentAnalytics({projectPath: process.cwd()}) + } catch (error) { + this.log(formatConnectionError(error)) + return + } + + if (alreadyEnabled) { + this.log('Analytics already enabled') + return + } + + // collectConsent may call this.error() for non-TTY without --yes; + // that throws CLIError and oclif's exit handler surfaces a non-zero + // exit code. Do NOT wrap it in try/catch. + const accepted = await this.collectConsent(flags.yes) + if (!accepted) { + this.log('Analytics not enabled') + return + } + try { - const response = await this.setAnalytics(true, {projectPath: process.cwd()}) - this.log(response.previous === response.current ? 'Analytics already enabled' : 'Analytics enabled') + await this.setAnalytics(true, {projectPath: process.cwd()}) } catch (error) { this.log(formatConnectionError(error)) + return } + + // TODO(M2): when IAnalyticsClient lands, emit `analytics_enabled` as + // the first event after this write — opt-in itself is the first + // opt-in event (industry practice). + this.log('Analytics enabled') } protected async setAnalytics( @@ -35,4 +102,22 @@ Disable any time with: brv analytics disable` options, ) } + + private async collectConsent(yesFlag: boolean): Promise { + const disclosure = await this.loadDisclosure() + this.log(disclosure) + + if (yesFlag) { + return true + } + + if (!this.isInteractive()) { + this.error( + 'Cannot enable analytics in non-interactive mode without confirmation.\n' + + 'Re-run in a terminal, or pass --yes to accept the disclosure non-interactively.', + ) + } + + return this.confirmDisclosure() + } } diff --git a/src/server/templates/sections/analytics-disclosure.md b/src/server/templates/sections/analytics-disclosure.md new file mode 100644 index 000000000..471bc794e --- /dev/null +++ b/src/server/templates/sections/analytics-disclosure.md @@ -0,0 +1,35 @@ +# ByteRover CLI Analytics Disclosure + +Lorem ipsum placeholder copy. PM and legal will replace each section's +body before the M1 release. Section headers are load-bearing for tests +and must remain stable. + +## What is collected + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Event names and +super properties (`device_id`, `cli_version`, `os`, `node_version`, +`environment`) are recorded. No content of your queries, files, or +memory is collected. + +## Which surfaces are tracked + +Lorem ipsum: TUI, oclif commands, MCP server, local web UI, and agent +processes. + +## Where it goes + +Lorem ipsum dolor sit amet. Events flow to the ByteRover Analytics +Service, with Mixpanel acting as a sub-processor. + +## Cross-device alias + +Lorem ipsum: if you log in on this device, prior anonymous activity here +is permanently linked to your account. + +## How to disable + +Lorem ipsum: run `brv analytics disable` at any time to stop collection. + +## Privacy policy + +https://byterover.dev/privacy diff --git a/src/shared/constants/privacy.ts b/src/shared/constants/privacy.ts new file mode 100644 index 000000000..d070455da --- /dev/null +++ b/src/shared/constants/privacy.ts @@ -0,0 +1,6 @@ +/** + * Public privacy policy URL for ByteRover CLI analytics. + * Placeholder until M1.5 lands the canonical docs page; reviewers should + * update this constant when the byterover-docs URL is finalized. + */ +export const PRIVACY_POLICY_URL = 'https://byterover.dev/privacy' diff --git a/test/commands/analytics/enable.test.ts b/test/commands/analytics/enable.test.ts index e3dd1188d..4a67834db 100644 --- a/test/commands/analytics/enable.test.ts +++ b/test/commands/analytics/enable.test.ts @@ -1,23 +1,65 @@ import type {ConnectionResult, ITransportClient} from '@campfirein/brv-transport-client' import type {Config} from '@oclif/core' -import {NoInstanceRunningError} from '@campfirein/brv-transport-client' import {Config as OclifConfig} from '@oclif/core' import {expect} from 'chai' +import {readFile} from 'node:fs/promises' +import {dirname, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' import sinon, {restore, stub} from 'sinon' import type {DaemonClientOptions} from '../../../src/oclif/lib/daemon-client.js' -import type {GlobalConfigSetAnalyticsResponse} from '../../../src/shared/transport/events/global-config-events.js' +import type { + GlobalConfigGetResponse, + GlobalConfigSetAnalyticsResponse, +} from '../../../src/shared/transport/events/global-config-events.js' import Enable from '../../../src/oclif/commands/analytics/enable.js' +import {PRIVACY_POLICY_URL} from '../../../src/shared/constants/privacy.js' import {GlobalConfigEvents} from '../../../src/shared/transport/events/global-config-events.js' +interface TestHooks { + confirmCalled?: sinon.SinonStub + confirmResult?: boolean + disclosureText?: string + isTTY?: boolean +} + class TestableEnableCommand extends Enable { private readonly mockConnector: () => Promise + private readonly testHooks: TestHooks - constructor(mockConnector: () => Promise, config: Config, argv: string[] = []) { + constructor( + mockConnector: () => Promise, + testHooks: TestHooks, + config: Config, + argv: string[] = [], + ) { super(argv, config) this.mockConnector = mockConnector + this.testHooks = testHooks + } + + protected override async confirmDisclosure(): Promise { + this.testHooks.confirmCalled?.() + return this.testHooks.confirmResult ?? true + } + + protected override async getCurrentAnalytics(options?: DaemonClientOptions): Promise { + return super.getCurrentAnalytics({ + maxRetries: 1, + retryDelayMs: 0, + transportConnector: this.mockConnector, + ...options, + }) + } + + protected override isInteractive(): boolean { + return this.testHooks.isTTY ?? true + } + + protected override async loadDisclosure(): Promise { + return this.testHooks.disclosureText ?? 'mock disclosure' } protected override async setAnalytics( @@ -33,7 +75,7 @@ class TestableEnableCommand extends Enable { } } -describe('analytics enable command', () => { +describe('analytics enable command (M1.4 disclosure UX)', () => { let config: Config let loggedMessages: string[] let mockClient: sinon.SinonStubbedInstance @@ -59,7 +101,7 @@ describe('analytics enable command', () => { once: stub(), onStateChange: stub().returns(() => {}), request: stub() as unknown as ITransportClient['request'], - requestWithAck: stub().resolves({current: true, previous: false}), + requestWithAck: stub(), } as unknown as sinon.SinonStubbedInstance mockConnector = stub<[], Promise>().resolves({ @@ -72,64 +114,144 @@ describe('analytics enable command', () => { restore() }) - function createCommand(argv: string[] = []): TestableEnableCommand { - const command = new TestableEnableCommand(mockConnector, config, argv) + function mockGetThenSet(currentAnalytics: boolean, setResponse?: GlobalConfigSetAnalyticsResponse): void { + const requestStub = mockClient.requestWithAck as sinon.SinonStub + const getResponse: GlobalConfigGetResponse = { + analytics: currentAnalytics, + deviceId: 'test-device', + version: '1.0.0', + } + requestStub.withArgs(GlobalConfigEvents.GET).resolves(getResponse) + if (setResponse) { + requestStub.withArgs(GlobalConfigEvents.SET_ANALYTICS).resolves(setResponse) + } + } + + function createCommand(testHooks: TestHooks, argv: string[] = []): TestableEnableCommand { + const command = new TestableEnableCommand(mockConnector, testHooks, config, argv) stub(command, 'log').callsFake((msg?: string) => { if (msg) loggedMessages.push(msg) }) return command } - function mockSetAnalyticsResponse(response: GlobalConfigSetAnalyticsResponse): void { - ;(mockClient.requestWithAck as sinon.SinonStub).resolves(response) - } + describe('1. interactive accept flips the flag', () => { + it('should call SET_ANALYTICS true and print confirmation when user accepts', async () => { + mockGetThenSet(false, {current: true, previous: false}) + const confirmCalled = stub() + + await createCommand({confirmCalled, confirmResult: true, isTTY: true}).run() + + expect(confirmCalled.calledOnce, 'disclosure prompt should be shown').to.be.true + expect(loggedMessages.some((m) => m.includes('Analytics enabled'))).to.be.true + expect(loggedMessages.some((m) => m.includes('not enabled'))).to.be.false - describe('toggle from disabled to enabled', () => { - it('should print "Analytics enabled" when previous was false', async () => { - mockSetAnalyticsResponse({current: true, previous: false}) + const requestStub = mockClient.requestWithAck as sinon.SinonStub + const setCalls = requestStub.getCalls().filter((c) => c.args[0] === GlobalConfigEvents.SET_ANALYTICS) + expect(setCalls).to.have.lengthOf(1) + expect(setCalls[0].args[1]).to.deep.equal({analytics: true}) + }) + }) + + describe('2. interactive reject leaves flag unchanged', () => { + it('should NOT call SET_ANALYTICS and print "Analytics not enabled" when user rejects', async () => { + mockGetThenSet(false) + const confirmCalled = stub() + + await createCommand({confirmCalled, confirmResult: false, isTTY: true}).run() + + expect(confirmCalled.calledOnce, 'disclosure prompt should be shown').to.be.true + expect(loggedMessages.some((m) => m.includes('Analytics not enabled'))).to.be.true + + const requestStub = mockClient.requestWithAck as sinon.SinonStub + const setCalls = requestStub.getCalls().filter((c) => c.args[0] === GlobalConfigEvents.SET_ANALYTICS) + expect(setCalls, 'no SET_ANALYTICS write should occur on reject').to.have.lengthOf(0) + }) + }) + + describe('3. --yes flag bypasses the prompt', () => { + it('should flip the flag without showing the disclosure prompt', async () => { + mockGetThenSet(false, {current: true, previous: false}) + const confirmCalled = stub() - await createCommand().run() + await createCommand({confirmCalled, confirmResult: true, isTTY: true}, ['--yes']).run() + expect(confirmCalled.called, 'prompt must NOT be shown when --yes is passed').to.be.false expect(loggedMessages.some((m) => m.includes('Analytics enabled'))).to.be.true - expect(loggedMessages.some((m) => m.includes('already'))).to.be.false + + const requestStub = mockClient.requestWithAck as sinon.SinonStub + const setCalls = requestStub.getCalls().filter((c) => c.args[0] === GlobalConfigEvents.SET_ANALYTICS) + expect(setCalls).to.have.lengthOf(1) }) }) - describe('idempotent (already enabled)', () => { - it('should print "Analytics already enabled" when previous equals current', async () => { - mockSetAnalyticsResponse({current: true, previous: true}) + describe('4. already-enabled state skips the prompt entirely', () => { + it('should print "Analytics already enabled" with no prompt and no SET_ANALYTICS call', async () => { + mockGetThenSet(true) + const confirmCalled = stub() - await createCommand().run() + await createCommand({confirmCalled, confirmResult: true, isTTY: true}).run() + expect(confirmCalled.called, 'prompt must NOT be shown when already enabled').to.be.false expect(loggedMessages.some((m) => m.includes('Analytics already enabled'))).to.be.true + + const requestStub = mockClient.requestWithAck as sinon.SinonStub + const setCalls = requestStub.getCalls().filter((c) => c.args[0] === GlobalConfigEvents.SET_ANALYTICS) + expect(setCalls, 'no SET_ANALYTICS write when already enabled').to.have.lengthOf(0) }) }) - describe('connection error', () => { - it('should print formatted connection error when daemon unavailable', async () => { - mockConnector.rejects(new NoInstanceRunningError()) + describe('5. non-TTY without --yes refuses with clear error', () => { + it('should exit non-zero and print a clear error directing the user to --yes', async () => { + mockGetThenSet(false) + const confirmCalled = stub() + + const command = createCommand({confirmCalled, confirmResult: true, isTTY: false}) + const errorStub = stub(command, 'error').throws(new Error('non-interactive refusal')) - await createCommand().run() + let caught: unknown + try { + await command.run() + } catch (error) { + caught = error + } - expect(loggedMessages.some((m) => m.includes('Daemon failed to start automatically'))).to.be.true + expect(caught, 'this.error must be invoked when stdin is non-TTY without --yes').to.be.instanceOf(Error) + expect(errorStub.calledOnce).to.be.true + const errorMessage = errorStub.firstCall.args[0] + expect(errorMessage).to.be.a('string').and.to.match(/--yes|non-interactive|interactive/i) + expect(confirmCalled.called, 'prompt must NOT be shown in non-TTY').to.be.false + + const requestStub = mockClient.requestWithAck as sinon.SinonStub + const setCalls = requestStub.getCalls().filter((c) => c.args[0] === GlobalConfigEvents.SET_ANALYTICS) + expect(setCalls, 'no SET_ANALYTICS write when refusing').to.have.lengthOf(0) }) }) - describe('transport contract', () => { - it('should issue exactly one SET_ANALYTICS request with {analytics: true}', async () => { - mockSetAnalyticsResponse({current: true, previous: false}) + describe('6. disclosure markdown contains all required sections', () => { + it('should include the five required sections plus the privacy policy link', async () => { + const here = dirname(fileURLToPath(import.meta.url)) + const disclosurePath = resolve(here, '../../../src/server/templates/sections/analytics-disclosure.md') + const text = await readFile(disclosurePath, 'utf8') - await createCommand().run() + expect(text, 'what-is-collected section').to.match(/what is collected/i) + expect(text, 'which-surfaces section').to.match(/which surfaces|surfaces are tracked/i) + expect(text, 'where-it-goes section').to.match(/where (it )?goes/i) + expect(text, 'cross-device alias section').to.match(/cross-device|alias/i) + expect(text, 'how-to-disable section').to.match(/how to disable|brv analytics disable/i) + expect(text, 'privacy policy link').to.include(PRIVACY_POLICY_URL) + }) + }) - const requestStub = mockClient.requestWithAck as sinon.SinonStub - expect(requestStub.callCount).to.equal(1) - expect(requestStub.firstCall.args[0]).to.equal(GlobalConfigEvents.SET_ANALYTICS) - expect(requestStub.firstCall.args[1]).to.deep.equal({analytics: true}) + describe('7. privacy policy URL constant', () => { + it('should be a non-empty https URL', () => { + expect(PRIVACY_POLICY_URL).to.be.a('string').and.not.be.empty + expect(PRIVACY_POLICY_URL).to.match(/^https:\/\//) }) }) describe('help text', () => { - it('should declare a description string', () => { + it('should declare a non-empty description string', () => { expect(Enable.description).to.be.a('string').and.not.be.empty }) }) From ca2e7153d677aa46c93c635e001d9b4e7d221839 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 6 May 2026 09:14:37 +0700 Subject: [PATCH 05/87] feat: [ENG-2618] address review feedback on M1 analytics work --- src/oclif/commands/analytics/enable.ts | 6 +++++ src/oclif/commands/analytics/status.ts | 3 ++- .../core/domain/entities/global-config.ts | 15 +++++++++++ .../handlers/global-config-handler.ts | 13 +++++---- .../domain/entities/global-config.test.ts | 27 +++++++++++++++++++ 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/oclif/commands/analytics/enable.ts b/src/oclif/commands/analytics/enable.ts index 1b0803e3e..d91a2cc64 100644 --- a/src/oclif/commands/analytics/enable.ts +++ b/src/oclif/commands/analytics/enable.ts @@ -12,6 +12,12 @@ import { } from '../../../shared/transport/events/global-config-events.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' +// The disclosure markdown is a static asset (PM/legal own its copy) that the +// build copies into dist/server/templates/sections/. Reading it as a file from +// oclif is a runtime fs read, not a TypeScript import, so the oclif-server +// boundary rule is preserved. If a future need surfaces the disclosure to +// other surfaces (e.g. the M1.7 webui rendering it as HTML), consider moving +// the asset under src/shared/ or serving it via a daemon transport event. const here = dirname(fileURLToPath(import.meta.url)) const DISCLOSURE_PATH = resolve(here, '../../../server/templates/sections/analytics-disclosure.md') diff --git a/src/oclif/commands/analytics/status.ts b/src/oclif/commands/analytics/status.ts index 652b97d13..540d0c607 100644 --- a/src/oclif/commands/analytics/status.ts +++ b/src/oclif/commands/analytics/status.ts @@ -1,5 +1,6 @@ import {Command, Flags} from '@oclif/core' +import {PRIVACY_POLICY_URL} from '../../../shared/constants/privacy.js' import { GlobalConfigEvents, type GlobalConfigGetResponse, @@ -16,7 +17,7 @@ Analytics is opt-in (default: off). When enabled, ByteRover collects anonymous usage telemetry (event names, CLI version, OS, Node version, environment) to improve the product. No content of your queries, files, or memory is collected. -Privacy policy: https://byterover.dev/privacy (placeholder until M1.5) +Privacy policy: ${PRIVACY_POLICY_URL} (placeholder until M1.5) Toggle: brv analytics enable | brv analytics disable` public static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --format json'] public static flags = { diff --git a/src/server/core/domain/entities/global-config.ts b/src/server/core/domain/entities/global-config.ts index 7f161ebf2..96325a347 100644 --- a/src/server/core/domain/entities/global-config.ts +++ b/src/server/core/domain/entities/global-config.ts @@ -115,4 +115,19 @@ export class GlobalConfig { version: this.version, } } + + /** + * Returns a new GlobalConfig with the analytics flag set to the given value. + * deviceId and version are preserved. The original instance is not mutated. + * + * @param value - The new analytics value + * @returns A new GlobalConfig instance + */ + public withAnalytics(value: boolean): GlobalConfig { + return new GlobalConfig({ + analytics: value, + deviceId: this.deviceId, + version: this.version, + }) + } } diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts index bc547784c..a961d40ea 100644 --- a/src/server/infra/transport/handlers/global-config-handler.ts +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -62,18 +62,17 @@ export class GlobalConfigHandler { private async setAnalytics(analytics: boolean): Promise { const existing = await this.globalConfigStore.read() - const current = existing ?? GlobalConfig.create(randomUUID()) - const previous = current.analytics + const previous = existing?.analytics ?? false + // Idempotent fast path: short-circuit before generating a deviceId. + // If existing is undefined and the requested value matches the default + // (false), no file is created — the next GET will seed. if (previous === analytics) { return {current: previous, previous} } - const updated = GlobalConfig.fromJson({...current.toJson(), analytics}) - if (!updated) { - throw new Error('Failed to construct updated GlobalConfig') - } - + const current = existing ?? GlobalConfig.create(randomUUID()) + const updated = current.withAnalytics(analytics) await this.globalConfigStore.write(updated) return {current: updated.analytics, previous} } diff --git a/test/unit/server/core/domain/entities/global-config.test.ts b/test/unit/server/core/domain/entities/global-config.test.ts index fa4981f01..9dd91344f 100644 --- a/test/unit/server/core/domain/entities/global-config.test.ts +++ b/test/unit/server/core/domain/entities/global-config.test.ts @@ -187,6 +187,33 @@ describe('GlobalConfig', () => { }) }) + describe('withAnalytics()', () => { + it('should produce a new instance with the given analytics value', () => { + const original = GlobalConfig.create(validDeviceId) + const updated = original.withAnalytics(true) + + expect(updated.analytics).to.equal(true) + expect(updated.deviceId).to.equal(validDeviceId) + expect(updated.version).to.equal(GLOBAL_CONFIG_VERSION) + }) + + it('should not mutate the original instance', () => { + const original = GlobalConfig.create(validDeviceId) + original.withAnalytics(true) + + expect(original.analytics).to.equal(false) + }) + + it('should return a new instance even when value matches current', () => { + const original = GlobalConfig.create(validDeviceId) + const same = original.withAnalytics(false) + + expect(same).to.not.equal(original) + expect(same.analytics).to.equal(false) + expect(same.deviceId).to.equal(original.deviceId) + }) + }) + describe('immutability', () => { it('should have readonly properties', () => { const config = GlobalConfig.create(validDeviceId) From 27de472c8e42c170a07b410e28d12fb1245cca6d Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 6 May 2026 10:36:37 +0700 Subject: [PATCH 06/87] feat: [ENG-2623] add analytics domain types + IAnalyticsClient + no-op Establishes the consumer-facing contract for the daemon-side analytics machinery. Future M2 sub-tickets (queue, resolvers, real client, transport handler, IPC bridge) build against this interface; the no-op serves as the daemon's default before M2.5 wires the real one and as a stub for unit tests. Adds the domain types in src/server/core/domain/analytics/: - AnalyticsEvent (internal camelCase: name, properties, timestamp) - Identity (wire snake_case: user_id?, device_id, email?, name?) - AnalyticsEventWithIdentity (Identity-stamped event) - AnalyticsBatchJson (wire shape: schema_version: 1, events) - AnalyticsBatch class with private constructor + static create + static fromJson (graceful failure, returns undefined on malformed input) + toJson, mirroring the GlobalConfig pattern Adds IAnalyticsClient { track; flush } in core/interfaces/analytics/ and NoOpAnalyticsClient in infra/analytics/. The no-op's track() is a true no-op (no allocations beyond the call frame, no buffering); its flush() always resolves to an empty batch. Tests: 24 cases. AnalyticsBatch round-trips empty + populated batches, and fromJson rejects 13 distinct malformations (missing schema_version, schema_version != 1, events not array, missing/non-string event name, missing identity, missing/empty device_id, non-number timestamp, non-object properties, etc). NoOpAnalyticsClient verifies track does not throw under varied input and flush stays empty after many tracks. --- src/server/core/domain/analytics/batch.ts | 92 ++++++++++ src/server/core/domain/analytics/event.ts | 12 ++ src/server/core/domain/analytics/identity.ts | 16 ++ .../analytics/i-analytics-client.ts | 26 +++ .../infra/analytics/no-op-analytics-client.ts | 19 ++ .../core/domain/analytics/batch.test.ts | 167 ++++++++++++++++++ .../analytics/no-op-analytics-client.test.ts | 42 +++++ 7 files changed, 374 insertions(+) create mode 100644 src/server/core/domain/analytics/batch.ts create mode 100644 src/server/core/domain/analytics/event.ts create mode 100644 src/server/core/domain/analytics/identity.ts create mode 100644 src/server/core/interfaces/analytics/i-analytics-client.ts create mode 100644 src/server/infra/analytics/no-op-analytics-client.ts create mode 100644 test/unit/server/core/domain/analytics/batch.test.ts create mode 100644 test/unit/server/infra/analytics/no-op-analytics-client.test.ts diff --git a/src/server/core/domain/analytics/batch.ts b/src/server/core/domain/analytics/batch.ts new file mode 100644 index 000000000..d00ac0d67 --- /dev/null +++ b/src/server/core/domain/analytics/batch.ts @@ -0,0 +1,92 @@ +/* eslint-disable camelcase */ +import type {AnalyticsEvent} from './event.js' +import type {Identity} from './identity.js' + +/** + * An analytics event after identity stamping. This is the unit of work + * that flows through the queue and ultimately ends up on the wire. + */ +export type AnalyticsEventWithIdentity = AnalyticsEvent & Readonly<{identity: Identity}> + +/** + * Wire shape for a batch of analytics events. `schema_version: 1` is the + * only currently-supported value. + */ +export type AnalyticsBatchJson = Readonly<{ + events: ReadonlyArray + schema_version: 1 +}> + +const isIdentity = (value: unknown): value is Identity => { + if (typeof value !== 'object' || value === null) return false + const obj = value as Record + if (typeof obj.device_id !== 'string' || obj.device_id.trim().length === 0) { + return false + } + + if (obj.user_id !== undefined && typeof obj.user_id !== 'string') return false + if (obj.email !== undefined && typeof obj.email !== 'string') return false + if (obj.name !== undefined && typeof obj.name !== 'string') return false + + return true +} + +const isAnalyticsEventWithIdentity = (value: unknown): value is AnalyticsEventWithIdentity => { + if (typeof value !== 'object' || value === null) return false + const obj = value as Record + + if (typeof obj.name !== 'string') return false + if (typeof obj.timestamp !== 'number') return false + if (typeof obj.properties !== 'object' || obj.properties === null || Array.isArray(obj.properties)) return false + if (!isIdentity(obj.identity)) return false + + return true +} + +const isAnalyticsBatchJson = (json: unknown): json is AnalyticsBatchJson => { + if (typeof json !== 'object' || json === null || Array.isArray(json)) return false + const obj = json as Record + + if (obj.schema_version !== 1) return false + if (!Array.isArray(obj.events)) return false + + return obj.events.every((event) => isAnalyticsEventWithIdentity(event)) +} + +/** + * A batch of identity-stamped analytics events. Immutable. Constructed + * via `create()` in-process or `fromJson()` at the wire boundary; + * `toJson()` produces the canonical `AnalyticsBatchJson` shape. + */ +export class AnalyticsBatch { + public readonly events: ReadonlyArray + public readonly schema_version: 1 + + private constructor(events: ReadonlyArray) { + this.events = events + this.schema_version = 1 + } + + /** + * Constructs a batch from a list of identity-stamped events. + */ + public static create(events: ReadonlyArray): AnalyticsBatch { + return new AnalyticsBatch(events) + } + + /** + * Deserializes a batch from JSON. Returns `undefined` for any malformed + * input (graceful failure — the caller can drop the batch and log). + */ + public static fromJson(json: unknown): AnalyticsBatch | undefined { + if (!isAnalyticsBatchJson(json)) return undefined + return new AnalyticsBatch(json.events) + } + + /** + * Serializes the batch to its wire shape. + */ + public toJson(): AnalyticsBatchJson { + return {events: this.events, schema_version: this.schema_version} + } +} diff --git a/src/server/core/domain/analytics/event.ts b/src/server/core/domain/analytics/event.ts new file mode 100644 index 000000000..def148485 --- /dev/null +++ b/src/server/core/domain/analytics/event.ts @@ -0,0 +1,12 @@ +/** + * Internal analytics event shape, before identity stamping. CamelCase + * member names follow internal TS conventions; serializers at the wire + * boundary convert (or, for analytics, the wire shape happens to coincide + * with these field names — `name`, `properties`, `timestamp` are not + * snake_cased on the wire). + */ +export type AnalyticsEvent = Readonly<{ + name: string + properties: Record + timestamp: number +}> diff --git a/src/server/core/domain/analytics/identity.ts b/src/server/core/domain/analytics/identity.ts new file mode 100644 index 000000000..74ef818a3 --- /dev/null +++ b/src/server/core/domain/analytics/identity.ts @@ -0,0 +1,16 @@ + + +/** + * Wire-format identity attached to every analytics event. `device_id` is + * always present (M1.1 invariant); `user_id` / `email` / `name` are only + * present when the user is authenticated. + * + * Snake_case on the wire per the analytics spec; this is the only + * identity shape — no internal camelCase variant exists. + */ +export type Identity = Readonly<{ + device_id: string + email?: string + name?: string + user_id?: string +}> diff --git a/src/server/core/interfaces/analytics/i-analytics-client.ts b/src/server/core/interfaces/analytics/i-analytics-client.ts new file mode 100644 index 000000000..17205f7ce --- /dev/null +++ b/src/server/core/interfaces/analytics/i-analytics-client.ts @@ -0,0 +1,26 @@ +import type {AnalyticsBatch} from '../../domain/analytics/batch.js' + +/** + * Consumer-facing analytics tracking contract. Every consumer surface + * (TUI, oclif commands, MCP server, webui, agent processes) ultimately + * routes events into an implementation of this interface inside the + * daemon. Implementations are responsible for identity resolution, + * super-property stamping, and queueing; consumers just call `track()`. + * + * The interface is intentionally minimal so that consumers depend on a + * stable contract while the implementation evolves (e.g. M2.1 ships a + * no-op, M2.5 ships the real client, M4 adds network sends). + */ +export interface IAnalyticsClient { + /** + * Drains the queue and returns the events as a serializable batch. + * Used by the network sender (M4) and by tests. + */ + flush: () => Promise + + /** + * Records an analytics event. When the analytics flag is disabled the + * call must be a true no-op (no allocations, no resolver calls). + */ + track: (event: string, properties?: Record) => void +} diff --git a/src/server/infra/analytics/no-op-analytics-client.ts b/src/server/infra/analytics/no-op-analytics-client.ts new file mode 100644 index 000000000..7d20b26d1 --- /dev/null +++ b/src/server/infra/analytics/no-op-analytics-client.ts @@ -0,0 +1,19 @@ +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' + +import {AnalyticsBatch} from '../../core/domain/analytics/batch.js' + +/** + * Default analytics client used by the daemon before the real client is + * wired (M2.5) and by tests that need a stand-in. `track()` is a true + * no-op — no buffering, no resolver calls; `flush()` always resolves to + * an empty batch. + */ +export class NoOpAnalyticsClient implements IAnalyticsClient { + public async flush(): Promise { + return AnalyticsBatch.create([]) + } + + public track(_event: string, _properties?: Record): void { + // intentional no-op + } +} diff --git a/test/unit/server/core/domain/analytics/batch.test.ts b/test/unit/server/core/domain/analytics/batch.test.ts new file mode 100644 index 000000000..a2a2297e0 --- /dev/null +++ b/test/unit/server/core/domain/analytics/batch.test.ts @@ -0,0 +1,167 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {AnalyticsBatch} from '../../../../../../src/server/core/domain/analytics/batch.js' + +const validIdentity = { + device_id: '550e8400-e29b-41d4-a716-446655440000', +} + +const eventA = { + identity: validIdentity, + name: 'event_a', + properties: {x: 1}, + timestamp: 1_700_000_000_000, +} + +const eventB = { + identity: validIdentity, + name: 'event_b', + properties: {y: 'hello'}, + timestamp: 1_700_000_000_001, +} + +describe('AnalyticsBatch', () => { + describe('create()', () => { + it('should create an empty batch', () => { + const batch = AnalyticsBatch.create([]) + + expect(batch.schema_version).to.equal(1) + expect(batch.events).to.deep.equal([]) + }) + + it('should create a populated batch preserving event order', () => { + const batch = AnalyticsBatch.create([eventA, eventB]) + + expect(batch.events).to.have.lengthOf(2) + expect(batch.events[0].name).to.equal('event_a') + expect(batch.events[1].name).to.equal('event_b') + }) + }) + + describe('toJson()', () => { + it('should serialize an empty batch', () => { + const batch = AnalyticsBatch.create([]) + + expect(batch.toJson()).to.deep.equal({events: [], schema_version: 1}) + }) + + it('should serialize a populated batch with all event fields', () => { + const batch = AnalyticsBatch.create([eventA]) + const json = batch.toJson() + + expect(json.schema_version).to.equal(1) + expect(json.events).to.have.lengthOf(1) + expect(json.events[0]).to.deep.equal(eventA) + }) + }) + + describe('round-trip', () => { + it('should round-trip an empty batch through fromJson', () => { + const original = AnalyticsBatch.create([]) + const restored = AnalyticsBatch.fromJson(original.toJson()) + + expect(restored).to.not.be.undefined + expect(restored?.schema_version).to.equal(1) + expect(restored?.events).to.deep.equal([]) + }) + + it('should round-trip a populated batch', () => { + const original = AnalyticsBatch.create([eventA, eventB]) + const restored = AnalyticsBatch.fromJson(original.toJson()) + + expect(restored).to.not.be.undefined + expect(restored?.events).to.have.lengthOf(2) + expect(restored?.events[0]).to.deep.equal(eventA) + expect(restored?.events[1]).to.deep.equal(eventB) + }) + }) + + describe('fromJson() rejects malformed input', () => { + it('should return undefined for null', () => { + expect(AnalyticsBatch.fromJson(null)).to.be.undefined + }) + + it('should return undefined for non-object primitives', () => { + expect(AnalyticsBatch.fromJson('string')).to.be.undefined + expect(AnalyticsBatch.fromJson(123)).to.be.undefined + expect(AnalyticsBatch.fromJson(true)).to.be.undefined + }) + + it('should return undefined for an array (top-level)', () => { + expect(AnalyticsBatch.fromJson([])).to.be.undefined + }) + + it('should return undefined when schema_version is missing', () => { + expect(AnalyticsBatch.fromJson({events: []})).to.be.undefined + }) + + it('should return undefined when schema_version is not 1', () => { + expect(AnalyticsBatch.fromJson({events: [], schema_version: 2})).to.be.undefined + expect(AnalyticsBatch.fromJson({events: [], schema_version: 0})).to.be.undefined + expect(AnalyticsBatch.fromJson({events: [], schema_version: '1'})).to.be.undefined + }) + + it('should return undefined when events is not an array', () => { + expect(AnalyticsBatch.fromJson({events: {}, schema_version: 1})).to.be.undefined + expect(AnalyticsBatch.fromJson({events: 'foo', schema_version: 1})).to.be.undefined + expect(AnalyticsBatch.fromJson({schema_version: 1})).to.be.undefined + }) + + it('should return undefined when an event is missing name', () => { + const json = { + events: [{identity: validIdentity, properties: {}, timestamp: 1}], + schema_version: 1, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when an event has non-string name', () => { + const json = { + events: [{identity: validIdentity, name: 123, properties: {}, timestamp: 1}], + schema_version: 1, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when an event is missing identity', () => { + const json = { + events: [{name: 'x', properties: {}, timestamp: 1}], + schema_version: 1, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when identity is missing device_id', () => { + const json = { + events: [{identity: {}, name: 'x', properties: {}, timestamp: 1}], + schema_version: 1, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when identity has empty device_id', () => { + const json = { + events: [{identity: {device_id: ''}, name: 'x', properties: {}, timestamp: 1}], + schema_version: 1, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when an event has non-number timestamp', () => { + const json = { + events: [{identity: validIdentity, name: 'x', properties: {}, timestamp: 'now'}], + schema_version: 1, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when an event has non-object properties', () => { + const json = { + events: [{identity: validIdentity, name: 'x', properties: 'foo', timestamp: 1}], + schema_version: 1, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + }) +}) diff --git a/test/unit/server/infra/analytics/no-op-analytics-client.test.ts b/test/unit/server/infra/analytics/no-op-analytics-client.test.ts new file mode 100644 index 000000000..3a17cf65b --- /dev/null +++ b/test/unit/server/infra/analytics/no-op-analytics-client.test.ts @@ -0,0 +1,42 @@ +import {expect} from 'chai' + +import {NoOpAnalyticsClient} from '../../../../../src/server/infra/analytics/no-op-analytics-client.js' + +describe('NoOpAnalyticsClient', () => { + describe('track()', () => { + it('should return void without throwing for varied inputs', () => { + const client = new NoOpAnalyticsClient() + + expect(() => client.track('event_no_props')).to.not.throw() + expect(() => client.track('event_with_props', {key: 'value'})).to.not.throw() + expect(() => client.track('event_undefined_props')).to.not.throw() + expect(() => client.track('')).to.not.throw() + for (let i = 0; i < 1000; i++) { + expect(() => client.track(`event_${i}`, {iteration: i})).to.not.throw() + } + }) + }) + + describe('flush()', () => { + it('should resolve to an empty batch with schema_version: 1', async () => { + const client = new NoOpAnalyticsClient() + + const batch = await client.flush() + + expect(batch.schema_version).to.equal(1) + expect(batch.events).to.deep.equal([]) + }) + + it('should still return an empty batch after many track() calls (track is truly a no-op)', async () => { + const client = new NoOpAnalyticsClient() + + for (let i = 0; i < 100; i++) { + client.track(`event_${i}`, {x: i}) + } + + const batch = await client.flush() + + expect(batch.events).to.deep.equal([]) + }) + }) +}) From 9286c598109ce2ff07f86c35da3da4f9530c9e8c Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 6 May 2026 11:39:08 +0700 Subject: [PATCH 07/87] feat: [ENG-2624] add bounded analytics queue with drop-oldest semantics Adds the in-memory queue that M2.5's AnalyticsClient will buffer events into. Configurable cap with default 1000, drop-oldest on overflow, and a cumulative droppedCount that survives drain calls for later observability (surfaced in M4 via brv analytics status). The interface IAnalyticsQueue lives next to its sibling IAnalyticsClient under core/interfaces/analytics/. The BoundedQueue implementation in infra/analytics/ uses a plain Array with push + shift; at maxSize=1000 the O(n) shift on overflow is negligible. drain transfers ownership of the events array to the caller and re-initializes the internal queue, avoiding a defensive clone. dropped is a private counter that no method resets. The constructor validates maxSize is a non-negative integer; negative NaN, Infinity, and fractional values throw fast at construction time. Without this check, a negative maxSize would cause an infinite loop in push(): the `while events.length > maxSize` condition stays true at length === 0 when maxSize < 0, with shift() as a no-op. Tests cover the six ticket scenarios plus constructor validation: FIFO drain, empty-queue defaults, drop-oldest with droppedCount tracking, multi-drop FIFO order, cumulative droppedCount across drains, size() invariant, default-cap behavior, drain ownership transfer, and 5 constructor cases (negative, NaN, Infinity, fractional all throw; maxSize === 0 is accepted as a degenerate but valid cap). 17 tests total. Test fixtures use a fresh identity object per makeEvent call rather than a shared module-scope reference, so future tests cannot accidentally mutate the shared identity and bleed across cases. Note: had to introduce a small pushAll(queue, events[]) helper in the test file because the unicorn/no-array-push-push lint rule fires on consecutive .push() calls regardless of receiver type. The helper wraps a for-of loop, which the rule accepts. --- .../interfaces/analytics/i-analytics-queue.ts | 33 ++++ src/server/infra/analytics/bounded-queue.ts | 49 +++++ .../infra/analytics/bounded-queue.test.ts | 186 ++++++++++++++++++ 3 files changed, 268 insertions(+) create mode 100644 src/server/core/interfaces/analytics/i-analytics-queue.ts create mode 100644 src/server/infra/analytics/bounded-queue.ts create mode 100644 test/unit/server/infra/analytics/bounded-queue.test.ts diff --git a/src/server/core/interfaces/analytics/i-analytics-queue.ts b/src/server/core/interfaces/analytics/i-analytics-queue.ts new file mode 100644 index 000000000..1a1e66244 --- /dev/null +++ b/src/server/core/interfaces/analytics/i-analytics-queue.ts @@ -0,0 +1,33 @@ +import type {AnalyticsEventWithIdentity} from '../../domain/analytics/batch.js' + +/** + * In-memory queue contract for identity-stamped analytics events. + * Implementations enforce a configurable cap with drop-oldest semantics + * and track a cumulative dropped count for later observability. + */ +export interface IAnalyticsQueue { + /** + * Drains the queue and returns the events in FIFO order. Caller takes + * ownership; the queue is empty afterwards. `droppedCount()` is NOT + * reset by this call. + */ + drain: () => AnalyticsEventWithIdentity[] + + /** + * Returns the cumulative number of events dropped due to the cap + * across the queue's lifetime. Never reset. + */ + droppedCount: () => number + + /** + * Pushes an event onto the queue. If the queue is at capacity, the + * oldest event is dropped to make room and `droppedCount()` is + * incremented. + */ + push: (event: AnalyticsEventWithIdentity) => void + + /** + * Returns the current number of events in the queue. + */ + size: () => number +} diff --git a/src/server/infra/analytics/bounded-queue.ts b/src/server/infra/analytics/bounded-queue.ts new file mode 100644 index 000000000..696c4d715 --- /dev/null +++ b/src/server/infra/analytics/bounded-queue.ts @@ -0,0 +1,49 @@ +import type {AnalyticsEventWithIdentity} from '../../core/domain/analytics/batch.js' +import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' + +const DEFAULT_MAX_SIZE = 1000 + +/** + * In-memory bounded queue with drop-oldest semantics. Newest pushes + * always succeed; if the queue is at capacity, the oldest event is + * removed first. `droppedCount` is cumulative across the queue's + * lifetime — neither `drain` nor any other method resets it. + * + * Backing store is a plain Array; at the default `maxSize` of 1000 the + * O(n) cost of `Array.prototype.shift()` on overflow is negligible. + */ +export class BoundedQueue implements IAnalyticsQueue { + private dropped = 0 + private events: AnalyticsEventWithIdentity[] = [] + private readonly maxSize: number + + public constructor(maxSize: number = DEFAULT_MAX_SIZE) { + if (!Number.isInteger(maxSize) || maxSize < 0) { + throw new Error(`BoundedQueue maxSize must be a non-negative integer; got ${maxSize}`) + } + + this.maxSize = maxSize + } + + public drain(): AnalyticsEventWithIdentity[] { + const drained = this.events + this.events = [] + return drained + } + + public droppedCount(): number { + return this.dropped + } + + public push(event: AnalyticsEventWithIdentity): void { + this.events.push(event) + while (this.events.length > this.maxSize) { + this.events.shift() + this.dropped++ + } + } + + public size(): number { + return this.events.length + } +} diff --git a/test/unit/server/infra/analytics/bounded-queue.test.ts b/test/unit/server/infra/analytics/bounded-queue.test.ts new file mode 100644 index 000000000..1ddf0dce8 --- /dev/null +++ b/test/unit/server/infra/analytics/bounded-queue.test.ts @@ -0,0 +1,186 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import type {AnalyticsEventWithIdentity} from '../../../../../src/server/core/domain/analytics/batch.js' + +import {BoundedQueue} from '../../../../../src/server/infra/analytics/bounded-queue.js' + +function makeEvent(name: string): AnalyticsEventWithIdentity { + return { + identity: {device_id: '550e8400-e29b-41d4-a716-446655440000'}, + name, + properties: {}, + timestamp: 0, + } +} + +function pushAll(queue: BoundedQueue, events: AnalyticsEventWithIdentity[]): void { + for (const event of events) { + queue.push(event) + } +} + +describe('BoundedQueue', () => { + describe('constructor validation', () => { + it('should throw when maxSize is negative', () => { + expect(() => new BoundedQueue(-1)).to.throw(/non-negative/) + }) + + it('should throw when maxSize is NaN', () => { + expect(() => new BoundedQueue(Number.NaN)).to.throw(/non-negative/) + }) + + it('should throw when maxSize is Infinity', () => { + expect(() => new BoundedQueue(Number.POSITIVE_INFINITY)).to.throw(/non-negative/) + }) + + it('should throw when maxSize is fractional', () => { + expect(() => new BoundedQueue(1.5)).to.throw(/non-negative/) + }) + + it('should accept maxSize === 0 (degenerate but valid)', () => { + expect(() => new BoundedQueue(0)).to.not.throw() + }) + }) + + describe('basic FIFO behavior (ticket scenario 1)', () => { + it('should return pushed events in FIFO order on drain', () => { + const queue = new BoundedQueue(10) + const eventA = makeEvent('a') + const eventB = makeEvent('b') + const eventC = makeEvent('c') + + pushAll(queue, [eventA, eventB, eventC]) + + const drained = queue.drain() + + expect(drained).to.deep.equal([eventA, eventB, eventC]) + }) + }) + + describe('empty queue (ticket scenario 2)', () => { + it('should return [] on drain when empty', () => { + const queue = new BoundedQueue(10) + + expect(queue.drain()).to.deep.equal([]) + }) + + it('should return 0 from droppedCount() when empty', () => { + const queue = new BoundedQueue(10) + + expect(queue.droppedCount()).to.equal(0) + }) + + it('should return 0 from size() when empty', () => { + const queue = new BoundedQueue(10) + + expect(queue.size()).to.equal(0) + }) + }) + + describe('drop-oldest semantics (ticket scenario 3)', () => { + it('should drop the oldest event when pushing beyond maxSize', () => { + const queue = new BoundedQueue(3) + const events = [makeEvent('a'), makeEvent('b'), makeEvent('c'), makeEvent('d')] + + for (const event of events) { + queue.push(event) + } + + const drained = queue.drain() + + expect(drained).to.have.lengthOf(3) + expect(drained[0].name).to.equal('b') + expect(drained[1].name).to.equal('c') + expect(drained[2].name).to.equal('d') + expect(queue.droppedCount()).to.equal(1) + }) + + it('should track multiple drops in FIFO drop order', () => { + const queue = new BoundedQueue(2) + + pushAll(queue, ['a', 'b', 'c', 'd', 'e'].map((n) => makeEvent(n))) + + const drained = queue.drain() + + expect(drained.map((event) => event.name)).to.deep.equal(['d', 'e']) + expect(queue.droppedCount()).to.equal(3) + }) + }) + + describe('cumulative droppedCount (ticket scenario 4)', () => { + it('should not reset droppedCount across drains', () => { + const queue = new BoundedQueue(2) + + pushAll(queue, ['a', 'b', 'c'].map((n) => makeEvent(n))) + expect(queue.droppedCount()).to.equal(1) + + queue.drain() + expect(queue.droppedCount(), 'drain must NOT reset droppedCount').to.equal(1) + + pushAll(queue, ['d', 'e', 'f'].map((n) => makeEvent(n))) + expect(queue.droppedCount()).to.equal(2) + + queue.drain() + expect(queue.droppedCount()).to.equal(2) + }) + }) + + describe('size() (ticket scenario 5)', () => { + it('should reflect current queue length', () => { + const queue = new BoundedQueue(10) + + expect(queue.size()).to.equal(0) + queue.push(makeEvent('a')) + expect(queue.size()).to.equal(1) + queue.push(makeEvent('b')) + expect(queue.size()).to.equal(2) + }) + + it('should return zero after drain', () => { + const queue = new BoundedQueue(10) + pushAll(queue, [makeEvent('a'), makeEvent('b')]) + + queue.drain() + + expect(queue.size()).to.equal(0) + }) + + it('should never exceed maxSize after pushes', () => { + const queue = new BoundedQueue(3) + + for (let i = 0; i < 10; i++) { + queue.push(makeEvent(`event_${i}`)) + } + + expect(queue.size()).to.equal(3) + }) + }) + + describe('default maxSize (ticket scenario 6)', () => { + it('should default to 1000 and drop 1 when 1001 events are pushed', () => { + const queue = new BoundedQueue() + + for (let i = 0; i < 1001; i++) { + queue.push(makeEvent(`event_${i}`)) + } + + expect(queue.size()).to.equal(1000) + expect(queue.droppedCount()).to.equal(1) + }) + }) + + describe('drain ownership transfer', () => { + it('should return a fresh empty queue after drain (caller owns drained events)', () => { + const queue = new BoundedQueue(10) + pushAll(queue, [makeEvent('a'), makeEvent('b')]) + + const firstDrain = queue.drain() + queue.push(makeEvent('c')) + const secondDrain = queue.drain() + + expect(firstDrain.map((event) => event.name)).to.deep.equal(['a', 'b']) + expect(secondDrain.map((event) => event.name)).to.deep.equal(['c']) + }) + }) +}) From a27cd33961a45574b2411ee716f74466118a3914 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 6 May 2026 15:32:47 +0700 Subject: [PATCH 08/87] feat: [ENG-2625] add super-properties resolver Adds the resolver that stamps the five super properties onto every analytics event: device_id, cli_version, os, node_version, environment. M2.5's AnalyticsClient will inject this resolver and merge its output into every track() call. Wire-format snake_case throughout (matches the rest of the analytics domain). cli_version, os, node_version, and environment are static across the daemon's lifetime and lazy-cached on first resolve(). The package.json reader is invoked exactly once per resolver instance, verified by a sinon-stub assertion. device_id is re-read from IGlobalConfigStore on every resolve() call so a swapped GlobalConfig is observable. The tradeoff: resolve() returns Promise instead of sync SuperProperties, which cascades async to M2.5's AnalyticsClient.track() (deviation from M2.5's spec to be addressed when M2.5 lands). The cli-version reader was duplicated inline in brv-server.ts; this commit extracts it to src/server/utils/read-cli-version.ts and both sites now import the single source. The walk-up depth changes from 4 to 3 levels (the new utility lives one level closer to the project root) but resolves to the same package.json. environment defaults to 'production' when BRV_ENV is unset or set to any value other than 'development'. Fail-safe in unfamiliar territory. 10 tests cover the seven ticket scenarios plus three additional cases: device_id re-read on every call, environment default when BRV_ENV is unset, and BRV_ENV=production explicitly. Tests save & restore process.env.BRV_ENV per scenario to avoid cross-test pollution. --- .../analytics/i-super-properties-resolver.ts | 23 +++ .../analytics/super-properties-resolver.ts | 52 ++++++ src/server/infra/daemon/brv-server.ts | 22 +-- src/server/utils/read-cli-version.ts | 29 ++++ .../super-properties-resolver.test.ts | 149 ++++++++++++++++++ 5 files changed, 255 insertions(+), 20 deletions(-) create mode 100644 src/server/core/interfaces/analytics/i-super-properties-resolver.ts create mode 100644 src/server/infra/analytics/super-properties-resolver.ts create mode 100644 src/server/utils/read-cli-version.ts create mode 100644 test/unit/server/infra/analytics/super-properties-resolver.test.ts diff --git a/src/server/core/interfaces/analytics/i-super-properties-resolver.ts b/src/server/core/interfaces/analytics/i-super-properties-resolver.ts new file mode 100644 index 000000000..9f786aefa --- /dev/null +++ b/src/server/core/interfaces/analytics/i-super-properties-resolver.ts @@ -0,0 +1,23 @@ + + +/** + * Super properties stamped onto every analytics event. Wire-format + * snake_case throughout. `device_id` is sourced from `GlobalConfig`; + * the remaining four are static across the daemon's lifetime. + */ +export type SuperProperties = Readonly<{ + cli_version: string + device_id: string + environment: 'development' | 'production' + node_version: string + os: NodeJS.Platform +}> + +/** + * Resolves the five super properties for analytics events. + * `resolve()` is async because `device_id` is sourced from + * `IGlobalConfigStore.read()` which is itself async. + */ +export interface ISuperPropertiesResolver { + resolve: () => Promise +} diff --git a/src/server/infra/analytics/super-properties-resolver.ts b/src/server/infra/analytics/super-properties-resolver.ts new file mode 100644 index 000000000..2a24945b4 --- /dev/null +++ b/src/server/infra/analytics/super-properties-resolver.ts @@ -0,0 +1,52 @@ +/* eslint-disable camelcase */ +import type {ISuperPropertiesResolver, SuperProperties} from '../../core/interfaces/analytics/i-super-properties-resolver.js' +import type {IGlobalConfigStore} from '../../core/interfaces/storage/i-global-config-store.js' + +import {readCliVersion} from '../../utils/read-cli-version.js' + +type StaticFields = Readonly<{ + cli_version: string + environment: 'development' | 'production' + node_version: string + os: NodeJS.Platform +}> + +/** + * Resolves the five super properties attached to every analytics event. + * + * `cli_version`, `os`, `node_version`, and `environment` are cached on + * first `resolve()` and never re-read (no static value can change at + * runtime). `device_id` is re-read from `IGlobalConfigStore` on every + * call so a swapped GlobalConfig is observable; reads are cheap and the + * value is stable in practice. + */ +export class SuperPropertiesResolver implements ISuperPropertiesResolver { + private readonly globalConfigStore: IGlobalConfigStore + private staticFields: StaticFields | undefined + private readonly versionReader: () => string + + public constructor(globalConfigStore: IGlobalConfigStore, versionReader: () => string = readCliVersion) { + this.globalConfigStore = globalConfigStore + this.versionReader = versionReader + } + + public async resolve(): Promise { + if (!this.staticFields) { + this.staticFields = { + cli_version: this.versionReader(), + environment: process.env.BRV_ENV === 'development' ? 'development' : 'production', + node_version: process.version, + os: process.platform, + } + } + + const config = await this.globalConfigStore.read() + return { + cli_version: this.staticFields.cli_version, + device_id: config?.deviceId ?? '', + environment: this.staticFields.environment, + node_version: this.staticFields.node_version, + os: this.staticFields.os, + } + } +} diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index 027c3bce2..8930422b0 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -26,7 +26,7 @@ import {GlobalInstanceManager} from '@campfirein/brv-transport-client' import express from 'express' import {fork, type StdioOptions} from 'node:child_process' import {randomUUID} from 'node:crypto' -import {mkdirSync, readdirSync, readFileSync, unlinkSync} from 'node:fs' +import {mkdirSync, readdirSync, unlinkSync} from 'node:fs' import {dirname, join} from 'node:path' import {fileURLToPath} from 'node:url' @@ -50,6 +50,7 @@ import { import {getGlobalDataDir} from '../../utils/global-data-path.js' import {getProjectDataDir} from '../../utils/path-utils.js' import {crashLog, processLog} from '../../utils/process-logger.js' +import {readCliVersion} from '../../utils/read-cli-version.js' import {ClientManager} from '../client/client-manager.js' import {ProjectConfigStore} from '../config/file-config-store.js' import {readContextTreeRemoteUrl} from '../context-tree/read-context-tree-remote.js' @@ -94,25 +95,6 @@ function log(msg: string): void { processLog(`[Daemon] ${msg}`) } -/** - * Reads the CLI version from package.json. - * Walks up from the compiled file location to find the project root. - */ -function readCliVersion(): string { - try { - const currentDir = dirname(fileURLToPath(import.meta.url)) - // Both src/ and dist/ are 4 levels deep: server/infra/daemon/brv-server - const pkgPath = join(currentDir, '..', '..', '..', '..', 'package.json') - const pkg: unknown = JSON.parse(readFileSync(pkgPath, 'utf8')) - if (typeof pkg === 'object' && pkg !== null && 'version' in pkg && typeof pkg.version === 'string') { - return pkg.version - } - } catch { - // Best-effort — return fallback - } - - return 'unknown' -} /** * Removes old daemon log files, keeping the most recent ones. diff --git a/src/server/utils/read-cli-version.ts b/src/server/utils/read-cli-version.ts new file mode 100644 index 000000000..0b0f26002 --- /dev/null +++ b/src/server/utils/read-cli-version.ts @@ -0,0 +1,29 @@ +import {readFileSync} from 'node:fs' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +const FALLBACK_VERSION = 'unknown' + +/** + * Reads the CLI version from `package.json`. Walks up three directory + * levels from this file's location to find the project root, which works + * for both source (`src/server/utils/`) and compiled (`dist/server/utils/`) + * paths since both sit at the same depth. + * + * Returns `'unknown'` on any read or parse failure (best-effort). + */ +export function readCliVersion(): string { + try { + const currentDir = dirname(fileURLToPath(import.meta.url)) + // src/ and dist/ are 3 levels deep: server/utils/read-cli-version + const pkgPath = join(currentDir, '..', '..', '..', 'package.json') + const pkg: unknown = JSON.parse(readFileSync(pkgPath, 'utf8')) + if (typeof pkg === 'object' && pkg !== null && 'version' in pkg && typeof pkg.version === 'string') { + return pkg.version + } + } catch { + // Best-effort — return fallback + } + + return FALLBACK_VERSION +} diff --git a/test/unit/server/infra/analytics/super-properties-resolver.test.ts b/test/unit/server/infra/analytics/super-properties-resolver.test.ts new file mode 100644 index 000000000..d1147cf95 --- /dev/null +++ b/test/unit/server/infra/analytics/super-properties-resolver.test.ts @@ -0,0 +1,149 @@ + +import {expect} from 'chai' +import {restore, stub} from 'sinon' + +import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces/storage/i-global-config-store.js' + +import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' +import {SuperPropertiesResolver} from '../../../../../src/server/infra/analytics/super-properties-resolver.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +function makeStubStore(deviceId = validDeviceId): IGlobalConfigStore { + const config = GlobalConfig.fromJson({ + analytics: false, + deviceId, + version: '0.0.1', + }) + if (!config) { + throw new Error('test fixture: GlobalConfig.fromJson must succeed') + } + + return { + read: stub().resolves(config), + write: stub().resolves(), + } +} + +describe('SuperPropertiesResolver', () => { + let savedBrvEnv: string | undefined + + beforeEach(() => { + savedBrvEnv = process.env.BRV_ENV + }) + + afterEach(() => { + if (savedBrvEnv === undefined) { + delete process.env.BRV_ENV + } else { + process.env.BRV_ENV = savedBrvEnv + } + + restore() + }) + + describe('resolved shape (ticket scenario 1)', () => { + it('should contain all five keys', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props).to.have.all.keys('device_id', 'cli_version', 'os', 'node_version', 'environment') + }) + }) + + describe('device_id (ticket scenario 2)', () => { + it('should match what IGlobalConfigStore returned', async () => { + const customId = '11111111-1111-1111-1111-111111111111' + const resolver = new SuperPropertiesResolver(makeStubStore(customId), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props.device_id).to.equal(customId) + }) + + it('should re-read device_id on every resolve() call', async () => { + const store = makeStubStore() + const resolver = new SuperPropertiesResolver(store, () => '1.2.3') + + await resolver.resolve() + await resolver.resolve() + await resolver.resolve() + + const readStub = store.read as ReturnType + expect(readStub.callCount).to.equal(3) + }) + }) + + describe('cli_version (ticket scenario 3)', () => { + it('should match what versionReader returned', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '9.9.9') + + const props = await resolver.resolve() + + expect(props.cli_version).to.equal('9.9.9') + }) + }) + + describe('os (ticket scenario 4)', () => { + it('should match process.platform', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props.os).to.equal(process.platform) + }) + }) + + describe('node_version (ticket scenario 5)', () => { + it('should match process.version', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props.node_version).to.equal(process.version) + }) + }) + + describe('environment (ticket scenario 6)', () => { + it("should be 'development' when BRV_ENV=development", async () => { + process.env.BRV_ENV = 'development' + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props.environment).to.equal('development') + }) + + it("should be 'production' when BRV_ENV=production", async () => { + process.env.BRV_ENV = 'production' + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props.environment).to.equal('production') + }) + + it("should default to 'production' when BRV_ENV is unset", async () => { + delete process.env.BRV_ENV + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props.environment).to.equal('production') + }) + }) + + describe('static-field caching (ticket scenario 7)', () => { + it('should call versionReader only once across many resolve() calls', async () => { + const versionReader = stub().returns('1.2.3') + const resolver = new SuperPropertiesResolver(makeStubStore(), versionReader) + + await resolver.resolve() + await resolver.resolve() + await resolver.resolve() + + expect(versionReader.callCount).to.equal(1) + }) + }) +}) From 2c055e7935a02a64b15cccd422a7674b2e7a6bcf Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 6 May 2026 16:31:53 +0700 Subject: [PATCH 09/87] feat: [ENG-2626] add identity resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds IdentityResolver — produces the per-event Identity that M2.5 will stamp onto every analytics event. Anonymous: {device_id} only. Registered: {user_id, email?, name?, device_id} where empty user fields are OMITTED (not present as undefined) so the wire envelope stays clean for downstream serializers. Per ticket DoD, defines a consumer-side IAuthStateReader interface (1 method: getToken()) co-located with IIdentityResolver in core/interfaces/analytics/. The full IAuthStateStore is broader; the resolver only needs sync access to the cached token. This keeps the auth module unaware of the analytics consumer, matching CLAUDE.md "interfaces at the consumer". resolve() is async because IGlobalConfigStore.read() is async (same precedent as M2.3 super-properties resolver). Each call re-reads both sources so auth-state transitions mid-batch are observable to M2.5 (M2.5 Test #3 requires this). Empty-field omission uses conditional spread: ...(token.userEmail ? {email: token.userEmail} : {}) which OMITS the key entirely. Tests assert via chai's .to.not.have.property('email') to distinguish absent keys from explicit-undefined values. 9 tests cover all 6 ticket scenarios plus 2 bonus cases (empty userName, missing GlobalConfig). Auth-transition tests use a mutable authReader wrapper to flip identity between resolve() calls, verifying per-call freshness with no caching. --- .../analytics/i-identity-resolver.ts | 28 +++ .../infra/analytics/identity-resolver.ts | 42 ++++ .../infra/analytics/identity-resolver.test.ts | 191 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 src/server/core/interfaces/analytics/i-identity-resolver.ts create mode 100644 src/server/infra/analytics/identity-resolver.ts create mode 100644 test/unit/server/infra/analytics/identity-resolver.test.ts diff --git a/src/server/core/interfaces/analytics/i-identity-resolver.ts b/src/server/core/interfaces/analytics/i-identity-resolver.ts new file mode 100644 index 000000000..00307189b --- /dev/null +++ b/src/server/core/interfaces/analytics/i-identity-resolver.ts @@ -0,0 +1,28 @@ +import type {Identity} from '../../domain/analytics/identity.js' +import type {AuthToken} from '../../domain/entities/auth-token.js' + +/** + * Minimal consumer-side view of the auth state store that the identity + * resolver needs. Defined here next to the consumer (not in the auth + * module) per CLAUDE.md "interfaces at the consumer". + * + * The full auth state store has additional methods (loadToken, + * onAuthChanged, etc.); the resolver only needs synchronous access to + * the current cached token. + */ +export interface IAuthStateReader { + getToken: () => AuthToken | undefined +} + +/** + * Resolves the per-event analytics Identity. Each `resolve()` call reads + * the current auth + global config state so auth-state transitions + * mid-batch are observable to consumers (M2.5 stamps identity per + * `track()` call). + * + * Async because `device_id` is sourced from `IGlobalConfigStore` which + * is itself async; matches the M2.3 super-properties precedent. + */ +export interface IIdentityResolver { + resolve: () => Promise +} diff --git a/src/server/infra/analytics/identity-resolver.ts b/src/server/infra/analytics/identity-resolver.ts new file mode 100644 index 000000000..dca4db3ef --- /dev/null +++ b/src/server/infra/analytics/identity-resolver.ts @@ -0,0 +1,42 @@ +/* eslint-disable camelcase */ +import type {Identity} from '../../core/domain/analytics/identity.js' +import type {IAuthStateReader, IIdentityResolver} from '../../core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../core/interfaces/storage/i-global-config-store.js' + +/** + * Builds the per-event Identity from current auth state + GlobalConfig. + * + * - Anonymous (no auth token) → `{device_id}` only. + * - Registered → `{user_id, email?, name?, device_id}` where empty + * `email` / `name` cause the property to be OMITTED (not present + * as `undefined`) so downstream serializers don't emit `"email": null`. + * + * No caching — each `resolve()` call re-reads both sources so auth-state + * transitions take effect immediately. + */ +export class IdentityResolver implements IIdentityResolver { + private readonly authStateReader: IAuthStateReader + private readonly globalConfigStore: IGlobalConfigStore + + public constructor(authStateReader: IAuthStateReader, globalConfigStore: IGlobalConfigStore) { + this.authStateReader = authStateReader + this.globalConfigStore = globalConfigStore + } + + public async resolve(): Promise { + const config = await this.globalConfigStore.read() + const device_id = config?.deviceId ?? '' + const token = this.authStateReader.getToken() + + if (!token) { + return {device_id} + } + + return { + device_id, + user_id: token.userId, + ...(token.userEmail ? {email: token.userEmail} : {}), + ...(token.userName ? {name: token.userName} : {}), + } + } +} diff --git a/test/unit/server/infra/analytics/identity-resolver.test.ts b/test/unit/server/infra/analytics/identity-resolver.test.ts new file mode 100644 index 000000000..888cf03aa --- /dev/null +++ b/test/unit/server/infra/analytics/identity-resolver.test.ts @@ -0,0 +1,191 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {IAuthStateReader} from '../../../../../src/server/core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces/storage/i-global-config-store.js' + +import {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' +import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' +import {IdentityResolver} from '../../../../../src/server/infra/analytics/identity-resolver.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +function makeStubStore(deviceId: string = validDeviceId, configPresent = true): IGlobalConfigStore { + if (!configPresent) { + return { + read: stub().resolves(), + write: stub().resolves(), + } + } + + const config = GlobalConfig.fromJson({ + analytics: false, + deviceId, + version: '0.0.1', + }) + if (!config) { + throw new Error('test fixture: GlobalConfig.fromJson must succeed') + } + + return { + read: stub().resolves(config), + write: stub().resolves(), + } +} + +function makeAuthReader(token?: AuthToken): IAuthStateReader { + return {getToken: () => token} +} + +function makeMutableAuthReader(): {reader: IAuthStateReader; setToken: (t: AuthToken | undefined) => void} { + let currentToken: AuthToken | undefined + return { + reader: {getToken: () => currentToken}, + setToken(t) { + currentToken = t + }, + } +} + +function makeFullToken(): AuthToken { + return new AuthToken({ + accessToken: 'access-abc', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'refresh-xyz', + sessionKey: 'session-key', + userEmail: 'alice@example.com', + userId: 'user-123', + userName: 'Alice', + }) +} + +function makeTokenWithEmpty(opts: {userEmail?: string; userName?: string}): AuthToken { + return new AuthToken({ + accessToken: 'access-abc', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'refresh-xyz', + sessionKey: 'session-key', + userEmail: opts.userEmail ?? 'alice@example.com', + userId: 'user-123', + userName: opts.userName, + }) +} + +describe('IdentityResolver', () => { + describe('anonymous (ticket scenario 1)', () => { + it('should return only device_id when no auth token is present', async () => { + const resolver = new IdentityResolver(makeAuthReader(), makeStubStore()) + + const identity = await resolver.resolve() + + expect(identity).to.deep.equal({device_id: validDeviceId}) + expect(identity).to.not.have.property('user_id') + expect(identity).to.not.have.property('email') + expect(identity).to.not.have.property('name') + }) + }) + + describe('registered with full user (ticket scenario 2)', () => { + it('should return user_id, email, name, and device_id', async () => { + const resolver = new IdentityResolver(makeAuthReader(makeFullToken()), makeStubStore()) + + const identity = await resolver.resolve() + + expect(identity).to.deep.equal({ + device_id: validDeviceId, + email: 'alice@example.com', + name: 'Alice', + user_id: 'user-123', + }) + }) + }) + + describe('registered with empty userEmail (ticket scenario 3)', () => { + it('should omit email property entirely (not present as undefined)', async () => { + const token = makeTokenWithEmpty({userEmail: ''}) + const resolver = new IdentityResolver(makeAuthReader(token), makeStubStore()) + + const identity = await resolver.resolve() + + expect(identity).to.not.have.property('email') + expect(identity.user_id).to.equal('user-123') + expect(identity.device_id).to.equal(validDeviceId) + }) + }) + + describe('auth state transitions (ticket scenarios 4 + 5)', () => { + it('should pick up the new identity when transitioning anonymous → registered', async () => { + const {reader, setToken} = makeMutableAuthReader() + const resolver = new IdentityResolver(reader, makeStubStore()) + + const first = await resolver.resolve() + expect(first).to.deep.equal({device_id: validDeviceId}) + + setToken(makeFullToken()) + const second = await resolver.resolve() + + expect(second).to.deep.equal({ + device_id: validDeviceId, + email: 'alice@example.com', + name: 'Alice', + user_id: 'user-123', + }) + }) + + it('should pick up anonymous when transitioning registered → anonymous', async () => { + const {reader, setToken} = makeMutableAuthReader() + setToken(makeFullToken()) + const resolver = new IdentityResolver(reader, makeStubStore()) + + const first = await resolver.resolve() + expect(first.user_id).to.equal('user-123') + + setToken(undefined) + const second = await resolver.resolve() + + expect(second).to.deep.equal({device_id: validDeviceId}) + expect(second).to.not.have.property('user_id') + }) + }) + + describe('device_id always present (ticket scenario 6)', () => { + it('should include device_id when registered', async () => { + const resolver = new IdentityResolver(makeAuthReader(makeFullToken()), makeStubStore()) + + const identity = await resolver.resolve() + + expect(identity.device_id).to.equal(validDeviceId) + }) + + it('should include device_id when anonymous', async () => { + const resolver = new IdentityResolver(makeAuthReader(), makeStubStore()) + + const identity = await resolver.resolve() + + expect(identity.device_id).to.equal(validDeviceId) + }) + }) + + describe('empty userName (bonus)', () => { + it('should omit name property entirely when userName is missing', async () => { + const token = makeTokenWithEmpty({userName: undefined}) + const resolver = new IdentityResolver(makeAuthReader(token), makeStubStore()) + + const identity = await resolver.resolve() + + expect(identity).to.not.have.property('name') + expect(identity.user_id).to.equal('user-123') + }) + }) + + describe('missing GlobalConfig (bonus)', () => { + it("should default device_id to '' when the store returns undefined", async () => { + const resolver = new IdentityResolver(makeAuthReader(), makeStubStore('', false)) + + const identity = await resolver.resolve() + + expect(identity).to.deep.equal({device_id: ''}) + }) + }) +}) From 05baa76f81ea49e9b092917d4a4c371403b19f68 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 6 May 2026 22:22:49 +0700 Subject: [PATCH 10/87] feat: [ENG-2627] add daemon analytics client + daemon_start sample Composes M2.2 (queue), M2.3 (super-props), M2.4 (identity) into the daemon-scoped AnalyticsClient. GlobalConfigHandler now caches the analytics flag synchronously so AnalyticsClient.isEnabled stays sync; refreshCache() is awaited in setupFeatureHandlers before construction so the very first track() (daemon_start) sees the correct flag. Adds an ESLint no-restricted-imports rule preventing IAnalyticsClient from being imported outside src/server/infra/; non-daemon consumers should reach analytics through the analytics:track transport event (M2.6). --- eslint.config.mjs | 18 + src/oclif/commands/analytics/enable.ts | 3 - .../infra/analytics/analytics-client.ts | 78 +++++ src/server/infra/daemon/brv-server.ts | 10 +- src/server/infra/process/feature-handlers.ts | 44 ++- .../handlers/global-config-handler.ts | 60 +++- .../analytics/daemon-tracking.test.ts | 170 ++++++++++ .../infra/analytics/analytics-client.test.ts | 317 ++++++++++++++++++ 8 files changed, 688 insertions(+), 12 deletions(-) create mode 100644 src/server/infra/analytics/analytics-client.ts create mode 100644 test/integration/analytics/daemon-tracking.test.ts create mode 100644 test/unit/server/infra/analytics/analytics-client.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 9e5a345dc..7789818f9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,6 +20,24 @@ export default [ }, }, }, + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + ignores: ['src/server/infra/**'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/analytics/i-analytics-client', '**/analytics/i-analytics-client.js'], + message: + 'IAnalyticsClient is daemon-internal. Only code under src/server/infra/ may import it; other consumers should use the transport event analytics:track (M2.6).', + }, + ], + }, + ], + }, + }, // Web UI (browser environment) — allow browser globals and React naming conventions { files: ['src/webui/**/*.ts', 'src/webui/**/*.tsx'], diff --git a/src/oclif/commands/analytics/enable.ts b/src/oclif/commands/analytics/enable.ts index d91a2cc64..3567ad9d4 100644 --- a/src/oclif/commands/analytics/enable.ts +++ b/src/oclif/commands/analytics/enable.ts @@ -92,9 +92,6 @@ Disable any time with: brv analytics disable` return } - // TODO(M2): when IAnalyticsClient lands, emit `analytics_enabled` as - // the first event after this write — opt-in itself is the first - // opt-in event (industry practice). this.log('Analytics enabled') } diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts new file mode 100644 index 000000000..63054217a --- /dev/null +++ b/src/server/infra/analytics/analytics-client.ts @@ -0,0 +1,78 @@ +import type {AnalyticsEventWithIdentity} from '../../core/domain/analytics/batch.js' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' +import type {IIdentityResolver} from '../../core/interfaces/analytics/i-identity-resolver.js' +import type {ISuperPropertiesResolver} from '../../core/interfaces/analytics/i-super-properties-resolver.js' + +import {AnalyticsBatch} from '../../core/domain/analytics/batch.js' + +export interface AnalyticsClientDeps { + identityResolver: IIdentityResolver + isEnabled: () => boolean + queue: IAnalyticsQueue + superPropsResolver: ISuperPropertiesResolver +} + +/** + * Daemon-scoped analytics client. Implements the M2.1 IAnalyticsClient + * contract by composing M2.2 (queue), M2.3 (super-props), and M2.4 + * (identity). + * + * `track()` is sync per the M2.1 interface — when enabled, the actual + * resolve+enqueue work is fire-and-forget via `void this.trackAsync()`, + * matching the established `auth-state-store.ts` pattern. Errors during + * the async work are silently swallowed: analytics MUST NOT crash the + * consumer, and per ticket scope no error reporting surface exists yet. + * + * When disabled, `track()` is a true no-op: no resolver calls, no + * allocations beyond the function call frame. + */ +export class AnalyticsClient implements IAnalyticsClient { + private readonly deps: AnalyticsClientDeps + + public constructor(deps: AnalyticsClientDeps) { + this.deps = deps + } + + public async flush(): Promise { + return AnalyticsBatch.create(this.deps.queue.drain()) + } + + public track(event: string, properties?: Record): void { + if (!this.deps.isEnabled()) return + // Capture the timestamp synchronously at call-site so it reflects WHEN the + // user action happened, not when the async resolver chain settled. Under + // burst load (many tracks queued before the first resolver completes) this + // preserves the inter-event durations downstream consumers care about. + const timestamp = Date.now() + // eslint-disable-next-line no-void + void this.trackAsync(event, properties, timestamp) + } + + private async trackAsync( + event: string, + properties: Record | undefined, + timestamp: number, + ): Promise { + try { + const [identity, superProps] = await Promise.all([ + this.deps.identityResolver.resolve(), + this.deps.superPropsResolver.resolve(), + ]) + + const stamped: AnalyticsEventWithIdentity = { + identity, + name: event, + // Super-properties are authoritative: they overwrite any user-supplied + // property with the same key. This guarantees a consistent envelope + // (cli_version, device_id, environment, node_version, os) on every event. + properties: {...properties, ...superProps}, + timestamp, + } + + this.deps.queue.push(stamped) + } catch { + // Analytics MUST NOT crash the consumer. Errors silently dropped. + } + } +} diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index 8930422b0..2af959899 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -627,7 +627,7 @@ async function main(): Promise { // Feature handlers (auth, init, status, push, pull, etc.) require async OIDC discovery. // Placed after daemon:getState so the debug endpoint is available immediately, // without waiting for OIDC discovery (~400ms). - await setupFeatureHandlers({ + const {analyticsClient} = await setupFeatureHandlers({ authStateStore, broadcastToProject(projectPath, event, data) { broadcastToProjectRoom(projectRegistry, projectRouter, projectPath, event, data) @@ -650,9 +650,17 @@ async function main(): Promise { await authStateStore.loadToken() authStateStore.startPolling() + // Fire `daemon_start` AFTER loadToken() so IdentityResolver sees the real + // auth state. Doing it inside setupFeatureHandlers (before loadToken) would + // stamp every daemon_start anonymously even for logged-in users. + analyticsClient.track('daemon_start') + // 11. Start idle timer + register signal handlers idleTimeoutPolicy.start() + // TODO(M4): await analyticsClient.flush() and ship the batch before exit. + // Today, queued events are dropped on SIGTERM/SIGINT — acceptable per the + // M2 ticket scope ("in-memory only"); revisit when the network sender lands. process.once('SIGTERM', () => { log('SIGTERM received') shutdownHandler.shutdown().catch((error: unknown) => { diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index b63bbd7ca..bbb7582cb 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -8,6 +8,7 @@ import {access} from 'node:fs/promises' import {join} from 'node:path' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' import type {IConnectorManager} from '../../core/interfaces/connectors/i-connector-manager.js' import type {IProviderConfigStore} from '../../core/interfaces/i-provider-config-store.js' import type {IProviderKeychainStore} from '../../core/interfaces/i-provider-keychain-store.js' @@ -22,6 +23,10 @@ import {getAuthConfig} from '../../config/auth.config.js' import {getCurrentConfig} from '../../config/environment.js' import {API_V1_PATH, BRV_DIR} from '../../constants.js' import {getProjectDataDir} from '../../utils/path-utils.js' +import {AnalyticsClient} from '../analytics/analytics-client.js' +import {BoundedQueue} from '../analytics/bounded-queue.js' +import {IdentityResolver} from '../analytics/identity-resolver.js' +import {SuperPropertiesResolver} from '../analytics/super-properties-resolver.js' import {OAuthService} from '../auth/oauth-service.js' import {OidcDiscoveryService} from '../auth/oidc-discovery-service.js' import {SystemBrowserLauncher} from '../browser/system-browser-launcher.js' @@ -87,6 +92,17 @@ export interface FeatureHandlersOptions { webuiPort?: number } +/** + * Result of setting up feature handlers. The daemon-scoped analytics + * client is returned so the caller (brv-server.ts) can fire `daemon_start` + * AFTER auth state is loaded — emitting it inside this function would + * stamp the event with anonymous identity even for logged-in users, + * because authStateStore.loadToken() runs after setupFeatureHandlers. + */ +export interface SetupFeatureHandlersResult { + readonly analyticsClient: IAnalyticsClient +} + /** * Setup all feature handlers on the transport server. * These handlers implement the TUI ↔ Server event contract (auth:*, config:*, status:*, etc.). @@ -103,7 +119,7 @@ export async function setupFeatureHandlers({ resolveProjectPath, transport, webuiPort, -}: FeatureHandlersOptions): Promise { +}: FeatureHandlersOptions): Promise { const envConfig = getCurrentConfig() const tokenStore = createTokenStore() const projectConfigStore = new ProjectConfigStore() @@ -122,10 +138,26 @@ export async function setupFeatureHandlers({ // Global handlers (no project context needed) new ConfigHandler({transport}).setup() - new GlobalConfigHandler({ - globalConfigStore: new FileGlobalConfigStore(), - transport, - }).setup() + // GlobalConfig: handler retains a sync-cached `analytics` flag so M2.5's + // AnalyticsClient.isEnabled can be a sync getter (file reads are async). + // refreshCache() must complete BEFORE AnalyticsClient is constructed so + // the very first track() call (daemon_start) sees the correct flag. + const globalConfigStore = new FileGlobalConfigStore() + const globalConfigHandler = new GlobalConfigHandler({globalConfigStore, transport}) + globalConfigHandler.setup() + await globalConfigHandler.refreshCache() + + // M2.5: assemble the daemon-scoped analytics client. Construction happens + // here because the resolvers and queue share the same `globalConfigStore` + // instance already in scope. The `daemon_start` event is NOT fired here — + // it is fired by the caller (brv-server.ts) after authStateStore.loadToken() + // resolves so the event reflects the real identity instead of anonymous. + const analyticsClient: IAnalyticsClient = new AnalyticsClient({ + identityResolver: new IdentityResolver(authStateStore, globalConfigStore), + isEnabled: () => globalConfigHandler.getCachedAnalytics(), + queue: new BoundedQueue(), + superPropsResolver: new SuperPropertiesResolver(globalConfigStore), + }) new AuthHandler({ authService: new OAuthService(authConfig), @@ -331,4 +363,6 @@ export async function setupFeatureHandlers({ new SourceHandler({ resolveProjectPath, transport }).setup() log('Feature handlers registered') + + return {analyticsClient} } diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts index a961d40ea..acbd65b63 100644 --- a/src/server/infra/transport/handlers/global-config-handler.ts +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -18,13 +18,21 @@ export interface GlobalConfigHandlerDeps { /** * Handles globalConfig:get and globalConfig:setAnalytics events. - * Re-reads the file every call (no in-memory cache) so the daemon always - * reflects the latest on-disk state, including writes from sibling commands. - * If no config exists yet, seeds a fresh one with a stable deviceId. + * Re-reads the file every call (no in-memory cache for transport responses) + * so the daemon always reflects the latest on-disk state. If no config + * exists yet, the GET path seeds a fresh one with a stable deviceId. * SET_ANALYTICS is idempotent: if the requested state matches current state, * the file is not rewritten. + * + * Maintains a SYNC in-process cache of the analytics flag for consumers + * that need a synchronous getter (M2.5's AnalyticsClient.isEnabled). The + * cache is populated at construction (eager initial read) and refreshed + * after every successful SET_ANALYTICS write. Transport responses still + * read fresh from disk — the cache is purely an in-process bridge for + * sync consumers. */ export class GlobalConfigHandler { + private cachedAnalytics: boolean | undefined private readonly globalConfigStore: IGlobalConfigStore private readonly transport: ITransportServer @@ -33,6 +41,44 @@ export class GlobalConfigHandler { this.transport = deps.transport } + /** + * Synchronous getter for the cached analytics flag. Used by daemon-side + * consumers (M2.5's AnalyticsClient) that cannot await the async store. + * + * THROWS if called before `refreshCache()` has resolved (or before any + * GET/SET handler has populated the cache). A silent default-false here + * caused a real product-correctness bug during M2.5 development — + * `daemon_start` would observe analytics=false even when the user had it + * enabled on disk. Failing loud forces the lifecycle requirement to + * surface during bootstrap rather than silently miscount. + */ + getCachedAnalytics(): boolean { + if (this.cachedAnalytics === undefined) { + throw new Error( + 'GlobalConfigHandler.getCachedAnalytics() called before refreshCache() resolved. ' + + 'Daemon bootstrap must `await handler.refreshCache()` before constructing any consumer that reads the cache.', + ) + } + + return this.cachedAnalytics + } + + /** + * Synchronously refreshes the cached analytics flag from disk. Daemon + * bootstrap awaits this once before constructing AnalyticsClient so + * the very first `track()` (e.g. `daemon_start`) sees the correct + * enabled state. Subsequent updates happen automatically inside + * SET_ANALYTICS without any caller involvement. + */ + async refreshCache(): Promise { + try { + const existing = await this.globalConfigStore.read() + this.cachedAnalytics = existing?.analytics ?? false + } catch { + // Best-effort — leave cache at default false on read failure + } + } + setup(): void { this.transport.onRequest(GlobalConfigEvents.GET, async () => this.read()) this.transport.onRequest( @@ -44,6 +90,7 @@ export class GlobalConfigHandler { private async read(): Promise { const existing = await this.globalConfigStore.read() if (existing) { + this.cachedAnalytics = existing.analytics return { analytics: existing.analytics, deviceId: existing.deviceId, @@ -53,6 +100,7 @@ export class GlobalConfigHandler { const seeded = GlobalConfig.create(randomUUID()) await this.globalConfigStore.write(seeded) + this.cachedAnalytics = seeded.analytics return { analytics: seeded.analytics, deviceId: seeded.deviceId, @@ -68,12 +116,18 @@ export class GlobalConfigHandler { // If existing is undefined and the requested value matches the default // (false), no file is created — the next GET will seed. if (previous === analytics) { + this.cachedAnalytics = previous return {current: previous, previous} } const current = existing ?? GlobalConfig.create(randomUUID()) const updated = current.withAnalytics(analytics) await this.globalConfigStore.write(updated) + // Cache is in-process-authoritative — we trust the value just written. + // Cross-process changes (another daemon writing the same file, manual + // edits) are NOT observable until the next daemon restart. The + // single-daemon model makes this safe today. + this.cachedAnalytics = updated.analytics return {current: updated.analytics, previous} } } diff --git a/test/integration/analytics/daemon-tracking.test.ts b/test/integration/analytics/daemon-tracking.test.ts new file mode 100644 index 000000000..d8eb5245a --- /dev/null +++ b/test/integration/analytics/daemon-tracking.test.ts @@ -0,0 +1,170 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' +import {existsSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {AuthToken} from '../../../src/server/core/domain/entities/auth-token.js' +import type {IAuthStateReader} from '../../../src/server/core/interfaces/analytics/i-identity-resolver.js' + +import {AnalyticsBatch} from '../../../src/server/core/domain/analytics/batch.js' +import {GlobalConfig} from '../../../src/server/core/domain/entities/global-config.js' +import {AnalyticsClient} from '../../../src/server/infra/analytics/analytics-client.js' +import {BoundedQueue} from '../../../src/server/infra/analytics/bounded-queue.js' +import {IdentityResolver} from '../../../src/server/infra/analytics/identity-resolver.js' +import {SuperPropertiesResolver} from '../../../src/server/infra/analytics/super-properties-resolver.js' +import {FileGlobalConfigStore} from '../../../src/server/infra/storage/file-global-config-store.js' +import {GlobalConfigHandler} from '../../../src/server/infra/transport/handlers/global-config-handler.js' +import {createMockTransportServer} from '../../helpers/mock-factories.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +async function waitForQueueSize(queue: BoundedQueue, expected: number, timeoutMs = 1000): Promise { + const start = Date.now() + while (queue.size() < expected) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitForQueueSize: expected ${expected}, got ${queue.size()} after ${timeoutMs}ms`) + } + + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setImmediate(resolve) + }) + } +} + +function makeAnonAuthReader(): IAuthStateReader { + const noToken: AuthToken | undefined = undefined + return {getToken: () => noToken} +} + +describe('daemon analytics tracking integration (ticket scenario 6)', () => { + let testDir: string + let testConfigPath: string + let store: FileGlobalConfigStore + + beforeEach(() => { + testDir = join(tmpdir(), `test-daemon-tracking-${Date.now()}-${randomUUID().slice(0, 8)}`) + testConfigPath = join(testDir, 'config.json') + store = new FileGlobalConfigStore({ + getConfigDir: () => testDir, + getConfigPath: () => testConfigPath, + }) + }) + + afterEach(async () => { + if (existsSync(testDir)) { + await rm(testDir, {force: true, recursive: true}) + } + }) + + it('should land daemon_start in the queue with full identity + super properties when analytics is enabled', async () => { + // Pre-seed the on-disk config so analytics is enabled and deviceId is stable + // for assertions. This mirrors what M1.3 `brv analytics enable` writes. + const seeded = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) + if (!seeded) throw new Error('test fixture: seeded GlobalConfig must be valid') + await store.write(seeded) + + // Compose the daemon's analytics dependencies the same way feature-handlers.ts does. + const transport = createMockTransportServer() + const handler = new GlobalConfigHandler({globalConfigStore: store, transport}) + handler.setup() + await handler.refreshCache() + + const queue = new BoundedQueue() + const client = new AnalyticsClient({ + identityResolver: new IdentityResolver(makeAnonAuthReader(), store), + isEnabled: () => handler.getCachedAnalytics(), + queue, + superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), + }) + + // Fire the daemon_start sample event exactly as feature-handlers.ts does. + const before = Date.now() + client.track('daemon_start') + await waitForQueueSize(queue, 1) + const after = Date.now() + + const batch = await client.flush() + expect(batch.events).to.have.lengthOf(1) + const [event] = batch.events + + expect(event.name).to.equal('daemon_start') + expect(event.timestamp).to.be.at.least(before) + expect(event.timestamp).to.be.at.most(after) + + // Anonymous identity: device_id only (no token in the stub reader) + expect(event.identity).to.deep.equal({device_id: validDeviceId}) + + // All five super properties stamped onto event.properties + expect(event.properties.cli_version).to.equal('3.10.3') + expect(event.properties.device_id).to.equal(validDeviceId) + expect(event.properties.environment).to.be.oneOf(['development', 'production']) + expect(event.properties.node_version).to.equal(process.version) + expect(event.properties.os).to.equal(process.platform) + }) + + it('should produce a batch that round-trips through AnalyticsBatch.fromJson', async () => { + const seeded = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) + if (!seeded) throw new Error('test fixture: seeded GlobalConfig must be valid') + await store.write(seeded) + + const transport = createMockTransportServer() + const handler = new GlobalConfigHandler({globalConfigStore: store, transport}) + handler.setup() + await handler.refreshCache() + + const queue = new BoundedQueue() + const client = new AnalyticsClient({ + identityResolver: new IdentityResolver(makeAnonAuthReader(), store), + isEnabled: () => handler.getCachedAnalytics(), + queue, + superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), + }) + + client.track('daemon_start') + await waitForQueueSize(queue, 1) + + const batch = await client.flush() + const restored = AnalyticsBatch.fromJson(batch.toJson()) + + expect(restored).to.not.be.undefined + expect(restored?.schema_version).to.equal(1) + expect(restored?.events).to.have.lengthOf(1) + expect(restored?.events[0].name).to.equal('daemon_start') + expect(restored?.events[0].identity.device_id).to.equal(validDeviceId) + }) + + it('should drop daemon_start silently when analytics is disabled (default opt-in)', async () => { + // No pre-seeded config — handler.refreshCache() leaves cachedAnalytics at default false. + const transport = createMockTransportServer() + const handler = new GlobalConfigHandler({globalConfigStore: store, transport}) + handler.setup() + await handler.refreshCache() + + const queue = new BoundedQueue() + const client = new AnalyticsClient({ + identityResolver: new IdentityResolver(makeAnonAuthReader(), store), + isEnabled: () => handler.getCachedAnalytics(), + queue, + superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), + }) + + client.track('daemon_start') + // Give the event loop a few ticks; if track() were not a true no-op, + // any resolver work would land here. Two setImmediates is enough because + // the disabled path returns synchronously without scheduling anything. + await new Promise((resolve) => { + setImmediate(resolve) + }) + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(queue.size()).to.equal(0) + const batch = await client.flush() + expect(batch.events).to.deep.equal([]) + }) +}) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts new file mode 100644 index 000000000..7d2ffcf9a --- /dev/null +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -0,0 +1,317 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {Identity} from '../../../../../src/server/core/domain/analytics/identity.js' +import type {IIdentityResolver} from '../../../../../src/server/core/interfaces/analytics/i-identity-resolver.js' +import type {ISuperPropertiesResolver, SuperProperties} from '../../../../../src/server/core/interfaces/analytics/i-super-properties-resolver.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsClient} from '../../../../../src/server/infra/analytics/analytics-client.js' +import {BoundedQueue} from '../../../../../src/server/infra/analytics/bounded-queue.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +function makeAnonIdentity(): Identity { + return {device_id: validDeviceId} +} + +function makeRegisteredIdentity(): Identity { + return { + device_id: validDeviceId, + email: 'alice@example.com', + name: 'Alice', + user_id: 'user-123', + } +} + +function makeSuperProps(): SuperProperties { + return { + cli_version: '3.10.3', + device_id: validDeviceId, + environment: 'production', + node_version: 'v24.13.1', + os: 'darwin', + } +} + +function makeStubIdentityResolver(identity: Identity): IIdentityResolver { + return {resolve: stub().resolves(identity)} +} + +function makeStubSuperPropsResolver(props: SuperProperties): ISuperPropertiesResolver { + return {resolve: stub().resolves(props)} +} + +async function flushMicrotasks(): Promise { + // Drain the microtask queue so fire-and-forget async work completes + await new Promise((resolve) => { + setImmediate(resolve) + }) + await new Promise((resolve) => { + setImmediate(resolve) + }) +} + +describe('AnalyticsClient', () => { + describe('disabled state (ticket scenario 1)', () => { + it('should be a true no-op when isEnabled returns false', async () => { + const queue = new BoundedQueue() + const identityResolver = makeStubIdentityResolver(makeAnonIdentity()) + const superPropsResolver = makeStubSuperPropsResolver(makeSuperProps()) + + const client = new AnalyticsClient({ + identityResolver, + isEnabled: () => false, + queue, + superPropsResolver, + }) + + for (let i = 0; i < 1000; i++) { + client.track(`event_${i}`, {x: i}) + } + + await flushMicrotasks() + + expect(queue.size()).to.equal(0) + expect((identityResolver.resolve as ReturnType).called, 'identityResolver.resolve must NOT be called').to.be.false + expect((superPropsResolver.resolve as ReturnType).called, 'superPropsResolver.resolve must NOT be called').to.be.false + }) + }) + + describe('enabled state (ticket scenario 2)', () => { + it('should resolve identity + super-props and push to queue with timestamp', async () => { + const queue = new BoundedQueue() + const identity = makeRegisteredIdentity() + const superProps = makeSuperProps() + + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(identity), + isEnabled: () => true, + queue, + superPropsResolver: makeStubSuperPropsResolver(superProps), + }) + + const before = Date.now() + client.track('e1', {x: 1}) + await flushMicrotasks() + const after = Date.now() + + const batch = await client.flush() + + expect(batch.events).to.have.lengthOf(1) + const [event] = batch.events + expect(event.name).to.equal('e1') + expect(event.identity).to.deep.equal(identity) + expect(event.timestamp).to.be.at.least(before) + expect(event.timestamp).to.be.at.most(after) + + // user property merged + expect(event.properties.x).to.equal(1) + // all 5 super properties stamped + expect(event.properties.cli_version).to.equal('3.10.3') + expect(event.properties.device_id).to.equal(validDeviceId) + expect(event.properties.environment).to.equal('production') + expect(event.properties.node_version).to.equal('v24.13.1') + expect(event.properties.os).to.equal('darwin') + }) + }) + + describe('auth transition mid-batch (ticket scenario 3)', () => { + it('should reflect per-track identity resolution when auth state flips', async () => { + const queue = new BoundedQueue() + let currentIdentity: Identity = makeAnonIdentity() + const identityResolver: IIdentityResolver = { + resolve: async () => currentIdentity, + } + const superPropsResolver = makeStubSuperPropsResolver(makeSuperProps()) + + const client = new AnalyticsClient({ + identityResolver, + isEnabled: () => true, + queue, + superPropsResolver, + }) + + client.track('a1') + client.track('a2') + await flushMicrotasks() + + currentIdentity = makeRegisteredIdentity() + client.track('r1') + client.track('r2') + await flushMicrotasks() + + const batch = await client.flush() + expect(batch.events).to.have.lengthOf(4) + expect(batch.events[0].identity).to.deep.equal(makeAnonIdentity()) + expect(batch.events[1].identity).to.deep.equal(makeAnonIdentity()) + expect(batch.events[2].identity).to.deep.equal(makeRegisteredIdentity()) + expect(batch.events[3].identity).to.deep.equal(makeRegisteredIdentity()) + }) + }) + + describe('queue cap honored (ticket scenario 4)', () => { + it('should drop excess events per the bounded queue contract', async () => { + const queue = new BoundedQueue(5) + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + queue, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + for (let i = 0; i < 10; i++) { + client.track(`event_${i}`) + } + + await flushMicrotasks() + + const batch = await client.flush() + expect(batch.events).to.have.lengthOf(5) + expect(queue.droppedCount()).to.equal(5) + }) + }) + + describe('flush returns valid AnalyticsBatch (ticket scenario 5)', () => { + it('should return a batch that round-trips through fromJson', async () => { + const queue = new BoundedQueue() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + queue, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track('round_trip') + await flushMicrotasks() + + const batch = await client.flush() + const restored = AnalyticsBatch.fromJson(batch.toJson()) + + expect(restored).to.not.be.undefined + expect(restored?.events).to.have.lengthOf(1) + expect(restored?.events[0].name).to.equal('round_trip') + }) + + it('should return an empty batch when the queue has been fully drained', async () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + queue: new BoundedQueue(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + const first = await client.flush() + expect(first.events).to.deep.equal([]) + }) + }) + + describe('error containment (analytics must not crash consumers)', () => { + it('should silently drop the event when identity resolution rejects', async () => { + const queue = new BoundedQueue() + const identityResolver: IIdentityResolver = { + resolve: () => Promise.reject(new Error('identity boom')), + } + + const client = new AnalyticsClient({ + identityResolver, + isEnabled: () => true, + queue, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Must not throw to the caller + expect(() => client.track('boom')).to.not.throw() + + await flushMicrotasks() + + expect(queue.size()).to.equal(0) + }) + + it('should silently drop the event when super-properties resolution rejects', async () => { + const queue = new BoundedQueue() + const superPropsResolver: ISuperPropertiesResolver = { + resolve: () => Promise.reject(new Error('super-props boom')), + } + + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + queue, + superPropsResolver, + }) + + expect(() => client.track('boom')).to.not.throw() + + await flushMicrotasks() + + expect(queue.size()).to.equal(0) + }) + }) + + describe('timestamp captured at call site, not resolver settle time', () => { + it('should stamp timestamp when track() is called even if resolvers settle later', async () => { + const queue = new BoundedQueue() + let resolveIdentity!: (id: Identity) => void + const slowIdentityResolver: IIdentityResolver = { + resolve: () => + new Promise((resolve) => { + resolveIdentity = resolve + }), + } + + const client = new AnalyticsClient({ + identityResolver: slowIdentityResolver, + isEnabled: () => true, + queue, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + const before = Date.now() + client.track('e1') + const after = Date.now() + + // Hold the resolver pending across a real timer gap so settle-time and + // call-time diverge meaningfully — without this the bug is too subtle to detect. + await new Promise((resolve) => { + setTimeout(resolve, 50) + }) + + const settleStart = Date.now() + resolveIdentity(makeAnonIdentity()) + await flushMicrotasks() + + const batch = await client.flush() + expect(batch.events).to.have.lengthOf(1) + // Captured-at-call: timestamp falls within the call-site window… + expect(batch.events[0].timestamp).to.be.at.least(before) + expect(batch.events[0].timestamp).to.be.at.most(after) + // …and is BEFORE the resolver settled (proving capture-at-call, not capture-at-settle). + expect(batch.events[0].timestamp).to.be.lessThan(settleStart) + }) + }) + + describe('super-properties precedence', () => { + it('should let super-properties win on key conflict with user properties', async () => { + const queue = new BoundedQueue() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + queue, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track('e1', {cli_version: 'lying', custom: 'kept'}) + await flushMicrotasks() + + const batch = await client.flush() + expect(batch.events).to.have.lengthOf(1) + const [event] = batch.events + // Super-property wins + expect(event.properties.cli_version).to.equal('3.10.3') + // User property without conflict is preserved + expect(event.properties.custom).to.equal('kept') + }) + }) +}) From 30a57b9705b7728186dadfe8e3911b588aea1022 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Thu, 7 May 2026 08:14:29 +0700 Subject: [PATCH 11/87] feat: [ENG-2627] address second-pass review on daemon analytics client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refreshCache(): catch block now explicitly sets cachedAnalytics=false on read failure. Under the prior fix the field defaulted to undefined, so a silent catch left getCachedAnalytics() throwing — which would crash the daemon on bootstrap if any IGlobalConfigStore implementation ever rejected from read(). Production FileGlobalConfigStore catches its own errors, but defense-in-depth restores the comment's promise. - GlobalConfigHandler class JSDoc: corrected stale "populated at construction" wording. The eager constructor-refresh was removed during the prior fix-pass; consumers now MUST await refreshCache(). - AnalyticsClient class JSDoc: tightened the no-crash invariant. The guarantee covers async resolver/queue errors only — a sync isEnabled() throw (e.g. getCachedAnalytics before refreshCache) propagates by design to surface bootstrap-misconfiguration loudly. - New integration test locks the refreshCache fail-safe: a stub IGlobalConfigStore whose read() rejects must leave the cache at false (NOT undefined), and getCachedAnalytics() must NOT throw afterwards. --- .../infra/analytics/analytics-client.ts | 15 +++++++++--- .../handlers/global-config-handler.ts | 16 +++++++++---- .../analytics/daemon-tracking.test.ts | 24 +++++++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index 63054217a..b5334c67d 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -19,10 +19,19 @@ export interface AnalyticsClientDeps { * (identity). * * `track()` is sync per the M2.1 interface — when enabled, the actual - * resolve+enqueue work is fire-and-forget via `void this.trackAsync()`, + * resolve+enqueue work is fire-and-forget via the async trackAsync, * matching the established `auth-state-store.ts` pattern. Errors during - * the async work are silently swallowed: analytics MUST NOT crash the - * consumer, and per ticket scope no error reporting surface exists yet. + * the async work (resolver rejection, queue push failure) are silently + * swallowed: analytics MUST NOT crash a correctly-configured consumer, + * and per ticket scope no error reporting surface exists yet. + * + * The no-crash guarantee covers ASYNC errors only. The sync `isEnabled()` + * callback is called directly; if it throws, the throw propagates to the + * caller. This is intentional: `isEnabled` is wired to + * GlobalConfigHandler.getCachedAnalytics(), which throws when invoked + * before `refreshCache()` has populated the cache. That throw surfaces + * a bootstrap-misconfiguration bug loudly rather than silently miscounting. + * Callers MUST ensure the cache is populated before the first `track()`. * * When disabled, `track()` is a true no-op: no resolver calls, no * allocations beyond the function call frame. diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts index acbd65b63..0063e4a72 100644 --- a/src/server/infra/transport/handlers/global-config-handler.ts +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -26,10 +26,11 @@ export interface GlobalConfigHandlerDeps { * * Maintains a SYNC in-process cache of the analytics flag for consumers * that need a synchronous getter (M2.5's AnalyticsClient.isEnabled). The - * cache is populated at construction (eager initial read) and refreshed - * after every successful SET_ANALYTICS write. Transport responses still - * read fresh from disk — the cache is purely an in-process bridge for - * sync consumers. + * cache is populated by an explicit `await refreshCache()` (the daemon + * bootstrap awaits this once before constructing AnalyticsClient) and + * refreshed after every successful SET_ANALYTICS write or GET seed. + * Transport responses still read fresh from disk — the cache is purely + * an in-process bridge for sync consumers. */ export class GlobalConfigHandler { private cachedAnalytics: boolean | undefined @@ -75,7 +76,12 @@ export class GlobalConfigHandler { const existing = await this.globalConfigStore.read() this.cachedAnalytics = existing?.analytics ?? false } catch { - // Best-effort — leave cache at default false on read failure + // Fail-safe: explicitly set the cache to false on any read failure so + // a subsequent getCachedAnalytics() does NOT throw. Production + // FileGlobalConfigStore catches its own errors and never throws, but + // we MUST handle a hypothetical store that does — otherwise a + // bootstrap read failure would crash the daemon when track() runs. + this.cachedAnalytics = false } } diff --git a/test/integration/analytics/daemon-tracking.test.ts b/test/integration/analytics/daemon-tracking.test.ts index d8eb5245a..a880fa637 100644 --- a/test/integration/analytics/daemon-tracking.test.ts +++ b/test/integration/analytics/daemon-tracking.test.ts @@ -8,6 +8,7 @@ import {join} from 'node:path' import type {AuthToken} from '../../../src/server/core/domain/entities/auth-token.js' import type {IAuthStateReader} from '../../../src/server/core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../../src/server/core/interfaces/storage/i-global-config-store.js' import {AnalyticsBatch} from '../../../src/server/core/domain/analytics/batch.js' import {GlobalConfig} from '../../../src/server/core/domain/entities/global-config.js' @@ -167,4 +168,27 @@ describe('daemon analytics tracking integration (ticket scenario 6)', () => { const batch = await client.flush() expect(batch.events).to.deep.equal([]) }) + + it('should fall back to disabled (not throw) when the config store read rejects during refreshCache', async () => { + // FileGlobalConfigStore catches its own errors and never throws, but a + // hypothetical alternative implementation might. Verify refreshCache's + // catch block leaves the cache in a usable state — getCachedAnalytics + // must return false rather than throw, otherwise the daemon would crash + // on bootstrap when track() runs. + const throwingStore: IGlobalConfigStore = { + async read() { + throw new Error('read boom') + }, + async write() { + // unused in this test + }, + } + const transport = createMockTransportServer() + const handler = new GlobalConfigHandler({globalConfigStore: throwingStore, transport}) + handler.setup() + await handler.refreshCache() + + expect(() => handler.getCachedAnalytics()).to.not.throw() + expect(handler.getCachedAnalytics()).to.equal(false) + }) }) From ff6453ee95c0d4b985aa983c04d07169e74d8138 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Thu, 7 May 2026 16:21:54 +0700 Subject: [PATCH 12/87] feat: [ENG-2628] add analytics:track transport event + handler + helper Implements M2.6 transport mechanism for analytics: - analytics:track transport event with wire-level Zod validation (src/shared/transport/events/analytics-events.ts, AnalyticsTrackPayloadSchema in core/domain/transport/schemas.ts) - Daemon-side AnalyticsHandler routes valid payloads to AnalyticsClient.track (src/server/infra/transport/handlers/ analytics-handler.ts, wired in feature-handlers.ts) - Shared emitAnalytics helper at src/shared/analytics/emit.ts for in-process consumers (TUI, oclif, MCP, webui) - Round-trip integration test exercises stub-emit -> handler -> queue with full identity + super-properties stamping Deviation from ticket spec (see ENG-2628 Linear comment): - Oclif sample consumer (cli_invocation from lifecycle hook) NOT shipped -- team decided against oclif lifecycle hooks. Follow-up ticket needed. Tests added: 21 (schema 9, handler 5, helper 4, integration 3). --- src/server/core/domain/transport/schemas.ts | 17 ++ src/server/infra/process/feature-handlers.ts | 5 + .../transport/handlers/analytics-handler.ts | 45 +++++ src/server/infra/transport/handlers/index.ts | 2 + src/shared/analytics/emit.ts | 24 +++ .../transport/events/analytics-events.ts | 8 + src/shared/transport/events/index.ts | 3 + test/integration/analytics/transport.test.ts | 180 ++++++++++++++++++ .../transport/analytics-track-schema.test.ts | 58 ++++++ .../handlers/analytics-handler.test.ts | 95 +++++++++ test/unit/shared/analytics/emit.test.ts | 67 +++++++ 11 files changed, 504 insertions(+) create mode 100644 src/server/infra/transport/handlers/analytics-handler.ts create mode 100644 src/shared/analytics/emit.ts create mode 100644 src/shared/transport/events/analytics-events.ts create mode 100644 test/integration/analytics/transport.test.ts create mode 100644 test/unit/server/core/domain/transport/analytics-track-schema.test.ts create mode 100644 test/unit/server/infra/transport/handlers/analytics-handler.test.ts create mode 100644 test/unit/shared/analytics/emit.test.ts diff --git a/src/server/core/domain/transport/schemas.ts b/src/server/core/domain/transport/schemas.ts index d2c0d1765..eab923f4f 100644 --- a/src/server/core/domain/transport/schemas.ts +++ b/src/server/core/domain/transport/schemas.ts @@ -957,3 +957,20 @@ export type AgentRestartRequest = z.infer export type AgentRestartResponse = z.infer export type AgentNewSessionRequest = z.infer export type AgentNewSessionResponse = z.infer + +// ============================================================================ +// Analytics Events (analytics:track) +// ============================================================================ + +/** + * Wire-level validation for analytics:track payloads. Identity and super + * properties are stamped daemon-side on receipt; per-event property + * schemas (cli_invocation, mcp_tool_called, …) are designed in M2.8. + * The handler validates only the wire shape here. + */ +export const AnalyticsTrackPayloadSchema = z.object({ + event: z.string().min(1), + properties: z.record(z.string(), z.unknown()).optional(), +}) + +export type AnalyticsTrackPayload = z.infer diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index bbb7582cb..0d0b1f452 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -55,6 +55,7 @@ import {createTokenStore} from '../storage/token-store.js' import {HttpTeamService} from '../team/http-team-service.js' import {FsTemplateLoader} from '../template/fs-template-loader.js' import { + AnalyticsHandler, AuthHandler, ConfigHandler, ConnectorsHandler, @@ -159,6 +160,10 @@ export async function setupFeatureHandlers({ superPropsResolver: new SuperPropertiesResolver(globalConfigStore), }) + // M2.6: route incoming analytics:track events from non-forked clients + // (TUI, oclif, MCP, webui) to the same singleton. + new AnalyticsHandler({analyticsClient, transport}).setup() + new AuthHandler({ authService: new OAuthService(authConfig), authStateStore, diff --git a/src/server/infra/transport/handlers/analytics-handler.ts b/src/server/infra/transport/handlers/analytics-handler.ts new file mode 100644 index 000000000..648dc79a2 --- /dev/null +++ b/src/server/infra/transport/handlers/analytics-handler.ts @@ -0,0 +1,45 @@ +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import {AnalyticsEvents, type AnalyticsTrackRequest} from '../../../../shared/transport/events/analytics-events.js' +import {AnalyticsTrackPayloadSchema} from '../../../core/domain/transport/schemas.js' + +export interface AnalyticsHandlerDeps { + analyticsClient: IAnalyticsClient + transport: ITransportServer +} + +/** + * Daemon-side handler for `analytics:track` (M2.6). Routes validated + * payloads to the daemon-scoped AnalyticsClient (M2.5), which stamps + * identity + super-properties and enqueues for later flush. + * + * Validation is wire-level only (event is non-empty string, properties + * is record-or-undefined). Per-event property schemas (cli_invocation, + * mcp_tool_called, …) are designed in M2.8. + * + * Malformed payloads and any throw from track() are silently dropped: + * analytics MUST NOT crash the emitting client. + */ +export class AnalyticsHandler { + private readonly analyticsClient: IAnalyticsClient + private readonly transport: ITransportServer + + public constructor(deps: AnalyticsHandlerDeps) { + this.analyticsClient = deps.analyticsClient + this.transport = deps.transport + } + + public setup(): void { + this.transport.onRequest(AnalyticsEvents.TRACK, async (data: unknown) => { + const parsed = AnalyticsTrackPayloadSchema.safeParse(data) + if (!parsed.success) return + + try { + this.analyticsClient.track(parsed.data.event, parsed.data.properties) + } catch { + // Defensive: never crash the emitter. + } + }) + } +} diff --git a/src/server/infra/transport/handlers/index.ts b/src/server/infra/transport/handlers/index.ts index 4d48214b0..45a092e72 100644 --- a/src/server/infra/transport/handlers/index.ts +++ b/src/server/infra/transport/handlers/index.ts @@ -1,3 +1,5 @@ +export {AnalyticsHandler} from './analytics-handler.js' +export type {AnalyticsHandlerDeps} from './analytics-handler.js' export {AuthHandler} from './auth-handler.js' export type {AuthHandlerDeps} from './auth-handler.js' export {ConfigHandler} from './config-handler.js' diff --git a/src/shared/analytics/emit.ts b/src/shared/analytics/emit.ts new file mode 100644 index 000000000..69ce6e22f --- /dev/null +++ b/src/shared/analytics/emit.ts @@ -0,0 +1,24 @@ +import type {ITransportClient} from '@campfirein/brv-transport-client' + +import {AnalyticsEvents, type AnalyticsTrackRequest} from '../transport/events/analytics-events.js' + +/** + * Fire-and-forget analytics emission for non-forked daemon clients + * (TUI, oclif, MCP, webui). Uses `client.request` (no ack) so the caller + * never waits on the daemon. + * + * NEVER throws. If the client is not connected or `request` throws for + * any reason, the error is swallowed: analytics MUST NOT crash the caller. + */ +export function emitAnalytics( + client: ITransportClient, + event: string, + properties?: Record, +): void { + const payload: AnalyticsTrackRequest = {event, properties} + try { + client.request(AnalyticsEvents.TRACK, payload) + } catch { + // Intentional: analytics must not crash consumers. + } +} diff --git a/src/shared/transport/events/analytics-events.ts b/src/shared/transport/events/analytics-events.ts new file mode 100644 index 000000000..1e306d202 --- /dev/null +++ b/src/shared/transport/events/analytics-events.ts @@ -0,0 +1,8 @@ +export const AnalyticsEvents = { + TRACK: 'analytics:track', +} as const + +export interface AnalyticsTrackRequest { + readonly event: string + readonly properties?: Record +} diff --git a/src/shared/transport/events/index.ts b/src/shared/transport/events/index.ts index ad60cafc4..5877ec010 100644 --- a/src/shared/transport/events/index.ts +++ b/src/shared/transport/events/index.ts @@ -3,6 +3,7 @@ export * from '../types/dto.js' // Event constants and types export * from './agent-events.js' +export * from './analytics-events.js' export * from './auth-events.js' export * from './client-events.js' export * from './config-events.js' @@ -30,6 +31,7 @@ export * from './worktree-events.js' // Utility exports import {AgentEvents} from './agent-events.js' +import {AnalyticsEvents} from './analytics-events.js' import {AuthEvents} from './auth-events.js' import {ClientEvents} from './client-events.js' import {ConfigEvents} from './config-events.js' @@ -61,6 +63,7 @@ import {WorktreeEvents} from './worktree-events.js' */ export const AllEventGroups = [ AgentEvents, + AnalyticsEvents, AuthEvents, ClientEvents, ConfigEvents, diff --git a/test/integration/analytics/transport.test.ts b/test/integration/analytics/transport.test.ts new file mode 100644 index 000000000..4cabef5f4 --- /dev/null +++ b/test/integration/analytics/transport.test.ts @@ -0,0 +1,180 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' +import {existsSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {AuthToken} from '../../../src/server/core/domain/entities/auth-token.js' +import type {IAuthStateReader} from '../../../src/server/core/interfaces/analytics/i-identity-resolver.js' + +import {GlobalConfig} from '../../../src/server/core/domain/entities/global-config.js' +import {AnalyticsClient} from '../../../src/server/infra/analytics/analytics-client.js' +import {BoundedQueue} from '../../../src/server/infra/analytics/bounded-queue.js' +import {IdentityResolver} from '../../../src/server/infra/analytics/identity-resolver.js' +import {SuperPropertiesResolver} from '../../../src/server/infra/analytics/super-properties-resolver.js' +import {FileGlobalConfigStore} from '../../../src/server/infra/storage/file-global-config-store.js' +import {AnalyticsHandler} from '../../../src/server/infra/transport/handlers/analytics-handler.js' +import {GlobalConfigHandler} from '../../../src/server/infra/transport/handlers/global-config-handler.js' +import {AnalyticsEvents} from '../../../src/shared/transport/events/analytics-events.js' +import {createMockTransportServer} from '../../helpers/mock-factories.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +type AnalyticsTrackHandler = (data: unknown, clientId: string) => Promise + +async function waitForQueueSize(queue: BoundedQueue, expected: number, timeoutMs = 1000): Promise { + const start = Date.now() + while (queue.size() < expected) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitForQueueSize: expected ${expected}, got ${queue.size()} after ${timeoutMs}ms`) + } + + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setImmediate(resolve) + }) + } +} + +function makeAnonAuthReader(): IAuthStateReader { + const noToken: AuthToken | undefined = undefined + return {getToken: () => noToken} +} + +describe('analytics:track transport round-trip integration (M2.6)', () => { + let testDir: string + let testConfigPath: string + let store: FileGlobalConfigStore + + beforeEach(() => { + testDir = join(tmpdir(), `test-analytics-transport-${Date.now()}-${randomUUID().slice(0, 8)}`) + testConfigPath = join(testDir, 'config.json') + store = new FileGlobalConfigStore({ + getConfigDir: () => testDir, + getConfigPath: () => testConfigPath, + }) + }) + + afterEach(async () => { + if (existsSync(testDir)) { + await rm(testDir, {force: true, recursive: true}) + } + }) + + it('should land a client-emitted event in the daemon queue with full identity + super-properties', async () => { + // Pre-seed config so analytics is enabled and deviceId is stable. + const seeded = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) + if (!seeded) throw new Error('test fixture: seeded GlobalConfig must be valid') + await store.write(seeded) + + // Compose daemon dependencies the same way feature-handlers.ts does. + const transport = createMockTransportServer() + const globalConfigHandler = new GlobalConfigHandler({globalConfigStore: store, transport}) + globalConfigHandler.setup() + await globalConfigHandler.refreshCache() + + const queue = new BoundedQueue() + const analyticsClient = new AnalyticsClient({ + identityResolver: new IdentityResolver(makeAnonAuthReader(), store), + isEnabled: () => globalConfigHandler.getCachedAnalytics(), + queue, + superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), + }) + + new AnalyticsHandler({analyticsClient, transport}).setup() + + // Simulate what `emitAnalytics(client, 'cli_invocation', {command_id})` would + // deliver to the daemon's analytics:track handler. + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + expect(handler, 'analytics:track handler must be registered').to.exist + + await handler({event: 'cli_invocation', properties: {command_id: 'status'}}, 'client-1') + await waitForQueueSize(queue, 1) + + const batch = await analyticsClient.flush() + expect(batch.events).to.have.lengthOf(1) + const [event] = batch.events + + expect(event.name).to.equal('cli_invocation') + expect(event.identity).to.deep.equal({device_id: validDeviceId}) + + // User-supplied property preserved + expect(event.properties.command_id).to.equal('status') + + // All five super-properties stamped on receipt + expect(event.properties.cli_version).to.equal('3.10.3') + expect(event.properties.device_id).to.equal(validDeviceId) + expect(event.properties.environment).to.be.oneOf(['development', 'production']) + expect(event.properties.node_version).to.equal(process.version) + expect(event.properties.os).to.equal(process.platform) + }) + + it('should drop the event silently when analytics is disabled (default opt-in)', async () => { + const transport = createMockTransportServer() + const globalConfigHandler = new GlobalConfigHandler({globalConfigStore: store, transport}) + globalConfigHandler.setup() + await globalConfigHandler.refreshCache() + + const queue = new BoundedQueue() + const analyticsClient = new AnalyticsClient({ + identityResolver: new IdentityResolver(makeAnonAuthReader(), store), + isEnabled: () => globalConfigHandler.getCachedAnalytics(), + queue, + superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), + }) + + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + await handler({event: 'cli_invocation', properties: {command_id: 'status'}}, 'client-1') + // Two ticks suffice — the disabled path is sync inside track() and never schedules async work. + await new Promise((resolve) => { + setImmediate(resolve) + }) + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(queue.size()).to.equal(0) + }) + + it('should drop a malformed payload (empty event) without enqueueing', async () => { + const seeded = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) + if (!seeded) throw new Error('test fixture: seeded GlobalConfig must be valid') + await store.write(seeded) + + const transport = createMockTransportServer() + const globalConfigHandler = new GlobalConfigHandler({globalConfigStore: store, transport}) + globalConfigHandler.setup() + await globalConfigHandler.refreshCache() + + const queue = new BoundedQueue() + const analyticsClient = new AnalyticsClient({ + identityResolver: new IdentityResolver(makeAnonAuthReader(), store), + isEnabled: () => globalConfigHandler.getCachedAnalytics(), + queue, + superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), + }) + + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + + // Various malformed payloads + await handler({event: ''}, 'client-1') + await handler({properties: {x: 1}}, 'client-1') + await handler(null, 'client-1') + + // Drain — none should land + await new Promise((resolve) => { + setImmediate(resolve) + }) + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(queue.size()).to.equal(0) + }) +}) diff --git a/test/unit/server/core/domain/transport/analytics-track-schema.test.ts b/test/unit/server/core/domain/transport/analytics-track-schema.test.ts new file mode 100644 index 000000000..aeea12d14 --- /dev/null +++ b/test/unit/server/core/domain/transport/analytics-track-schema.test.ts @@ -0,0 +1,58 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {AnalyticsTrackPayloadSchema} from '../../../../../../src/server/core/domain/transport/schemas.js' + +describe('AnalyticsTrackPayloadSchema', () => { + describe('valid payloads', () => { + it('should accept {event} only', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({event: 'cli_invocation'}) + expect(result.success).to.equal(true) + }) + + it('should accept {event, properties}', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({ + event: 'cli_invocation', + properties: {command_id: 'status'}, + }) + expect(result.success).to.equal(true) + }) + + it('should accept empty properties object', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({event: 'e', properties: {}}) + expect(result.success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('should reject missing event', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({properties: {x: 1}}) + expect(result.success).to.equal(false) + }) + + it('should reject empty-string event', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({event: ''}) + expect(result.success).to.equal(false) + }) + + it('should reject non-string event', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({event: 42}) + expect(result.success).to.equal(false) + }) + + it('should reject non-object properties', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({event: 'e', properties: 'oops'}) + expect(result.success).to.equal(false) + }) + + it('should reject array properties', () => { + const result = AnalyticsTrackPayloadSchema.safeParse({event: 'e', properties: [1, 2]}) + expect(result.success).to.equal(false) + }) + + it('should reject null payload', () => { + const result = AnalyticsTrackPayloadSchema.safeParse(null) + expect(result.success).to.equal(false) + }) + }) +}) diff --git a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts new file mode 100644 index 000000000..93d0d434f --- /dev/null +++ b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts @@ -0,0 +1,95 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {AnalyticsBatch} from '../../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsHandler} from '../../../../../../src/server/infra/transport/handlers/analytics-handler.js' +import {AnalyticsEvents, type AnalyticsTrackRequest} from '../../../../../../src/shared/transport/events/analytics-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +type AnalyticsTrackHandler = (data: unknown, clientId: string) => Promise + +function makeStubAnalyticsClient(): IAnalyticsClient { + return { + flush: stub().resolves(AnalyticsBatch.create([])), + track: stub(), + } +} + +describe('AnalyticsHandler', () => { + it('should register a handler for analytics:track on setup()', () => { + const transport = createMockTransportServer() + const analyticsClient = makeStubAnalyticsClient() + + new AnalyticsHandler({analyticsClient, transport}).setup() + + expect(transport._handlers.has(AnalyticsEvents.TRACK)).to.equal(true) + }) + + it('should route a valid payload to analyticsClient.track with matching args', async () => { + const transport = createMockTransportServer() + const analyticsClient = makeStubAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + const payload: AnalyticsTrackRequest = {event: 'cli_invocation', properties: {command_id: 'status'}} + await handler(payload, 'client-1') + + const trackStub = analyticsClient.track as ReturnType + expect(trackStub.calledOnce).to.equal(true) + expect(trackStub.firstCall.args[0]).to.equal('cli_invocation') + expect(trackStub.firstCall.args[1]).to.deep.equal({command_id: 'status'}) + }) + + it('should route a payload with no properties', async () => { + const transport = createMockTransportServer() + const analyticsClient = makeStubAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + await handler({event: 'no_props'}, 'client-1') + + const trackStub = analyticsClient.track as ReturnType + expect(trackStub.calledOnce).to.equal(true) + expect(trackStub.firstCall.args[0]).to.equal('no_props') + expect(trackStub.firstCall.args[1]).to.equal(undefined) + }) + + it('should drop invalid payload silently (no throw, no track call)', async () => { + const transport = createMockTransportServer() + const analyticsClient = makeStubAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + + await handler({event: ''}, 'client-1') + await handler({properties: {x: 1}}, 'client-1') + await handler({event: 42}, 'client-1') + await handler(null, 'client-1') + + const trackStub = analyticsClient.track as ReturnType + expect(trackStub.called, 'track must NOT be called for invalid payloads').to.equal(false) + }) + + it('should not throw when analyticsClient.track itself throws', async () => { + const transport = createMockTransportServer() + const analyticsClient: IAnalyticsClient = { + flush: stub().resolves(AnalyticsBatch.create([])), + track: stub().throws(new Error('boom')), + } + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + + let caught: unknown + try { + await handler({event: 'e'}, 'client-1') + } catch (error) { + caught = error + } + + expect(caught, 'handler must NOT propagate track() errors').to.equal(undefined) + }) +}) diff --git a/test/unit/shared/analytics/emit.test.ts b/test/unit/shared/analytics/emit.test.ts new file mode 100644 index 000000000..240880471 --- /dev/null +++ b/test/unit/shared/analytics/emit.test.ts @@ -0,0 +1,67 @@ +/* eslint-disable camelcase */ +import type {ITransportClient} from '@campfirein/brv-transport-client' + +import {expect} from 'chai' +import {stub} from 'sinon' + +import {emitAnalytics} from '../../../../src/shared/analytics/emit.js' +import {AnalyticsEvents} from '../../../../src/shared/transport/events/analytics-events.js' + +function makeStubClient(overrides: Partial = {}): ITransportClient { + return { + connect: stub(), + disconnect: stub(), + getClientId: stub(), + getState: stub(), + isConnected: stub().resolves(true), + joinRoom: stub(), + leaveRoom: stub(), + on: stub().returns(() => {}), + once: stub(), + onStateChange: stub().returns(() => {}), + request: stub(), + requestWithAck: stub(), + ...overrides, + } as unknown as ITransportClient +} + +describe('emitAnalytics', () => { + it('should call client.request with analytics:track and the expected payload', () => { + const client = makeStubClient() + + emitAnalytics(client, 'cli_invocation', {command_id: 'status'}) + + const requestStub = client.request as ReturnType + expect(requestStub.calledOnce).to.equal(true) + expect(requestStub.firstCall.args[0]).to.equal(AnalyticsEvents.TRACK) + expect(requestStub.firstCall.args[1]).to.deep.equal({event: 'cli_invocation', properties: {command_id: 'status'}}) + }) + + it('should send {event, properties: undefined} when no properties given', () => { + const client = makeStubClient() + + emitAnalytics(client, 'no_props') + + const requestStub = client.request as ReturnType + expect(requestStub.calledOnce).to.equal(true) + expect(requestStub.firstCall.args[1]).to.deep.equal({event: 'no_props', properties: undefined}) + }) + + it('should NOT throw when client.request throws (e.g. TransportNotConnectedError)', () => { + const client = makeStubClient({ + request: stub().throws(new Error('not connected')) as unknown as ITransportClient['request'], + }) + + expect(() => emitAnalytics(client, 'e1')).to.not.throw() + }) + + it('should emit exactly ONE event per call', () => { + const client = makeStubClient() + + emitAnalytics(client, 'a') + emitAnalytics(client, 'b') + + const requestStub = client.request as ReturnType + expect(requestStub.callCount).to.equal(2) + }) +}) From 34c8aa2dcd151fe82b30b6b01e35f82fd94551b1 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Fri, 8 May 2026 13:16:01 +0700 Subject: [PATCH 13/87] feat: [ENG-2686] add per-event analytics schema catalog (M2.8) Schema-only milestone. No emitter wiring, no daemon-side validation. Each shipped analytics event now has a TypeScript type plus a Zod .strict() runtime schema, and typed enums eliminate magic strings. Events covered (7): - daemon_start (M2.5): empty schema; super-properties cover cold-start - cli_invocation (M2.6): command_id, flag_names, is_tty, is_ci, terminal_program?, runtime, package_manager - mcp_session_start (M2.6): client_name - mcp_tool_called (M2.6): tool_name, client_name, success, duration_ms - task_created: task_type, task_id, has_files, has_folder - task_completed: task_type, task_id, duration_ms - task_failed: task_type, task_id, duration_ms Typed enums: - AnalyticsEventNames (7 entries): wire-format event names - TaskTypes (5 entries): mirrors daemon TaskInfo.type union - TASK_TYPE_VALUES tuple: single source of truth used by every task_* schema's z.enum(...) call Privacy fixture walks ALL_EVENT_SCHEMAS and rejects any field name on the forbidden PII list (cwd, argv, path, email, result, ...). Adding a banned field name later fails the build. Wire-shape source of truth: AnalyticsTrackPayloadSchema lives in shared/transport/events/analytics-events.ts (alongside the AnalyticsEvents constants). Both the daemon handler and the shared emitAnalytics helper consume the same Zod-derived AnalyticsTrackPayload type, so the wire shape can no longer drift between two declarations. The previous server-side duplicate has been removed. Drift detection: a new compile-time + runtime test asserts the shared TaskTypes enum stays in lock-step with the server-side TaskTypeSchema. If a daemon contributor adds a new TaskInfo.type value and forgets to mirror it, the build fails loudly instead of emitting events that silently fail wire-side validation. Magic-string cleanup: the only existing analytics emit site at brv-server.ts now passes AnalyticsEventNames.DAEMON_START instead of the raw string literal. Deliberate deviations from ticket text: 1. command_id is z.string() not a typed enum: oclif manifest is the source of truth (~80 commands), a hardcoded mirror would rot. 2. Emitter wiring (oclif init, MCP oninitialized, MCP wrapper) and TaskAnalyticsHook are deferred to a follow-up ticket; this milestone ships schema declarations only. 3. task_failed has no error_class/error_code: that would require a breaking ITaskLifecycleHook.onTaskError signature change which belongs in its own ticket. 29 files changed, 0 files modified outside this milestone's scope. Tests: 7500 passing (+74 new under shared/analytics/ + drift + payload schema). Lint, typecheck, build all green. --- src/server/core/domain/transport/schemas.ts | 20 +---- src/server/infra/daemon/brv-server.ts | 3 +- .../transport/handlers/analytics-handler.ts | 9 +- src/shared/analytics/emit.ts | 4 +- src/shared/analytics/event-names.ts | 24 ++++++ src/shared/analytics/events/cli-invocation.ts | 27 ++++++ src/shared/analytics/events/daemon-start.ts | 14 ++++ src/shared/analytics/events/index.ts | 43 ++++++++++ .../analytics/events/mcp-session-start.ts | 18 ++++ .../analytics/events/mcp-tool-called.ts | 20 +++++ src/shared/analytics/events/task-completed.ts | 21 +++++ src/shared/analytics/events/task-created.ts | 25 ++++++ src/shared/analytics/events/task-failed.ts | 25 ++++++ src/shared/analytics/task-types.ts | 33 ++++++++ .../transport/events/analytics-events.ts | 21 ++++- .../transport/analytics-track-schema.test.ts | 2 +- .../domain/transport/task-types-drift.test.ts | 33 ++++++++ .../handlers/analytics-handler.test.ts | 4 +- .../unit/shared/analytics/event-names.test.ts | 33 ++++++++ .../analytics/events/cli-invocation.test.ts | 83 +++++++++++++++++++ .../analytics/events/daemon-start.test.ts | 27 ++++++ .../events/mcp-session-start.test.ts | 30 +++++++ .../analytics/events/mcp-tool-called.test.ts | 63 ++++++++++++++ .../analytics/events/task-completed.test.ts | 46 ++++++++++ .../analytics/events/task-created.test.ts | 53 ++++++++++++ .../analytics/events/task-failed.test.ts | 46 ++++++++++ .../shared/analytics/privacy-fixture.test.ts | 66 +++++++++++++++ test/unit/shared/analytics/task-types.test.ts | 42 ++++++++++ 28 files changed, 806 insertions(+), 29 deletions(-) create mode 100644 src/shared/analytics/event-names.ts create mode 100644 src/shared/analytics/events/cli-invocation.ts create mode 100644 src/shared/analytics/events/daemon-start.ts create mode 100644 src/shared/analytics/events/index.ts create mode 100644 src/shared/analytics/events/mcp-session-start.ts create mode 100644 src/shared/analytics/events/mcp-tool-called.ts create mode 100644 src/shared/analytics/events/task-completed.ts create mode 100644 src/shared/analytics/events/task-created.ts create mode 100644 src/shared/analytics/events/task-failed.ts create mode 100644 src/shared/analytics/task-types.ts create mode 100644 test/unit/server/core/domain/transport/task-types-drift.test.ts create mode 100644 test/unit/shared/analytics/event-names.test.ts create mode 100644 test/unit/shared/analytics/events/cli-invocation.test.ts create mode 100644 test/unit/shared/analytics/events/daemon-start.test.ts create mode 100644 test/unit/shared/analytics/events/mcp-session-start.test.ts create mode 100644 test/unit/shared/analytics/events/mcp-tool-called.test.ts create mode 100644 test/unit/shared/analytics/events/task-completed.test.ts create mode 100644 test/unit/shared/analytics/events/task-created.test.ts create mode 100644 test/unit/shared/analytics/events/task-failed.test.ts create mode 100644 test/unit/shared/analytics/privacy-fixture.test.ts create mode 100644 test/unit/shared/analytics/task-types.test.ts diff --git a/src/server/core/domain/transport/schemas.ts b/src/server/core/domain/transport/schemas.ts index eab923f4f..2709df818 100644 --- a/src/server/core/domain/transport/schemas.ts +++ b/src/server/core/domain/transport/schemas.ts @@ -958,19 +958,7 @@ export type AgentRestartResponse = z.infer export type AgentNewSessionRequest = z.infer export type AgentNewSessionResponse = z.infer -// ============================================================================ -// Analytics Events (analytics:track) -// ============================================================================ - -/** - * Wire-level validation for analytics:track payloads. Identity and super - * properties are stamped daemon-side on receipt; per-event property - * schemas (cli_invocation, mcp_tool_called, …) are designed in M2.8. - * The handler validates only the wire shape here. - */ -export const AnalyticsTrackPayloadSchema = z.object({ - event: z.string().min(1), - properties: z.record(z.string(), z.unknown()).optional(), -}) - -export type AnalyticsTrackPayload = z.infer +// Analytics wire shape moved to src/shared/transport/events/analytics-events.ts +// so both shared callers (emitAnalytics) and the daemon handler reference a +// single Zod-derived type. Keeping a server-side duplicate caused a drift +// risk between the manual interface and the schema-derived type. diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index 2af959899..4990733b9 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -32,6 +32,7 @@ import {fileURLToPath} from 'node:url' import type {BrvConfig} from '../../core/domain/entities/brv-config.js' +import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' import {ReviewEvents} from '../../../shared/transport/events/review-events.js' import { AGENT_IDLE_CHECK_INTERVAL_MS, @@ -653,7 +654,7 @@ async function main(): Promise { // Fire `daemon_start` AFTER loadToken() so IdentityResolver sees the real // auth state. Doing it inside setupFeatureHandlers (before loadToken) would // stamp every daemon_start anonymously even for logged-in users. - analyticsClient.track('daemon_start') + analyticsClient.track(AnalyticsEventNames.DAEMON_START) // 11. Start idle timer + register signal handlers idleTimeoutPolicy.start() diff --git a/src/server/infra/transport/handlers/analytics-handler.ts b/src/server/infra/transport/handlers/analytics-handler.ts index 648dc79a2..542a8079a 100644 --- a/src/server/infra/transport/handlers/analytics-handler.ts +++ b/src/server/infra/transport/handlers/analytics-handler.ts @@ -1,8 +1,11 @@ import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' -import {AnalyticsEvents, type AnalyticsTrackRequest} from '../../../../shared/transport/events/analytics-events.js' -import {AnalyticsTrackPayloadSchema} from '../../../core/domain/transport/schemas.js' +import { + AnalyticsEvents, + type AnalyticsTrackPayload, + AnalyticsTrackPayloadSchema, +} from '../../../../shared/transport/events/analytics-events.js' export interface AnalyticsHandlerDeps { analyticsClient: IAnalyticsClient @@ -31,7 +34,7 @@ export class AnalyticsHandler { } public setup(): void { - this.transport.onRequest(AnalyticsEvents.TRACK, async (data: unknown) => { + this.transport.onRequest(AnalyticsEvents.TRACK, async (data: unknown) => { const parsed = AnalyticsTrackPayloadSchema.safeParse(data) if (!parsed.success) return diff --git a/src/shared/analytics/emit.ts b/src/shared/analytics/emit.ts index 69ce6e22f..a7e9914eb 100644 --- a/src/shared/analytics/emit.ts +++ b/src/shared/analytics/emit.ts @@ -1,6 +1,6 @@ import type {ITransportClient} from '@campfirein/brv-transport-client' -import {AnalyticsEvents, type AnalyticsTrackRequest} from '../transport/events/analytics-events.js' +import {AnalyticsEvents, type AnalyticsTrackPayload} from '../transport/events/analytics-events.js' /** * Fire-and-forget analytics emission for non-forked daemon clients @@ -15,7 +15,7 @@ export function emitAnalytics( event: string, properties?: Record, ): void { - const payload: AnalyticsTrackRequest = {event, properties} + const payload: AnalyticsTrackPayload = {event, properties} try { client.request(AnalyticsEvents.TRACK, payload) } catch { diff --git a/src/shared/analytics/event-names.ts b/src/shared/analytics/event-names.ts new file mode 100644 index 000000000..086f1b76c --- /dev/null +++ b/src/shared/analytics/event-names.ts @@ -0,0 +1,24 @@ + + +/** + * Canonical wire-format names for every analytics event the daemon may emit. + * + * These are the values that travel as `event.name` in the analytics batch + * (see `AnalyticsBatch` in server/core/domain/analytics/batch.ts). + * + * Snake_case values per the analytics spec; the keys are SCREAMING_SNAKE for + * use as in-source constants. Adding a new event REQUIRES adding both: + * 1. A new entry here. + * 2. A new schema file in ./events/ and registration in ./events/index.ts. + */ +export const AnalyticsEventNames = { + CLI_INVOCATION: 'cli_invocation', + DAEMON_START: 'daemon_start', + MCP_SESSION_START: 'mcp_session_start', + MCP_TOOL_CALLED: 'mcp_tool_called', + TASK_COMPLETED: 'task_completed', + TASK_CREATED: 'task_created', + TASK_FAILED: 'task_failed', +} as const + +export type AnalyticsEventName = (typeof AnalyticsEventNames)[keyof typeof AnalyticsEventNames] diff --git a/src/shared/analytics/events/cli-invocation.ts b/src/shared/analytics/events/cli-invocation.ts new file mode 100644 index 000000000..62efc9874 --- /dev/null +++ b/src/shared/analytics/events/cli-invocation.ts @@ -0,0 +1,27 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `cli_invocation`. + * + * Every field is a user CHOICE (env, runtime, flag NAMES) — flag VALUES are + * never captured because they may carry file paths, query text, or secrets. + * + * `command_id` is the oclif command identifier (e.g. "vc:add", "query", + * "curate:learn"). It is intentionally typed as a free string here: + * the oclif manifest is the source of truth and changes per release; + * mirroring the full ~80-entry list in TypeScript would rot quickly. + */ +export const CliInvocationSchema = z + .object({ + command_id: z.string().min(1), + flag_names: z.array(z.string()), + is_ci: z.boolean(), + is_tty: z.boolean(), + package_manager: z.enum(['npm', 'yarn', 'pnpm', 'bun', 'unknown']), + runtime: z.enum(['node', 'bun']), + terminal_program: z.string().min(1).optional(), + }) + .strict() + +export type CliInvocationProps = z.infer diff --git a/src/shared/analytics/events/daemon-start.ts b/src/shared/analytics/events/daemon-start.ts new file mode 100644 index 000000000..f212e63da --- /dev/null +++ b/src/shared/analytics/events/daemon-start.ts @@ -0,0 +1,14 @@ + +import {z} from 'zod' + +/** + * Per-event schema for `daemon_start`. + * + * No properties: every cold-start dimension worth tracking is already + * stamped as a super property on every event (cli_version, os, + * node_version, environment, device_id) by the SuperPropertiesResolver. + * Strict mode rejects accidental property bleed. + */ +export const DaemonStartSchema = z.object({}).strict() + +export type DaemonStartProps = z.infer diff --git a/src/shared/analytics/events/index.ts b/src/shared/analytics/events/index.ts new file mode 100644 index 000000000..948fc4ab5 --- /dev/null +++ b/src/shared/analytics/events/index.ts @@ -0,0 +1,43 @@ +import {AnalyticsEventNames} from '../event-names.js' +import {type CliInvocationProps, CliInvocationSchema} from './cli-invocation.js' +import {type DaemonStartProps, DaemonStartSchema} from './daemon-start.js' +import {type McpSessionStartProps, McpSessionStartSchema} from './mcp-session-start.js' +import {type McpToolCalledProps, McpToolCalledSchema} from './mcp-tool-called.js' +import {type TaskCompletedProps, TaskCompletedSchema} from './task-completed.js' +import {type TaskCreatedProps, TaskCreatedSchema} from './task-created.js' +import {type TaskFailedProps, TaskFailedSchema} from './task-failed.js' + +/** + * Registry of every shipped event schema, keyed by wire name. Used by: + * - The privacy fixture, which walks every entry and asserts no field + * name appears on the forbidden PII list. + * - Future per-event validation at emit time. + * + * Direct schema/type imports go through the per-event files + * (./cli-invocation.js, ./daemon-start.js, …). This module deliberately + * exports only the aggregate registry and the discriminated union, so it + * never duplicates per-event re-exports. + */ +export const ALL_EVENT_SCHEMAS = { + [AnalyticsEventNames.CLI_INVOCATION]: CliInvocationSchema, + [AnalyticsEventNames.DAEMON_START]: DaemonStartSchema, + [AnalyticsEventNames.MCP_SESSION_START]: McpSessionStartSchema, + [AnalyticsEventNames.MCP_TOOL_CALLED]: McpToolCalledSchema, + [AnalyticsEventNames.TASK_COMPLETED]: TaskCompletedSchema, + [AnalyticsEventNames.TASK_CREATED]: TaskCreatedSchema, + [AnalyticsEventNames.TASK_FAILED]: TaskFailedSchema, +} as const + +/** + * Discriminated union over every event in the catalog. A consumer can + * destructure {name, properties} and TypeScript will narrow `properties` + * against the matching per-event type. + */ +export type AnyAnalyticsEvent = + | {name: typeof AnalyticsEventNames.CLI_INVOCATION; properties: CliInvocationProps} + | {name: typeof AnalyticsEventNames.DAEMON_START; properties: DaemonStartProps} + | {name: typeof AnalyticsEventNames.MCP_SESSION_START; properties: McpSessionStartProps} + | {name: typeof AnalyticsEventNames.MCP_TOOL_CALLED; properties: McpToolCalledProps} + | {name: typeof AnalyticsEventNames.TASK_COMPLETED; properties: TaskCompletedProps} + | {name: typeof AnalyticsEventNames.TASK_CREATED; properties: TaskCreatedProps} + | {name: typeof AnalyticsEventNames.TASK_FAILED; properties: TaskFailedProps} diff --git a/src/shared/analytics/events/mcp-session-start.ts b/src/shared/analytics/events/mcp-session-start.ts new file mode 100644 index 000000000..cda85a98a --- /dev/null +++ b/src/shared/analytics/events/mcp-session-start.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `mcp_session_start`. + * + * `client_name` is the IDE's self-reported product name (e.g. "Cursor", + * "Claude Code"), captured via the MCP `oninitialized` handshake. It is + * never a person's name; the field is named for the MCP client identity, + * not user identity. + */ +export const McpSessionStartSchema = z + .object({ + client_name: z.string().min(1), + }) + .strict() + +export type McpSessionStartProps = z.infer diff --git a/src/shared/analytics/events/mcp-tool-called.ts b/src/shared/analytics/events/mcp-tool-called.ts new file mode 100644 index 000000000..6356d0338 --- /dev/null +++ b/src/shared/analytics/events/mcp-tool-called.ts @@ -0,0 +1,20 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `mcp_tool_called`. + * + * Captures the funnel for IDE-driven tool invocations (`brv-query`, + * `brv-curate`). User-supplied tool arguments (the query text, curate goal, + * file paths) are NEVER captured — only universal metadata. + */ +export const McpToolCalledSchema = z + .object({ + client_name: z.string().min(1), + duration_ms: z.number().int().nonnegative(), + success: z.boolean(), + tool_name: z.enum(['brv-query', 'brv-curate']), + }) + .strict() + +export type McpToolCalledProps = z.infer diff --git a/src/shared/analytics/events/task-completed.ts b/src/shared/analytics/events/task-completed.ts new file mode 100644 index 000000000..9aa1ac3e5 --- /dev/null +++ b/src/shared/analytics/events/task-completed.ts @@ -0,0 +1,21 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +import {TASK_TYPE_VALUES} from '../task-types.js' + +/** + * Per-event schema for `task_completed`. + * + * Successful task termination. The `result` payload (LLM output, search + * results, curated content) is NEVER captured here — it is forbidden by + * the privacy fixture. + */ +export const TaskCompletedSchema = z + .object({ + duration_ms: z.number().int().nonnegative(), + task_id: z.string().min(1), + task_type: z.enum(TASK_TYPE_VALUES), + }) + .strict() + +export type TaskCompletedProps = z.infer diff --git a/src/shared/analytics/events/task-created.ts b/src/shared/analytics/events/task-created.ts new file mode 100644 index 000000000..43c622ddf --- /dev/null +++ b/src/shared/analytics/events/task-created.ts @@ -0,0 +1,25 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +import {TASK_TYPE_VALUES} from '../task-types.js' + +/** + * Per-event schema for `task_created`. + * + * Funnel entry point. `has_files` / `has_folder` are booleans only — the + * actual paths are forbidden, never enter the analytics payload. + * + * `task_id` is an internal UUID generated by the daemon; it is not user PII + * and exists solely to correlate `task_created` with later + * `task_completed` / `task_failed` events. + */ +export const TaskCreatedSchema = z + .object({ + has_files: z.boolean(), + has_folder: z.boolean(), + task_id: z.string().min(1), + task_type: z.enum(TASK_TYPE_VALUES), + }) + .strict() + +export type TaskCreatedProps = z.infer diff --git a/src/shared/analytics/events/task-failed.ts b/src/shared/analytics/events/task-failed.ts new file mode 100644 index 000000000..a48b823ca --- /dev/null +++ b/src/shared/analytics/events/task-failed.ts @@ -0,0 +1,25 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +import {TASK_TYPE_VALUES} from '../task-types.js' + +/** + * Per-event schema for `task_failed`. + * + * Error path. The error message and stack trace are intentionally NOT + * captured here: they may contain file paths, secrets, or user content. + * Strict mode rejects any attempt to add `error_message` / `stack` later. + * + * Adding `error_class` / `error_code` would require extending + * `ITaskLifecycleHook.onTaskError` to deliver the structured error object, + * which is a separate ticket. + */ +export const TaskFailedSchema = z + .object({ + duration_ms: z.number().int().nonnegative(), + task_id: z.string().min(1), + task_type: z.enum(TASK_TYPE_VALUES), + }) + .strict() + +export type TaskFailedProps = z.infer diff --git a/src/shared/analytics/task-types.ts b/src/shared/analytics/task-types.ts new file mode 100644 index 000000000..d14e60186 --- /dev/null +++ b/src/shared/analytics/task-types.ts @@ -0,0 +1,33 @@ + + +/** + * Canonical wire-format values for `task_type` on task_* analytics events. + * Mirrors the daemon's `TaskInfo.type` union (see + * server/core/domain/transport/task-info.ts). + * + * Adding a new daemon task type REQUIRES adding it here so per-event schemas + * accept it; otherwise the analytics hook will silently emit an event that + * fails wire-side validation. + */ +export const TaskTypes = { + CURATE: 'curate', + CURATE_FOLDER: 'curate-folder', + DREAM: 'dream', + QUERY: 'query', + SEARCH: 'search', +} as const + +export type TaskType = (typeof TaskTypes)[keyof typeof TaskTypes] + +/** + * Tuple form of TaskTypes used as a runtime list (e.g. `z.enum(TASK_TYPE_VALUES)`). + * Single source of truth: per-event schemas import this instead of redeclaring + * the literal array, so adding a new daemon task type is a one-place change. + */ +export const TASK_TYPE_VALUES = [ + TaskTypes.CURATE, + TaskTypes.CURATE_FOLDER, + TaskTypes.DREAM, + TaskTypes.QUERY, + TaskTypes.SEARCH, +] as const diff --git a/src/shared/transport/events/analytics-events.ts b/src/shared/transport/events/analytics-events.ts index 1e306d202..e810ecae9 100644 --- a/src/shared/transport/events/analytics-events.ts +++ b/src/shared/transport/events/analytics-events.ts @@ -1,8 +1,21 @@ +import {z} from 'zod' + export const AnalyticsEvents = { TRACK: 'analytics:track', } as const -export interface AnalyticsTrackRequest { - readonly event: string - readonly properties?: Record -} +/** + * Wire-level validation for `analytics:track` payloads. Identity and super + * properties are stamped daemon-side on receipt; per-event property schemas + * (cli_invocation, mcp_tool_called, ...) are designed in M2.8. + * + * Single source of truth for the wire shape: callers (emitAnalytics) and the + * daemon handler (AnalyticsHandler) both use the inferred type so they cannot + * drift independently. + */ +export const AnalyticsTrackPayloadSchema = z.object({ + event: z.string().min(1), + properties: z.record(z.string(), z.unknown()).optional(), +}) + +export type AnalyticsTrackPayload = z.infer diff --git a/test/unit/server/core/domain/transport/analytics-track-schema.test.ts b/test/unit/server/core/domain/transport/analytics-track-schema.test.ts index aeea12d14..a941f7a1c 100644 --- a/test/unit/server/core/domain/transport/analytics-track-schema.test.ts +++ b/test/unit/server/core/domain/transport/analytics-track-schema.test.ts @@ -1,7 +1,7 @@ /* eslint-disable camelcase */ import {expect} from 'chai' -import {AnalyticsTrackPayloadSchema} from '../../../../../../src/server/core/domain/transport/schemas.js' +import {AnalyticsTrackPayloadSchema} from '../../../../../../src/shared/transport/events/analytics-events.js' describe('AnalyticsTrackPayloadSchema', () => { describe('valid payloads', () => { diff --git a/test/unit/server/core/domain/transport/task-types-drift.test.ts b/test/unit/server/core/domain/transport/task-types-drift.test.ts new file mode 100644 index 000000000..7ef7a187d --- /dev/null +++ b/test/unit/server/core/domain/transport/task-types-drift.test.ts @@ -0,0 +1,33 @@ + +import {expect} from 'chai' + +import {type TaskType as ServerTaskType, TaskTypeSchema} from '../../../../../../src/server/core/domain/transport/schemas.js' +import {type TaskType as SharedTaskType, TASK_TYPE_VALUES, TaskTypes} from '../../../../../../src/shared/analytics/task-types.js' + +/** + * Compile-time bidirectional check: if either side drifts (a daemon contributor + * adds a new value to the server-side TaskTypeSchema, or a refactor removes one + * from the shared TaskTypes enum), one of the assertions below fails to type-check. + * + * Without this, M2.8's task_* event schemas would silently reject the new task + * type at emit time, and the failure would only surface as missing analytics — + * not a build error. + */ +type _AssertSharedExtendsServer = SharedTaskType extends ServerTaskType ? true : never +type _AssertServerExtendsShared = ServerTaskType extends SharedTaskType ? true : never + +const _bothDirections: [_AssertSharedExtendsServer, _AssertServerExtendsShared] = [true, true] + +describe('TaskType ↔ TaskTypes drift detection', () => { + it('should mention the compile-time guard so the file is not pruned', () => { + expect(_bothDirections).to.deep.equal([true, true]) + }) + + it('should agree at runtime: TaskTypeSchema.options matches Object.values(TaskTypes)', () => { + expect([...TaskTypeSchema.options].sort()).to.deep.equal(Object.values(TaskTypes).sort()) + }) + + it('should agree at runtime: TASK_TYPE_VALUES matches TaskTypeSchema.options', () => { + expect([...TASK_TYPE_VALUES].sort()).to.deep.equal([...TaskTypeSchema.options].sort()) + }) +}) diff --git a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts index 93d0d434f..15ccc7f3b 100644 --- a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts @@ -6,7 +6,7 @@ import type {IAnalyticsClient} from '../../../../../../src/server/core/interface import {AnalyticsBatch} from '../../../../../../src/server/core/domain/analytics/batch.js' import {AnalyticsHandler} from '../../../../../../src/server/infra/transport/handlers/analytics-handler.js' -import {AnalyticsEvents, type AnalyticsTrackRequest} from '../../../../../../src/shared/transport/events/analytics-events.js' +import {AnalyticsEvents, type AnalyticsTrackPayload} from '../../../../../../src/shared/transport/events/analytics-events.js' import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' type AnalyticsTrackHandler = (data: unknown, clientId: string) => Promise @@ -34,7 +34,7 @@ describe('AnalyticsHandler', () => { new AnalyticsHandler({analyticsClient, transport}).setup() const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler - const payload: AnalyticsTrackRequest = {event: 'cli_invocation', properties: {command_id: 'status'}} + const payload: AnalyticsTrackPayload = {event: 'cli_invocation', properties: {command_id: 'status'}} await handler(payload, 'client-1') const trackStub = analyticsClient.track as ReturnType diff --git a/test/unit/shared/analytics/event-names.test.ts b/test/unit/shared/analytics/event-names.test.ts new file mode 100644 index 000000000..bdb990dac --- /dev/null +++ b/test/unit/shared/analytics/event-names.test.ts @@ -0,0 +1,33 @@ + +import {expect} from 'chai' + +import {type AnalyticsEventName, AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' + +describe('AnalyticsEventNames', () => { + it('should expose exactly the seven shipped event names', () => { + expect(Object.keys(AnalyticsEventNames).sort()).to.deep.equal([ + 'CLI_INVOCATION', + 'DAEMON_START', + 'MCP_SESSION_START', + 'MCP_TOOL_CALLED', + 'TASK_COMPLETED', + 'TASK_CREATED', + 'TASK_FAILED', + ]) + }) + + it('should map each key to a snake_case wire string', () => { + expect(AnalyticsEventNames.DAEMON_START).to.equal('daemon_start') + expect(AnalyticsEventNames.CLI_INVOCATION).to.equal('cli_invocation') + expect(AnalyticsEventNames.MCP_SESSION_START).to.equal('mcp_session_start') + expect(AnalyticsEventNames.MCP_TOOL_CALLED).to.equal('mcp_tool_called') + expect(AnalyticsEventNames.TASK_CREATED).to.equal('task_created') + expect(AnalyticsEventNames.TASK_COMPLETED).to.equal('task_completed') + expect(AnalyticsEventNames.TASK_FAILED).to.equal('task_failed') + }) + + it('should expose AnalyticsEventName as the union of values', () => { + const sample: AnalyticsEventName = AnalyticsEventNames.DAEMON_START + expect(sample).to.equal('daemon_start') + }) +}) diff --git a/test/unit/shared/analytics/events/cli-invocation.test.ts b/test/unit/shared/analytics/events/cli-invocation.test.ts new file mode 100644 index 000000000..3731c9b7d --- /dev/null +++ b/test/unit/shared/analytics/events/cli-invocation.test.ts @@ -0,0 +1,83 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {CliInvocationSchema} from '../../../../../src/shared/analytics/events/cli-invocation.js' + +const baseValid = { + command_id: 'vc:add', + flag_names: ['--detach'], + is_ci: false, + is_tty: true, + package_manager: 'npm' as const, + runtime: 'node' as const, +} + +describe('CliInvocationSchema', () => { + describe('valid payloads', () => { + it('should accept all required fields without terminal_program', () => { + expect(CliInvocationSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('should accept terminal_program as a non-empty string', () => { + expect(CliInvocationSchema.safeParse({...baseValid, terminal_program: 'iTerm.app'}).success).to.equal(true) + }) + + it('should accept empty flag_names array', () => { + expect(CliInvocationSchema.safeParse({...baseValid, flag_names: []}).success).to.equal(true) + }) + + it('should accept runtime "bun"', () => { + expect(CliInvocationSchema.safeParse({...baseValid, runtime: 'bun'}).success).to.equal(true) + }) + + it('should accept all package_manager values', () => { + for (const pm of ['npm', 'yarn', 'pnpm', 'bun', 'unknown']) { + expect(CliInvocationSchema.safeParse({...baseValid, package_manager: pm}).success).to.equal(true) + } + }) + }) + + describe('invalid payloads', () => { + it('should reject empty command_id', () => { + expect(CliInvocationSchema.safeParse({...baseValid, command_id: ''}).success).to.equal(false) + }) + + it('should reject non-string command_id', () => { + expect(CliInvocationSchema.safeParse({...baseValid, command_id: 42}).success).to.equal(false) + }) + + it('should reject non-array flag_names', () => { + expect(CliInvocationSchema.safeParse({...baseValid, flag_names: 'oops'}).success).to.equal(false) + }) + + it('should reject non-boolean is_tty', () => { + expect(CliInvocationSchema.safeParse({...baseValid, is_tty: 'yes'}).success).to.equal(false) + }) + + it('should reject non-boolean is_ci', () => { + expect(CliInvocationSchema.safeParse({...baseValid, is_ci: 'no'}).success).to.equal(false) + }) + + it('should reject unknown runtime values', () => { + expect(CliInvocationSchema.safeParse({...baseValid, runtime: 'deno'}).success).to.equal(false) + }) + + it('should reject unknown package_manager values', () => { + expect(CliInvocationSchema.safeParse({...baseValid, package_manager: 'homebrew'}).success).to.equal(false) + }) + + it('should reject empty terminal_program when present', () => { + expect(CliInvocationSchema.safeParse({...baseValid, terminal_program: ''}).success).to.equal(false) + }) + + it('should reject unknown extra fields (strict)', () => { + expect(CliInvocationSchema.safeParse({...baseValid, sneaky: 'leak'}).success).to.equal(false) + }) + + it('should reject missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {command_id: _, ...withoutCommandId} = baseValid + expect(CliInvocationSchema.safeParse(withoutCommandId).success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/events/daemon-start.test.ts b/test/unit/shared/analytics/events/daemon-start.test.ts new file mode 100644 index 000000000..1c00e158b --- /dev/null +++ b/test/unit/shared/analytics/events/daemon-start.test.ts @@ -0,0 +1,27 @@ + +import {expect} from 'chai' + +import {DaemonStartSchema} from '../../../../../src/shared/analytics/events/daemon-start.js' + +describe('DaemonStartSchema', () => { + it('should accept an empty object', () => { + const result = DaemonStartSchema.safeParse({}) + expect(result.success).to.equal(true) + }) + + it('should reject unknown fields (strict)', () => { + const result = DaemonStartSchema.safeParse({extra: 'nope'}) + expect(result.success).to.equal(false) + }) + + it('should reject null', () => { + const result = DaemonStartSchema.safeParse(null) + expect(result.success).to.equal(false) + }) + + it('should reject non-object payloads', () => { + expect(DaemonStartSchema.safeParse('hi').success).to.equal(false) + expect(DaemonStartSchema.safeParse(42).success).to.equal(false) + expect(DaemonStartSchema.safeParse([]).success).to.equal(false) + }) +}) diff --git a/test/unit/shared/analytics/events/mcp-session-start.test.ts b/test/unit/shared/analytics/events/mcp-session-start.test.ts new file mode 100644 index 000000000..87d13705f --- /dev/null +++ b/test/unit/shared/analytics/events/mcp-session-start.test.ts @@ -0,0 +1,30 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {McpSessionStartSchema} from '../../../../../src/shared/analytics/events/mcp-session-start.js' + +describe('McpSessionStartSchema', () => { + it('should accept a valid client_name', () => { + expect(McpSessionStartSchema.safeParse({client_name: 'Cursor'}).success).to.equal(true) + }) + + it('should reject empty client_name', () => { + expect(McpSessionStartSchema.safeParse({client_name: ''}).success).to.equal(false) + }) + + it('should reject missing client_name', () => { + expect(McpSessionStartSchema.safeParse({}).success).to.equal(false) + }) + + it('should reject non-string client_name', () => { + expect(McpSessionStartSchema.safeParse({client_name: 42}).success).to.equal(false) + }) + + it('should reject unknown extra fields (strict)', () => { + expect(McpSessionStartSchema.safeParse({client_name: 'Cursor', client_version: '1.0.0'}).success).to.equal(false) + }) + + it('should reject null', () => { + expect(McpSessionStartSchema.safeParse(null).success).to.equal(false) + }) +}) diff --git a/test/unit/shared/analytics/events/mcp-tool-called.test.ts b/test/unit/shared/analytics/events/mcp-tool-called.test.ts new file mode 100644 index 000000000..978f6cba2 --- /dev/null +++ b/test/unit/shared/analytics/events/mcp-tool-called.test.ts @@ -0,0 +1,63 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {McpToolCalledSchema} from '../../../../../src/shared/analytics/events/mcp-tool-called.js' + +const baseValid = { + client_name: 'Cursor', + duration_ms: 123, + success: true, + tool_name: 'brv-query' as const, +} + +describe('McpToolCalledSchema', () => { + describe('valid payloads', () => { + it('should accept tool_name="brv-query"', () => { + expect(McpToolCalledSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('should accept tool_name="brv-curate"', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, tool_name: 'brv-curate'}).success).to.equal(true) + }) + + it('should accept success=false', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, success: false}).success).to.equal(true) + }) + + it('should accept duration_ms=0', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, duration_ms: 0}).success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('should reject unknown tool_name', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, tool_name: 'mystery-tool'}).success).to.equal(false) + }) + + it('should reject empty client_name', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, client_name: ''}).success).to.equal(false) + }) + + it('should reject negative duration_ms', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, duration_ms: -1}).success).to.equal(false) + }) + + it('should reject non-integer duration_ms', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, duration_ms: 1.5}).success).to.equal(false) + }) + + it('should reject non-boolean success', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, success: 1}).success).to.equal(false) + }) + + it('should reject unknown extra fields (strict)', () => { + expect(McpToolCalledSchema.safeParse({...baseValid, error_class: 'TimeoutError'}).success).to.equal(false) + }) + + it('should reject missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {success: _, ...withoutSuccess} = baseValid + expect(McpToolCalledSchema.safeParse(withoutSuccess).success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/events/task-completed.test.ts b/test/unit/shared/analytics/events/task-completed.test.ts new file mode 100644 index 000000000..0a26154a1 --- /dev/null +++ b/test/unit/shared/analytics/events/task-completed.test.ts @@ -0,0 +1,46 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {TaskCompletedSchema} from '../../../../../src/shared/analytics/events/task-completed.js' + +const baseValid = { + duration_ms: 250, + task_id: '550e8400-e29b-41d4-a716-446655440000', + task_type: 'query' as const, +} + +describe('TaskCompletedSchema', () => { + it('should accept a valid payload', () => { + expect(TaskCompletedSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('should accept duration_ms=0', () => { + expect(TaskCompletedSchema.safeParse({...baseValid, duration_ms: 0}).success).to.equal(true) + }) + + it('should reject negative duration_ms', () => { + expect(TaskCompletedSchema.safeParse({...baseValid, duration_ms: -1}).success).to.equal(false) + }) + + it('should reject non-integer duration_ms', () => { + expect(TaskCompletedSchema.safeParse({...baseValid, duration_ms: 1.5}).success).to.equal(false) + }) + + it('should reject unknown task_type', () => { + expect(TaskCompletedSchema.safeParse({...baseValid, task_type: 'mystery'}).success).to.equal(false) + }) + + it('should reject empty task_id', () => { + expect(TaskCompletedSchema.safeParse({...baseValid, task_id: ''}).success).to.equal(false) + }) + + it('should reject unknown extra fields (strict)', () => { + expect(TaskCompletedSchema.safeParse({...baseValid, result: 'leaked output'}).success).to.equal(false) + }) + + it('should reject missing required field', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {duration_ms: _, ...withoutDuration} = baseValid + expect(TaskCompletedSchema.safeParse(withoutDuration).success).to.equal(false) + }) +}) diff --git a/test/unit/shared/analytics/events/task-created.test.ts b/test/unit/shared/analytics/events/task-created.test.ts new file mode 100644 index 000000000..88d31d461 --- /dev/null +++ b/test/unit/shared/analytics/events/task-created.test.ts @@ -0,0 +1,53 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {TaskCreatedSchema} from '../../../../../src/shared/analytics/events/task-created.js' + +const baseValid = { + has_files: false, + has_folder: false, + task_id: '550e8400-e29b-41d4-a716-446655440000', + task_type: 'curate' as const, +} + +describe('TaskCreatedSchema', () => { + describe('valid payloads', () => { + it('should accept all task_type values', () => { + for (const t of ['curate', 'curate-folder', 'query', 'search', 'dream']) { + expect(TaskCreatedSchema.safeParse({...baseValid, task_type: t}).success).to.equal(true) + } + }) + + it('should accept has_files=true and has_folder=true', () => { + expect(TaskCreatedSchema.safeParse({...baseValid, has_files: true, has_folder: true}).success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('should reject unknown task_type', () => { + expect(TaskCreatedSchema.safeParse({...baseValid, task_type: 'mystery'}).success).to.equal(false) + }) + + it('should reject empty task_id', () => { + expect(TaskCreatedSchema.safeParse({...baseValid, task_id: ''}).success).to.equal(false) + }) + + it('should reject non-boolean has_files', () => { + expect(TaskCreatedSchema.safeParse({...baseValid, has_files: 'yes'}).success).to.equal(false) + }) + + it('should reject non-boolean has_folder', () => { + expect(TaskCreatedSchema.safeParse({...baseValid, has_folder: 1}).success).to.equal(false) + }) + + it('should reject unknown extra fields (strict)', () => { + expect(TaskCreatedSchema.safeParse({...baseValid, file_path: '/leaked'}).success).to.equal(false) + }) + + it('should reject missing required field', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {task_id: _, ...withoutTaskId} = baseValid + expect(TaskCreatedSchema.safeParse(withoutTaskId).success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/events/task-failed.test.ts b/test/unit/shared/analytics/events/task-failed.test.ts new file mode 100644 index 000000000..0cf8e65bd --- /dev/null +++ b/test/unit/shared/analytics/events/task-failed.test.ts @@ -0,0 +1,46 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {TaskFailedSchema} from '../../../../../src/shared/analytics/events/task-failed.js' + +const baseValid = { + duration_ms: 9000, + task_id: '550e8400-e29b-41d4-a716-446655440000', + task_type: 'curate' as const, +} + +describe('TaskFailedSchema', () => { + it('should accept a valid payload', () => { + expect(TaskFailedSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('should accept duration_ms=0', () => { + expect(TaskFailedSchema.safeParse({...baseValid, duration_ms: 0}).success).to.equal(true) + }) + + it('should reject negative duration_ms', () => { + expect(TaskFailedSchema.safeParse({...baseValid, duration_ms: -1}).success).to.equal(false) + }) + + it('should reject unknown task_type', () => { + expect(TaskFailedSchema.safeParse({...baseValid, task_type: 'mystery'}).success).to.equal(false) + }) + + it('should reject empty task_id', () => { + expect(TaskFailedSchema.safeParse({...baseValid, task_id: ''}).success).to.equal(false) + }) + + it('should reject error_message field — privacy lock (strict)', () => { + expect(TaskFailedSchema.safeParse({...baseValid, error_message: 'EACCES /home/u/secret.txt'}).success).to.equal(false) + }) + + it('should reject unknown extra fields (strict)', () => { + expect(TaskFailedSchema.safeParse({...baseValid, stack: 'at Foo:bar'}).success).to.equal(false) + }) + + it('should reject missing required field', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {task_id: _, ...withoutTaskId} = baseValid + expect(TaskFailedSchema.safeParse(withoutTaskId).success).to.equal(false) + }) +}) diff --git a/test/unit/shared/analytics/privacy-fixture.test.ts b/test/unit/shared/analytics/privacy-fixture.test.ts new file mode 100644 index 000000000..65be7d78e --- /dev/null +++ b/test/unit/shared/analytics/privacy-fixture.test.ts @@ -0,0 +1,66 @@ + +import {expect} from 'chai' +import {z} from 'zod' + +import {ALL_EVENT_SCHEMAS} from '../../../../src/shared/analytics/events/index.js' + +const FORBIDDEN_FIELD_NAMES: ReadonlySet = new Set([ + 'argv', + 'content', + 'cwd', + 'email', + 'error_message', + 'file_path', + 'folder_path', + 'goal', + 'home_dir', + 'hostname', + 'ip', + 'mac', + 'output', + 'path', + 'project_path', + 'prompt', + 'query', + 'result', + 'stack', + 'worktree_root', +]) + +function getShapeFieldNames(schema: z.ZodTypeAny): string[] { + // Zod object schemas expose `.shape`. Strip ZodOptional / ZodDefault wrappers + // are irrelevant for field-name auditing; we only care about top-level keys. + if (schema instanceof z.ZodObject) { + return Object.keys(schema.shape as Record) + } + + return [] +} + +describe('analytics privacy fixture (smoke)', () => { + it('should not declare any field name on the forbidden PII list across all event schemas', () => { + const violations: Array<{eventName: string; field: string}> = [] + + for (const [eventName, schema] of Object.entries(ALL_EVENT_SCHEMAS)) { + for (const field of getShapeFieldNames(schema)) { + if (FORBIDDEN_FIELD_NAMES.has(field)) { + violations.push({eventName, field}) + } + } + } + + expect(violations, `forbidden PII fields detected: ${JSON.stringify(violations)}`).to.deep.equal([]) + }) + + it('should expose every shipped event name under ALL_EVENT_SCHEMAS', () => { + expect(Object.keys(ALL_EVENT_SCHEMAS).sort()).to.deep.equal([ + 'cli_invocation', + 'daemon_start', + 'mcp_session_start', + 'mcp_tool_called', + 'task_completed', + 'task_created', + 'task_failed', + ]) + }) +}) diff --git a/test/unit/shared/analytics/task-types.test.ts b/test/unit/shared/analytics/task-types.test.ts new file mode 100644 index 000000000..4126f1329 --- /dev/null +++ b/test/unit/shared/analytics/task-types.test.ts @@ -0,0 +1,42 @@ + +import {expect} from 'chai' + +import {TASK_TYPE_VALUES, type TaskType, TaskTypes} from '../../../../src/shared/analytics/task-types.js' + +describe('TaskTypes', () => { + it('should expose exactly the five daemon task types', () => { + expect(Object.keys(TaskTypes).sort()).to.deep.equal([ + 'CURATE', + 'CURATE_FOLDER', + 'DREAM', + 'QUERY', + 'SEARCH', + ]) + }) + + it('should map each key to the wire string used by the daemon TaskInfo.type', () => { + expect(TaskTypes.CURATE).to.equal('curate') + expect(TaskTypes.CURATE_FOLDER).to.equal('curate-folder') + expect(TaskTypes.QUERY).to.equal('query') + expect(TaskTypes.SEARCH).to.equal('search') + expect(TaskTypes.DREAM).to.equal('dream') + }) + + it('should expose TaskType as the union of values', () => { + const sample: TaskType = TaskTypes.CURATE + expect(sample).to.equal('curate') + }) + + describe('TASK_TYPE_VALUES', () => { + it('should contain every TaskTypes value exactly once', () => { + expect([...TASK_TYPE_VALUES].sort()).to.deep.equal(Object.values(TaskTypes).sort()) + }) + + it('should be a runtime tuple usable by z.enum', () => { + // Smoke check: TASK_TYPE_VALUES is intended as the source for + // `z.enum(TASK_TYPE_VALUES)` in per-event schemas. Length must be + // non-zero (zod rejects empty enum tuples). + expect(TASK_TYPE_VALUES.length).to.be.greaterThan(0) + }) + }) +}) From b69f2263b4f364692dcf53e72b82ca0fe7dd9c32 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Fri, 8 May 2026 14:00:57 +0700 Subject: [PATCH 14/87] feat: [ENG-2686] harden M2.8 catalog (Zod guards, typed emit, privacy depth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review-driven hardening fixes on top of the M2.8 catalog. Zero behavior changes for existing wire/runtime callers; all changes are type-tightening or test-quality improvements. NF2 — Typed `emitAnalytics`. The shared emit boundary now takes a generic `` and derives the properties type from the M2.8 discriminated union. Magic-string typos (`'daemon_starts'`) and wrong-shape payloads (`tool_name` on `cli_invocation`) become compile errors instead of runtime drops. The properties argument is optional only for events whose schema has no required keys (e.g. `daemon_start`); other events require a fully-shaped payload. NF5 — Replace batch.ts hand-rolled type guards with Zod. The three `as Record` casts (which violated CLAUDE.md "avoid `as Type` assertions") are gone, replaced by IdentityWireSchema / AnalyticsEventWithIdentityWireSchema / AnalyticsBatchJsonSchema. All 19 existing fromJson rejection tests still pass — Zod preserves the same edge cases (empty/whitespace device_id, missing schema_version, non-object properties, etc.) at ~half the line count. I5 — Privacy fixture walker recurses into nested ZodObject, ZodArray elements, and ZodOptional / ZodNullable wrappers. Three new regression tests verify the walker catches forbidden names (`email`, `password`, `token`, `api_key`) embedded inside nested, arrayed, and optional/nullable shapes. Today's schemas are flat, but the fixture now stays correct as future schemas grow. I6 — Extend FORBIDDEN_FIELD_NAMES from 20 to 41 entries: adds secrets (`access_token`, `auth_token`, `api_key`, `cookie`, `credential`, `password`, `secret`, `session_id`, `session_token`, `token`, `auth_header`), additional PII (`address`, `display_name`, `first_name`, `last_name`, `phone`, `phone_number`, `ssn`, `username`). The list now matches a realistic threat model for the analytics pipeline. NF7 — Already covered by existing test #6 in test/commands/analytics/enable.test.ts (retracted; my round-4 review hallucinated the gap because I read only the first 80 lines of the file). Tests: 7503 passing (+3 walker tests). Lint, typecheck, build all green. No production wiring changed; the only existing emit site (brv-server.ts) still calls `analyticsClient.track(...)` directly and is unaffected. --- src/server/core/domain/analytics/batch.ts | 72 ++++++++------- src/shared/analytics/emit.ts | 29 +++++- test/unit/shared/analytics/emit.test.ts | 38 ++++++-- .../shared/analytics/privacy-fixture.test.ts | 88 ++++++++++++++++++- 4 files changed, 174 insertions(+), 53 deletions(-) diff --git a/src/server/core/domain/analytics/batch.ts b/src/server/core/domain/analytics/batch.ts index d00ac0d67..4fea83b89 100644 --- a/src/server/core/domain/analytics/batch.ts +++ b/src/server/core/domain/analytics/batch.ts @@ -1,4 +1,6 @@ /* eslint-disable camelcase */ +import {z} from 'zod' + import type {AnalyticsEvent} from './event.js' import type {Identity} from './identity.js' @@ -17,41 +19,32 @@ export type AnalyticsBatchJson = Readonly<{ schema_version: 1 }> -const isIdentity = (value: unknown): value is Identity => { - if (typeof value !== 'object' || value === null) return false - const obj = value as Record - if (typeof obj.device_id !== 'string' || obj.device_id.trim().length === 0) { - return false - } - - if (obj.user_id !== undefined && typeof obj.user_id !== 'string') return false - if (obj.email !== undefined && typeof obj.email !== 'string') return false - if (obj.name !== undefined && typeof obj.name !== 'string') return false - - return true -} - -const isAnalyticsEventWithIdentity = (value: unknown): value is AnalyticsEventWithIdentity => { - if (typeof value !== 'object' || value === null) return false - const obj = value as Record - - if (typeof obj.name !== 'string') return false - if (typeof obj.timestamp !== 'number') return false - if (typeof obj.properties !== 'object' || obj.properties === null || Array.isArray(obj.properties)) return false - if (!isIdentity(obj.identity)) return false - - return true -} - -const isAnalyticsBatchJson = (json: unknown): json is AnalyticsBatchJson => { - if (typeof json !== 'object' || json === null || Array.isArray(json)) return false - const obj = json as Record - - if (obj.schema_version !== 1) return false - if (!Array.isArray(obj.events)) return false - - return obj.events.every((event) => isAnalyticsEventWithIdentity(event)) -} +/** + * Wire-validation Zod schemas. Used by `fromJson` to deserialize untrusted + * JSON. Zod replaces the previous hand-rolled type guards (which relied on + * `as Record` casts that violate CLAUDE.md's + * "avoid `as Type` assertions" rule). + */ +const IdentityWireSchema = z.object({ + device_id: z.string().refine((s) => s.trim().length > 0, { + message: 'device_id must be non-empty', + }), + email: z.string().optional(), + name: z.string().optional(), + user_id: z.string().optional(), +}) + +const AnalyticsEventWithIdentityWireSchema = z.object({ + identity: IdentityWireSchema, + name: z.string(), + properties: z.record(z.string(), z.unknown()), + timestamp: z.number(), +}) + +const AnalyticsBatchJsonSchema = z.object({ + events: z.array(AnalyticsEventWithIdentityWireSchema), + schema_version: z.literal(1), +}) /** * A batch of identity-stamped analytics events. Immutable. Constructed @@ -79,8 +72,13 @@ export class AnalyticsBatch { * input (graceful failure — the caller can drop the batch and log). */ public static fromJson(json: unknown): AnalyticsBatch | undefined { - if (!isAnalyticsBatchJson(json)) return undefined - return new AnalyticsBatch(json.events) + const parsed = AnalyticsBatchJsonSchema.safeParse(json) + if (!parsed.success) return undefined + // Zod's inferred event shape structurally matches AnalyticsEventWithIdentity + // (z.string().optional() is `string | undefined`, equivalent to optional + // properties on Identity). TypeScript widens the inferred mutable shape + // into the Readonly wrapper without an `as` cast. + return new AnalyticsBatch(parsed.data.events) } /** diff --git a/src/shared/analytics/emit.ts b/src/shared/analytics/emit.ts index a7e9914eb..ab3411c8a 100644 --- a/src/shared/analytics/emit.ts +++ b/src/shared/analytics/emit.ts @@ -1,7 +1,29 @@ import type {ITransportClient} from '@campfirein/brv-transport-client' +import type {AnalyticsEventName} from './event-names.js' +import type {AnyAnalyticsEvent} from './events/index.js' + import {AnalyticsEvents, type AnalyticsTrackPayload} from '../transport/events/analytics-events.js' +/** + * Type-derived properties for a given event name. Combined with the + * generic `` on `emitAnalytics`, callers + * cannot pass an unknown event name and cannot pass mismatched + * properties for a known event. Magic-string typos (e.g. + * `'daemon_starts'`) and wrong-shape payloads (e.g. `tool_name` on + * `cli_invocation`) become compile errors instead of runtime drops. + */ +type PropsForEvent = Extract['properties'] + +/** + * If the event has no required properties (e.g. `daemon_start`), the + * `properties` argument is optional. Otherwise it is required. Implemented + * via a rest tuple so the call site stays ergonomic. + */ +type PropsArg = keyof PropsForEvent extends never + ? [properties?: PropsForEvent] + : [properties: PropsForEvent] + /** * Fire-and-forget analytics emission for non-forked daemon clients * (TUI, oclif, MCP, webui). Uses `client.request` (no ack) so the caller @@ -10,11 +32,12 @@ import {AnalyticsEvents, type AnalyticsTrackPayload} from '../transport/events/a * NEVER throws. If the client is not connected or `request` throws for * any reason, the error is swallowed: analytics MUST NOT crash the caller. */ -export function emitAnalytics( +export function emitAnalytics( client: ITransportClient, - event: string, - properties?: Record, + event: E, + ...rest: PropsArg ): void { + const [properties] = rest const payload: AnalyticsTrackPayload = {event, properties} try { client.request(AnalyticsEvents.TRACK, payload) diff --git a/test/unit/shared/analytics/emit.test.ts b/test/unit/shared/analytics/emit.test.ts index 240880471..82939229b 100644 --- a/test/unit/shared/analytics/emit.test.ts +++ b/test/unit/shared/analytics/emit.test.ts @@ -4,7 +4,10 @@ import type {ITransportClient} from '@campfirein/brv-transport-client' import {expect} from 'chai' import {stub} from 'sinon' +import type {CliInvocationProps} from '../../../../src/shared/analytics/events/cli-invocation.js' + import {emitAnalytics} from '../../../../src/shared/analytics/emit.js' +import {AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' import {AnalyticsEvents} from '../../../../src/shared/transport/events/analytics-events.js' function makeStubClient(overrides: Partial = {}): ITransportClient { @@ -25,26 +28,43 @@ function makeStubClient(overrides: Partial = {}): ITransportCl } as unknown as ITransportClient } +const fullCliInvocation: CliInvocationProps = { + command_id: 'status', + flag_names: [], + is_ci: false, + is_tty: true, + package_manager: 'npm', + runtime: 'node', +} + describe('emitAnalytics', () => { - it('should call client.request with analytics:track and the expected payload', () => { + it('should call client.request with analytics:track and the expected payload (typed event + props)', () => { const client = makeStubClient() - emitAnalytics(client, 'cli_invocation', {command_id: 'status'}) + emitAnalytics(client, AnalyticsEventNames.CLI_INVOCATION, fullCliInvocation) const requestStub = client.request as ReturnType expect(requestStub.calledOnce).to.equal(true) expect(requestStub.firstCall.args[0]).to.equal(AnalyticsEvents.TRACK) - expect(requestStub.firstCall.args[1]).to.deep.equal({event: 'cli_invocation', properties: {command_id: 'status'}}) + expect(requestStub.firstCall.args[1]).to.deep.equal({ + event: AnalyticsEventNames.CLI_INVOCATION, + properties: fullCliInvocation, + }) }) - it('should send {event, properties: undefined} when no properties given', () => { + it('should accept the daemon_start event with no properties argument', () => { const client = makeStubClient() - emitAnalytics(client, 'no_props') + // daemon_start has empty `{}` schema; the typed PropsArg makes properties + // optional in this case so callers do not have to pass `{}` explicitly. + emitAnalytics(client, AnalyticsEventNames.DAEMON_START) const requestStub = client.request as ReturnType expect(requestStub.calledOnce).to.equal(true) - expect(requestStub.firstCall.args[1]).to.deep.equal({event: 'no_props', properties: undefined}) + expect(requestStub.firstCall.args[1]).to.deep.equal({ + event: AnalyticsEventNames.DAEMON_START, + properties: undefined, + }) }) it('should NOT throw when client.request throws (e.g. TransportNotConnectedError)', () => { @@ -52,14 +72,14 @@ describe('emitAnalytics', () => { request: stub().throws(new Error('not connected')) as unknown as ITransportClient['request'], }) - expect(() => emitAnalytics(client, 'e1')).to.not.throw() + expect(() => emitAnalytics(client, AnalyticsEventNames.DAEMON_START)).to.not.throw() }) it('should emit exactly ONE event per call', () => { const client = makeStubClient() - emitAnalytics(client, 'a') - emitAnalytics(client, 'b') + emitAnalytics(client, AnalyticsEventNames.DAEMON_START) + emitAnalytics(client, AnalyticsEventNames.CLI_INVOCATION, fullCliInvocation) const requestStub = client.request as ReturnType expect(requestStub.callCount).to.equal(2) diff --git a/test/unit/shared/analytics/privacy-fixture.test.ts b/test/unit/shared/analytics/privacy-fixture.test.ts index 65be7d78e..381946505 100644 --- a/test/unit/shared/analytics/privacy-fixture.test.ts +++ b/test/unit/shared/analytics/privacy-fixture.test.ts @@ -5,33 +5,80 @@ import {z} from 'zod' import {ALL_EVENT_SCHEMAS} from '../../../../src/shared/analytics/events/index.js' const FORBIDDEN_FIELD_NAMES: ReadonlySet = new Set([ + // Secrets / credentials + 'access_token', + // PII identifiers (super-properties carry email/name when authenticated; + // event payloads must NEVER repeat them) + 'address', + 'api_key', + // Filesystem paths (M1 spec: "no file paths") 'argv', + 'auth_header', + 'auth_token', + // User content (M1 spec: "no content of queries, files, or memory") 'content', + 'cookie', + 'credential', 'cwd', + 'display_name', 'email', + // Errors that may carry paths/secrets/content 'error_message', 'file_path', + 'first_name', 'folder_path', 'goal', 'home_dir', + // Network identifiers 'hostname', 'ip', + 'last_name', 'mac', 'output', + 'password', 'path', + 'phone', + 'phone_number', 'project_path', 'prompt', 'query', 'result', + 'secret', + 'session_id', + 'session_token', + 'ssn', 'stack', + 'token', + 'username', 'worktree_root', ]) -function getShapeFieldNames(schema: z.ZodTypeAny): string[] { - // Zod object schemas expose `.shape`. Strip ZodOptional / ZodDefault wrappers - // are irrelevant for field-name auditing; we only care about top-level keys. +/** + * Recursively collect every field name reachable from a Zod schema, including + * fields inside nested ZodObject, ZodOptional / ZodNullable wrappers, and + * ZodArray element schemas. The privacy fixture must audit nested shapes + * because adding `{error: {message, code}}` should surface `message` as a + * forbidden name even though the top level only declares `error`. + */ +function getShapeFieldNames(schema: z.ZodTypeAny, seen: Set = new Set()): string[] { + if (seen.has(schema)) return [] + seen.add(schema) + + if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) { + return getShapeFieldNames(schema.unwrap() as z.ZodTypeAny, seen) + } + + if (schema instanceof z.ZodArray) { + return getShapeFieldNames(schema.element as z.ZodTypeAny, seen) + } + if (schema instanceof z.ZodObject) { - return Object.keys(schema.shape as Record) + const out: string[] = [] + for (const [key, value] of Object.entries(schema.shape as Record)) { + out.push(key, ...getShapeFieldNames(value, seen)) + } + + return out } return [] @@ -63,4 +110,37 @@ describe('analytics privacy fixture (smoke)', () => { 'task_failed', ]) }) + + describe('walker coverage (regression guard)', () => { + it('should catch a forbidden field name nested inside a ZodObject', () => { + // Synthetic bad schema. If the walker stays at top-level, `email` is missed. + const nestedBad = z.object({ + outer: z.object({ + email: z.string(), + }), + }) + const fields = getShapeFieldNames(nestedBad) + expect(fields).to.include('email') + }) + + it('should catch a forbidden field name inside ZodArray element', () => { + const arrayBad = z.object({ + items: z.array(z.object({password: z.string()})), + }) + const fields = getShapeFieldNames(arrayBad) + expect(fields).to.include('password') + }) + + it('should unwrap ZodOptional and ZodNullable when walking', () => { + const optionalBad = z.object({ + wrapper: z.object({token: z.string()}).optional(), + }) + const nullableBad = z.object({ + // eslint-disable-next-line camelcase + wrapper: z.object({api_key: z.string()}).nullable(), + }) + expect(getShapeFieldNames(optionalBad)).to.include('token') + expect(getShapeFieldNames(nullableBad)).to.include('api_key') + }) + }) }) From 0530002c78a3e39c2041c3278071fd25fb5e02e2 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Sun, 10 May 2026 21:36:34 +0700 Subject: [PATCH 15/87] feat: [ENG-2723] add StoredAnalyticsRecord domain type for M9.1 - New stored-record.ts: StoredAnalyticsRecord extends AnalyticsEventWithIdentity with {id, status, attempts}; Readonly via z.infer wrapper for consistency with rest of analytics domain (Identity, AnalyticsEvent, batch types) - Zod schema for read-from-disk validation; default strip mode (matches batch.ts precedent, forward-compat with future field additions) - Export MAX_ATTEMPTS=3 constant for M9.2's updateStatus retry-cap policy - toWireEvent helper strips local-only fields (id, status, attempts) for M4 backend ship path - batch.ts and existing M2 wire types unchanged (boundary preserved) - 23 unit tests: 1 const + 16 schema + 6 helper; full suite 7526 passing --- .../core/domain/analytics/stored-record.ts | 86 ++++++ .../domain/analytics/stored-record.test.ts | 258 ++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 src/server/core/domain/analytics/stored-record.ts create mode 100644 test/unit/server/core/domain/analytics/stored-record.test.ts diff --git a/src/server/core/domain/analytics/stored-record.ts b/src/server/core/domain/analytics/stored-record.ts new file mode 100644 index 000000000..ce85744a4 --- /dev/null +++ b/src/server/core/domain/analytics/stored-record.ts @@ -0,0 +1,86 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +import type {AnalyticsEventWithIdentity} from './batch.js' + +/** + * Maximum number of send attempts before a record terminates as `'failed'`. + * + * Consumed inside `JsonlAnalyticsStore.updateStatus` (M9.2): on a `'failed'` + * update the store increments `attempts`; the row only transitions to + * terminal `status='failed'` when `attempts >= MAX_ATTEMPTS`. Otherwise it + * stays at `status='pending'` so the next flush cycle re-attempts it. + * + * M10.3 verifies the composition end-to-end. + */ +export const MAX_ATTEMPTS = 3 + +/** + * Local-only status enum for daemon-side persistence. Never serialized to + * the wire — see `toWireEvent` for the strip path. + */ +const StoredStatusSchema = z.enum(['pending', 'sent', 'failed']) + +export type StoredStatus = z.infer + +/** + * Mirrors the wire identity schema from `batch.ts` so disk-persisted rows + * are validated against the same contract events flow through. Kept private + * here (mirror per M9.1 plan) rather than refactoring `batch.ts` to export + * its private schemas — keeps M9.1 minimal and avoids touching the M2.6 + * wire boundary. + */ +const IdentityWireSchema = z.object({ + device_id: z.string().refine((s) => s.trim().length > 0, { + message: 'device_id must be non-empty', + }), + email: z.string().optional(), + name: z.string().optional(), + user_id: z.string().optional(), +}) + +/** + * A local-only stored record. Extends the wire-format + * `AnalyticsEventWithIdentity` shape with three daemon-internal fields: + * + * - `id`: stable per-row identifier (uuid v4) for `updateStatus` mutations + * - `status`: `'pending' | 'sent' | 'failed'` + * - `attempts`: number of send attempts (0..MAX_ATTEMPTS) + * + * The wire format that goes to the backend (M3+) stays unchanged — these + * extra fields are local metadata and NEVER leave the daemon. `toWireEvent` + * is the strip helper M4's HTTP sender uses when shipping a batch. + */ +export const StoredAnalyticsRecordSchema = z.object({ + attempts: z.number().int().min(0), + id: z.string().min(1), + identity: IdentityWireSchema, + name: z.string(), + properties: z.record(z.string(), z.unknown()), + status: StoredStatusSchema, + timestamp: z.number(), +}) + +/** + * `Readonly<>` wrapper aligns with the rest of the analytics domain + * (`Identity`, `AnalyticsEvent`, `AnalyticsEventWithIdentity` are all + * `Readonly<>`). A stored row is a frozen-in-time snapshot of the disk + * state; M9.2 mutates by spread + rewrite, never in-place. + */ +export type StoredAnalyticsRecord = Readonly> + +/** + * Strips local-only fields (`id`, `status`, `attempts`) from a stored + * record and returns the wire-format `AnalyticsEventWithIdentity` that can + * be shipped to the backend. M4's HTTP sender uses this on the way out; + * M9.3 (in-process) and M11.2 (over transport) both keep the local fields + * for their own purposes. + */ +export function toWireEvent(record: StoredAnalyticsRecord): AnalyticsEventWithIdentity { + return { + identity: record.identity, + name: record.name, + properties: record.properties, + timestamp: record.timestamp, + } +} diff --git a/test/unit/server/core/domain/analytics/stored-record.test.ts b/test/unit/server/core/domain/analytics/stored-record.test.ts new file mode 100644 index 000000000..2b4ed2dd4 --- /dev/null +++ b/test/unit/server/core/domain/analytics/stored-record.test.ts @@ -0,0 +1,258 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import { + MAX_ATTEMPTS, + StoredAnalyticsRecordSchema, + toWireEvent, +} from '../../../../../../src/server/core/domain/analytics/stored-record.js' + +const validIdentity = { + device_id: '550e8400-e29b-41d4-a716-446655440000', +} + +const validRecord = { + attempts: 0, + id: '11111111-2222-3333-4444-555555555555', + identity: validIdentity, + name: 'cli_invocation', + properties: {x: 1}, + status: 'pending' as const, + timestamp: 1_700_000_000_000, +} + +describe('StoredAnalyticsRecord', () => { + describe('MAX_ATTEMPTS', () => { + it('should export the cap as 3', () => { + expect(MAX_ATTEMPTS).to.equal(3) + }) + }) + + describe('StoredAnalyticsRecordSchema', () => { + it('should accept a valid record', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse(validRecord) + + expect(parsed.success).to.equal(true) + if (parsed.success) { + expect(parsed.data.id).to.equal(validRecord.id) + expect(parsed.data.status).to.equal('pending') + expect(parsed.data.attempts).to.equal(0) + expect(parsed.data.name).to.equal('cli_invocation') + } + }) + + it('should accept all three status values', () => { + for (const status of ['pending', 'sent', 'failed'] as const) { + const parsed = StoredAnalyticsRecordSchema.safeParse({...validRecord, status}) + expect(parsed.success, `status=${status} should parse`).to.equal(true) + } + }) + + it('should reject a record missing id', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + attempts: validRecord.attempts, + identity: validRecord.identity, + name: validRecord.name, + properties: validRecord.properties, + status: validRecord.status, + timestamp: validRecord.timestamp, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record with empty id', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({...validRecord, id: ''}) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record with unknown status', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({...validRecord, status: 'unknown'}) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record with negative attempts', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({...validRecord, attempts: -1}) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record with fractional attempts', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({...validRecord, attempts: 1.5}) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record missing identity.device_id', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + ...validRecord, + identity: {device_id: ''}, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record with non-object properties', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + ...validRecord, + properties: 'not-an-object', + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record missing name', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + attempts: validRecord.attempts, + id: validRecord.id, + identity: validRecord.identity, + properties: validRecord.properties, + status: validRecord.status, + timestamp: validRecord.timestamp, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record missing timestamp', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + attempts: validRecord.attempts, + id: validRecord.id, + identity: validRecord.identity, + name: validRecord.name, + properties: validRecord.properties, + status: validRecord.status, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record missing properties', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + attempts: validRecord.attempts, + id: validRecord.id, + identity: validRecord.identity, + name: validRecord.name, + status: validRecord.status, + timestamp: validRecord.timestamp, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record missing attempts', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + id: validRecord.id, + identity: validRecord.identity, + name: validRecord.name, + properties: validRecord.properties, + status: validRecord.status, + timestamp: validRecord.timestamp, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record missing status', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + attempts: validRecord.attempts, + id: validRecord.id, + identity: validRecord.identity, + name: validRecord.name, + properties: validRecord.properties, + timestamp: validRecord.timestamp, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should reject a record missing identity', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + attempts: validRecord.attempts, + id: validRecord.id, + name: validRecord.name, + properties: validRecord.properties, + status: validRecord.status, + timestamp: validRecord.timestamp, + }) + + expect(parsed.success).to.equal(false) + }) + + it('should silently strip extra unknown fields (Zod default behavior, matches batch.ts precedent)', () => { + // Decision (M9.1, 2026-05-10): use Zod default strip (NOT `.strict()` or `.passthrough()`). + // Mirrors batch.ts wire schemas — strip is forward-compatible: a future binary that adds + // a new known field, reading rows written by the old binary, will not crash. Cost: if a + // row on disk has unknown extra fields, M9.2 read-modify-rewrite will lose them. Accepted + // because we do not currently support out-of-schema extension. + const parsed = StoredAnalyticsRecordSchema.safeParse({ + ...validRecord, + unknown_extra_field: 'should be stripped', + }) + + expect(parsed.success).to.equal(true) + if (parsed.success) { + expect(parsed.data).to.not.have.property('unknown_extra_field') + expect(parsed.data.id).to.equal(validRecord.id) + } + }) + }) + + describe('toWireEvent()', () => { + it('should strip id, status, and attempts from the record', () => { + const wire = toWireEvent(validRecord) + + expect(wire).to.deep.equal({ + identity: validIdentity, + name: 'cli_invocation', + properties: {x: 1}, + timestamp: 1_700_000_000_000, + }) + }) + + it('should not retain id field', () => { + const wire = toWireEvent(validRecord) + expect(wire).to.not.have.property('id') + }) + + it('should not retain status field', () => { + const wire = toWireEvent(validRecord) + expect(wire).to.not.have.property('status') + }) + + it('should not retain attempts field', () => { + const wire = toWireEvent(validRecord) + expect(wire).to.not.have.property('attempts') + }) + + it('should preserve identity verbatim including optional fields', () => { + const recordWithFullIdentity = { + ...validRecord, + identity: { + device_id: validIdentity.device_id, + email: 'user@example.com', + name: 'Test User', + user_id: 'user-123', + }, + } + const wire = toWireEvent(recordWithFullIdentity) + + expect(wire.identity).to.deep.equal(recordWithFullIdentity.identity) + }) + + it('should strip local fields when chained after Zod parse (sent record with attempts > 0)', () => { + const recordWithStatusSent = {...validRecord, attempts: 2, status: 'sent' as const} + const parsed = StoredAnalyticsRecordSchema.safeParse(recordWithStatusSent) + + expect(parsed.success).to.equal(true) + if (parsed.success) { + const wire = toWireEvent(parsed.data) + expect(wire.name).to.equal('cli_invocation') + expect(wire).to.not.have.property('attempts') + expect(wire).to.not.have.property('status') + } + }) + }) +}) From caab092e247e600aa6a221ef4cf104cdb145665d Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Sun, 10 May 2026 22:21:02 +0700 Subject: [PATCH 16/87] feat: [ENG-2724] add JsonlAnalyticsStore for M9.2 durable persistence - New IJsonlAnalyticsStore interface: append/updateStatus/list/loadPending plus droppedSentCount/droppedFullCount counters for M4.6 observability - New JsonlAnalyticsStore class: file-backed JSONL at {baseDir}/analytics-queue.jsonl - Retry-cap policy lives inside updateStatus(_, 'failed'): increment attempts; stay 'pending' while attempts < MAX_ATTEMPTS; flip terminal 'failed' at cap; no overshoot on terminal rows - Write serialization via private writeChain Promise mutex; eliminates appendFile vs readFile/rename interleave race for concurrent track + flush - Atomic rewrite: tmp file + rename (mirrors FileQueryLogStore.writeAtomic) - Append uses FileHandle.sync() for fsync durability so row survives daemon kill - File-size cap (default 5000 rows / 10MB): drop oldest sent first; pending and failed never dropped; if cap full of pending+failed, append silent no-op plus droppedFullCount++ - list sort: (timestamp DESC, id DESC) for stable same-timestamp ordering - Read methods (list, loadPending) skip the write chain; consistent snapshot via atomic rename - Corruption tolerance: skip JSON.parse-failures and Zod-schema-failures - 30 unit tests using tmpdir(); full suite 7554 passing --- .../analytics/i-jsonl-analytics-store.ts | 106 ++++ .../infra/analytics/jsonl-analytics-store.ts | 262 +++++++++ .../analytics/jsonl-analytics-store.test.ts | 498 ++++++++++++++++++ 3 files changed, 866 insertions(+) create mode 100644 src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts create mode 100644 src/server/infra/analytics/jsonl-analytics-store.ts create mode 100644 test/unit/server/infra/analytics/jsonl-analytics-store.test.ts diff --git a/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts b/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts new file mode 100644 index 000000000..4f3b2c81e --- /dev/null +++ b/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts @@ -0,0 +1,106 @@ +import type {StoredAnalyticsRecord, StoredStatus} from '../../domain/analytics/stored-record.js' + +/** + * Filter and pagination options for `list()`. + * + * `offset >= 0`, `limit >= 1`. Caller validates bounds (M11.1 transport + * schema enforces `limit 1..200`); the store does not re-validate. + */ +export type JsonlAnalyticsStoreListOptions = { + eventName?: string + limit: number + offset: number + status?: StoredStatus +} + +/** + * Result of `list()`. `total` is the post-filter row count (NOT total file + * rows), so a UI can render "showing X-Y of total" correctly. + */ +export type JsonlAnalyticsStoreListResult = Readonly<{ + rows: StoredAnalyticsRecord[] + total: number +}> + +/** + * The two terminal/transitional statuses callers may write. `'pending'` is + * the implicit initial state set by `append()` and is NEVER a valid input + * to `updateStatus`. + */ +export type JsonlAnalyticsStoreUpdateStatus = 'failed' | 'sent' + +/** + * Daemon-side durable JSONL store for analytics records (M9.2). + * + * Contract: + * - `append` is the only producer; new rows always start at + * `status='pending', attempts=0`. + * - `updateStatus(ids, 'sent')` flips to terminal `'sent'` (no attempts + * change). + * - `updateStatus(ids, 'failed')` is the **retry-cap gate**: increments + * `attempts` and only transitions to terminal `'failed'` once + * `attempts >= MAX_ATTEMPTS`; otherwise the row stays at `'pending'` + * so the next flush retries. Callers do NOT branch on the cap. + * - `loadPending()` returns rows at `status='pending'` only (which under + * the cap policy includes both fresh `attempts=0` rows and in-flight + * `attempts=1..MAX_ATTEMPTS-1` retries). + * - `list()` paginates with optional filters; sort order is + * `(timestamp DESC, id DESC)`. + * - All mutating calls (`append`, `updateStatus`) serialize through a + * single in-process Promise chain on the store instance — concurrent + * `append` and `updateStatus` cannot lose rows. + * - File-size cap with drop-oldest-sent-first compaction. Pending and + * failed rows are never dropped by compaction. + */ +export interface IJsonlAnalyticsStore { + /** + * Append a new record (`status='pending', attempts=0`) to the JSONL + * file with fsync. If the file-size cap would be exceeded, oldest + * `'sent'` rows are dropped first; if no `'sent'` rows exist to drop, + * the append becomes a silent no-op and `droppedFullCount()` is + * incremented (analytics MUST NOT crash the consumer). + */ + append: (record: StoredAnalyticsRecord) => Promise + + /** + * Cumulative count of `append` calls dropped because the cap was full + * with no `'sent'` rows to evict (file saturated with pending+failed). + * Never reset; surfaced for `brv analytics status` (M4.6). + */ + droppedFullCount: () => number + + /** + * Cumulative count of `'sent'` rows dropped by compaction across the + * store's lifetime. Never reset; surfaced for `brv analytics status` + * (M4.6). + */ + droppedSentCount: () => number + + /** + * Read paginated, filtered rows. Sort order is + * `(timestamp DESC, id DESC)`. `total` is the post-filter row count. + * Returns empty result when the file does not exist yet. + */ + list: (opts: JsonlAnalyticsStoreListOptions) => Promise + + /** + * Read all rows currently at `status='pending'`. Used by M10.2's + * `flush()` as the source-of-truth for what to ship next. Returns + * empty array when the file does not exist yet. + */ + loadPending: () => Promise + + /** + * Mirror a per-record send result back to disk. + * + * `'sent'`: flip `status` to `'sent'`. `attempts` unchanged. + * + * `'failed'`: increment `attempts`. If `attempts >= MAX_ATTEMPTS` the + * row transitions to terminal `status='failed'`; otherwise stays at + * `status='pending'` (next flush retries). A `'failed'` update on a + * row already at terminal `status='failed'` is a no-op (no overshoot). + * + * Empty `ids` array is a no-op. Non-matching ids are silently ignored. + */ + updateStatus: (ids: readonly string[], status: JsonlAnalyticsStoreUpdateStatus) => Promise +} diff --git a/src/server/infra/analytics/jsonl-analytics-store.ts b/src/server/infra/analytics/jsonl-analytics-store.ts new file mode 100644 index 000000000..bd2267a8b --- /dev/null +++ b/src/server/infra/analytics/jsonl-analytics-store.ts @@ -0,0 +1,262 @@ +import {randomUUID} from 'node:crypto' +import {mkdir, open, readFile, rename, rm, writeFile} from 'node:fs/promises' +import {dirname, join} from 'node:path' + +import type {StoredAnalyticsRecord} from '../../core/domain/analytics/stored-record.js' +import type { + IJsonlAnalyticsStore, + JsonlAnalyticsStoreListOptions, + JsonlAnalyticsStoreListResult, + JsonlAnalyticsStoreUpdateStatus, +} from '../../core/interfaces/analytics/i-jsonl-analytics-store.js' + +import {MAX_ATTEMPTS, StoredAnalyticsRecordSchema} from '../../core/domain/analytics/stored-record.js' + +const DEFAULT_FILE_NAME = 'analytics-queue.jsonl' +const DEFAULT_MAX_ROWS = 5000 +const DEFAULT_MAX_BYTES = 10 * 1024 * 1024 + +/** + * Constructor options. `baseDir` is required (caller injects + * `getGlobalDataDir()` in production; tests pass a `tmpdir()`-derived + * path). The other fields default to plan-locked values. + */ +export type JsonlAnalyticsStoreOptions = { + baseDir: string + fileName?: string + maxBytes?: number + maxRows?: number +} + +/** + * File-backed JSONL store implementation. See `IJsonlAnalyticsStore` for + * the consumer contract. + * + * Design notes: + * - Atomic rewrite for `updateStatus` and compaction: write to + * `${path}.${randomUUID()}.tmp` then `rename`. Mirrors + * `FileQueryLogStore.writeAtomic`. + * - All mutating calls (`append`, `updateStatus`) flow through a single + * `writeChain` Promise. This eliminates the `appendFile` vs + * `readFile/rename` race where a `track()`-time append could land + * between `updateStatus`'s read snapshot and rename and be silently + * overwritten. + * - Read methods (`list`, `loadPending`) do NOT enter the write chain — + * reads do not corrupt and a caller that needs strict consistency + * should sequence its own reads after its writes. + * - Retry-cap policy lives inside `updateStatus(_, 'failed')` (NOT in + * the caller). Plan-locked: increment attempts; row stays + * `'pending'` while `attempts < MAX_ATTEMPTS`; flips to terminal + * `'failed'` at the cap; no-op on rows already terminal. + */ +export class JsonlAnalyticsStore implements IJsonlAnalyticsStore { + private droppedFullCounter = 0 + private droppedSentCounter = 0 + private readonly filePath: string + private readonly maxBytes: number + private readonly maxRows: number + private writeChain: Promise = Promise.resolve() + + public constructor(opts: JsonlAnalyticsStoreOptions) { + this.filePath = join(opts.baseDir, opts.fileName ?? DEFAULT_FILE_NAME) + this.maxRows = opts.maxRows ?? DEFAULT_MAX_ROWS + this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES + } + + public async append(record: StoredAnalyticsRecord): Promise { + return this.enqueue(async () => this.doAppend(record)) + } + + public droppedFullCount(): number { + return this.droppedFullCounter + } + + public droppedSentCount(): number { + return this.droppedSentCounter + } + + public async list(opts: JsonlAnalyticsStoreListOptions): Promise { + const all = await this.readAllRecords() + const filtered = all.filter((row) => { + if (opts.eventName !== undefined && row.name !== opts.eventName) return false + if (opts.status !== undefined && row.status !== opts.status) return false + return true + }) + // Sort by (timestamp DESC, id DESC). Same-timestamp tie broken by id DESC for stable ordering. + filtered.sort((a, b) => { + if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp + if (a.id < b.id) return 1 + if (a.id > b.id) return -1 + return 0 + }) + const rows = filtered.slice(opts.offset, opts.offset + opts.limit) + return {rows, total: filtered.length} + } + + public async loadPending(): Promise { + const all = await this.readAllRecords() + return all.filter((r) => r.status === 'pending') + } + + public async updateStatus(ids: readonly string[], status: JsonlAnalyticsStoreUpdateStatus): Promise { + if (ids.length === 0) return + const idSet = new Set(ids) + return this.enqueue(async () => this.doUpdateStatus(idSet, status)) + } + + /** + * Atomic file rewrite via `tmp + rename`. Mirrors `FileQueryLogStore`. + * On failure, removes the tmp file and re-throws. + */ + private async atomicRewrite(rows: readonly StoredAnalyticsRecord[]): Promise { + await this.ensureDir() + const content = rows.length === 0 ? '' : rows.map((r) => JSON.stringify(r)).join('\n') + '\n' + const tmpPath = `${this.filePath}.${randomUUID()}.tmp` + try { + await writeFile(tmpPath, content, 'utf8') + await rename(tmpPath, this.filePath) + } catch (error) { + await rm(tmpPath, {force: true}).catch(() => {}) + throw error + } + } + + /** + * Drop oldest `'sent'` rows (insertion order = file order = oldest-first) + * until under cap or out of `'sent'` rows. Pending and failed are never + * dropped. Returns the kept rows + count of sent rows actually removed. + */ + private compactRows(rows: readonly StoredAnalyticsRecord[]): {kept: StoredAnalyticsRecord[]; sentDropped: number} { + const kept = [...rows] + let sentDropped = 0 + while (this.exceedsCap(kept)) { + const sentIdx = kept.findIndex((r) => r.status === 'sent') + if (sentIdx === -1) break + kept.splice(sentIdx, 1) + sentDropped++ + } + + return {kept, sentDropped} + } + + private async doAppend(record: StoredAnalyticsRecord): Promise { + await this.ensureDir() + const all = await this.readAllRecords() + const simulated = [...all, record] + + if (this.exceedsCap(simulated)) { + const {kept, sentDropped} = this.compactRows(simulated) + if (this.exceedsCap(kept)) { + // Even after dropping all sent rows, still over cap. Silently drop the new append. + if (sentDropped > 0) { + this.droppedSentCounter += sentDropped + // Persist whatever sent rows we did manage to drop, but exclude the new record. + await this.atomicRewrite(kept.filter((r) => r.id !== record.id)) + } + + this.droppedFullCounter++ + return + } + + // Compaction succeeded: write the compacted set (which already includes the new record). + this.droppedSentCounter += sentDropped + await this.atomicRewrite(kept) + return + } + + // Normal path: explicit fsync via FileHandle.sync() so the row survives daemon kill. + const line = JSON.stringify(record) + '\n' + const handle = await open(this.filePath, 'a') + try { + await handle.appendFile(line, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + } + + private async doUpdateStatus(idSet: Set, status: JsonlAnalyticsStoreUpdateStatus): Promise { + const all = await this.readAllRecords() + let mutated = false + const next = all.map((row): StoredAnalyticsRecord => { + if (!idSet.has(row.id)) return row + + if (status === 'sent') { + if (row.status === 'sent') return row + mutated = true + return {...row, status: 'sent'} + } + + // status === 'failed' — retry-cap gate. Skip rows already at terminal failed (no overshoot). + if (row.status === 'failed') return row + const nextAttempts = row.attempts + 1 + mutated = true + if (nextAttempts >= MAX_ATTEMPTS) { + return {...row, attempts: nextAttempts, status: 'failed'} + } + + return {...row, attempts: nextAttempts, status: 'pending'} + }) + + if (!mutated) return + await this.atomicRewrite(next) + } + + /** + * Serialize `work` against any in-flight write. Caller awaits `next` + * to observe errors from this specific call. The chain itself swallows + * errors so a failure in one `enqueue` does NOT reject all subsequent + * calls. + */ + private enqueue(work: () => Promise): Promise { + const next = this.writeChain.then(async () => work()) + this.writeChain = next.then( + () => {}, + () => {}, + ) + return next + } + + private async ensureDir(): Promise { + await mkdir(dirname(this.filePath), {recursive: true}) + } + + private exceedsCap(rows: readonly StoredAnalyticsRecord[]): boolean { + if (rows.length > this.maxRows) return true + let bytes = 0 + for (const r of rows) { + bytes += Buffer.byteLength(JSON.stringify(r) + '\n', 'utf8') + if (bytes > this.maxBytes) return true + } + + return false + } + + private async readAllRecords(): Promise { + let content: string + try { + content = await readFile(this.filePath, 'utf8') + } catch { + return [] + } + + const records: StoredAnalyticsRecord[] = [] + for (const line of content.split('\n')) { + if (line.length === 0) continue + let raw: unknown + try { + raw = JSON.parse(line) + } catch { + // Skip unparseable line (corrupt write or partial flush). + continue + } + + const parsed = StoredAnalyticsRecordSchema.safeParse(raw) + if (parsed.success) { + records.push(parsed.data) + } + } + + return records + } +} diff --git a/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts b/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts new file mode 100644 index 000000000..609062b10 --- /dev/null +++ b/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts @@ -0,0 +1,498 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' +import {mkdir, readFile, stat, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {StoredAnalyticsRecord} from '../../../../../src/server/core/domain/analytics/stored-record.js' + +import {MAX_ATTEMPTS, StoredAnalyticsRecordSchema} from '../../../../../src/server/core/domain/analytics/stored-record.js' +import {JsonlAnalyticsStore} from '../../../../../src/server/infra/analytics/jsonl-analytics-store.js' + +const validIdentity = { + device_id: '550e8400-e29b-41d4-a716-446655440000', +} + +async function freshTempDir(): Promise { + const dir = join(tmpdir(), `jsonl-store-${randomUUID()}`) + await mkdir(dir, {recursive: true}) + return dir +} + +function makeRecord(overrides: Partial = {}): StoredAnalyticsRecord { + return { + attempts: 0, + id: randomUUID(), + identity: validIdentity, + name: 'cli_invocation', + properties: {}, + status: 'pending', + timestamp: Date.now(), + ...overrides, + } +} + +async function readJsonlRows(filePath: string): Promise { + try { + const content = await readFile(filePath, 'utf8') + const records: StoredAnalyticsRecord[] = [] + for (const line of content.split('\n')) { + if (line.length === 0) continue + const parsed = StoredAnalyticsRecordSchema.parse(JSON.parse(line)) + records.push(parsed) + } + + return records + } catch { + return [] + } +} + +function findRow(rows: StoredAnalyticsRecord[], id: string): StoredAnalyticsRecord { + const row = rows.find((r) => r.id === id) + if (row === undefined) throw new Error(`expected row with id=${id}`) + return row +} + +describe('JsonlAnalyticsStore', () => { + describe('append()', () => { + it('should write one row plus newline to a fresh file', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + const record = makeRecord({id: 'rec-1', name: 'event-a'}) + + await store.append(record) + + const filePath = join(baseDir, 'analytics-queue.jsonl') + const content = await readFile(filePath, 'utf8') + expect(content.endsWith('\n')).to.equal(true) + expect(content.split('\n').filter((l) => l.length > 0)).to.have.lengthOf(1) + const rows = await readJsonlRows(filePath) + expect(rows[0].id).to.equal('rec-1') + expect(rows[0].name).to.equal('event-a') + }) + + it('should append multiple rows in arrival order', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + + await store.append(makeRecord({id: 'r1'})) + await store.append(makeRecord({id: 'r2'})) + await store.append(makeRecord({id: 'r3'})) + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows.map((r) => r.id)).to.deep.equal(['r1', 'r2', 'r3']) + }) + + it('should create the base directory if it does not exist', async () => { + const parent = await freshTempDir() + const baseDir = join(parent, 'nested', 'path') + const store = new JsonlAnalyticsStore({baseDir}) + + await store.append(makeRecord({id: 'r1'})) + + const stats = await stat(join(baseDir, 'analytics-queue.jsonl')) + expect(stats.isFile()).to.equal(true) + }) + }) + + describe("updateStatus(ids, 'sent')", () => { + it('should flip status to sent and leave attempts unchanged', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({attempts: 1, id: 'r1', status: 'pending'})) + + await store.updateStatus(['r1'], 'sent') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('sent') + expect(rows[0].attempts).to.equal(1) + }) + + it('should leave other rows untouched', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + await store.append(makeRecord({id: 'r2'})) + await store.append(makeRecord({id: 'r3'})) + + await store.updateStatus(['r2'], 'sent') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(findRow(rows, 'r1').status).to.equal('pending') + expect(findRow(rows, 'r2').status).to.equal('sent') + expect(findRow(rows, 'r3').status).to.equal('pending') + }) + + it('should be no-op for empty ids array', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + + await store.updateStatus([], 'sent') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('pending') + }) + + it('should be no-op for non-matching ids', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + + await store.updateStatus(['does-not-exist'], 'sent') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('pending') + }) + }) + + describe("updateStatus(ids, 'failed') retry-cap policy", () => { + it('should keep status pending after first failure (attempts=1)', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({attempts: 0, id: 'r1', status: 'pending'})) + + await store.updateStatus(['r1'], 'failed') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('pending') + expect(rows[0].attempts).to.equal(1) + }) + + it('should still be pending after second failure (attempts=2)', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({attempts: 0, id: 'r1', status: 'pending'})) + + await store.updateStatus(['r1'], 'failed') + await store.updateStatus(['r1'], 'failed') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('pending') + expect(rows[0].attempts).to.equal(2) + }) + + it(`should transition to terminal 'failed' at MAX_ATTEMPTS (${MAX_ATTEMPTS})`, async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({attempts: 0, id: 'r1', status: 'pending'})) + + for (let i = 0; i < MAX_ATTEMPTS; i++) { + // eslint-disable-next-line no-await-in-loop + await store.updateStatus(['r1'], 'failed') + } + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('failed') + expect(rows[0].attempts).to.equal(MAX_ATTEMPTS) + }) + + it("should be no-op on a row already at terminal 'failed' (no overshoot)", async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({attempts: 0, id: 'r1', status: 'pending'})) + for (let i = 0; i < MAX_ATTEMPTS; i++) { + // eslint-disable-next-line no-await-in-loop + await store.updateStatus(['r1'], 'failed') + } + + await store.updateStatus(['r1'], 'failed') + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows[0].status).to.equal('failed') + expect(rows[0].attempts).to.equal(MAX_ATTEMPTS) + }) + }) + + describe('list()', () => { + it('should return empty result when file does not exist', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + + const result = await store.list({limit: 10, offset: 0}) + + expect(result.rows).to.deep.equal([]) + expect(result.total).to.equal(0) + }) + + it('should paginate via offset and limit', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + for (let i = 0; i < 10; i++) { + // eslint-disable-next-line no-await-in-loop + await store.append(makeRecord({id: `r${i}`, timestamp: i})) + } + + const page1 = await store.list({limit: 3, offset: 0}) + const page2 = await store.list({limit: 3, offset: 3}) + + expect(page1.rows).to.have.lengthOf(3) + expect(page2.rows).to.have.lengthOf(3) + expect(page1.total).to.equal(10) + expect(page2.total).to.equal(10) + const ids = [...page1.rows.map((r) => r.id), ...page2.rows.map((r) => r.id)] + expect(new Set(ids).size).to.equal(6) // no duplicates between pages + }) + + it('should filter by eventName', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1', name: 'event-a'})) + await store.append(makeRecord({id: 'r2', name: 'event-b'})) + await store.append(makeRecord({id: 'r3', name: 'event-a'})) + + const result = await store.list({eventName: 'event-a', limit: 10, offset: 0}) + + expect(result.total).to.equal(2) + expect(result.rows.map((r) => r.id).sort()).to.deep.equal(['r1', 'r3']) + }) + + it('should filter by status', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + await store.append(makeRecord({id: 'r2'})) + await store.append(makeRecord({id: 'r3'})) + await store.updateStatus(['r2'], 'sent') + + const pending = await store.list({limit: 10, offset: 0, status: 'pending'}) + const sent = await store.list({limit: 10, offset: 0, status: 'sent'}) + + expect(pending.total).to.equal(2) + expect(sent.total).to.equal(1) + expect(sent.rows[0].id).to.equal('r2') + }) + + it('should filter by both eventName and status', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1', name: 'event-a'})) + await store.append(makeRecord({id: 'r2', name: 'event-b'})) + await store.append(makeRecord({id: 'r3', name: 'event-a'})) + await store.updateStatus(['r1'], 'sent') + + const result = await store.list({eventName: 'event-a', limit: 10, offset: 0, status: 'sent'}) + + expect(result.total).to.equal(1) + expect(result.rows[0].id).to.equal('r1') + }) + + it('should sort by (timestamp DESC, id DESC) for stable ordering on same-timestamp', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'aaa', timestamp: 100})) + await store.append(makeRecord({id: 'bbb', timestamp: 200})) + await store.append(makeRecord({id: 'ccc', timestamp: 100})) + + const result = await store.list({limit: 10, offset: 0}) + + // Newest timestamp first; same timestamp tie broken by id DESC + expect(result.rows.map((r) => r.id)).to.deep.equal(['bbb', 'ccc', 'aaa']) + }) + + it('should return correct total post-filter when offset > total', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + + const result = await store.list({limit: 10, offset: 100}) + + expect(result.rows).to.deep.equal([]) + expect(result.total).to.equal(1) + }) + }) + + describe('loadPending()', () => { + it('should return empty when file does not exist', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + + const rows = await store.loadPending() + + expect(rows).to.deep.equal([]) + }) + + it("should return only 'pending' rows", async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + await store.append(makeRecord({id: 'r2'})) + await store.append(makeRecord({id: 'r3'})) + await store.updateStatus(['r2'], 'sent') + + const rows = await store.loadPending() + + expect(rows.map((r) => r.id).sort()).to.deep.equal(['r1', 'r3']) + }) + + it('should include pending rows with attempts > 0 (in-flight retries)', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1', status: 'pending'})) + await store.updateStatus(['r1'], 'failed') // attempts=1, still pending + + const rows = await store.loadPending() + + expect(rows).to.have.lengthOf(1) + expect(rows[0].id).to.equal('r1') + expect(rows[0].status).to.equal('pending') + expect(rows[0].attempts).to.equal(1) + }) + + it("should exclude rows that reached terminal 'failed'", async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + for (let i = 0; i < MAX_ATTEMPTS; i++) { + // eslint-disable-next-line no-await-in-loop + await store.updateStatus(['r1'], 'failed') + } + + const rows = await store.loadPending() + + expect(rows).to.deep.equal([]) + }) + }) + + describe('concurrency (write serialization)', () => { + it('should not lose appends interleaved with updateStatus', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + await store.append(makeRecord({id: 'r2'})) + await store.append(makeRecord({id: 'r3'})) + + // Interleave: kick off updateStatus + a fresh append without awaiting; both go into writeChain. + const updatePromise = store.updateStatus(['r1', 'r2'], 'sent') + const appendPromise = store.append(makeRecord({id: 'r-NEW'})) + + await Promise.all([updatePromise, appendPromise]) + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id).sort() + expect(ids).to.include('r-NEW') // append must NOT be lost by the rewrite + expect(rows).to.have.lengthOf(4) + expect(findRow(rows, 'r1').status).to.equal('sent') + expect(findRow(rows, 'r2').status).to.equal('sent') + }) + }) + + describe('cap edge cases', () => { + it('should drop oldest sent row when row cap exceeded', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir, maxRows: 3}) + await store.append(makeRecord({id: 'r1', timestamp: 100})) + await store.append(makeRecord({id: 'r2', timestamp: 200})) + await store.append(makeRecord({id: 'r3', timestamp: 300})) + await store.updateStatus(['r1', 'r2'], 'sent') // r1 oldest sent; r2 newer sent + + await store.append(makeRecord({id: 'r4', timestamp: 400})) // triggers cap + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id).sort() + expect(ids).to.not.include('r1') // oldest sent dropped + expect(ids).to.include('r2') + expect(ids).to.include('r3') + expect(ids).to.include('r4') + expect(store.droppedSentCount()).to.equal(1) + }) + + it('should preserve pending and failed rows during compaction', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir, maxRows: 3}) + await store.append(makeRecord({id: 'r1', timestamp: 100})) // pending + await store.append(makeRecord({id: 'r2', timestamp: 200})) // sent (will be dropped) + await store.append(makeRecord({id: 'r3', timestamp: 300})) // pending + await store.updateStatus(['r2'], 'sent') + + await store.append(makeRecord({id: 'r4', timestamp: 400})) // triggers cap + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id).sort() + expect(ids).to.include('r1') // pending preserved + expect(ids).to.include('r3') // pending preserved + expect(ids).to.not.include('r2') // sent dropped + }) + + it('should silently no-op append when cap full of pending+failed (no sent to drop)', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir, maxRows: 2}) + await store.append(makeRecord({id: 'r1', timestamp: 100})) + await store.append(makeRecord({id: 'r2', timestamp: 200})) // both pending; no sent rows + + await store.append(makeRecord({id: 'r3', timestamp: 300})) // should silently drop NEW row + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id).sort() + expect(ids).to.not.include('r3') + expect(ids).to.deep.equal(['r1', 'r2']) + expect(store.droppedFullCount()).to.equal(1) + }) + + it('should track droppedFullCount cumulatively across multiple no-op appends', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir, maxRows: 1}) + await store.append(makeRecord({id: 'r1'})) + + await store.append(makeRecord({id: 'r2'})) + await store.append(makeRecord({id: 'r3'})) + + expect(store.droppedFullCount()).to.equal(2) + }) + + it('should silently skip malformed JSON lines on read', async () => { + const baseDir = await freshTempDir() + const filePath = join(baseDir, 'analytics-queue.jsonl') + const good = makeRecord({id: 'good'}) + // Two bad lines (non-JSON garbage) sandwiching a good one. + await writeFile( + filePath, + ['this is not json', JSON.stringify(good), 'partial-write-{'].join('\n') + '\n', + 'utf8', + ) + const store = new JsonlAnalyticsStore({baseDir}) + + const rows = await store.loadPending() + + expect(rows).to.have.lengthOf(1) + expect(rows[0].id).to.equal('good') + }) + + it('should silently skip schema-invalid JSON objects on read', async () => { + const baseDir = await freshTempDir() + const filePath = join(baseDir, 'analytics-queue.jsonl') + const good = makeRecord({id: 'good'}) + // First line parses as JSON but fails Zod (missing required fields). + await writeFile( + filePath, + [JSON.stringify({notAValidRecord: true}), JSON.stringify(good)].join('\n') + '\n', + 'utf8', + ) + const store = new JsonlAnalyticsStore({baseDir}) + + const rows = await store.loadPending() + + expect(rows).to.have.lengthOf(1) + expect(rows[0].id).to.equal('good') + }) + + it('should respect byte cap as well as row cap', async () => { + const baseDir = await freshTempDir() + // Tiny byte cap to force compaction quickly + const store = new JsonlAnalyticsStore({baseDir, maxBytes: 500, maxRows: 10_000}) + const big = 'x'.repeat(200) // each row > 200 bytes serialized + await store.append(makeRecord({id: 'r1', properties: {data: big}})) + await store.append(makeRecord({id: 'r2', properties: {data: big}})) + await store.updateStatus(['r1'], 'sent') + + await store.append(makeRecord({id: 'r3', properties: {data: big}})) // triggers byte-cap + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id).sort() + expect(ids).to.not.include('r1') // dropped (sent + oldest) + expect(store.droppedSentCount()).to.be.greaterThanOrEqual(1) + }) + }) +}) From de4153e51478842c842f358e9382f120178d6d12 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Sun, 10 May 2026 22:58:46 +0700 Subject: [PATCH 17/87] feat: [ENG-2725] wire JsonlAnalyticsStore into AnalyticsClient.trackAsync (M9.3) - Widen IAnalyticsQueue + BoundedQueue from AnalyticsEventWithIdentity to StoredAnalyticsRecord; id propagates through the queue - AnalyticsClientDeps adds jsonlStore: IJsonlAnalyticsStore - trackAsync: resolve identity + super-props, generate randomUUID id, compose StoredAnalyticsRecord{status:'pending', attempts:0}, await jsonlStore.append() FIRST, then queue.push() on success - JSONL append failure: queue NOT pushed, silent drop preserves "JSONL is source of truth" invariant; "MUST NOT crash consumer" honored - feature-handlers.ts constructs ONE JsonlAnalyticsStore({baseDir: getGlobalDataDir()}) and injects into AnalyticsClient. Same instance will be shared with M11.2's analytics-list-handler - 6 new unit tests covering dual-write happy path, JSONL-fail fallback, uuid distinctness, queue.size() = JSONL row count, disabled no-op - All existing M2 AnalyticsClient + BoundedQueue tests pass with widened type - Integration tests (daemon-tracking, transport) updated with real JsonlAnalyticsStore({baseDir: testDir}) per tmpdir convention - Full suite 7562 passing --- .../interfaces/analytics/i-analytics-queue.ts | 23 ++- .../infra/analytics/analytics-client.ts | 20 +- src/server/infra/analytics/bounded-queue.ts | 27 +-- src/server/infra/process/feature-handlers.ts | 9 + .../analytics/daemon-tracking.test.ts | 4 + test/integration/analytics/transport.test.ts | 4 + .../infra/analytics/analytics-client.test.ts | 172 +++++++++++++++++- .../infra/analytics/bounded-queue.test.ts | 11 +- 8 files changed, 243 insertions(+), 27 deletions(-) diff --git a/src/server/core/interfaces/analytics/i-analytics-queue.ts b/src/server/core/interfaces/analytics/i-analytics-queue.ts index 1a1e66244..98f97bf57 100644 --- a/src/server/core/interfaces/analytics/i-analytics-queue.ts +++ b/src/server/core/interfaces/analytics/i-analytics-queue.ts @@ -1,33 +1,38 @@ -import type {AnalyticsEventWithIdentity} from '../../domain/analytics/batch.js' +import type {StoredAnalyticsRecord} from '../../domain/analytics/stored-record.js' /** - * In-memory queue contract for identity-stamped analytics events. + * In-memory queue contract for identity-stamped analytics records. * Implementations enforce a configurable cap with drop-oldest semantics * and track a cumulative dropped count for later observability. + * + * Carries `StoredAnalyticsRecord` (with `id`/`status`/`attempts` local + * metadata) since M9.3 — JSONL is the durable source of truth and the + * queue is a fast in-memory mirror. M10.2's `flush()` reads from JSONL + * (not this queue), so any drop-oldest eviction here is recoverable. */ export interface IAnalyticsQueue { /** - * Drains the queue and returns the events in FIFO order. Caller takes + * Drains the queue and returns the records in FIFO order. Caller takes * ownership; the queue is empty afterwards. `droppedCount()` is NOT * reset by this call. */ - drain: () => AnalyticsEventWithIdentity[] + drain: () => StoredAnalyticsRecord[] /** - * Returns the cumulative number of events dropped due to the cap + * Returns the cumulative number of records dropped due to the cap * across the queue's lifetime. Never reset. */ droppedCount: () => number /** - * Pushes an event onto the queue. If the queue is at capacity, the - * oldest event is dropped to make room and `droppedCount()` is + * Pushes a record onto the queue. If the queue is at capacity, the + * oldest record is dropped to make room and `droppedCount()` is * incremented. */ - push: (event: AnalyticsEventWithIdentity) => void + push: (record: StoredAnalyticsRecord) => void /** - * Returns the current number of events in the queue. + * Returns the current number of records in the queue. */ size: () => number } diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index b5334c67d..8a2eda976 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -1,7 +1,10 @@ -import type {AnalyticsEventWithIdentity} from '../../core/domain/analytics/batch.js' +import {randomUUID} from 'node:crypto' + +import type {StoredAnalyticsRecord} from '../../core/domain/analytics/stored-record.js' import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' import type {IIdentityResolver} from '../../core/interfaces/analytics/i-identity-resolver.js' +import type {IJsonlAnalyticsStore} from '../../core/interfaces/analytics/i-jsonl-analytics-store.js' import type {ISuperPropertiesResolver} from '../../core/interfaces/analytics/i-super-properties-resolver.js' import {AnalyticsBatch} from '../../core/domain/analytics/batch.js' @@ -9,6 +12,7 @@ import {AnalyticsBatch} from '../../core/domain/analytics/batch.js' export interface AnalyticsClientDeps { identityResolver: IIdentityResolver isEnabled: () => boolean + jsonlStore: IJsonlAnalyticsStore queue: IAnalyticsQueue superPropsResolver: ISuperPropertiesResolver } @@ -69,17 +73,27 @@ export class AnalyticsClient implements IAnalyticsClient { this.deps.superPropsResolver.resolve(), ]) - const stamped: AnalyticsEventWithIdentity = { + // M9.3: compose a StoredAnalyticsRecord — JSONL is the durable source of + // truth (M10.2's flush reads from JSONL, not the queue). The queue is a + // fast in-memory mirror for status display / future webui hot path. + const record: StoredAnalyticsRecord = { + attempts: 0, + id: randomUUID(), identity, name: event, // Super-properties are authoritative: they overwrite any user-supplied // property with the same key. This guarantees a consistent envelope // (cli_version, device_id, environment, node_version, os) on every event. properties: {...properties, ...superProps}, + status: 'pending', timestamp, } - this.deps.queue.push(stamped) + // Persist to JSONL FIRST. If this throws, the catch silently drops and the + // queue is NOT pushed — preserves the "JSONL is source of truth" invariant + // (no events visible to status display that aren't durably stored). + await this.deps.jsonlStore.append(record) + this.deps.queue.push(record) } catch { // Analytics MUST NOT crash the consumer. Errors silently dropped. } diff --git a/src/server/infra/analytics/bounded-queue.ts b/src/server/infra/analytics/bounded-queue.ts index 696c4d715..70ef4eafa 100644 --- a/src/server/infra/analytics/bounded-queue.ts +++ b/src/server/infra/analytics/bounded-queue.ts @@ -1,21 +1,26 @@ -import type {AnalyticsEventWithIdentity} from '../../core/domain/analytics/batch.js' +import type {StoredAnalyticsRecord} from '../../core/domain/analytics/stored-record.js' import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' const DEFAULT_MAX_SIZE = 1000 /** * In-memory bounded queue with drop-oldest semantics. Newest pushes - * always succeed; if the queue is at capacity, the oldest event is + * always succeed; if the queue is at capacity, the oldest record is * removed first. `droppedCount` is cumulative across the queue's * lifetime — neither `drain` nor any other method resets it. * * Backing store is a plain Array; at the default `maxSize` of 1000 the * O(n) cost of `Array.prototype.shift()` on overflow is negligible. + * + * Since M9.3 the queue carries `StoredAnalyticsRecord` (with `id` local + * metadata) as a fast in-memory mirror of the JSONL source-of-truth. + * Drop-oldest evictions here are recoverable because M10.2's `flush()` + * reads from JSONL, not from this queue. */ export class BoundedQueue implements IAnalyticsQueue { private dropped = 0 - private events: AnalyticsEventWithIdentity[] = [] private readonly maxSize: number + private records: StoredAnalyticsRecord[] = [] public constructor(maxSize: number = DEFAULT_MAX_SIZE) { if (!Number.isInteger(maxSize) || maxSize < 0) { @@ -25,9 +30,9 @@ export class BoundedQueue implements IAnalyticsQueue { this.maxSize = maxSize } - public drain(): AnalyticsEventWithIdentity[] { - const drained = this.events - this.events = [] + public drain(): StoredAnalyticsRecord[] { + const drained = this.records + this.records = [] return drained } @@ -35,15 +40,15 @@ export class BoundedQueue implements IAnalyticsQueue { return this.dropped } - public push(event: AnalyticsEventWithIdentity): void { - this.events.push(event) - while (this.events.length > this.maxSize) { - this.events.shift() + public push(record: StoredAnalyticsRecord): void { + this.records.push(record) + while (this.records.length > this.maxSize) { + this.records.shift() this.dropped++ } } public size(): number { - return this.events.length + return this.records.length } } diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 0d0b1f452..fd81dbae0 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -22,10 +22,12 @@ import {ReviewEvents} from '../../../shared/transport/events/review-events.js' import {getAuthConfig} from '../../config/auth.config.js' import {getCurrentConfig} from '../../config/environment.js' import {API_V1_PATH, BRV_DIR} from '../../constants.js' +import {getGlobalDataDir} from '../../utils/global-data-path.js' import {getProjectDataDir} from '../../utils/path-utils.js' import {AnalyticsClient} from '../analytics/analytics-client.js' import {BoundedQueue} from '../analytics/bounded-queue.js' import {IdentityResolver} from '../analytics/identity-resolver.js' +import {JsonlAnalyticsStore} from '../analytics/jsonl-analytics-store.js' import {SuperPropertiesResolver} from '../analytics/super-properties-resolver.js' import {OAuthService} from '../auth/oauth-service.js' import {OidcDiscoveryService} from '../auth/oidc-discovery-service.js' @@ -153,9 +155,16 @@ export async function setupFeatureHandlers({ // instance already in scope. The `daemon_start` event is NOT fired here — // it is fired by the caller (brv-server.ts) after authStateStore.loadToken() // resolves so the event reflects the real identity instead of anonymous. + // + // M9.3: a single JsonlAnalyticsStore instance is constructed here and + // injected into the AnalyticsClient. The same instance will be shared with + // M11.2's analytics-list-handler when it lands so both read/write the same + // file. Storage path: `/analytics-queue.jsonl`. + const jsonlAnalyticsStore = new JsonlAnalyticsStore({baseDir: getGlobalDataDir()}) const analyticsClient: IAnalyticsClient = new AnalyticsClient({ identityResolver: new IdentityResolver(authStateStore, globalConfigStore), isEnabled: () => globalConfigHandler.getCachedAnalytics(), + jsonlStore: jsonlAnalyticsStore, queue: new BoundedQueue(), superPropsResolver: new SuperPropertiesResolver(globalConfigStore), }) diff --git a/test/integration/analytics/daemon-tracking.test.ts b/test/integration/analytics/daemon-tracking.test.ts index a880fa637..2eaf3a3e7 100644 --- a/test/integration/analytics/daemon-tracking.test.ts +++ b/test/integration/analytics/daemon-tracking.test.ts @@ -15,6 +15,7 @@ import {GlobalConfig} from '../../../src/server/core/domain/entities/global-conf import {AnalyticsClient} from '../../../src/server/infra/analytics/analytics-client.js' import {BoundedQueue} from '../../../src/server/infra/analytics/bounded-queue.js' import {IdentityResolver} from '../../../src/server/infra/analytics/identity-resolver.js' +import {JsonlAnalyticsStore} from '../../../src/server/infra/analytics/jsonl-analytics-store.js' import {SuperPropertiesResolver} from '../../../src/server/infra/analytics/super-properties-resolver.js' import {FileGlobalConfigStore} from '../../../src/server/infra/storage/file-global-config-store.js' import {GlobalConfigHandler} from '../../../src/server/infra/transport/handlers/global-config-handler.js' @@ -78,6 +79,7 @@ describe('daemon analytics tracking integration (ticket scenario 6)', () => { const client = new AnalyticsClient({ identityResolver: new IdentityResolver(makeAnonAuthReader(), store), isEnabled: () => handler.getCachedAnalytics(), + jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), queue, superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), }) @@ -121,6 +123,7 @@ describe('daemon analytics tracking integration (ticket scenario 6)', () => { const client = new AnalyticsClient({ identityResolver: new IdentityResolver(makeAnonAuthReader(), store), isEnabled: () => handler.getCachedAnalytics(), + jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), queue, superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), }) @@ -149,6 +152,7 @@ describe('daemon analytics tracking integration (ticket scenario 6)', () => { const client = new AnalyticsClient({ identityResolver: new IdentityResolver(makeAnonAuthReader(), store), isEnabled: () => handler.getCachedAnalytics(), + jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), queue, superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), }) diff --git a/test/integration/analytics/transport.test.ts b/test/integration/analytics/transport.test.ts index 4cabef5f4..1c0449316 100644 --- a/test/integration/analytics/transport.test.ts +++ b/test/integration/analytics/transport.test.ts @@ -13,6 +13,7 @@ import {GlobalConfig} from '../../../src/server/core/domain/entities/global-conf import {AnalyticsClient} from '../../../src/server/infra/analytics/analytics-client.js' import {BoundedQueue} from '../../../src/server/infra/analytics/bounded-queue.js' import {IdentityResolver} from '../../../src/server/infra/analytics/identity-resolver.js' +import {JsonlAnalyticsStore} from '../../../src/server/infra/analytics/jsonl-analytics-store.js' import {SuperPropertiesResolver} from '../../../src/server/infra/analytics/super-properties-resolver.js' import {FileGlobalConfigStore} from '../../../src/server/infra/storage/file-global-config-store.js' import {AnalyticsHandler} from '../../../src/server/infra/transport/handlers/analytics-handler.js' @@ -79,6 +80,7 @@ describe('analytics:track transport round-trip integration (M2.6)', () => { const analyticsClient = new AnalyticsClient({ identityResolver: new IdentityResolver(makeAnonAuthReader(), store), isEnabled: () => globalConfigHandler.getCachedAnalytics(), + jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), queue, superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), }) @@ -121,6 +123,7 @@ describe('analytics:track transport round-trip integration (M2.6)', () => { const analyticsClient = new AnalyticsClient({ identityResolver: new IdentityResolver(makeAnonAuthReader(), store), isEnabled: () => globalConfigHandler.getCachedAnalytics(), + jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), queue, superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), }) @@ -154,6 +157,7 @@ describe('analytics:track transport round-trip integration (M2.6)', () => { const analyticsClient = new AnalyticsClient({ identityResolver: new IdentityResolver(makeAnonAuthReader(), store), isEnabled: () => globalConfigHandler.getCachedAnalytics(), + jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), queue, superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), }) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index 7d2ffcf9a..f3f0ccc00 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -1,15 +1,42 @@ /* eslint-disable camelcase */ import {expect} from 'chai' -import {stub} from 'sinon' +import {spy, stub} from 'sinon' import type {Identity} from '../../../../../src/server/core/domain/analytics/identity.js' +import type {StoredAnalyticsRecord} from '../../../../../src/server/core/domain/analytics/stored-record.js' import type {IIdentityResolver} from '../../../../../src/server/core/interfaces/analytics/i-identity-resolver.js' +import type {IJsonlAnalyticsStore} from '../../../../../src/server/core/interfaces/analytics/i-jsonl-analytics-store.js' import type {ISuperPropertiesResolver, SuperProperties} from '../../../../../src/server/core/interfaces/analytics/i-super-properties-resolver.js' import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' import {AnalyticsClient} from '../../../../../src/server/infra/analytics/analytics-client.js' import {BoundedQueue} from '../../../../../src/server/infra/analytics/bounded-queue.js' +type FakeJsonlStore = IJsonlAnalyticsStore & { + appendSpy: ReturnType + readonly records: StoredAnalyticsRecord[] +} + +function makeFakeJsonlStore(opts: {appendError?: Error} = {}): FakeJsonlStore { + const records: StoredAnalyticsRecord[] = [] + const appendImpl = async (record: StoredAnalyticsRecord): Promise => { + if (opts.appendError) throw opts.appendError + records.push(record) + } + + const appendSpy = spy(appendImpl) + return { + append: appendSpy, + appendSpy, + droppedFullCount: () => 0, + droppedSentCount: () => 0, + list: async () => ({rows: [...records], total: records.length}), + loadPending: async () => records.filter((r) => r.status === 'pending'), + records, + async updateStatus() {}, + } +} + const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' function makeAnonIdentity(): Identity { @@ -63,6 +90,7 @@ describe('AnalyticsClient', () => { const client = new AnalyticsClient({ identityResolver, isEnabled: () => false, + jsonlStore: makeFakeJsonlStore(), queue, superPropsResolver, }) @@ -88,6 +116,7 @@ describe('AnalyticsClient', () => { const client = new AnalyticsClient({ identityResolver: makeStubIdentityResolver(identity), isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), queue, superPropsResolver: makeStubSuperPropsResolver(superProps), }) @@ -129,6 +158,7 @@ describe('AnalyticsClient', () => { const client = new AnalyticsClient({ identityResolver, isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), queue, superPropsResolver, }) @@ -157,6 +187,7 @@ describe('AnalyticsClient', () => { const client = new AnalyticsClient({ identityResolver: makeStubIdentityResolver(makeAnonIdentity()), isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), queue, superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -179,6 +210,7 @@ describe('AnalyticsClient', () => { const client = new AnalyticsClient({ identityResolver: makeStubIdentityResolver(makeAnonIdentity()), isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), queue, superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -198,6 +230,7 @@ describe('AnalyticsClient', () => { const client = new AnalyticsClient({ identityResolver: makeStubIdentityResolver(makeAnonIdentity()), isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), queue: new BoundedQueue(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -217,6 +250,7 @@ describe('AnalyticsClient', () => { const client = new AnalyticsClient({ identityResolver, isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), queue, superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -238,6 +272,7 @@ describe('AnalyticsClient', () => { const client = new AnalyticsClient({ identityResolver: makeStubIdentityResolver(makeAnonIdentity()), isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), queue, superPropsResolver, }) @@ -264,6 +299,7 @@ describe('AnalyticsClient', () => { const client = new AnalyticsClient({ identityResolver: slowIdentityResolver, isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), queue, superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -298,6 +334,7 @@ describe('AnalyticsClient', () => { const client = new AnalyticsClient({ identityResolver: makeStubIdentityResolver(makeAnonIdentity()), isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), queue, superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -314,4 +351,137 @@ describe('AnalyticsClient', () => { expect(event.properties.custom).to.equal('kept') }) }) + + describe('M9.3 JSONL-first persistence (dual write)', () => { + it('should append to JSONL before pushing to queue (happy path)', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track('e1', {x: 1}) + await flushMicrotasks() + + // JSONL has the row + expect(jsonlStore.records).to.have.lengthOf(1) + const stored = jsonlStore.records[0] + expect(stored.name).to.equal('e1') + expect(stored.status).to.equal('pending') + expect(stored.attempts).to.equal(0) + expect(stored.id).to.be.a('string').and.have.length.greaterThan(0) + // Queue mirror has the same record (id propagates) + expect(queue.size()).to.equal(1) + const [drained] = queue.drain() + expect(drained.id).to.equal(stored.id) + }) + + it('should generate distinct uuid id per track call', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + for (let i = 0; i < 5; i++) { + client.track(`event_${i}`) + } + + await flushMicrotasks() + + const ids = jsonlStore.records.map((r) => r.id) + expect(new Set(ids).size).to.equal(5) // all distinct + expect(jsonlStore.records).to.have.lengthOf(5) + }) + + it('should NOT push to queue when JSONL append fails', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore({appendError: new Error('disk full')}) + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + expect(() => client.track('boom')).to.not.throw() + await flushMicrotasks() + + // JSONL append rejected (called once, but no record persisted) + expect(jsonlStore.appendSpy.calledOnce).to.equal(true) + expect(jsonlStore.records).to.have.lengthOf(0) + // Queue must NOT receive the event when JSONL persist failed + expect(queue.size()).to.equal(0) + }) + + it('should NOT push to queue and NOT crash when JSONL fails on every track', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore({appendError: new Error('persistent disk error')}) + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + for (let i = 0; i < 100; i++) { + expect(() => client.track(`event_${i}`)).to.not.throw() + } + + await flushMicrotasks() + + expect(queue.size()).to.equal(0) + }) + + it('should track queue.size() growth equal to JSONL row count under non-burst load', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + const N = 20 + for (let i = 0; i < N; i++) { + client.track(`event_${i}`) + } + + await flushMicrotasks() + + expect(queue.size()).to.equal(N) + expect(jsonlStore.records).to.have.lengthOf(N) + }) + + it('should NOT call jsonlStore.append when analytics disabled', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => false, + jsonlStore, + queue, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track('e1') + await flushMicrotasks() + + expect(jsonlStore.appendSpy.called).to.equal(false) + expect(jsonlStore.records).to.have.lengthOf(0) + expect(queue.size()).to.equal(0) + }) + }) }) diff --git a/test/unit/server/infra/analytics/bounded-queue.test.ts b/test/unit/server/infra/analytics/bounded-queue.test.ts index 1ddf0dce8..23df6e822 100644 --- a/test/unit/server/infra/analytics/bounded-queue.test.ts +++ b/test/unit/server/infra/analytics/bounded-queue.test.ts @@ -1,20 +1,25 @@ /* eslint-disable camelcase */ import {expect} from 'chai' -import type {AnalyticsEventWithIdentity} from '../../../../../src/server/core/domain/analytics/batch.js' +import type {StoredAnalyticsRecord} from '../../../../../src/server/core/domain/analytics/stored-record.js' import {BoundedQueue} from '../../../../../src/server/infra/analytics/bounded-queue.js' -function makeEvent(name: string): AnalyticsEventWithIdentity { +let eventCounter = 0 +function makeEvent(name: string): StoredAnalyticsRecord { + eventCounter += 1 return { + attempts: 0, + id: `id-${eventCounter}`, identity: {device_id: '550e8400-e29b-41d4-a716-446655440000'}, name, properties: {}, + status: 'pending', timestamp: 0, } } -function pushAll(queue: BoundedQueue, events: AnalyticsEventWithIdentity[]): void { +function pushAll(queue: BoundedQueue, events: StoredAnalyticsRecord[]): void { for (const event of events) { queue.push(event) } From 6f00cbf024f5f716dce05fc40b5c95a3b8a0183b Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 11 May 2026 00:20:17 +0700 Subject: [PATCH 18/87] fix: [ENG-2724] tighten JSONL=truth invariant via JsonlCapFullError doAppend used to silently return when the file-size cap was full of non-sent rows; AnalyticsClient.trackAsync awaits append and only proceeds to queue.push if it succeeds, so a silent return let the in-memory mirror queue diverge from disk in the saturation edge case. Replace the silent return with a typed JsonlCapFullError thrown after incrementing droppedFullCounter and persisting any partial sent-row compaction. The existing catch in trackAsync swallows the new error transparently, so the no-crash guarantee is preserved while the JSONL=truth invariant is restored. --- .../analytics/i-jsonl-analytics-store.ts | 12 ++- .../infra/analytics/analytics-client.ts | 8 +- .../infra/analytics/jsonl-analytics-store.ts | 28 ++++++- .../analytics/jsonl-analytics-store.test.ts | 76 ++++++++++++++++--- 4 files changed, 107 insertions(+), 17 deletions(-) diff --git a/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts b/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts index 4f3b2c81e..c4f2bfceb 100644 --- a/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts +++ b/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts @@ -56,9 +56,15 @@ export interface IJsonlAnalyticsStore { /** * Append a new record (`status='pending', attempts=0`) to the JSONL * file with fsync. If the file-size cap would be exceeded, oldest - * `'sent'` rows are dropped first; if no `'sent'` rows exist to drop, - * the append becomes a silent no-op and `droppedFullCount()` is - * incremented (analytics MUST NOT crash the consumer). + * `'sent'` rows are dropped first; if dropping every available `'sent'` + * row still leaves the file over cap, the append throws + * `JsonlCapFullError` after incrementing `droppedFullCount()`. + * + * The throw is the only signal callers have that the record did NOT land + * on disk — needed so the in-memory mirror queue (`IAnalyticsQueue`) does + * not push a record that JSONL never persisted (JSONL=truth invariant). + * Callers that don't care MUST still catch: analytics MUST NOT crash + * the consumer. */ append: (record: StoredAnalyticsRecord) => Promise diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index 8a2eda976..b3e03ca92 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -89,9 +89,11 @@ export class AnalyticsClient implements IAnalyticsClient { timestamp, } - // Persist to JSONL FIRST. If this throws, the catch silently drops and the - // queue is NOT pushed — preserves the "JSONL is source of truth" invariant - // (no events visible to status display that aren't durably stored). + // Persist to JSONL FIRST. If `append` throws — disk error, or + // `JsonlCapFullError` when the file-size cap is saturated with non-sent + // rows — the outer catch silently drops and queue.push is skipped. This + // preserves the "JSONL is source of truth" invariant: no record reaches + // the in-memory mirror queue without a durable on-disk row. await this.deps.jsonlStore.append(record) this.deps.queue.push(record) } catch { diff --git a/src/server/infra/analytics/jsonl-analytics-store.ts b/src/server/infra/analytics/jsonl-analytics-store.ts index bd2267a8b..5f256946d 100644 --- a/src/server/infra/analytics/jsonl-analytics-store.ts +++ b/src/server/infra/analytics/jsonl-analytics-store.ts @@ -16,6 +16,27 @@ const DEFAULT_FILE_NAME = 'analytics-queue.jsonl' const DEFAULT_MAX_ROWS = 5000 const DEFAULT_MAX_BYTES = 10 * 1024 * 1024 +/** + * Thrown by `append` when the file-size cap cannot accommodate the new + * record even after dropping every available `'sent'` row. The store has + * already persisted any partial compaction and incremented + * `droppedFullCount()`; the throw signals to the caller that THIS specific + * record did NOT land on disk so it can skip mirror writes (e.g. queue + * push) and keep the JSONL=truth invariant intact. + * + * Callers that don't care still MUST catch — analytics MUST NOT crash the + * consumer. + */ +export class JsonlCapFullError extends Error { + public readonly recordId: string + + public constructor(recordId: string) { + super(`JSONL cap full: record ${recordId} dropped (no sent rows left to evict)`) + this.name = 'JsonlCapFullError' + this.recordId = recordId + } +} + /** * Constructor options. `baseDir` is required (caller injects * `getGlobalDataDir()` in production; tests pass a `tmpdir()`-derived @@ -147,7 +168,10 @@ export class JsonlAnalyticsStore implements IJsonlAnalyticsStore { if (this.exceedsCap(simulated)) { const {kept, sentDropped} = this.compactRows(simulated) if (this.exceedsCap(kept)) { - // Even after dropping all sent rows, still over cap. Silently drop the new append. + // Even after dropping all sent rows, still over cap. Drop the new record and signal the + // caller so it can skip any mirror write (queue push). A silent return here would let + // AnalyticsClient.trackAsync diverge from disk: queue would carry an event that JSONL + // never persisted, breaking the JSONL=truth invariant. if (sentDropped > 0) { this.droppedSentCounter += sentDropped // Persist whatever sent rows we did manage to drop, but exclude the new record. @@ -155,7 +179,7 @@ export class JsonlAnalyticsStore implements IJsonlAnalyticsStore { } this.droppedFullCounter++ - return + throw new JsonlCapFullError(record.id) } // Compaction succeeded: write the compacted set (which already includes the new record). diff --git a/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts b/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts index 609062b10..ad8b4f081 100644 --- a/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts +++ b/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts @@ -8,7 +8,7 @@ import {join} from 'node:path' import type {StoredAnalyticsRecord} from '../../../../../src/server/core/domain/analytics/stored-record.js' import {MAX_ATTEMPTS, StoredAnalyticsRecordSchema} from '../../../../../src/server/core/domain/analytics/stored-record.js' -import {JsonlAnalyticsStore} from '../../../../../src/server/infra/analytics/jsonl-analytics-store.js' +import {JsonlAnalyticsStore, JsonlCapFullError} from '../../../../../src/server/infra/analytics/jsonl-analytics-store.js' const validIdentity = { device_id: '550e8400-e29b-41d4-a716-446655440000', @@ -416,13 +416,24 @@ describe('JsonlAnalyticsStore', () => { expect(ids).to.not.include('r2') // sent dropped }) - it('should silently no-op append when cap full of pending+failed (no sent to drop)', async () => { + it('should throw JsonlCapFullError when cap full of pending+failed (no sent to drop)', async () => { const baseDir = await freshTempDir() const store = new JsonlAnalyticsStore({baseDir, maxRows: 2}) await store.append(makeRecord({id: 'r1', timestamp: 100})) await store.append(makeRecord({id: 'r2', timestamp: 200})) // both pending; no sent rows - await store.append(makeRecord({id: 'r3', timestamp: 300})) // should silently drop NEW row + // The new record cannot land — file already at cap and no sent rows to evict. + // The store throws JsonlCapFullError so AnalyticsClient can skip its mirror queue.push, + // preserving the JSONL=truth invariant. A silent return would let the queue diverge from disk. + let caught: unknown + try { + await store.append(makeRecord({id: 'r3', timestamp: 300})) + } catch (error) { + caught = error + } + + expect(caught, 'append must throw on cap-full silent-drop').to.be.instanceOf(JsonlCapFullError) + expect((caught as JsonlCapFullError).recordId).to.equal('r3') const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) const ids = rows.map((r) => r.id).sort() @@ -431,17 +442,61 @@ describe('JsonlAnalyticsStore', () => { expect(store.droppedFullCount()).to.equal(1) }) - it('should track droppedFullCount cumulatively across multiple no-op appends', async () => { + it('should track droppedFullCount cumulatively across multiple cap-full throws', async () => { const baseDir = await freshTempDir() const store = new JsonlAnalyticsStore({baseDir, maxRows: 1}) await store.append(makeRecord({id: 'r1'})) - await store.append(makeRecord({id: 'r2'})) - await store.append(makeRecord({id: 'r3'})) + // Each cap-full append throws; counter increments before the throw so callers can still observe it. + for (const id of ['r2', 'r3']) { + let caught: unknown + try { + // eslint-disable-next-line no-await-in-loop + await store.append(makeRecord({id})) + } catch (error) { + caught = error + } + + expect(caught, `append('${id}') must throw on cap-full`).to.be.instanceOf(JsonlCapFullError) + } expect(store.droppedFullCount()).to.equal(2) }) + it('should throw JsonlCapFullError after partial byte-cap compaction insufficient to make room', async () => { + const baseDir = await freshTempDir() + // Tight byte cap so a single big pending row + a tiny sent row + a new big pending row + // exceeds cap even after dropping the small sent row. + const big = 'x'.repeat(400) + const tiny = 'x'.repeat(10) + const store = new JsonlAnalyticsStore({baseDir, maxBytes: 900, maxRows: 10_000}) + await store.append(makeRecord({id: 'sent-tiny', properties: {data: tiny}})) + await store.updateStatus(['sent-tiny'], 'sent') + await store.append(makeRecord({id: 'p1', properties: {data: big}})) // ~400-byte payload, fits + + // p2 is ~400 bytes; together with p1 (~400) the two pending rows alone are ~800 bytes. + // Dropping sent-tiny saves ~10 bytes; combined with p1+p2 the file is still over the 900-byte cap. + let caught: unknown + try { + await store.append(makeRecord({id: 'p2', properties: {data: big}})) + } catch (error) { + caught = error + } + + expect(caught, 'append must throw when even full sent compaction leaves file over byte cap').to.be.instanceOf( + JsonlCapFullError, + ) + expect((caught as JsonlCapFullError).recordId).to.equal('p2') + + // The store still persisted the sent-row drop (partial compaction) before throwing, + // so observers see the most-up-to-date state on disk. + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id).sort() + expect(ids).to.deep.equal(['p1']) // sent-tiny dropped, p2 not added + expect(store.droppedFullCount()).to.equal(1) + expect(store.droppedSentCount()).to.equal(1) + }) + it('should silently skip malformed JSON lines on read', async () => { const baseDir = await freshTempDir() const filePath = join(baseDir, 'analytics-queue.jsonl') @@ -480,9 +535,12 @@ describe('JsonlAnalyticsStore', () => { it('should respect byte cap as well as row cap', async () => { const baseDir = await freshTempDir() - // Tiny byte cap to force compaction quickly - const store = new JsonlAnalyticsStore({baseDir, maxBytes: 500, maxRows: 10_000}) - const big = 'x'.repeat(200) // each row > 200 bytes serialized + const big = 'x'.repeat(200) + // Compute the serialized row size dynamically so the cap holds 2 rows comfortably + // but 3 rows tip over, regardless of identity/uuid serialization length drift. + const sampleSize = JSON.stringify(makeRecord({properties: {data: big}})).length + 1 + const maxBytes = sampleSize * 2 + 50 + const store = new JsonlAnalyticsStore({baseDir, maxBytes, maxRows: 10_000}) await store.append(makeRecord({id: 'r1', properties: {data: big}})) await store.append(makeRecord({id: 'r2', properties: {data: big}})) await store.updateStatus(['r1'], 'sent') From 91246c641fe1de0f4aa3b58a23942908d104e24a Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 11 May 2026 00:50:28 +0700 Subject: [PATCH 19/87] feat: [ENG-2727] add IAnalyticsSender + NoOpAnalyticsSender (M10.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define the daemon-side sender contract M10.2's flush will invoke and the no-op default that ships before M4.2 wires the real HTTP sender. NoOpAnalyticsSender.send returns {succeeded: [], failed: []} (both empty) so M10.2's mirror wiring — updateStatus(succeeded, 'sent') and updateStatus(failed, 'failed') — receives empty input and becomes no-ops. Pending JSONL rows stay at status='pending' until the real sender plugs in. Returning empty arrays rather than echoing every input id as failed eliminates the data-loss hazard that would otherwise appear if M4.3 (the flush scheduler) lands before M4.2 (the HTTP sender). This ticket ships the interface + no-op file only. AnalyticsClient.deps is NOT extended with a sender field and feature-handlers.ts is not modified — wiring is owned by M10.2. --- .../analytics/i-analytics-sender.ts | 36 +++++++++ .../infra/analytics/no-op-analytics-sender.ts | 21 ++++++ .../analytics/no-op-analytics-sender.test.ts | 73 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/server/core/interfaces/analytics/i-analytics-sender.ts create mode 100644 src/server/infra/analytics/no-op-analytics-sender.ts create mode 100644 test/unit/server/infra/analytics/no-op-analytics-sender.test.ts diff --git a/src/server/core/interfaces/analytics/i-analytics-sender.ts b/src/server/core/interfaces/analytics/i-analytics-sender.ts new file mode 100644 index 000000000..3c585f9a6 --- /dev/null +++ b/src/server/core/interfaces/analytics/i-analytics-sender.ts @@ -0,0 +1,36 @@ +import type {StoredAnalyticsRecord} from '../../domain/analytics/stored-record.js' + +/** + * Per-send outcome. Each input record's `id` is mirrored back in exactly + * one of `succeeded` / `failed`; M10.2's flush wiring will then translate + * those id arrays into `JsonlAnalyticsStore.updateStatus` calls. + * + * Both arrays empty is a valid result and is what `NoOpAnalyticsSender` + * returns — it leaves JSONL state untouched ("nothing was processed"). + */ +export type SendResult = Readonly<{ + failed: string[] + succeeded: string[] +}> + +/** + * Daemon-side sender contract. M10.2's `AnalyticsClient.flush` invokes + * `send()` with a snapshot of pending JSONL rows; the sender's only + * responsibility is to attempt transmission and return the per-record + * outcome as id arrays. + * + * Implementations: + * - `NoOpAnalyticsSender` (this milestone): returns `{succeeded: [], failed: []}` + * — JSONL stays untouched until M4.2 wires the real HTTP sender. + * - `HttpAnalyticsSender` (M4.2): serializes records to the wire format and + * POSTs the batch to the telemetry backend. + */ +export interface IAnalyticsSender { + /** + * Attempts to ship `records`. Returns the per-record outcome as id arrays. + * MUST NOT throw — analytics MUST NOT crash the daemon. Implementations + * that hit a transient error (network failure, 5xx) should classify + * those records as `failed` and let M9.2's retry-cap policy handle them. + */ + send: (records: readonly StoredAnalyticsRecord[]) => Promise +} diff --git a/src/server/infra/analytics/no-op-analytics-sender.ts b/src/server/infra/analytics/no-op-analytics-sender.ts new file mode 100644 index 000000000..ab9da9402 --- /dev/null +++ b/src/server/infra/analytics/no-op-analytics-sender.ts @@ -0,0 +1,21 @@ +import type {StoredAnalyticsRecord} from '../../core/domain/analytics/stored-record.js' +import type {IAnalyticsSender, SendResult} from '../../core/interfaces/analytics/i-analytics-sender.js' + +/** + * Default sender used until M4.2 wires the real HTTP sender. `send()` is + * semantically inert: it returns both arrays empty, so when M10.2's flush + * mirrors the result back to JSONL via `updateStatus(succeeded, 'sent')` + * and `updateStatus(failed, 'failed')`, both calls receive empty input + * and become no-ops. Pending JSONL rows stay at `status='pending'` and + * the next flush tick (after the real sender plugs in) ships them. + * + * Returning empty arrays — rather than echoing every input id as + * `failed` — eliminates the data-loss hazard that would otherwise appear + * if M4.3 (the flush scheduler) lands before M4.2 (the HTTP sender): + * scheduled ticks remain observable but non-destructive. + */ +export class NoOpAnalyticsSender implements IAnalyticsSender { + public async send(_records: readonly StoredAnalyticsRecord[]): Promise { + return {failed: [], succeeded: []} + } +} diff --git a/test/unit/server/infra/analytics/no-op-analytics-sender.test.ts b/test/unit/server/infra/analytics/no-op-analytics-sender.test.ts new file mode 100644 index 000000000..60b50bd18 --- /dev/null +++ b/test/unit/server/infra/analytics/no-op-analytics-sender.test.ts @@ -0,0 +1,73 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' + +import type {StoredAnalyticsRecord} from '../../../../../src/server/core/domain/analytics/stored-record.js' +import type {SendResult} from '../../../../../src/server/core/interfaces/analytics/i-analytics-sender.js' + +import {NoOpAnalyticsSender} from '../../../../../src/server/infra/analytics/no-op-analytics-sender.js' + +const validIdentity = {device_id: '550e8400-e29b-41d4-a716-446655440000'} + +function makeRecord(overrides: Partial = {}): StoredAnalyticsRecord { + return { + attempts: 0, + id: randomUUID(), + identity: validIdentity, + name: 'cli_invocation', + properties: {}, + status: 'pending', + timestamp: 0, + ...overrides, + } +} + +describe('NoOpAnalyticsSender', () => { + describe('send()', () => { + it('should return both arrays empty for empty input', async () => { + const sender = new NoOpAnalyticsSender() + + const result = await sender.send([]) + + expect(result).to.deep.equal({failed: [], succeeded: []}) + }) + + it('should return both arrays empty for a single-record input', async () => { + const sender = new NoOpAnalyticsSender() + + const result = await sender.send([makeRecord({id: 'r1'})]) + + expect(result).to.deep.equal({failed: [], succeeded: []}) + }) + + it('should return both arrays empty for a many-record input', async () => { + const sender = new NoOpAnalyticsSender() + const records = Array.from({length: 50}, (_, i) => makeRecord({id: `r${i}`})) + + const result = await sender.send(records) + + expect(result).to.deep.equal({failed: [], succeeded: []}) + }) + + it('should leave JSONL state untouched when result is piped through a fake updateStatus recorder', async () => { + // Locked decision: NoOpAnalyticsSender must be semantically inert under M10.2's mirror wiring. + // Piping its result into updateStatus(succeeded, 'sent') + updateStatus(failed, 'failed') + // must produce ZERO status mutations, so JSONL rows stay at status='pending' until M4.2 + // wires the real HTTP sender. This guards the data-loss hazard called out in M10/README.md. + const sender = new NoOpAnalyticsSender() + const records = [makeRecord({id: 'r1'}), makeRecord({id: 'r2'}), makeRecord({id: 'r3'})] + + const recorder: Array<{ids: readonly string[]; status: 'failed' | 'sent'}> = [] + const fakeUpdateStatus = async (ids: readonly string[], status: 'failed' | 'sent'): Promise => { + if (ids.length === 0) return + recorder.push({ids, status}) + } + + const result: SendResult = await sender.send(records) + await fakeUpdateStatus(result.succeeded, 'sent') + await fakeUpdateStatus(result.failed, 'failed') + + expect(recorder, 'NoOp must not produce any status mutation').to.deep.equal([]) + }) + }) +}) From da71e34f0c47c44004c5b9290a250512c8ea87d2 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 11 May 2026 01:12:24 +0700 Subject: [PATCH 20/87] feat: [ENG-2729] wire AnalyticsClient.flush to read from JSONL via sender (M10.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flush() now reads pending rows from jsonlStore.loadPending() instead of queue.drain(), invokes the registered IAnalyticsSender, and mirrors the per-record outcome back via updateStatus(succeeded, 'sent') and updateStatus(failed, 'failed'). Reading from JSONL closes the burst-overflow gap: events tracked beyond the in-memory queue's maxSize are durably stored and remain shippable. A sender that throws is wrapped in try/catch and treated as all-failed — the daemon never crashes on transmission errors. The retry-cap policy remains owned by M9.2's updateStatus (increment attempts, stay 'pending' until cap, then terminal 'failed'); this ticket is a thin caller. Daemon construction injects NoOpAnalyticsSender so flush ticks are observable but non-destructive — JSONL rows stay at status='pending' until M4.2 wires the real HTTP sender, eliminating the data-loss hazard that would otherwise appear if M4.3 (the scheduler) ran first. The bounded queue is no longer the flush source; M9.3 still pushes to it on track for fast read-side access (status display, future webui). --- .../infra/analytics/analytics-client.ts | 36 ++- src/server/infra/process/feature-handlers.ts | 5 + .../analytics/daemon-tracking.test.ts | 4 + test/integration/analytics/transport.test.ts | 4 + .../infra/analytics/analytics-client.test.ts | 277 +++++++++++++++++- 5 files changed, 319 insertions(+), 7 deletions(-) diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index b3e03ca92..464788faf 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -3,17 +3,20 @@ import {randomUUID} from 'node:crypto' import type {StoredAnalyticsRecord} from '../../core/domain/analytics/stored-record.js' import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' +import type {IAnalyticsSender, SendResult} from '../../core/interfaces/analytics/i-analytics-sender.js' import type {IIdentityResolver} from '../../core/interfaces/analytics/i-identity-resolver.js' import type {IJsonlAnalyticsStore} from '../../core/interfaces/analytics/i-jsonl-analytics-store.js' import type {ISuperPropertiesResolver} from '../../core/interfaces/analytics/i-super-properties-resolver.js' import {AnalyticsBatch} from '../../core/domain/analytics/batch.js' +import {toWireEvent} from '../../core/domain/analytics/stored-record.js' export interface AnalyticsClientDeps { identityResolver: IIdentityResolver isEnabled: () => boolean jsonlStore: IJsonlAnalyticsStore queue: IAnalyticsQueue + sender: IAnalyticsSender superPropsResolver: ISuperPropertiesResolver } @@ -47,8 +50,39 @@ export class AnalyticsClient implements IAnalyticsClient { this.deps = deps } + /** + * Reads pending rows from JSONL (NOT from the in-memory queue), invokes + * the registered sender, and mirrors the per-record outcome back to JSONL + * via `updateStatus`. The queue is intentionally bypassed: it can drop + * oldest entries on burst overflow (>maxSize), and a queue-based flush + * would miss those rows even though JSONL still has them. + * + * Returns an `AnalyticsBatch` of wire-shape events (id/attempts/status + * stripped via `toWireEvent`) so a future caller can inspect what was + * shipped on this tick. `flush()` itself does NOT transmit — the sender + * does. The returned batch reflects the input snapshot, not the per-record + * succeeded/failed split. + * + * A sender that throws is treated as `{succeeded: [], failed: }` + * — analytics MUST NOT crash the daemon. M9.2's `updateStatus(_, 'failed')` + * owns the retry-cap policy: rows stay at `'pending'` until + * `attempts >= MAX_ATTEMPTS`, then transition to terminal `'failed'`. + * `flush()` is a thin caller — it does not inspect attempts. + */ public async flush(): Promise { - return AnalyticsBatch.create(this.deps.queue.drain()) + const records = await this.deps.jsonlStore.loadPending() + + let result: SendResult + try { + result = await this.deps.sender.send(records) + } catch { + result = {failed: records.map((r) => r.id), succeeded: []} + } + + await this.deps.jsonlStore.updateStatus(result.succeeded, 'sent') + await this.deps.jsonlStore.updateStatus(result.failed, 'failed') + + return AnalyticsBatch.create(records.map((r) => toWireEvent(r))) } public track(event: string, properties?: Record): void { diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index fd81dbae0..3b96b68af 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -28,6 +28,7 @@ import {AnalyticsClient} from '../analytics/analytics-client.js' import {BoundedQueue} from '../analytics/bounded-queue.js' import {IdentityResolver} from '../analytics/identity-resolver.js' import {JsonlAnalyticsStore} from '../analytics/jsonl-analytics-store.js' +import {NoOpAnalyticsSender} from '../analytics/no-op-analytics-sender.js' import {SuperPropertiesResolver} from '../analytics/super-properties-resolver.js' import {OAuthService} from '../auth/oauth-service.js' import {OidcDiscoveryService} from '../auth/oidc-discovery-service.js' @@ -161,11 +162,15 @@ export async function setupFeatureHandlers({ // M11.2's analytics-list-handler when it lands so both read/write the same // file. Storage path: `/analytics-queue.jsonl`. const jsonlAnalyticsStore = new JsonlAnalyticsStore({baseDir: getGlobalDataDir()}) + // M10.2: inject the M10.1 no-op sender. M4.2 will replace this with the real HTTP sender. + // The no-op returns {succeeded: [], failed: []} so flush ticks are observable but + // non-destructive — JSONL rows stay at status='pending' until the real sender plugs in. const analyticsClient: IAnalyticsClient = new AnalyticsClient({ identityResolver: new IdentityResolver(authStateStore, globalConfigStore), isEnabled: () => globalConfigHandler.getCachedAnalytics(), jsonlStore: jsonlAnalyticsStore, queue: new BoundedQueue(), + sender: new NoOpAnalyticsSender(), superPropsResolver: new SuperPropertiesResolver(globalConfigStore), }) diff --git a/test/integration/analytics/daemon-tracking.test.ts b/test/integration/analytics/daemon-tracking.test.ts index 2eaf3a3e7..b4ce585ba 100644 --- a/test/integration/analytics/daemon-tracking.test.ts +++ b/test/integration/analytics/daemon-tracking.test.ts @@ -16,6 +16,7 @@ import {AnalyticsClient} from '../../../src/server/infra/analytics/analytics-cli import {BoundedQueue} from '../../../src/server/infra/analytics/bounded-queue.js' import {IdentityResolver} from '../../../src/server/infra/analytics/identity-resolver.js' import {JsonlAnalyticsStore} from '../../../src/server/infra/analytics/jsonl-analytics-store.js' +import {NoOpAnalyticsSender} from '../../../src/server/infra/analytics/no-op-analytics-sender.js' import {SuperPropertiesResolver} from '../../../src/server/infra/analytics/super-properties-resolver.js' import {FileGlobalConfigStore} from '../../../src/server/infra/storage/file-global-config-store.js' import {GlobalConfigHandler} from '../../../src/server/infra/transport/handlers/global-config-handler.js' @@ -81,6 +82,7 @@ describe('daemon analytics tracking integration (ticket scenario 6)', () => { isEnabled: () => handler.getCachedAnalytics(), jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), queue, + sender: new NoOpAnalyticsSender(), superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), }) @@ -125,6 +127,7 @@ describe('daemon analytics tracking integration (ticket scenario 6)', () => { isEnabled: () => handler.getCachedAnalytics(), jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), queue, + sender: new NoOpAnalyticsSender(), superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), }) @@ -154,6 +157,7 @@ describe('daemon analytics tracking integration (ticket scenario 6)', () => { isEnabled: () => handler.getCachedAnalytics(), jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), queue, + sender: new NoOpAnalyticsSender(), superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), }) diff --git a/test/integration/analytics/transport.test.ts b/test/integration/analytics/transport.test.ts index 1c0449316..1e31a4d11 100644 --- a/test/integration/analytics/transport.test.ts +++ b/test/integration/analytics/transport.test.ts @@ -14,6 +14,7 @@ import {AnalyticsClient} from '../../../src/server/infra/analytics/analytics-cli import {BoundedQueue} from '../../../src/server/infra/analytics/bounded-queue.js' import {IdentityResolver} from '../../../src/server/infra/analytics/identity-resolver.js' import {JsonlAnalyticsStore} from '../../../src/server/infra/analytics/jsonl-analytics-store.js' +import {NoOpAnalyticsSender} from '../../../src/server/infra/analytics/no-op-analytics-sender.js' import {SuperPropertiesResolver} from '../../../src/server/infra/analytics/super-properties-resolver.js' import {FileGlobalConfigStore} from '../../../src/server/infra/storage/file-global-config-store.js' import {AnalyticsHandler} from '../../../src/server/infra/transport/handlers/analytics-handler.js' @@ -82,6 +83,7 @@ describe('analytics:track transport round-trip integration (M2.6)', () => { isEnabled: () => globalConfigHandler.getCachedAnalytics(), jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), queue, + sender: new NoOpAnalyticsSender(), superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), }) @@ -125,6 +127,7 @@ describe('analytics:track transport round-trip integration (M2.6)', () => { isEnabled: () => globalConfigHandler.getCachedAnalytics(), jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), queue, + sender: new NoOpAnalyticsSender(), superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), }) @@ -159,6 +162,7 @@ describe('analytics:track transport round-trip integration (M2.6)', () => { isEnabled: () => globalConfigHandler.getCachedAnalytics(), jsonlStore: new JsonlAnalyticsStore({baseDir: testDir}), queue, + sender: new NoOpAnalyticsSender(), superPropsResolver: new SuperPropertiesResolver(store, () => '3.10.3'), }) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index f3f0ccc00..75464b9ce 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -4,21 +4,25 @@ import {spy, stub} from 'sinon' import type {Identity} from '../../../../../src/server/core/domain/analytics/identity.js' import type {StoredAnalyticsRecord} from '../../../../../src/server/core/domain/analytics/stored-record.js' +import type {IAnalyticsSender, SendResult} from '../../../../../src/server/core/interfaces/analytics/i-analytics-sender.js' import type {IIdentityResolver} from '../../../../../src/server/core/interfaces/analytics/i-identity-resolver.js' -import type {IJsonlAnalyticsStore} from '../../../../../src/server/core/interfaces/analytics/i-jsonl-analytics-store.js' +import type {IJsonlAnalyticsStore, JsonlAnalyticsStoreUpdateStatus} from '../../../../../src/server/core/interfaces/analytics/i-jsonl-analytics-store.js' import type {ISuperPropertiesResolver, SuperProperties} from '../../../../../src/server/core/interfaces/analytics/i-super-properties-resolver.js' import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' import {AnalyticsClient} from '../../../../../src/server/infra/analytics/analytics-client.js' import {BoundedQueue} from '../../../../../src/server/infra/analytics/bounded-queue.js' +import {NoOpAnalyticsSender} from '../../../../../src/server/infra/analytics/no-op-analytics-sender.js' type FakeJsonlStore = IJsonlAnalyticsStore & { appendSpy: ReturnType readonly records: StoredAnalyticsRecord[] + readonly updateStatusCalls: Array<{ids: readonly string[]; status: JsonlAnalyticsStoreUpdateStatus}> } function makeFakeJsonlStore(opts: {appendError?: Error} = {}): FakeJsonlStore { const records: StoredAnalyticsRecord[] = [] + const updateStatusCalls: Array<{ids: readonly string[]; status: JsonlAnalyticsStoreUpdateStatus}> = [] const appendImpl = async (record: StoredAnalyticsRecord): Promise => { if (opts.appendError) throw opts.appendError records.push(record) @@ -33,7 +37,56 @@ function makeFakeJsonlStore(opts: {appendError?: Error} = {}): FakeJsonlStore { list: async () => ({rows: [...records], total: records.length}), loadPending: async () => records.filter((r) => r.status === 'pending'), records, - async updateStatus() {}, + // Simplified mirror of M9.2's updateStatus for unit tests: 'sent' is a terminal flip; + // 'failed' flips status directly. The real retry-cap (increment attempts, stay + // 'pending' until cap) lives in M9.2 and is verified end-to-end in M10.3. + async updateStatus(ids: readonly string[], status: JsonlAnalyticsStoreUpdateStatus): Promise { + updateStatusCalls.push({ids: [...ids], status}) + if (ids.length === 0) return + const idSet = new Set(ids) + for (let i = 0; i < records.length; i++) { + if (idSet.has(records[i].id)) records[i] = {...records[i], status} + } + }, + updateStatusCalls, + } +} + +type FakeSender = IAnalyticsSender & { + readonly calls: Array +} + +type FakeSenderOpts = + | {error: Error; kind: 'throw';} + | {failedIds: readonly string[]; kind: 'mixed'; succeededIds: readonly string[]} + | {kind: 'all-failed'} + | {kind: 'all-succeeded'} + +function makeFakeSender(opts?: FakeSenderOpts): FakeSender { + const resolved: FakeSenderOpts = opts ?? {kind: 'all-succeeded'} + const calls: Array = [] + return { + calls, + async send(records: readonly StoredAnalyticsRecord[]): Promise { + calls.push([...records]) + switch (resolved.kind) { + case 'all-failed': { + return {failed: records.map((r) => r.id), succeeded: []} + } + + case 'all-succeeded': { + return {failed: [], succeeded: records.map((r) => r.id)} + } + + case 'mixed': { + return {failed: [...resolved.failedIds], succeeded: [...resolved.succeededIds]} + } + + case 'throw': { + throw resolved.error + } + } + }, } } @@ -80,6 +133,14 @@ async function flushMicrotasks(): Promise { }) } +async function seedPending(client: AnalyticsClient, count: number): Promise { + for (let i = 0; i < count; i++) { + client.track(`event_${i}`) + } + + await flushMicrotasks() +} + describe('AnalyticsClient', () => { describe('disabled state (ticket scenario 1)', () => { it('should be a true no-op when isEnabled returns false', async () => { @@ -92,6 +153,7 @@ describe('AnalyticsClient', () => { isEnabled: () => false, jsonlStore: makeFakeJsonlStore(), queue, + sender: makeFakeSender(), superPropsResolver, }) @@ -118,6 +180,7 @@ describe('AnalyticsClient', () => { isEnabled: () => true, jsonlStore: makeFakeJsonlStore(), queue, + sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(superProps), }) @@ -160,6 +223,7 @@ describe('AnalyticsClient', () => { isEnabled: () => true, jsonlStore: makeFakeJsonlStore(), queue, + sender: makeFakeSender(), superPropsResolver, }) @@ -181,14 +245,19 @@ describe('AnalyticsClient', () => { }) }) - describe('queue cap honored (ticket scenario 4)', () => { - it('should drop excess events per the bounded queue contract', async () => { + describe('M10.2 burst-overflow regression: flush reads from JSONL, not the bounded queue', () => { + it('should ship every tracked event even when the in-memory queue dropped half during a burst', async () => { + // M10.2's central architectural call: flush() reads from JSONL via loadPending(), + // NOT from the in-memory queue. Without this, events tracked beyond queue.maxSize + // would be silently dropped from the active flush path until daemon restart. const queue = new BoundedQueue(5) + const jsonlStore = makeFakeJsonlStore() const client = new AnalyticsClient({ identityResolver: makeStubIdentityResolver(makeAnonIdentity()), isEnabled: () => true, - jsonlStore: makeFakeJsonlStore(), + jsonlStore, queue, + sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -199,7 +268,10 @@ describe('AnalyticsClient', () => { await flushMicrotasks() const batch = await client.flush() - expect(batch.events).to.have.lengthOf(5) + // All 10 events durably stored and flushed — JSONL is the source of truth. + expect(batch.events).to.have.lengthOf(10) + expect(jsonlStore.records).to.have.lengthOf(10) + // The queue still honors its cap (the regression here is independent of queue eviction). expect(queue.droppedCount()).to.equal(5) }) }) @@ -212,6 +284,7 @@ describe('AnalyticsClient', () => { isEnabled: () => true, jsonlStore: makeFakeJsonlStore(), queue, + sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -232,6 +305,7 @@ describe('AnalyticsClient', () => { isEnabled: () => true, jsonlStore: makeFakeJsonlStore(), queue: new BoundedQueue(), + sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -252,6 +326,7 @@ describe('AnalyticsClient', () => { isEnabled: () => true, jsonlStore: makeFakeJsonlStore(), queue, + sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -274,6 +349,7 @@ describe('AnalyticsClient', () => { isEnabled: () => true, jsonlStore: makeFakeJsonlStore(), queue, + sender: makeFakeSender(), superPropsResolver, }) @@ -301,6 +377,7 @@ describe('AnalyticsClient', () => { isEnabled: () => true, jsonlStore: makeFakeJsonlStore(), queue, + sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -336,6 +413,7 @@ describe('AnalyticsClient', () => { isEnabled: () => true, jsonlStore: makeFakeJsonlStore(), queue, + sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -361,6 +439,7 @@ describe('AnalyticsClient', () => { isEnabled: () => true, jsonlStore, queue, + sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -388,6 +467,7 @@ describe('AnalyticsClient', () => { isEnabled: () => true, jsonlStore, queue, + sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -410,6 +490,7 @@ describe('AnalyticsClient', () => { isEnabled: () => true, jsonlStore, queue, + sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -431,6 +512,7 @@ describe('AnalyticsClient', () => { isEnabled: () => true, jsonlStore, queue, + sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -451,6 +533,7 @@ describe('AnalyticsClient', () => { isEnabled: () => true, jsonlStore, queue, + sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -473,6 +556,7 @@ describe('AnalyticsClient', () => { isEnabled: () => false, jsonlStore, queue, + sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) @@ -484,4 +568,185 @@ describe('AnalyticsClient', () => { expect(queue.size()).to.equal(0) }) }) + + describe('M10.2 mirror flush: invokes sender, mirrors result back to JSONL via updateStatus', () => { + it('should pass loadPending records to sender.send exactly once per flush', async () => { + const jsonlStore = makeFakeJsonlStore() + const sender = makeFakeSender() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 3) + await client.flush() + + expect(sender.calls).to.have.lengthOf(1) + const [shipped] = sender.calls + expect(shipped).to.have.lengthOf(3) + expect(shipped.map((r) => r.name).sort()).to.deep.equal(['event_0', 'event_1', 'event_2']) + }) + + it('should mirror all-succeeded result by flipping rows to status=sent', async () => { + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender: makeFakeSender({kind: 'all-succeeded'}), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 3) + await client.flush() + + expect(jsonlStore.records.map((r) => r.status)).to.deep.equal(['sent', 'sent', 'sent']) + // updateStatus(succeeded, 'sent') called with all 3 ids; updateStatus(failed, 'failed') called with empty + const calls = jsonlStore.updateStatusCalls + expect(calls.find((c) => c.status === 'sent')?.ids).to.have.lengthOf(3) + expect(calls.find((c) => c.status === 'failed')?.ids).to.have.lengthOf(0) + }) + + it('should mirror all-failed result by flipping rows to status=failed', async () => { + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender: makeFakeSender({kind: 'all-failed'}), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 2) + await client.flush() + + // Note: real M9.2 keeps rows at 'pending' until MAX_ATTEMPTS — the FAKE store flips to + // 'failed' immediately for unit-test simplicity. End-to-end retry-cap composition is + // verified in M10.3 against the real JsonlAnalyticsStore. + expect(jsonlStore.records.map((r) => r.status)).to.deep.equal(['failed', 'failed']) + const calls = jsonlStore.updateStatusCalls + expect(calls.find((c) => c.status === 'failed')?.ids).to.have.lengthOf(2) + expect(calls.find((c) => c.status === 'sent')?.ids).to.have.lengthOf(0) + }) + + it('should mirror mixed result: some ids to sent, some to failed', async () => { + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + // Late-bound: build the mixed sender with the actual record ids after seeding. + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 4) + const ids = jsonlStore.records.map((r) => r.id) + // Re-construct client with a mixed sender keyed off the seeded ids. + const jsonlStore2 = makeFakeJsonlStore() + const client2 = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: jsonlStore2, + queue: new BoundedQueue(), + sender: makeFakeSender({failedIds: [ids[2], ids[3]], kind: 'mixed', succeededIds: [ids[0], ids[1]]}), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Re-seed with the SAME ids by appending records directly into jsonlStore2.records. + for (const r of jsonlStore.records) jsonlStore2.records.push(r) + + await client2.flush() + + // First two sent, last two flipped to failed (per fake-store simplified policy). + expect(jsonlStore2.records.find((r) => r.id === ids[0])?.status).to.equal('sent') + expect(jsonlStore2.records.find((r) => r.id === ids[1])?.status).to.equal('sent') + expect(jsonlStore2.records.find((r) => r.id === ids[2])?.status).to.equal('failed') + expect(jsonlStore2.records.find((r) => r.id === ids[3])?.status).to.equal('failed') + }) + + it('should treat a sender that throws as all-failed (no daemon crash)', async () => { + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender: makeFakeSender({error: new Error('network boom'), kind: 'throw'}), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 3) + + // The flush itself must not throw — daemon survives. + let threw = false + try { + await client.flush() + } catch { + threw = true + } + + expect(threw, 'flush MUST NOT throw when sender throws').to.equal(false) + expect(jsonlStore.records.map((r) => r.status)).to.deep.equal(['failed', 'failed', 'failed']) + }) + + it('should leave JSONL untouched when the no-op sender is wired (regression for review issue #4)', async () => { + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender: new NoOpAnalyticsSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 5) + const beforeStatuses = jsonlStore.records.map((r) => r.status) + const beforeAttempts = jsonlStore.records.map((r) => r.attempts) + + await client.flush() + + expect(jsonlStore.records.map((r) => r.status)).to.deep.equal(beforeStatuses) + expect(jsonlStore.records.map((r) => r.attempts)).to.deep.equal(beforeAttempts) + // Both updateStatus calls received empty arrays (no-op sender returns {[],[]}). + expect(jsonlStore.updateStatusCalls).to.deep.equal([ + {ids: [], status: 'sent'}, + {ids: [], status: 'failed'}, + ]) + }) + + it('should return a wire-shape AnalyticsBatch (id/attempts/status stripped via toWireEvent)', async () => { + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 1) + const batch = await client.flush() + + expect(batch.events).to.have.lengthOf(1) + const [event] = batch.events + expect(event).to.have.property('name', 'event_0') + expect(event).to.have.property('timestamp') + expect(event).to.have.property('properties') + expect(event).to.have.property('identity') + // Local-only fields stripped on the wire. + expect(event).to.not.have.property('id') + expect(event).to.not.have.property('attempts') + expect(event).to.not.have.property('status') + }) + }) }) From 6bc1573371a894ba18f61e4835a4516338434dd3 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 11 May 2026 01:20:26 +0700 Subject: [PATCH 21/87] feat: [ENG-2730] verify retry-cap composition end-to-end (M10.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end integration test driving 3 flush cycles with an all-failing sender against the real JsonlAnalyticsStore. Asserts the retry-cap policy (M9.2 owns) composes correctly with M10.2's mirror-flush wiring: - pending(0) -> pending(1) -> pending(2) -> failed(3) over MAX_ATTEMPTS flush cycles - loadPending re-surfaces the row while attempts < MAX_ATTEMPTS - loadPending excludes the row once it transitions to terminal failed - a 4th updateStatus(failed) on a terminal row is a no-op (no overshoot) - subsequent flush cycles pass empty input to the sender No new behavior code — M9.2 already implements the cap, M10.2 already implements the mirror call. This ticket is the regression guard so a future change to either side cannot silently break the composition. --- test/integration/analytics/retry-cap.test.ts | 216 +++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 test/integration/analytics/retry-cap.test.ts diff --git a/test/integration/analytics/retry-cap.test.ts b/test/integration/analytics/retry-cap.test.ts new file mode 100644 index 000000000..acbf67292 --- /dev/null +++ b/test/integration/analytics/retry-cap.test.ts @@ -0,0 +1,216 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' +import {existsSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {Identity} from '../../../src/server/core/domain/analytics/identity.js' +import type {StoredAnalyticsRecord} from '../../../src/server/core/domain/analytics/stored-record.js' +import type {IAnalyticsSender, SendResult} from '../../../src/server/core/interfaces/analytics/i-analytics-sender.js' +import type {IIdentityResolver} from '../../../src/server/core/interfaces/analytics/i-identity-resolver.js' +import type {ISuperPropertiesResolver, SuperProperties} from '../../../src/server/core/interfaces/analytics/i-super-properties-resolver.js' + +import {MAX_ATTEMPTS} from '../../../src/server/core/domain/analytics/stored-record.js' +import {AnalyticsClient} from '../../../src/server/infra/analytics/analytics-client.js' +import {BoundedQueue} from '../../../src/server/infra/analytics/bounded-queue.js' +import {JsonlAnalyticsStore} from '../../../src/server/infra/analytics/jsonl-analytics-store.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +function makeAnonIdentity(): Identity { + return {device_id: validDeviceId} +} + +function makeSuperProps(): SuperProperties { + return { + cli_version: '3.10.3', + device_id: validDeviceId, + environment: 'production', + node_version: 'v24.13.1', + os: 'darwin', + } +} + +function makeStubIdentityResolver(identity: Identity): IIdentityResolver { + return {resolve: async () => identity} +} + +function makeStubSuperPropsResolver(props: SuperProperties): ISuperPropertiesResolver { + return {resolve: async () => props} +} + +type AllFailingSender = IAnalyticsSender & { + readonly nonEmptyCallCount: number + readonly perCallInputs: ReadonlyArray> +} + +function makeAllFailingSender(): AllFailingSender { + const perCallInputs: Array> = [] + return { + get nonEmptyCallCount() { + return perCallInputs.filter((records) => records.length > 0).length + }, + perCallInputs, + async send(records: readonly StoredAnalyticsRecord[]): Promise { + perCallInputs.push([...records]) + return {failed: records.map((r) => r.id), succeeded: []} + }, + } +} + +async function waitForRows(jsonlStore: JsonlAnalyticsStore, count: number, timeoutMs = 2000): Promise { + const start = Date.now() + while (true) { + // eslint-disable-next-line no-await-in-loop + const result = await jsonlStore.list({limit: 1000, offset: 0}) + if (result.rows.length >= count) return + if (Date.now() - start > timeoutMs) { + throw new Error(`waitForRows: expected ${count}, got ${result.rows.length} after ${timeoutMs}ms`) + } + + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setImmediate(resolve) + }) + } +} + +describe('M10.3 retry-cap end-to-end composition (M9.1 constant + M9.2 store + M10.2 flush)', () => { + let baseDir: string + + beforeEach(() => { + baseDir = join(tmpdir(), `analytics-retry-cap-${Date.now()}-${randomUUID().slice(0, 8)}`) + }) + + afterEach(async () => { + if (existsSync(baseDir)) { + await rm(baseDir, {force: true, recursive: true}) + } + }) + + it('should walk a row pending(0) → pending(1) → pending(2) → failed(3) over MAX_ATTEMPTS flush cycles', async () => { + expect(MAX_ATTEMPTS, 'this test is keyed off MAX_ATTEMPTS=3 from M9.1').to.equal(3) + + const jsonlStore = new JsonlAnalyticsStore({baseDir}) + const sender = makeAllFailingSender() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Track exactly one event so the cap walk is unambiguous. + client.track('retry_target') + await waitForRows(jsonlStore, 1) + + const initialRows = await jsonlStore.list({limit: 100, offset: 0}) + expect(initialRows.rows).to.have.lengthOf(1) + const targetId = initialRows.rows[0].id + expect(initialRows.rows[0].status).to.equal('pending') + expect(initialRows.rows[0].attempts).to.equal(0) + + // Flush #1: sender fails → updateStatus(failed) increments attempts to 1 but keeps status='pending'. + await client.flush() + let snap = await jsonlStore.list({limit: 100, offset: 0}) + expect(snap.rows[0].status, 'after flush #1: status stays pending').to.equal('pending') + expect(snap.rows[0].attempts, 'after flush #1: attempts=1').to.equal(1) + + // loadPending must STILL surface this row so flush #2 retries it. + let pending = await jsonlStore.loadPending() + expect(pending.map((r) => r.id), 'pending after flush #1 must include the row').to.include(targetId) + + // Flush #2: attempts=2, still pending. + await client.flush() + snap = await jsonlStore.list({limit: 100, offset: 0}) + expect(snap.rows[0].status, 'after flush #2: status stays pending').to.equal('pending') + expect(snap.rows[0].attempts, 'after flush #2: attempts=2').to.equal(2) + + pending = await jsonlStore.loadPending() + expect(pending.map((r) => r.id), 'pending after flush #2 must include the row').to.include(targetId) + + // Flush #3: attempts hits MAX_ATTEMPTS=3 → row transitions to terminal 'failed'. + await client.flush() + snap = await jsonlStore.list({limit: 100, offset: 0}) + expect(snap.rows[0].status, 'after flush #3: row transitions to terminal failed').to.equal('failed') + expect(snap.rows[0].attempts, 'after flush #3: attempts=MAX_ATTEMPTS').to.equal(MAX_ATTEMPTS) + + // loadPending now EXCLUDES the row — terminal-failed rows are not retried. + pending = await jsonlStore.loadPending() + expect(pending.map((r) => r.id), 'pending after terminal failed must NOT include the row').to.not.include(targetId) + + // The sender saw exactly MAX_ATTEMPTS non-empty inputs (once per flush cycle while pending). + expect(sender.nonEmptyCallCount, 'sender saw the row exactly MAX_ATTEMPTS times').to.equal(MAX_ATTEMPTS) + }) + + it('should leave terminal failed rows untouched on a 4th updateStatus(failed) — no overshoot', async () => { + const jsonlStore = new JsonlAnalyticsStore({baseDir}) + const sender = makeAllFailingSender() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track('overshoot_target') + await waitForRows(jsonlStore, 1) + const initial = await jsonlStore.list({limit: 100, offset: 0}) + const {id} = initial.rows[0] + + // Drive the row to terminal 'failed' (3 cycles). + for (let i = 0; i < MAX_ATTEMPTS; i++) { + // eslint-disable-next-line no-await-in-loop + await client.flush() + } + + const beforeOvershoot = await jsonlStore.list({limit: 100, offset: 0}) + expect(beforeOvershoot.rows[0].status).to.equal('failed') + expect(beforeOvershoot.rows[0].attempts).to.equal(MAX_ATTEMPTS) + + // Direct call to updateStatus — what would happen if a stale flush retried. + await jsonlStore.updateStatus([id], 'failed') + const afterOvershoot = await jsonlStore.list({limit: 100, offset: 0}) + expect(afterOvershoot.rows[0].status, 'terminal failed stays failed').to.equal('failed') + expect(afterOvershoot.rows[0].attempts, 'attempts MUST NOT overshoot the cap').to.equal(MAX_ATTEMPTS) + }) + + it('should NOT pull a terminal-failed row back into a subsequent flush', async () => { + const jsonlStore = new JsonlAnalyticsStore({baseDir}) + const sender = makeAllFailingSender() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track('exclusion_target') + await waitForRows(jsonlStore, 1) + + // Drive to terminal failed. + for (let i = 0; i < MAX_ATTEMPTS; i++) { + // eslint-disable-next-line no-await-in-loop + await client.flush() + } + + expect(sender.nonEmptyCallCount).to.equal(MAX_ATTEMPTS) + + // A 4th flush passes an EMPTY pending set to the sender — the row is not re-shipped. + await client.flush() + expect(sender.nonEmptyCallCount, 'flush after terminal must not re-ship the row').to.equal(MAX_ATTEMPTS) + expect(sender.perCallInputs.at(-1), '4th flush passes [] to sender').to.deep.equal([]) + + // Returned batch must be empty. + const batch = await client.flush() + expect(batch.events, 'flush over no pending rows yields empty batch').to.deep.equal([]) + }) +}) From 8d3e406448a83e3ce37ab48ea373b49ec1665951 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 11 May 2026 01:25:02 +0700 Subject: [PATCH 22/87] feat: [ENG-2726] add analytics:list transport event + Zod schemas (M11.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the analytics transport-events module with a new analytics:list event so a future webui page can paginate through stored events without crossing the tui/webui to server import boundary. AnalyticsListRequestSchema validates {offset, limit, eventName?, status?} with bounds offset >= 0 and limit 1..200 — protects the daemon from accidental mass reads. AnalyticsListResponseSchema reuses M9.1's StoredAnalyticsRecordSchema directly so the daemon-side store and the eventual webui consumer share a single source of truth for the row shape. The inferred TypeScript types AnalyticsListRequest / AnalyticsListResponse are exported for caller use. This ticket adds schemas only. The daemon handler that consumes them and the defensive property redaction live in M11.2 (ENG-2728). --- .../transport/events/analytics-events.ts | 35 ++++++ .../events/analytics-list-schema.test.ts | 111 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 test/unit/shared/transport/events/analytics-list-schema.test.ts diff --git a/src/shared/transport/events/analytics-events.ts b/src/shared/transport/events/analytics-events.ts index e810ecae9..a225aeac1 100644 --- a/src/shared/transport/events/analytics-events.ts +++ b/src/shared/transport/events/analytics-events.ts @@ -1,6 +1,9 @@ import {z} from 'zod' +import {StoredAnalyticsRecordSchema} from '../../../server/core/domain/analytics/stored-record.js' + export const AnalyticsEvents = { + LIST: 'analytics:list', TRACK: 'analytics:track', } as const @@ -19,3 +22,35 @@ export const AnalyticsTrackPayloadSchema = z.object({ }) export type AnalyticsTrackPayload = z.infer + +/** + * Request schema for `analytics:list` (M11.1). Pagination is offset/limit; + * filters by `eventName` (free-form) and `status` (M9.1 enum). + * + * Bounds (`limit 1..200`, `offset >= 0`) protect the daemon from accidental + * mass reads and align with the M9.2 store's read-mostly use case. + */ +export const AnalyticsListRequestSchema = z.object({ + eventName: z.string().optional(), + limit: z.number().int().min(1).max(200), + offset: z.number().int().min(0), + status: z.enum(['pending', 'sent', 'failed']).optional(), +}) + +export type AnalyticsListRequest = z.infer + +/** + * Response schema for `analytics:list`. Reuses M9.1's + * `StoredAnalyticsRecordSchema` directly — no separate "wire" variant — + * so a single source of truth covers both the daemon-side store and the + * webui consumer (M11.2's handler enforces this schema on the way out). + * + * `total` is the post-filter row count (NOT total file rows) so a UI can + * render "showing X-Y of total" correctly. + */ +export const AnalyticsListResponseSchema = z.object({ + rows: z.array(StoredAnalyticsRecordSchema), + total: z.number().int().min(0), +}) + +export type AnalyticsListResponse = z.infer diff --git a/test/unit/shared/transport/events/analytics-list-schema.test.ts b/test/unit/shared/transport/events/analytics-list-schema.test.ts new file mode 100644 index 000000000..b5f3b7f8b --- /dev/null +++ b/test/unit/shared/transport/events/analytics-list-schema.test.ts @@ -0,0 +1,111 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import { + AnalyticsEvents, + AnalyticsListRequestSchema, + AnalyticsListResponseSchema, +} from '../../../../../src/shared/transport/events/analytics-events.js' + +const validIdentity = {device_id: '550e8400-e29b-41d4-a716-446655440000'} + +function makeValidRow(overrides: Record = {}): Record { + return { + attempts: 0, + id: 'rec-1', + identity: validIdentity, + name: 'cli_invocation', + properties: {}, + status: 'pending', + timestamp: 1_700_000_000_000, + ...overrides, + } +} + +describe('analytics:list transport schema (M11.1)', () => { + describe('event constant', () => { + it('should expose LIST = "analytics:list"', () => { + expect(AnalyticsEvents.LIST).to.equal('analytics:list') + }) + }) + + describe('AnalyticsListRequestSchema', () => { + it('should accept a minimal valid request {offset, limit}', () => { + const parsed = AnalyticsListRequestSchema.safeParse({limit: 50, offset: 0}) + expect(parsed.success, 'minimal request must validate').to.equal(true) + }) + + it('should accept a request with optional eventName + status filters', () => { + const parsed = AnalyticsListRequestSchema.safeParse({ + eventName: 'cli_invocation', + limit: 10, + offset: 0, + status: 'pending', + }) + expect(parsed.success).to.equal(true) + }) + + it('should reject offset < 0', () => { + const parsed = AnalyticsListRequestSchema.safeParse({limit: 10, offset: -1}) + expect(parsed.success).to.equal(false) + }) + + it('should reject limit < 1', () => { + const parsed = AnalyticsListRequestSchema.safeParse({limit: 0, offset: 0}) + expect(parsed.success).to.equal(false) + }) + + it('should reject limit > 200', () => { + const parsed = AnalyticsListRequestSchema.safeParse({limit: 201, offset: 0}) + expect(parsed.success).to.equal(false) + }) + + it('should reject non-integer offset/limit', () => { + expect(AnalyticsListRequestSchema.safeParse({limit: 1.5, offset: 0}).success).to.equal(false) + expect(AnalyticsListRequestSchema.safeParse({limit: 10, offset: 1.5}).success).to.equal(false) + }) + + it('should reject an unknown status value', () => { + const parsed = AnalyticsListRequestSchema.safeParse({limit: 10, offset: 0, status: 'archived'}) + expect(parsed.success).to.equal(false) + }) + + it('should reject when offset is missing', () => { + const parsed = AnalyticsListRequestSchema.safeParse({limit: 10}) + expect(parsed.success).to.equal(false) + }) + + it('should reject when limit is missing', () => { + const parsed = AnalyticsListRequestSchema.safeParse({offset: 0}) + expect(parsed.success).to.equal(false) + }) + }) + + describe('AnalyticsListResponseSchema', () => { + it('should accept a response with empty rows + total=0', () => { + const parsed = AnalyticsListResponseSchema.safeParse({rows: [], total: 0}) + expect(parsed.success).to.equal(true) + }) + + it('should accept a response with one valid row + total=1', () => { + const parsed = AnalyticsListResponseSchema.safeParse({rows: [makeValidRow()], total: 1}) + expect(parsed.success).to.equal(true) + }) + + it('should reject a response when a row is malformed (missing required field)', () => { + const malformed = {...makeValidRow(), id: undefined} + const parsed = AnalyticsListResponseSchema.safeParse({rows: [malformed], total: 1}) + expect(parsed.success).to.equal(false) + }) + + it('should reject a response when total is negative', () => { + const parsed = AnalyticsListResponseSchema.safeParse({rows: [], total: -1}) + expect(parsed.success).to.equal(false) + }) + + it('should reject a response when total is non-integer', () => { + const parsed = AnalyticsListResponseSchema.safeParse({rows: [], total: 1.5}) + expect(parsed.success).to.equal(false) + }) + }) +}) From 9af05c9a22766ec5b0629d5e856ffbb8c387b50f Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 11 May 2026 01:32:49 +0700 Subject: [PATCH 23/87] feat: [ENG-2728] add AnalyticsListHandler for analytics:list (M11.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon-side handler for the M11.1 analytics:list transport event: validates the request via the shared Zod schema, delegates to JsonlAnalyticsStore.list, applies defense-in-depth property redaction, and returns {rows, total}. Defensive failure mode mirrors the existing AnalyticsHandler — malformed input or any store throw yields {rows: [], total: 0} so analytics queries never crash the webui caller. Extracted FORBIDDEN_FIELD_NAMES from the M2.8 privacy fixture into a runtime constant at src/shared/analytics/forbidden-field-names.ts together with a redactRecord helper. The fixture now imports the runtime set and adds a sentinel test that guards the extraction — any future drop from the runtime list is caught immediately. redactRecord drops top-level forbidden keys from record.properties only; the identity block (device_id, email, name, user_id) is intentionally preserved because those names there are legit identifiers, not event-specific content. The handler shares the same JsonlAnalyticsStore instance constructed for trackAsync (M9.3) so reads see exactly what the write path persisted. --- src/server/infra/process/feature-handlers.ts | 5 + .../handlers/analytics-list-handler.ts | 60 ++++++ src/server/infra/transport/handlers/index.ts | 2 + src/shared/analytics/forbidden-field-names.ts | 89 ++++++++ .../handlers/analytics-list-handler.test.ts | 192 ++++++++++++++++++ .../shared/analytics/privacy-fixture.test.ts | 17 +- .../shared/analytics/redact-record.test.ts | 128 ++++++++++++ 7 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 src/server/infra/transport/handlers/analytics-list-handler.ts create mode 100644 src/shared/analytics/forbidden-field-names.ts create mode 100644 test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts create mode 100644 test/unit/shared/analytics/redact-record.test.ts diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 3b96b68af..01a0f3800 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -59,6 +59,7 @@ import {HttpTeamService} from '../team/http-team-service.js' import {FsTemplateLoader} from '../template/fs-template-loader.js' import { AnalyticsHandler, + AnalyticsListHandler, AuthHandler, ConfigHandler, ConnectorsHandler, @@ -178,6 +179,10 @@ export async function setupFeatureHandlers({ // (TUI, oclif, MCP, webui) to the same singleton. new AnalyticsHandler({analyticsClient, transport}).setup() + // M11.2: webui-facing read API. Shares the same JsonlAnalyticsStore instance + // as the AnalyticsClient so reads see exactly what trackAsync persisted. + new AnalyticsListHandler({jsonlStore: jsonlAnalyticsStore, transport}).setup() + new AuthHandler({ authService: new OAuthService(authConfig), authStateStore, diff --git a/src/server/infra/transport/handlers/analytics-list-handler.ts b/src/server/infra/transport/handlers/analytics-list-handler.ts new file mode 100644 index 000000000..b0178997a --- /dev/null +++ b/src/server/infra/transport/handlers/analytics-list-handler.ts @@ -0,0 +1,60 @@ +import type {IJsonlAnalyticsStore} from '../../../core/interfaces/analytics/i-jsonl-analytics-store.js' +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import {redactRecord} from '../../../../shared/analytics/forbidden-field-names.js' +import { + AnalyticsEvents, + type AnalyticsListRequest, + AnalyticsListRequestSchema, + type AnalyticsListResponse, +} from '../../../../shared/transport/events/analytics-events.js' + +export interface AnalyticsListHandlerDeps { + jsonlStore: IJsonlAnalyticsStore + transport: ITransportServer +} + +const EMPTY_RESPONSE: AnalyticsListResponse = {rows: [], total: 0} + +/** + * Daemon-side handler for `analytics:list` (M11.2). Validates the + * inbound request against M11.1's Zod schema, delegates to + * `JsonlAnalyticsStore.list`, applies defense-in-depth property + * redaction (drops keys in `FORBIDDEN_FIELD_NAMES`), and returns + * `{rows, total}`. + * + * Defensive failure mode mirrors the existing `AnalyticsHandler`: + * malformed input or any throw from the store yields + * `{rows: [], total: 0}`. Analytics queries MUST NEVER crash the + * webui requester. + * + * Identity is intentionally NOT redacted — see `redactRecord` for the + * rationale (the four identity fields are super-properties, not + * event-specific content). + */ +export class AnalyticsListHandler { + private readonly jsonlStore: IJsonlAnalyticsStore + private readonly transport: ITransportServer + + public constructor(deps: AnalyticsListHandlerDeps) { + this.jsonlStore = deps.jsonlStore + this.transport = deps.transport + } + + public setup(): void { + this.transport.onRequest( + AnalyticsEvents.LIST, + async (data: unknown): Promise => { + const parsed = AnalyticsListRequestSchema.safeParse(data) + if (!parsed.success) return EMPTY_RESPONSE + + try { + const {rows, total} = await this.jsonlStore.list(parsed.data) + return {rows: rows.map((r) => redactRecord(r)), total} + } catch { + return EMPTY_RESPONSE + } + }, + ) + } +} diff --git a/src/server/infra/transport/handlers/index.ts b/src/server/infra/transport/handlers/index.ts index 45a092e72..1af15469a 100644 --- a/src/server/infra/transport/handlers/index.ts +++ b/src/server/infra/transport/handlers/index.ts @@ -1,5 +1,7 @@ export {AnalyticsHandler} from './analytics-handler.js' export type {AnalyticsHandlerDeps} from './analytics-handler.js' +export {AnalyticsListHandler} from './analytics-list-handler.js' +export type {AnalyticsListHandlerDeps} from './analytics-list-handler.js' export {AuthHandler} from './auth-handler.js' export type {AuthHandlerDeps} from './auth-handler.js' export {ConfigHandler} from './config-handler.js' diff --git a/src/shared/analytics/forbidden-field-names.ts b/src/shared/analytics/forbidden-field-names.ts new file mode 100644 index 000000000..c4b1479c2 --- /dev/null +++ b/src/shared/analytics/forbidden-field-names.ts @@ -0,0 +1,89 @@ +import type {StoredAnalyticsRecord} from '../../server/core/domain/analytics/stored-record.js' + +/** + * Field names that MUST NOT appear inside an analytics event's `properties` + * record. Originally extracted from the M2.8 privacy fixture + * (test/unit/shared/analytics/privacy-fixture.test.ts) which uses this set + * to assert that no per-event Zod schema declares any of these keys. + * + * M11.2 promotes the list to a runtime constant so the daemon's + * analytics-list-handler can apply defense-in-depth redaction on read. + * + * Categories: secrets/credentials, PII identifiers, filesystem paths, + * user content, error fields that may carry paths/secrets, network + * identifiers. + */ +export const FORBIDDEN_FIELD_NAMES: ReadonlySet = new Set([ + // Secrets / credentials + 'access_token', + // PII identifiers + 'address', + 'api_key', + // Filesystem paths + 'argv', + 'auth_header', + 'auth_token', + // User content + 'content', + 'cookie', + 'credential', + 'cwd', + 'display_name', + 'email', + // Errors that may carry paths/secrets/content + 'error_message', + 'file_path', + 'first_name', + 'folder_path', + 'goal', + 'home_dir', + // Network identifiers + 'hostname', + 'ip', + 'last_name', + 'mac', + 'output', + 'password', + 'path', + 'phone', + 'phone_number', + 'project_path', + 'prompt', + 'query', + 'result', + 'secret', + 'session_id', + 'session_token', + 'ssn', + 'stack', + 'token', + 'username', + 'worktree_root', +]) + +/** + * Defense-in-depth redaction for `record.properties`. Drops any top-level + * key whose name appears on `FORBIDDEN_FIELD_NAMES`; preserves all other + * keys verbatim. + * + * `record.identity` is INTENTIONALLY left untouched. The identity block + * (`device_id`, `email`, `name`, `user_id`) is the always-stamped + * super-property — `email` there is a legit identifier for the local + * user, not a content leak. The forbidden list applies only to + * event-specific property schemas, not to the identity envelope. + * + * Returns a fresh shallow clone — the caller can mutate the result + * without affecting the input. Only top-level `properties` keys are + * inspected; nested objects are passed through untouched (the M2.8 + * schema layer is responsible for preventing nested forbidden names + * from ever being declared). + */ +export function redactRecord(record: StoredAnalyticsRecord): StoredAnalyticsRecord { + const safeProperties: Record = {} + for (const [key, value] of Object.entries(record.properties)) { + if (FORBIDDEN_FIELD_NAMES.has(key)) continue + safeProperties[key] = value + } + + return {...record, properties: safeProperties} +} diff --git a/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts new file mode 100644 index 000000000..826992e0e --- /dev/null +++ b/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts @@ -0,0 +1,192 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {spy} from 'sinon' + +import type {StoredAnalyticsRecord} from '../../../../../../src/server/core/domain/analytics/stored-record.js' +import type { + IJsonlAnalyticsStore, + JsonlAnalyticsStoreListOptions, + JsonlAnalyticsStoreListResult, +} from '../../../../../../src/server/core/interfaces/analytics/i-jsonl-analytics-store.js' + +import {AnalyticsListHandler} from '../../../../../../src/server/infra/transport/handlers/analytics-list-handler.js' +import {AnalyticsEvents} from '../../../../../../src/shared/transport/events/analytics-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +type AnalyticsListRequestHandler = ( + data: unknown, + clientId: string, +) => Promise<{rows: StoredAnalyticsRecord[]; total: number}> + +const validIdentity = {device_id: '550e8400-e29b-41d4-a716-446655440000'} + +function makeRecord(overrides: Partial = {}): StoredAnalyticsRecord { + return { + attempts: 0, + id: `rec-${Math.random().toString(16).slice(2, 8)}`, + identity: validIdentity, + name: 'cli_invocation', + properties: {}, + status: 'pending', + timestamp: 1_700_000_000_000, + ...overrides, + } +} + +type FakeJsonlStore = IJsonlAnalyticsStore & { + listSpy: ReturnType +} + +function makeFakeJsonlStore(rows: StoredAnalyticsRecord[]): FakeJsonlStore { + const listImpl = async (opts: JsonlAnalyticsStoreListOptions): Promise => { + const filtered = rows.filter((row) => { + if (opts.eventName !== undefined && row.name !== opts.eventName) return false + if (opts.status !== undefined && row.status !== opts.status) return false + return true + }) + return {rows: filtered.slice(opts.offset, opts.offset + opts.limit), total: filtered.length} + } + + const listSpy = spy(listImpl) + return { + async append() {}, + droppedFullCount: () => 0, + droppedSentCount: () => 0, + list: listSpy, + listSpy, + loadPending: async () => rows.filter((r) => r.status === 'pending'), + async updateStatus() {}, + } +} + +describe('AnalyticsListHandler (M11.2)', () => { + it('should register a handler for analytics:list on setup()', () => { + const transport = createMockTransportServer() + new AnalyticsListHandler({jsonlStore: makeFakeJsonlStore([]), transport}).setup() + + expect(transport._handlers.has(AnalyticsEvents.LIST)).to.equal(true) + }) + + it('should return {rows: [], total: 0} for a malformed payload (no throw)', async () => { + const transport = createMockTransportServer() + const jsonlStore = makeFakeJsonlStore([makeRecord()]) + new AnalyticsListHandler({jsonlStore, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.LIST) as AnalyticsListRequestHandler + + for (const malformed of [null, undefined, {}, {limit: 'not-a-number'}, {limit: 10}, {offset: 0}]) { + // eslint-disable-next-line no-await-in-loop + const result = await handler(malformed, 'client-1') + expect(result).to.deep.equal({rows: [], total: 0}) + } + + expect(jsonlStore.listSpy.called, 'malformed payload must NOT reach the store').to.equal(false) + }) + + it('should forward offset/limit to jsonlStore.list and return its result', async () => { + const records = [ + makeRecord({id: 'r1', name: 'a'}), + makeRecord({id: 'r2', name: 'b'}), + makeRecord({id: 'r3', name: 'c'}), + ] + const transport = createMockTransportServer() + const jsonlStore = makeFakeJsonlStore(records) + new AnalyticsListHandler({jsonlStore, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.LIST) as AnalyticsListRequestHandler + + const result = await handler({limit: 2, offset: 1}, 'client-1') + + expect(jsonlStore.listSpy.calledOnce).to.equal(true) + expect(jsonlStore.listSpy.firstCall.args[0]).to.deep.equal({limit: 2, offset: 1}) + expect(result.total).to.equal(3) + expect(result.rows.map((r) => r.id)).to.deep.equal(['r2', 'r3']) + }) + + it('should forward eventName + status filter combos correctly', async () => { + const transport = createMockTransportServer() + const jsonlStore = makeFakeJsonlStore([]) + new AnalyticsListHandler({jsonlStore, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.LIST) as AnalyticsListRequestHandler + + await handler({eventName: 'cli_invocation', limit: 10, offset: 0, status: 'pending'}, 'client-1') + + expect(jsonlStore.listSpy.firstCall.args[0]).to.deep.equal({ + eventName: 'cli_invocation', + limit: 10, + offset: 0, + status: 'pending', + }) + }) + + it('should redact forbidden keys from row.properties before returning', async () => { + const records = [ + makeRecord({ + id: 'r1', + properties: {command_id: 'status', password: 'leak', token: 'jwt-xxx'}, + }), + ] + const transport = createMockTransportServer() + const jsonlStore = makeFakeJsonlStore(records) + new AnalyticsListHandler({jsonlStore, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.LIST) as AnalyticsListRequestHandler + const result = await handler({limit: 10, offset: 0}, 'client-1') + + expect(result.rows[0].properties).to.deep.equal({command_id: 'status'}) + // Source rows must NOT have been mutated. + expect(records[0].properties).to.have.property('password', 'leak') + }) + + it('should NOT redact identity (locked decision: identity block stays intact)', async () => { + const records = [ + makeRecord({ + id: 'r1', + identity: {device_id: validIdentity.device_id, email: 'alice@example.com', name: 'Alice', user_id: 'u-1'}, + properties: {command_id: 'status'}, + }), + ] + const transport = createMockTransportServer() + const jsonlStore = makeFakeJsonlStore(records) + new AnalyticsListHandler({jsonlStore, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.LIST) as AnalyticsListRequestHandler + const result = await handler({limit: 10, offset: 0}, 'client-1') + + expect(result.rows[0].identity).to.deep.equal({ + device_id: validIdentity.device_id, + email: 'alice@example.com', + name: 'Alice', + user_id: 'u-1', + }) + }) + + it('should return {rows: [], total: 0} when the store throws (no daemon crash)', async () => { + const transport = createMockTransportServer() + const throwingStore: IJsonlAnalyticsStore = { + async append() {}, + droppedFullCount: () => 0, + droppedSentCount: () => 0, + async list() { + throw new Error('store boom') + }, + loadPending: async () => [], + async updateStatus() {}, + } + new AnalyticsListHandler({jsonlStore: throwingStore, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.LIST) as AnalyticsListRequestHandler + + let result: undefined | {rows: StoredAnalyticsRecord[]; total: number} + let threw = false + try { + result = await handler({limit: 10, offset: 0}, 'client-1') + } catch { + threw = true + } + + expect(threw, 'handler MUST NOT propagate store throws').to.equal(false) + expect(result).to.deep.equal({rows: [], total: 0}) + }) +}) diff --git a/test/unit/shared/analytics/privacy-fixture.test.ts b/test/unit/shared/analytics/privacy-fixture.test.ts index 381946505..9d1922862 100644 --- a/test/unit/shared/analytics/privacy-fixture.test.ts +++ b/test/unit/shared/analytics/privacy-fixture.test.ts @@ -3,8 +3,12 @@ import {expect} from 'chai' import {z} from 'zod' import {ALL_EVENT_SCHEMAS} from '../../../../src/shared/analytics/events/index.js' +import {FORBIDDEN_FIELD_NAMES} from '../../../../src/shared/analytics/forbidden-field-names.js' -const FORBIDDEN_FIELD_NAMES: ReadonlySet = new Set([ +// Sentinel — the test below asserts the imported set still contains the canonical +// names this fixture audits against. Any drift between this fixture and the runtime +// constant would indicate the M11.2 extraction broke privacy coverage. +const FIXTURE_SENTINEL_NAMES: ReadonlySet = new Set([ // Secrets / credentials 'access_token', // PII identifiers (super-properties carry email/name when authenticated; @@ -85,6 +89,17 @@ function getShapeFieldNames(schema: z.ZodTypeAny, seen: Set = new } describe('analytics privacy fixture (smoke)', () => { + it('should keep the runtime FORBIDDEN_FIELD_NAMES set as a superset of this fixture sentinel', () => { + // Regression guard for the M11.2 extraction: any name this fixture historically + // audited against MUST still be present in the runtime constant. + const missing: string[] = [] + for (const name of FIXTURE_SENTINEL_NAMES) { + if (!FORBIDDEN_FIELD_NAMES.has(name)) missing.push(name) + } + + expect(missing, `runtime FORBIDDEN_FIELD_NAMES dropped: ${missing.join(', ')}`).to.deep.equal([]) + }) + it('should not declare any field name on the forbidden PII list across all event schemas', () => { const violations: Array<{eventName: string; field: string}> = [] diff --git a/test/unit/shared/analytics/redact-record.test.ts b/test/unit/shared/analytics/redact-record.test.ts new file mode 100644 index 000000000..3631cb923 --- /dev/null +++ b/test/unit/shared/analytics/redact-record.test.ts @@ -0,0 +1,128 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import type {StoredAnalyticsRecord} from '../../../../src/server/core/domain/analytics/stored-record.js' + +import {FORBIDDEN_FIELD_NAMES, redactRecord} from '../../../../src/shared/analytics/forbidden-field-names.js' + +const validIdentity = {device_id: '550e8400-e29b-41d4-a716-446655440000'} + +function makeRecord(overrides: Partial = {}): StoredAnalyticsRecord { + return { + attempts: 0, + id: 'rec-1', + identity: validIdentity, + name: 'cli_invocation', + properties: {}, + status: 'pending', + timestamp: 1_700_000_000_000, + ...overrides, + } +} + +describe('redactRecord (M11.2)', () => { + describe('FORBIDDEN_FIELD_NAMES exports', () => { + it('should export a non-empty Set of forbidden names', () => { + expect(FORBIDDEN_FIELD_NAMES).to.be.instanceOf(Set) + expect(FORBIDDEN_FIELD_NAMES.size).to.be.greaterThan(0) + }) + + it('should include canonical secret/credential names', () => { + for (const name of ['password', 'token', 'access_token', 'secret', 'cookie']) { + expect(FORBIDDEN_FIELD_NAMES.has(name), `forbidden list must include "${name}"`).to.equal(true) + } + }) + + it('should include canonical PII / path names that the M2.8 fixture forbids in event schemas', () => { + for (const name of ['email', 'phone', 'cwd', 'path', 'home_dir']) { + expect(FORBIDDEN_FIELD_NAMES.has(name)).to.equal(true) + } + }) + }) + + describe('redaction over record.properties', () => { + it('should drop forbidden keys from properties (top level)', () => { + const record = makeRecord({ + properties: {command_id: 'status', password: 'p455w0rd', token: 'jwt-xxx'}, + }) + + const out = redactRecord(record) + + expect(out.properties).to.not.have.property('password') + expect(out.properties).to.not.have.property('token') + expect(out.properties).to.have.property('command_id', 'status') + }) + + it('should preserve non-forbidden keys verbatim', () => { + const record = makeRecord({ + properties: {command_id: 'status', duration_ms: 42, success: true}, + }) + + const out = redactRecord(record) + + expect(out.properties).to.deep.equal({command_id: 'status', duration_ms: 42, success: true}) + }) + + it('should leave the record untouched when properties are empty', () => { + const record = makeRecord({properties: {}}) + + const out = redactRecord(record) + + expect(out.properties).to.deep.equal({}) + }) + + it('should NOT recurse into nested objects (top-level redaction only)', () => { + // The forbidden-list check applies only to the immediate keys of properties. + // A nested {meta: {password: '...'}} keeps the nested key — defense lives at the + // M2.8 schema layer (which prevents the schema from declaring nested forbidden + // names), and the runtime redactor is intentionally minimal. + const record = makeRecord({ + properties: {meta: {nested_ok: true, password: 'x'}, password: 'top-level'}, + }) + + const out = redactRecord(record) + + expect(out.properties).to.not.have.property('password') + expect(out.properties.meta).to.deep.equal({nested_ok: true, password: 'x'}) + }) + + it('should return a fresh object (caller-safe — does not mutate input)', () => { + const record = makeRecord({ + properties: {command_id: 'status', password: 'leak'}, + }) + + const out = redactRecord(record) + + expect(out).to.not.equal(record) + expect(out.properties).to.not.equal(record.properties) + // Input properties unchanged. + expect(record.properties).to.have.property('password') + }) + }) + + describe('identity is intentionally NOT redacted (locked decision)', () => { + it('should preserve identity.email even though "email" is on FORBIDDEN_FIELD_NAMES', () => { + const record = makeRecord({ + identity: {device_id: validIdentity.device_id, email: 'alice@example.com'}, + }) + + const out = redactRecord(record) + + expect(out.identity).to.deep.equal({device_id: validIdentity.device_id, email: 'alice@example.com'}) + }) + + it('should preserve identity.name and identity.user_id', () => { + const record = makeRecord({ + identity: {device_id: validIdentity.device_id, name: 'Alice', user_id: 'user-1'}, + }) + + const out = redactRecord(record) + + expect(out.identity).to.deep.equal({ + device_id: validIdentity.device_id, + name: 'Alice', + user_id: 'user-1', + }) + }) + }) +}) From 99ae29b401621c8b99efc5ad1fb103b14600f13d Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 11 May 2026 10:10:46 +0700 Subject: [PATCH 24/87] fix: [ENG-2729] single-flight guard for AnalyticsClient.flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flush() used to load pending rows from JSONL, invoke sender, and mirror updateStatus without any concurrency guard. JsonlAnalyticsStore.writeChain serializes the WRITES but not the READ-decisions — two parallel flush() calls both observed the same loadPending snapshot, both invoked the sender, and both fed updateStatus(failed, ids) into the chain serially. doUpdateStatus reads fresh state on each enqueue, so the second mirror saw the post-first state and bumped attempts a second time. A single failed cycle could therefore advance attempts by 2 instead of 1, tripping the M9.2 retry cap in MAX_ATTEMPTS/2 cycles. Add a pendingFlush?: Promise slot. Concurrent callers return the in-flight promise so both observe the same loadPending snapshot, the same sender invocation, and the same mirror writes. The slot is cleared in a finally so subsequent flushes run fresh whether the previous one resolved or rejected. Body extracted to a private runFlush() — the public flush() is now a thin single-flight wrapper. Latent until M4.3 (ENG-2645) wires the setInterval-based scheduler: a tick firing while the previous flush is still in flight would race otherwise. The fix lives at the client layer (the seat of the read-then-decide) rather than at the future scheduler. --- .../infra/analytics/analytics-client.ts | 42 +++++++++++---- .../infra/analytics/analytics-client.test.ts | 54 +++++++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index 464788faf..7b0ed8e8e 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -45,6 +45,14 @@ export interface AnalyticsClientDeps { */ export class AnalyticsClient implements IAnalyticsClient { private readonly deps: AnalyticsClientDeps + // Single-flight slot for an in-flight `flush()`. Concurrent callers join the + // existing promise instead of starting a second read-then-decide cycle — + // without this, two parallel flushes would both `loadPending()` the same set, + // both invoke `sender.send`, and both mirror `updateStatus(_, 'failed')` into + // the write chain (which serializes the WRITES but not the READ-decisions), + // double-incrementing `attempts` per cycle and tripping the M9.2 retry cap + // in MAX_ATTEMPTS/2 cycles instead of MAX_ATTEMPTS. + private pendingFlush?: Promise public constructor(deps: AnalyticsClientDeps) { this.deps = deps @@ -70,19 +78,17 @@ export class AnalyticsClient implements IAnalyticsClient { * `flush()` is a thin caller — it does not inspect attempts. */ public async flush(): Promise { - const records = await this.deps.jsonlStore.loadPending() + // Single-flight: if a flush is already running, hand its promise to the + // joining caller so both observe the same loadPending snapshot, the same + // sender invocation, and the same mirror writes. + if (this.pendingFlush !== undefined) return this.pendingFlush - let result: SendResult + this.pendingFlush = this.runFlush() try { - result = await this.deps.sender.send(records) - } catch { - result = {failed: records.map((r) => r.id), succeeded: []} + return await this.pendingFlush + } finally { + this.pendingFlush = undefined } - - await this.deps.jsonlStore.updateStatus(result.succeeded, 'sent') - await this.deps.jsonlStore.updateStatus(result.failed, 'failed') - - return AnalyticsBatch.create(records.map((r) => toWireEvent(r))) } public track(event: string, properties?: Record): void { @@ -96,6 +102,22 @@ export class AnalyticsClient implements IAnalyticsClient { void this.trackAsync(event, properties, timestamp) } + private async runFlush(): Promise { + const records = await this.deps.jsonlStore.loadPending() + + let result: SendResult + try { + result = await this.deps.sender.send(records) + } catch { + result = {failed: records.map((r) => r.id), succeeded: []} + } + + await this.deps.jsonlStore.updateStatus(result.succeeded, 'sent') + await this.deps.jsonlStore.updateStatus(result.failed, 'failed') + + return AnalyticsBatch.create(records.map((r) => toWireEvent(r))) + } + private async trackAsync( event: string, properties: Record | undefined, diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index 75464b9ce..fd7f6e864 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -749,4 +749,58 @@ describe('AnalyticsClient', () => { expect(event).to.not.have.property('status') }) }) + + describe('M10.2 single-flight: concurrent flush() invocations collapse to one underlying run', () => { + it('should call sender.send only once when two flush() calls are awaited in parallel', async () => { + // Without single-flight, both flushes load the same pending set, both call sender, and + // both mirror updateStatus(failed, ids) into the writeChain — the writes serialize but + // attempts get double-incremented (cycle counter advances 2x). The single-flight guard + // makes a concurrent call join the in-flight promise instead. + const jsonlStore = makeFakeJsonlStore() + const sender = makeFakeSender({kind: 'all-failed'}) + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 3) + + const [batchA, batchB] = await Promise.all([client.flush(), client.flush()]) + + // Single-flight collapsed both calls into one sender invocation with the same record set. + expect(sender.calls, 'two concurrent flushes must share one sender.send invocation').to.have.lengthOf(1) + // Concurrent callers receive the same batch object (joined in-flight promise). + expect(batchA).to.equal(batchB) + // updateStatus(failed, ids) called exactly once for the failed branch (succeeded branch + // is also called once with []). + const failedCalls = jsonlStore.updateStatusCalls.filter((c) => c.status === 'failed' && c.ids.length > 0) + expect(failedCalls, 'failed-updateStatus must run exactly once across the two concurrent flushes').to.have.lengthOf(1) + }) + + it('should release the in-flight slot after the flush settles so the next call runs fresh', async () => { + const jsonlStore = makeFakeJsonlStore() + const sender = makeFakeSender({kind: 'all-succeeded'}) + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 1) + await client.flush() // first flush: sender called, record marked sent + // After settle, loadPending now returns [] because record is 'sent'. + // The next flush should run fresh (NOT return the previous batch). + const second = await client.flush() + + expect(sender.calls, 'sequential flushes must each invoke sender').to.have.lengthOf(2) + expect(second.events, 'second flush sees no pending rows after first settled').to.deep.equal([]) + }) + }) }) From 12fe5d5dc7a78c616915fe81809b76d51ecfc9a9 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 11 May 2026 20:57:20 +0700 Subject: [PATCH 25/87] fix: address branch audit findings (D1 race + shared/server layering) Two follow-ups from auditing M1-M11 work on proj/analytics-system. 1. Restore GlobalConfigHandler pure-read invariant. read() was seeding+writing a fresh GlobalConfig when none existed on disk, re-introducing the D1 race that the original M1.2 review-fix plan closed. The seed-write was added when M2.5 introduced the sync cache, but refreshCache() already populates the cache at boot, so the read-side write is redundant. Return synthetic defaults (analytics: false, deviceId: '', version: GLOBAL_CONFIG_VERSION) when no config exists; deviceId generation stays deferred to the first SET_ANALYTICS write. Adds the previously-missing GlobalConfigHandler unit test file covering the pure-read contract, refreshCache fail-safe, idempotent fast-paths, and the deviceId seed on first enable. 2. Relocate StoredAnalyticsRecord schema to src/shared/. src/shared/transport/events/analytics-events.ts and src/shared/analytics/forbidden-field-names.ts were importing from src/server/core/domain/, inverting the shared/server layering. The wire schema describes a wire shape so it belongs in shared/; move stored-record.ts and update 16 import sites. No behaviour change; full analytics test sweep stays green. --- .../interfaces/analytics/i-analytics-queue.ts | 2 +- .../analytics/i-analytics-sender.ts | 2 +- .../analytics/i-jsonl-analytics-store.ts | 2 +- .../infra/analytics/analytics-client.ts | 4 +- src/server/infra/analytics/bounded-queue.ts | 2 +- .../infra/analytics/jsonl-analytics-store.ts | 4 +- .../infra/analytics/no-op-analytics-sender.ts | 2 +- .../handlers/global-config-handler.ts | 40 ++-- src/shared/analytics/forbidden-field-names.ts | 2 +- .../analytics/stored-record.ts | 33 ++-- .../transport/events/analytics-events.ts | 2 +- test/integration/analytics/retry-cap.test.ts | 4 +- .../handlers/global-config-handler.test.ts | 182 ++++++++++++++++++ .../infra/analytics/analytics-client.test.ts | 2 +- .../infra/analytics/bounded-queue.test.ts | 2 +- .../analytics/jsonl-analytics-store.test.ts | 4 +- .../analytics/no-op-analytics-sender.test.ts | 2 +- .../handlers/analytics-list-handler.test.ts | 2 +- .../shared/analytics/redact-record.test.ts | 2 +- .../analytics/stored-record.test.ts | 15 +- 20 files changed, 251 insertions(+), 59 deletions(-) rename src/{server/core/domain => shared}/analytics/stored-record.ts (67%) create mode 100644 test/unit/infra/transport/handlers/global-config-handler.test.ts rename test/unit/{server/core/domain => shared}/analytics/stored-record.test.ts (92%) diff --git a/src/server/core/interfaces/analytics/i-analytics-queue.ts b/src/server/core/interfaces/analytics/i-analytics-queue.ts index 98f97bf57..6da1a7637 100644 --- a/src/server/core/interfaces/analytics/i-analytics-queue.ts +++ b/src/server/core/interfaces/analytics/i-analytics-queue.ts @@ -1,4 +1,4 @@ -import type {StoredAnalyticsRecord} from '../../domain/analytics/stored-record.js' +import type {StoredAnalyticsRecord} from '../../../../shared/analytics/stored-record.js' /** * In-memory queue contract for identity-stamped analytics records. diff --git a/src/server/core/interfaces/analytics/i-analytics-sender.ts b/src/server/core/interfaces/analytics/i-analytics-sender.ts index 3c585f9a6..f47566267 100644 --- a/src/server/core/interfaces/analytics/i-analytics-sender.ts +++ b/src/server/core/interfaces/analytics/i-analytics-sender.ts @@ -1,4 +1,4 @@ -import type {StoredAnalyticsRecord} from '../../domain/analytics/stored-record.js' +import type {StoredAnalyticsRecord} from '../../../../shared/analytics/stored-record.js' /** * Per-send outcome. Each input record's `id` is mirrored back in exactly diff --git a/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts b/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts index c4f2bfceb..d6a56b995 100644 --- a/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts +++ b/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts @@ -1,4 +1,4 @@ -import type {StoredAnalyticsRecord, StoredStatus} from '../../domain/analytics/stored-record.js' +import type {StoredAnalyticsRecord, StoredStatus} from '../../../../shared/analytics/stored-record.js' /** * Filter and pagination options for `list()`. diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index 7b0ed8e8e..07cd68863 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -1,6 +1,6 @@ import {randomUUID} from 'node:crypto' -import type {StoredAnalyticsRecord} from '../../core/domain/analytics/stored-record.js' +import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' import type {IAnalyticsSender, SendResult} from '../../core/interfaces/analytics/i-analytics-sender.js' @@ -8,8 +8,8 @@ import type {IIdentityResolver} from '../../core/interfaces/analytics/i-identity import type {IJsonlAnalyticsStore} from '../../core/interfaces/analytics/i-jsonl-analytics-store.js' import type {ISuperPropertiesResolver} from '../../core/interfaces/analytics/i-super-properties-resolver.js' +import {toWireEvent} from '../../../shared/analytics/stored-record.js' import {AnalyticsBatch} from '../../core/domain/analytics/batch.js' -import {toWireEvent} from '../../core/domain/analytics/stored-record.js' export interface AnalyticsClientDeps { identityResolver: IIdentityResolver diff --git a/src/server/infra/analytics/bounded-queue.ts b/src/server/infra/analytics/bounded-queue.ts index 70ef4eafa..4c325f345 100644 --- a/src/server/infra/analytics/bounded-queue.ts +++ b/src/server/infra/analytics/bounded-queue.ts @@ -1,4 +1,4 @@ -import type {StoredAnalyticsRecord} from '../../core/domain/analytics/stored-record.js' +import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' const DEFAULT_MAX_SIZE = 1000 diff --git a/src/server/infra/analytics/jsonl-analytics-store.ts b/src/server/infra/analytics/jsonl-analytics-store.ts index 5f256946d..e664085ac 100644 --- a/src/server/infra/analytics/jsonl-analytics-store.ts +++ b/src/server/infra/analytics/jsonl-analytics-store.ts @@ -2,7 +2,7 @@ import {randomUUID} from 'node:crypto' import {mkdir, open, readFile, rename, rm, writeFile} from 'node:fs/promises' import {dirname, join} from 'node:path' -import type {StoredAnalyticsRecord} from '../../core/domain/analytics/stored-record.js' +import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' import type { IJsonlAnalyticsStore, JsonlAnalyticsStoreListOptions, @@ -10,7 +10,7 @@ import type { JsonlAnalyticsStoreUpdateStatus, } from '../../core/interfaces/analytics/i-jsonl-analytics-store.js' -import {MAX_ATTEMPTS, StoredAnalyticsRecordSchema} from '../../core/domain/analytics/stored-record.js' +import {MAX_ATTEMPTS, StoredAnalyticsRecordSchema} from '../../../shared/analytics/stored-record.js' const DEFAULT_FILE_NAME = 'analytics-queue.jsonl' const DEFAULT_MAX_ROWS = 5000 diff --git a/src/server/infra/analytics/no-op-analytics-sender.ts b/src/server/infra/analytics/no-op-analytics-sender.ts index ab9da9402..be14aa0e0 100644 --- a/src/server/infra/analytics/no-op-analytics-sender.ts +++ b/src/server/infra/analytics/no-op-analytics-sender.ts @@ -1,4 +1,4 @@ -import type {StoredAnalyticsRecord} from '../../core/domain/analytics/stored-record.js' +import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' import type {IAnalyticsSender, SendResult} from '../../core/interfaces/analytics/i-analytics-sender.js' /** diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts index 0063e4a72..682408f56 100644 --- a/src/server/infra/transport/handlers/global-config-handler.ts +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -9,6 +9,7 @@ import { type GlobalConfigSetAnalyticsRequest, type GlobalConfigSetAnalyticsResponse, } from '../../../../shared/transport/events/global-config-events.js' +import {GLOBAL_CONFIG_VERSION} from '../../../constants.js' import {GlobalConfig} from '../../../core/domain/entities/global-config.js' export interface GlobalConfigHandlerDeps { @@ -19,18 +20,24 @@ export interface GlobalConfigHandlerDeps { /** * Handles globalConfig:get and globalConfig:setAnalytics events. * Re-reads the file every call (no in-memory cache for transport responses) - * so the daemon always reflects the latest on-disk state. If no config - * exists yet, the GET path seeds a fresh one with a stable deviceId. - * SET_ANALYTICS is idempotent: if the requested state matches current state, - * the file is not rewritten. + * so the daemon always reflects the latest on-disk state. + * + * `read()` is a pure read: when no config file exists yet, returns + * synthetic defaults (analytics: false, empty deviceId). Persistence is + * deferred to the first SET_ANALYTICS write path, where deviceId is + * generated and stored atomically — keeping read() pure avoids a race + * where two concurrent GETs each create+write a different deviceId. + * + * SET_ANALYTICS is idempotent: if the requested state matches current + * state, the file is not rewritten. * * Maintains a SYNC in-process cache of the analytics flag for consumers - * that need a synchronous getter (M2.5's AnalyticsClient.isEnabled). The - * cache is populated by an explicit `await refreshCache()` (the daemon + * that need a synchronous getter (AnalyticsClient.isEnabled). The cache + * is populated by an explicit `await refreshCache()` (the daemon * bootstrap awaits this once before constructing AnalyticsClient) and - * refreshed after every successful SET_ANALYTICS write or GET seed. - * Transport responses still read fresh from disk — the cache is purely - * an in-process bridge for sync consumers. + * refreshed after every successful SET_ANALYTICS write or read of an + * existing on-disk config. Transport responses still read fresh from + * disk — the cache is purely an in-process bridge for sync consumers. */ export class GlobalConfigHandler { private cachedAnalytics: boolean | undefined @@ -104,13 +111,16 @@ export class GlobalConfigHandler { } } - const seeded = GlobalConfig.create(randomUUID()) - await this.globalConfigStore.write(seeded) - this.cachedAnalytics = seeded.analytics + // No config on disk yet — return synthetic defaults. Persistence is + // deferred to the first SET_ANALYTICS write path, where deviceId is + // generated and stored atomically. Keeping read() side-effect-free + // closes the race where two concurrent GETs would each create+write a + // different deviceId. Cache population for synchronous consumers + // happens via refreshCache() at daemon bootstrap, not here. return { - analytics: seeded.analytics, - deviceId: seeded.deviceId, - version: seeded.version, + analytics: false, + deviceId: '', + version: GLOBAL_CONFIG_VERSION, } } diff --git a/src/shared/analytics/forbidden-field-names.ts b/src/shared/analytics/forbidden-field-names.ts index c4b1479c2..62b4570a5 100644 --- a/src/shared/analytics/forbidden-field-names.ts +++ b/src/shared/analytics/forbidden-field-names.ts @@ -1,4 +1,4 @@ -import type {StoredAnalyticsRecord} from '../../server/core/domain/analytics/stored-record.js' +import type {StoredAnalyticsRecord} from './stored-record.js' /** * Field names that MUST NOT appear inside an analytics event's `properties` diff --git a/src/server/core/domain/analytics/stored-record.ts b/src/shared/analytics/stored-record.ts similarity index 67% rename from src/server/core/domain/analytics/stored-record.ts rename to src/shared/analytics/stored-record.ts index ce85744a4..aad43fcc2 100644 --- a/src/server/core/domain/analytics/stored-record.ts +++ b/src/shared/analytics/stored-record.ts @@ -1,8 +1,6 @@ /* eslint-disable camelcase */ import {z} from 'zod' -import type {AnalyticsEventWithIdentity} from './batch.js' - /** * Maximum number of send attempts before a record terminates as `'failed'`. * @@ -24,11 +22,10 @@ const StoredStatusSchema = z.enum(['pending', 'sent', 'failed']) export type StoredStatus = z.infer /** - * Mirrors the wire identity schema from `batch.ts` so disk-persisted rows - * are validated against the same contract events flow through. Kept private - * here (mirror per M9.1 plan) rather than refactoring `batch.ts` to export - * its private schemas — keeps M9.1 minimal and avoids touching the M2.6 - * wire boundary. + * Wire-format identity, snake_case per the analytics spec. `device_id` is + * always present; the rest are optional and only stamped when the user is + * authenticated. Kept here so the stored-record schema is self-contained + * and importable from any layer (no cross-layer reach into server/). */ const IdentityWireSchema = z.object({ device_id: z.string().refine((s) => s.trim().length > 0, { @@ -40,8 +37,8 @@ const IdentityWireSchema = z.object({ }) /** - * A local-only stored record. Extends the wire-format - * `AnalyticsEventWithIdentity` shape with three daemon-internal fields: + * A local-only stored record. Extends the wire-format analytics event + * shape with three daemon-internal fields: * * - `id`: stable per-row identifier (uuid v4) for `updateStatus` mutations * - `status`: `'pending' | 'sent' | 'failed'` @@ -69,14 +66,22 @@ export const StoredAnalyticsRecordSchema = z.object({ */ export type StoredAnalyticsRecord = Readonly> +/** + * The wire-shape view of a stored record (no `id` / `status` / `attempts`). + * Structurally identical to the daemon-side `AnalyticsEventWithIdentity` + * type; declared here as a `Pick` so this module has no dependency on + * server-side domain code and can be imported by `shared/`. + */ +export type WireAnalyticsEvent = Pick + /** * Strips local-only fields (`id`, `status`, `attempts`) from a stored - * record and returns the wire-format `AnalyticsEventWithIdentity` that can - * be shipped to the backend. M4's HTTP sender uses this on the way out; - * M9.3 (in-process) and M11.2 (over transport) both keep the local fields - * for their own purposes. + * record and returns the wire-format event shape that can be shipped to + * the backend. M4's HTTP sender uses this on the way out; M9.3 + * (in-process) and M11.2 (over transport) both keep the local fields for + * their own purposes. */ -export function toWireEvent(record: StoredAnalyticsRecord): AnalyticsEventWithIdentity { +export function toWireEvent(record: StoredAnalyticsRecord): WireAnalyticsEvent { return { identity: record.identity, name: record.name, diff --git a/src/shared/transport/events/analytics-events.ts b/src/shared/transport/events/analytics-events.ts index a225aeac1..38d971267 100644 --- a/src/shared/transport/events/analytics-events.ts +++ b/src/shared/transport/events/analytics-events.ts @@ -1,6 +1,6 @@ import {z} from 'zod' -import {StoredAnalyticsRecordSchema} from '../../../server/core/domain/analytics/stored-record.js' +import {StoredAnalyticsRecordSchema} from '../../analytics/stored-record.js' export const AnalyticsEvents = { LIST: 'analytics:list', diff --git a/test/integration/analytics/retry-cap.test.ts b/test/integration/analytics/retry-cap.test.ts index acbf67292..6dd725891 100644 --- a/test/integration/analytics/retry-cap.test.ts +++ b/test/integration/analytics/retry-cap.test.ts @@ -7,15 +7,15 @@ import {tmpdir} from 'node:os' import {join} from 'node:path' import type {Identity} from '../../../src/server/core/domain/analytics/identity.js' -import type {StoredAnalyticsRecord} from '../../../src/server/core/domain/analytics/stored-record.js' import type {IAnalyticsSender, SendResult} from '../../../src/server/core/interfaces/analytics/i-analytics-sender.js' import type {IIdentityResolver} from '../../../src/server/core/interfaces/analytics/i-identity-resolver.js' import type {ISuperPropertiesResolver, SuperProperties} from '../../../src/server/core/interfaces/analytics/i-super-properties-resolver.js' +import type {StoredAnalyticsRecord} from '../../../src/shared/analytics/stored-record.js' -import {MAX_ATTEMPTS} from '../../../src/server/core/domain/analytics/stored-record.js' import {AnalyticsClient} from '../../../src/server/infra/analytics/analytics-client.js' import {BoundedQueue} from '../../../src/server/infra/analytics/bounded-queue.js' import {JsonlAnalyticsStore} from '../../../src/server/infra/analytics/jsonl-analytics-store.js' +import {MAX_ATTEMPTS} from '../../../src/shared/analytics/stored-record.js' const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' diff --git a/test/unit/infra/transport/handlers/global-config-handler.test.ts b/test/unit/infra/transport/handlers/global-config-handler.test.ts new file mode 100644 index 000000000..7ae7ccc93 --- /dev/null +++ b/test/unit/infra/transport/handlers/global-config-handler.test.ts @@ -0,0 +1,182 @@ +import type {SinonStubbedInstance} from 'sinon' + +import {expect} from 'chai' +import {restore, stub} from 'sinon' + +import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces/storage/i-global-config-store.js' + +import {GLOBAL_CONFIG_VERSION} from '../../../../../src/server/constants.js' +import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' +import {GlobalConfigHandler} from '../../../../../src/server/infra/transport/handlers/global-config-handler.js' +import {GlobalConfigEvents} from '../../../../../src/shared/transport/events/global-config-events.js' +import {createMockTransportServer, type MockTransportServer} from '../../../../helpers/mock-factories.js' + +function createMockGlobalConfigStore(): SinonStubbedInstance { + return { + read: stub<[], Promise>().resolves(), + write: stub<[GlobalConfig], Promise>().resolves(), + } +} + +describe('GlobalConfigHandler', () => { + let store: SinonStubbedInstance + let transport: MockTransportServer + let handler: GlobalConfigHandler + + beforeEach(() => { + store = createMockGlobalConfigStore() + transport = createMockTransportServer() + handler = new GlobalConfigHandler({globalConfigStore: store, transport}) + handler.setup() + }) + + afterEach(() => { + restore() + }) + + async function callGet(): Promise<{analytics: boolean; deviceId: string; version: string}> { + const fn = transport._handlers.get(GlobalConfigEvents.GET) + expect(fn).to.exist + return fn!(undefined, 'client-1') + } + + async function callSet(analytics: boolean): Promise<{current: boolean; previous: boolean}> { + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + expect(fn).to.exist + return fn!({analytics}, 'client-1') + } + + describe('setup', () => { + it('registers GET and SET_ANALYTICS handlers', () => { + expect(transport._handlers.has(GlobalConfigEvents.GET)).to.be.true + expect(transport._handlers.has(GlobalConfigEvents.SET_ANALYTICS)).to.be.true + }) + }) + + describe('getCachedAnalytics', () => { + it('throws before refreshCache() resolves', () => { + expect(() => handler.getCachedAnalytics()).to.throw(/refreshCache/) + }) + + it('returns the cached flag after refreshCache() populates from disk', async () => { + const config = GlobalConfig.create('device-abc').withAnalytics(true) + store.read.resolves(config) + + await handler.refreshCache() + + expect(handler.getCachedAnalytics()).to.be.true + }) + }) + + describe('refreshCache', () => { + it('sets cache to false when no config exists on disk', async () => { + store.read.resolves() + + await handler.refreshCache() + + expect(handler.getCachedAnalytics()).to.be.false + }) + + it('swallows store.read errors and sets cache to false (fail-safe)', async () => { + store.read.rejects(new Error('disk failure')) + + await handler.refreshCache() + + expect(handler.getCachedAnalytics()).to.be.false + }) + }) + + describe('GET handler', () => { + it('returns disk values when config exists', async () => { + const config = GlobalConfig.create('device-xyz').withAnalytics(true) + store.read.resolves(config) + + const result = await callGet() + + expect(result).to.deep.equal({ + analytics: true, + deviceId: 'device-xyz', + version: config.version, + }) + expect(store.write.called, 'must not write on read').to.be.false + }) + + it('returns synthetic defaults and does NOT write when no config exists (D1 invariant)', async () => { + store.read.resolves() + + const result = await callGet() + + expect(result).to.deep.equal({ + analytics: false, + deviceId: '', + version: GLOBAL_CONFIG_VERSION, + }) + expect(store.write.called, 'read() must be pure — no write on missing config').to.be.false + }) + + it('updates the cached flag when config exists', async () => { + const config = GlobalConfig.create('device-1').withAnalytics(true) + store.read.resolves(config) + + await callGet() + + expect(handler.getCachedAnalytics()).to.be.true + }) + }) + + describe('SET_ANALYTICS handler', () => { + it('idempotent fast-path: no write when requested value matches current', async () => { + const config = GlobalConfig.create('device-1').withAnalytics(true) + store.read.resolves(config) + + const result = await callSet(true) + + expect(result).to.deep.equal({current: true, previous: true}) + expect(store.write.called, 'must not write on idempotent SET').to.be.false + }) + + it('idempotent fast-path: no write when toggling from default (no config) to false', async () => { + store.read.resolves() + + const result = await callSet(false) + + expect(result).to.deep.equal({current: false, previous: false}) + expect(store.write.called, 'must not seed a config just to match the default').to.be.false + }) + + it('round-trip: writes updated config and returns previous/current', async () => { + const config = GlobalConfig.create('device-1').withAnalytics(false) + store.read.resolves(config) + + const result = await callSet(true) + + expect(result).to.deep.equal({current: true, previous: false}) + expect(store.write.calledOnce).to.be.true + const written = store.write.firstCall.args[0] + expect(written.deviceId).to.equal('device-1') + expect(written.analytics).to.be.true + }) + + it('seeds a new deviceId when enabling for the first time (no config on disk)', async () => { + store.read.resolves() + + const result = await callSet(true) + + expect(result.current).to.be.true + expect(result.previous).to.be.false + expect(store.write.calledOnce).to.be.true + const written = store.write.firstCall.args[0] + expect(written.deviceId.length).to.be.greaterThan(0) + expect(written.analytics).to.be.true + }) + + it('updates the cached flag after a successful write', async () => { + const config = GlobalConfig.create('device-1').withAnalytics(false) + store.read.resolves(config) + + await callSet(true) + + expect(handler.getCachedAnalytics()).to.be.true + }) + }) +}) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index fd7f6e864..5934baef9 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -3,11 +3,11 @@ import {expect} from 'chai' import {spy, stub} from 'sinon' import type {Identity} from '../../../../../src/server/core/domain/analytics/identity.js' -import type {StoredAnalyticsRecord} from '../../../../../src/server/core/domain/analytics/stored-record.js' import type {IAnalyticsSender, SendResult} from '../../../../../src/server/core/interfaces/analytics/i-analytics-sender.js' import type {IIdentityResolver} from '../../../../../src/server/core/interfaces/analytics/i-identity-resolver.js' import type {IJsonlAnalyticsStore, JsonlAnalyticsStoreUpdateStatus} from '../../../../../src/server/core/interfaces/analytics/i-jsonl-analytics-store.js' import type {ISuperPropertiesResolver, SuperProperties} from '../../../../../src/server/core/interfaces/analytics/i-super-properties-resolver.js' +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' import {AnalyticsClient} from '../../../../../src/server/infra/analytics/analytics-client.js' diff --git a/test/unit/server/infra/analytics/bounded-queue.test.ts b/test/unit/server/infra/analytics/bounded-queue.test.ts index 23df6e822..c271e5c92 100644 --- a/test/unit/server/infra/analytics/bounded-queue.test.ts +++ b/test/unit/server/infra/analytics/bounded-queue.test.ts @@ -1,7 +1,7 @@ /* eslint-disable camelcase */ import {expect} from 'chai' -import type {StoredAnalyticsRecord} from '../../../../../src/server/core/domain/analytics/stored-record.js' +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' import {BoundedQueue} from '../../../../../src/server/infra/analytics/bounded-queue.js' diff --git a/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts b/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts index ad8b4f081..9eb01fa0a 100644 --- a/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts +++ b/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts @@ -5,10 +5,10 @@ import {mkdir, readFile, stat, writeFile} from 'node:fs/promises' import {tmpdir} from 'node:os' import {join} from 'node:path' -import type {StoredAnalyticsRecord} from '../../../../../src/server/core/domain/analytics/stored-record.js' +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' -import {MAX_ATTEMPTS, StoredAnalyticsRecordSchema} from '../../../../../src/server/core/domain/analytics/stored-record.js' import {JsonlAnalyticsStore, JsonlCapFullError} from '../../../../../src/server/infra/analytics/jsonl-analytics-store.js' +import {MAX_ATTEMPTS, StoredAnalyticsRecordSchema} from '../../../../../src/shared/analytics/stored-record.js' const validIdentity = { device_id: '550e8400-e29b-41d4-a716-446655440000', diff --git a/test/unit/server/infra/analytics/no-op-analytics-sender.test.ts b/test/unit/server/infra/analytics/no-op-analytics-sender.test.ts index 60b50bd18..323ed2a31 100644 --- a/test/unit/server/infra/analytics/no-op-analytics-sender.test.ts +++ b/test/unit/server/infra/analytics/no-op-analytics-sender.test.ts @@ -2,8 +2,8 @@ import {expect} from 'chai' import {randomUUID} from 'node:crypto' -import type {StoredAnalyticsRecord} from '../../../../../src/server/core/domain/analytics/stored-record.js' import type {SendResult} from '../../../../../src/server/core/interfaces/analytics/i-analytics-sender.js' +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' import {NoOpAnalyticsSender} from '../../../../../src/server/infra/analytics/no-op-analytics-sender.js' diff --git a/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts index 826992e0e..926a54a6b 100644 --- a/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts @@ -2,12 +2,12 @@ import {expect} from 'chai' import {spy} from 'sinon' -import type {StoredAnalyticsRecord} from '../../../../../../src/server/core/domain/analytics/stored-record.js' import type { IJsonlAnalyticsStore, JsonlAnalyticsStoreListOptions, JsonlAnalyticsStoreListResult, } from '../../../../../../src/server/core/interfaces/analytics/i-jsonl-analytics-store.js' +import type {StoredAnalyticsRecord} from '../../../../../../src/shared/analytics/stored-record.js' import {AnalyticsListHandler} from '../../../../../../src/server/infra/transport/handlers/analytics-list-handler.js' import {AnalyticsEvents} from '../../../../../../src/shared/transport/events/analytics-events.js' diff --git a/test/unit/shared/analytics/redact-record.test.ts b/test/unit/shared/analytics/redact-record.test.ts index 3631cb923..0918fc7c4 100644 --- a/test/unit/shared/analytics/redact-record.test.ts +++ b/test/unit/shared/analytics/redact-record.test.ts @@ -1,7 +1,7 @@ /* eslint-disable camelcase */ import {expect} from 'chai' -import type {StoredAnalyticsRecord} from '../../../../src/server/core/domain/analytics/stored-record.js' +import type {StoredAnalyticsRecord} from '../../../../src/shared/analytics/stored-record.js' import {FORBIDDEN_FIELD_NAMES, redactRecord} from '../../../../src/shared/analytics/forbidden-field-names.js' diff --git a/test/unit/server/core/domain/analytics/stored-record.test.ts b/test/unit/shared/analytics/stored-record.test.ts similarity index 92% rename from test/unit/server/core/domain/analytics/stored-record.test.ts rename to test/unit/shared/analytics/stored-record.test.ts index 2b4ed2dd4..273c027e0 100644 --- a/test/unit/server/core/domain/analytics/stored-record.test.ts +++ b/test/unit/shared/analytics/stored-record.test.ts @@ -1,11 +1,7 @@ /* eslint-disable camelcase */ import {expect} from 'chai' -import { - MAX_ATTEMPTS, - StoredAnalyticsRecordSchema, - toWireEvent, -} from '../../../../../../src/server/core/domain/analytics/stored-record.js' +import {MAX_ATTEMPTS, StoredAnalyticsRecordSchema, toWireEvent} from '../../../../src/shared/analytics/stored-record.js' const validIdentity = { device_id: '550e8400-e29b-41d4-a716-446655440000', @@ -182,11 +178,10 @@ describe('StoredAnalyticsRecord', () => { }) it('should silently strip extra unknown fields (Zod default behavior, matches batch.ts precedent)', () => { - // Decision (M9.1, 2026-05-10): use Zod default strip (NOT `.strict()` or `.passthrough()`). - // Mirrors batch.ts wire schemas — strip is forward-compatible: a future binary that adds - // a new known field, reading rows written by the old binary, will not crash. Cost: if a - // row on disk has unknown extra fields, M9.2 read-modify-rewrite will lose them. Accepted - // because we do not currently support out-of-schema extension. + // Use Zod default strip (NOT `.strict()` or `.passthrough()`). Mirrors batch.ts wire + // schemas: strip is forward-compatible — a future binary that adds a new known field, + // reading rows written by the old binary, will not crash. Cost: if a row on disk has + // unknown extra fields, the M9.2 read-modify-rewrite cycle will lose them. const parsed = StoredAnalyticsRecordSchema.safeParse({ ...validRecord, unknown_extra_field: 'should be stripped', From da091624a7414936e36c761b7e4d04d5d12df073 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Tue, 12 May 2026 22:09:06 +0700 Subject: [PATCH 26/87] feat: [ENG-2770] M12.1 add curate_operation_applied + curate_run_completed + query_completed event schemas Register three new per-event Zod schemas in the analytics catalog (curate_operation_applied, curate_run_completed, query_completed) plus their AnalyticsEventNames keys and ALL_EVENT_SCHEMAS / AnyAnalyticsEvent registry entries. Schemas are wire-only here; emission ships in M12.2 and frontmatter harvest in M12.3. All field names verified outside FORBIDDEN_FIELD_NAMES; per-event field caps follow plan AC (tags/keywords/related <= 50 entries, per-entry <= 256 chars; read_paths_with_metadata <= 10 entries; tier 0..4). The outer read_paths_with_metadata array is optional per AC; entries themselves carry the absolute_path plus optional frontmatter. --- src/shared/analytics/event-names.ts | 3 + .../events/curate-operation-applied.ts | 30 ++++ .../analytics/events/curate-run-completed.ts | 26 ++++ src/shared/analytics/events/index.ts | 9 ++ .../analytics/events/query-completed.ts | 43 ++++++ .../unit/shared/analytics/event-names.test.ts | 8 +- .../events/curate-operation-applied.test.ts | 102 +++++++++++++ .../events/curate-run-completed.test.ts | 89 ++++++++++++ .../analytics/events/query-completed.test.ts | 135 ++++++++++++++++++ .../shared/analytics/privacy-fixture.test.ts | 3 + 10 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 src/shared/analytics/events/curate-operation-applied.ts create mode 100644 src/shared/analytics/events/curate-run-completed.ts create mode 100644 src/shared/analytics/events/query-completed.ts create mode 100644 test/unit/shared/analytics/events/curate-operation-applied.test.ts create mode 100644 test/unit/shared/analytics/events/curate-run-completed.test.ts create mode 100644 test/unit/shared/analytics/events/query-completed.test.ts diff --git a/src/shared/analytics/event-names.ts b/src/shared/analytics/event-names.ts index 086f1b76c..d50dcb589 100644 --- a/src/shared/analytics/event-names.ts +++ b/src/shared/analytics/event-names.ts @@ -13,9 +13,12 @@ */ export const AnalyticsEventNames = { CLI_INVOCATION: 'cli_invocation', + CURATE_OPERATION_APPLIED: 'curate_operation_applied', + CURATE_RUN_COMPLETED: 'curate_run_completed', DAEMON_START: 'daemon_start', MCP_SESSION_START: 'mcp_session_start', MCP_TOOL_CALLED: 'mcp_tool_called', + QUERY_COMPLETED: 'query_completed', TASK_COMPLETED: 'task_completed', TASK_CREATED: 'task_created', TASK_FAILED: 'task_failed', diff --git a/src/shared/analytics/events/curate-operation-applied.ts b/src/shared/analytics/events/curate-operation-applied.ts new file mode 100644 index 000000000..12f4a0564 --- /dev/null +++ b/src/shared/analytics/events/curate-operation-applied.ts @@ -0,0 +1,30 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `curate_operation_applied`. + * + * Emitted by the daemon's `AnalyticsHook` (M12.2) once per successful curate + * operation. Each operation carries the affected file's absolute path, its + * knowledge-tree address, review/impact metadata, and (M12.3) the file's + * current-state frontmatter values for tags / keywords / related. + * + * All three frontmatter arrays are optional and absent on DELETE operations + * (the file is gone post-op) and on read failures (defensive). + */ +export const CurateOperationAppliedSchema = z + .object({ + absolute_path: z.string().min(1), + confidence: z.enum(['high', 'low']).optional(), + impact: z.enum(['high', 'low']).optional(), + keywords: z.array(z.string().max(256)).max(50).optional(), + knowledge_path: z.string().min(1), + needs_review: z.boolean(), + operation_type: z.enum(['ADD', 'UPDATE', 'DELETE', 'MERGE', 'UPSERT']), + related: z.array(z.string().max(256)).max(50).optional(), + tags: z.array(z.string().max(256)).max(50).optional(), + task_id: z.string().min(1), + }) + .strict() + +export type CurateOperationAppliedProps = z.infer diff --git a/src/shared/analytics/events/curate-run-completed.ts b/src/shared/analytics/events/curate-run-completed.ts new file mode 100644 index 000000000..8e1702f6c --- /dev/null +++ b/src/shared/analytics/events/curate-run-completed.ts @@ -0,0 +1,26 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `curate_run_completed`. + * + * Emitted by the daemon's `AnalyticsHook` (M12.2) at curate task terminal + * states (completed / partial / cancelled / error). Carries per-task + * operation counters so PMs can aggregate curate volume + outcome over time. + */ +export const CurateRunCompletedSchema = z + .object({ + duration_ms: z.number().int().nonnegative(), + operations_added: z.number().int().nonnegative(), + operations_deleted: z.number().int().nonnegative(), + operations_failed: z.number().int().nonnegative(), + operations_merged: z.number().int().nonnegative(), + operations_updated: z.number().int().nonnegative(), + outcome: z.enum(['completed', 'partial', 'cancelled', 'error']), + pending_review_count: z.number().int().nonnegative(), + task_id: z.string().min(1), + task_type: z.enum(['curate', 'curate-folder']), + }) + .strict() + +export type CurateRunCompletedProps = z.infer diff --git a/src/shared/analytics/events/index.ts b/src/shared/analytics/events/index.ts index 948fc4ab5..fb35d41c5 100644 --- a/src/shared/analytics/events/index.ts +++ b/src/shared/analytics/events/index.ts @@ -1,8 +1,11 @@ import {AnalyticsEventNames} from '../event-names.js' import {type CliInvocationProps, CliInvocationSchema} from './cli-invocation.js' +import {type CurateOperationAppliedProps, CurateOperationAppliedSchema} from './curate-operation-applied.js' +import {type CurateRunCompletedProps, CurateRunCompletedSchema} from './curate-run-completed.js' import {type DaemonStartProps, DaemonStartSchema} from './daemon-start.js' import {type McpSessionStartProps, McpSessionStartSchema} from './mcp-session-start.js' import {type McpToolCalledProps, McpToolCalledSchema} from './mcp-tool-called.js' +import {type QueryCompletedProps, QueryCompletedSchema} from './query-completed.js' import {type TaskCompletedProps, TaskCompletedSchema} from './task-completed.js' import {type TaskCreatedProps, TaskCreatedSchema} from './task-created.js' import {type TaskFailedProps, TaskFailedSchema} from './task-failed.js' @@ -20,9 +23,12 @@ import {type TaskFailedProps, TaskFailedSchema} from './task-failed.js' */ export const ALL_EVENT_SCHEMAS = { [AnalyticsEventNames.CLI_INVOCATION]: CliInvocationSchema, + [AnalyticsEventNames.CURATE_OPERATION_APPLIED]: CurateOperationAppliedSchema, + [AnalyticsEventNames.CURATE_RUN_COMPLETED]: CurateRunCompletedSchema, [AnalyticsEventNames.DAEMON_START]: DaemonStartSchema, [AnalyticsEventNames.MCP_SESSION_START]: McpSessionStartSchema, [AnalyticsEventNames.MCP_TOOL_CALLED]: McpToolCalledSchema, + [AnalyticsEventNames.QUERY_COMPLETED]: QueryCompletedSchema, [AnalyticsEventNames.TASK_COMPLETED]: TaskCompletedSchema, [AnalyticsEventNames.TASK_CREATED]: TaskCreatedSchema, [AnalyticsEventNames.TASK_FAILED]: TaskFailedSchema, @@ -35,9 +41,12 @@ export const ALL_EVENT_SCHEMAS = { */ export type AnyAnalyticsEvent = | {name: typeof AnalyticsEventNames.CLI_INVOCATION; properties: CliInvocationProps} + | {name: typeof AnalyticsEventNames.CURATE_OPERATION_APPLIED; properties: CurateOperationAppliedProps} + | {name: typeof AnalyticsEventNames.CURATE_RUN_COMPLETED; properties: CurateRunCompletedProps} | {name: typeof AnalyticsEventNames.DAEMON_START; properties: DaemonStartProps} | {name: typeof AnalyticsEventNames.MCP_SESSION_START; properties: McpSessionStartProps} | {name: typeof AnalyticsEventNames.MCP_TOOL_CALLED; properties: McpToolCalledProps} + | {name: typeof AnalyticsEventNames.QUERY_COMPLETED; properties: QueryCompletedProps} | {name: typeof AnalyticsEventNames.TASK_COMPLETED; properties: TaskCompletedProps} | {name: typeof AnalyticsEventNames.TASK_CREATED; properties: TaskCreatedProps} | {name: typeof AnalyticsEventNames.TASK_FAILED; properties: TaskFailedProps} diff --git a/src/shared/analytics/events/query-completed.ts b/src/shared/analytics/events/query-completed.ts new file mode 100644 index 000000000..b861608f4 --- /dev/null +++ b/src/shared/analytics/events/query-completed.ts @@ -0,0 +1,43 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-file structure inside `query_completed.read_paths_with_metadata`. + * Frontmatter arrays are optional and absent when the daemon cannot read + * the file (ENOENT, parse failure) — `absolute_path` alone still tells + * PMs which file the agent touched. + */ +const ReadPathWithMetadataSchema = z + .object({ + absolute_path: z.string().min(1), + keywords: z.array(z.string().max(256)).max(50).optional(), + related: z.array(z.string().max(256)).max(50).optional(), + tags: z.array(z.string().max(256)).max(50).optional(), + }) + .strict() + +/** + * Per-event schema for `query_completed`. + * + * Emitted by the daemon's `AnalyticsHook` (M12.2) at query task terminal + * states (completed / cancelled / error). Carries duration, retrieval + * tier hit, doc counts, and (M12.3) the per-file structure for the top-N + * (max 10) files the agent read during the query. + */ +export const QueryCompletedSchema = z + .object({ + cache_hit: z.boolean(), + duration_ms: z.number().int().nonnegative(), + matched_doc_count: z.number().int().nonnegative(), + outcome: z.enum(['completed', 'cancelled', 'error']), + read_doc_count: z.number().int().nonnegative(), + read_paths_with_metadata: z.array(ReadPathWithMetadataSchema).max(10).optional(), + read_tool_call_count: z.number().int().nonnegative(), + search_call_count: z.number().int().nonnegative(), + task_id: z.string().min(1), + task_type: z.literal('query'), + tier: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(), + }) + .strict() + +export type QueryCompletedProps = z.infer diff --git a/test/unit/shared/analytics/event-names.test.ts b/test/unit/shared/analytics/event-names.test.ts index bdb990dac..7023bcedf 100644 --- a/test/unit/shared/analytics/event-names.test.ts +++ b/test/unit/shared/analytics/event-names.test.ts @@ -4,12 +4,15 @@ import {expect} from 'chai' import {type AnalyticsEventName, AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' describe('AnalyticsEventNames', () => { - it('should expose exactly the seven shipped event names', () => { + it('should expose exactly the ten shipped event names', () => { expect(Object.keys(AnalyticsEventNames).sort()).to.deep.equal([ 'CLI_INVOCATION', + 'CURATE_OPERATION_APPLIED', + 'CURATE_RUN_COMPLETED', 'DAEMON_START', 'MCP_SESSION_START', 'MCP_TOOL_CALLED', + 'QUERY_COMPLETED', 'TASK_COMPLETED', 'TASK_CREATED', 'TASK_FAILED', @@ -19,8 +22,11 @@ describe('AnalyticsEventNames', () => { it('should map each key to a snake_case wire string', () => { expect(AnalyticsEventNames.DAEMON_START).to.equal('daemon_start') expect(AnalyticsEventNames.CLI_INVOCATION).to.equal('cli_invocation') + expect(AnalyticsEventNames.CURATE_OPERATION_APPLIED).to.equal('curate_operation_applied') + expect(AnalyticsEventNames.CURATE_RUN_COMPLETED).to.equal('curate_run_completed') expect(AnalyticsEventNames.MCP_SESSION_START).to.equal('mcp_session_start') expect(AnalyticsEventNames.MCP_TOOL_CALLED).to.equal('mcp_tool_called') + expect(AnalyticsEventNames.QUERY_COMPLETED).to.equal('query_completed') expect(AnalyticsEventNames.TASK_CREATED).to.equal('task_created') expect(AnalyticsEventNames.TASK_COMPLETED).to.equal('task_completed') expect(AnalyticsEventNames.TASK_FAILED).to.equal('task_failed') diff --git a/test/unit/shared/analytics/events/curate-operation-applied.test.ts b/test/unit/shared/analytics/events/curate-operation-applied.test.ts new file mode 100644 index 000000000..b273200d4 --- /dev/null +++ b/test/unit/shared/analytics/events/curate-operation-applied.test.ts @@ -0,0 +1,102 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {CurateOperationAppliedSchema} from '../../../../../src/shared/analytics/events/curate-operation-applied.js' + +const baseValid = { + absolute_path: '/Users/dev/project/.brv/context-tree/notes/test.md', + knowledge_path: 'notes/test', + needs_review: false, + operation_type: 'ADD' as const, + task_id: 'task-uuid-123', +} + +describe('CurateOperationAppliedSchema', () => { + describe('valid payloads', () => { + it('accepts the minimal required payload', () => { + expect(CurateOperationAppliedSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('accepts each operation_type enum value', () => { + for (const operation_type of ['ADD', 'UPDATE', 'DELETE', 'MERGE', 'UPSERT'] as const) { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, operation_type}).success).to.equal(true) + } + }) + + it('accepts optional impact and confidence enum values', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, confidence: 'high', impact: 'low'}).success).to.equal( + true, + ) + }) + + it('accepts needs_review=true', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, needs_review: true}).success).to.equal(true) + }) + + it('accepts payloads omitting any/all of tags, keywords, related', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid}).success).to.equal(true) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, tags: ['a']}).success).to.equal(true) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, keywords: ['k']}).success).to.equal(true) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, related: ['r']}).success).to.equal(true) + expect( + CurateOperationAppliedSchema.safeParse({...baseValid, keywords: ['k'], related: ['r'], tags: ['t']}).success, + ).to.equal(true) + }) + + it('accepts tags / keywords / related with exactly 50 entries each', () => { + const fifty = Array.from({length: 50}, (_, i) => `entry-${i}`) + expect( + CurateOperationAppliedSchema.safeParse({...baseValid, keywords: fifty, related: fifty, tags: fifty}).success, + ).to.equal(true) + }) + + it('accepts tags / keywords / related entries up to 256 chars each', () => { + const at256 = 'x'.repeat(256) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, tags: [at256]}).success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('rejects missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {operation_type: _omit, ...withoutOpType} = baseValid + expect(CurateOperationAppliedSchema.safeParse(withoutOpType).success).to.equal(false) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {task_id: _omit2, ...withoutTaskId} = baseValid + expect(CurateOperationAppliedSchema.safeParse(withoutTaskId).success).to.equal(false) + }) + + it('rejects out-of-enum operation_type', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, operation_type: 'RENAME'}).success).to.equal(false) + }) + + it('rejects out-of-enum impact and confidence', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, impact: 'medium'}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, confidence: 'maybe'}).success).to.equal(false) + }) + + it('rejects empty absolute_path / knowledge_path / task_id', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, absolute_path: ''}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, knowledge_path: ''}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, task_id: ''}).success).to.equal(false) + }) + + it('rejects tags / keywords / related with more than 50 entries', () => { + const fiftyOne = Array.from({length: 51}, (_, i) => `entry-${i}`) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, tags: fiftyOne}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, keywords: fiftyOne}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, related: fiftyOne}).success).to.equal(false) + }) + + it('rejects tags / keywords / related entries longer than 256 chars', () => { + const at257 = 'x'.repeat(257) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, tags: [at257]}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, keywords: [at257]}).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse({...baseValid, related: [at257]}).success).to.equal(false) + }) + + it('rejects unknown extra fields (strict)', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, mystery_field: 'oops'}).success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/events/curate-run-completed.test.ts b/test/unit/shared/analytics/events/curate-run-completed.test.ts new file mode 100644 index 000000000..d9e9e6728 --- /dev/null +++ b/test/unit/shared/analytics/events/curate-run-completed.test.ts @@ -0,0 +1,89 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {CurateRunCompletedSchema} from '../../../../../src/shared/analytics/events/curate-run-completed.js' + +const baseValid = { + duration_ms: 5000, + operations_added: 1, + operations_deleted: 0, + operations_failed: 0, + operations_merged: 0, + operations_updated: 2, + outcome: 'completed' as const, + pending_review_count: 0, + task_id: 'task-uuid-123', + task_type: 'curate' as const, +} + +describe('CurateRunCompletedSchema', () => { + describe('valid payloads', () => { + it('accepts the minimal required payload', () => { + expect(CurateRunCompletedSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('accepts each task_type enum value', () => { + for (const task_type of ['curate', 'curate-folder'] as const) { + expect(CurateRunCompletedSchema.safeParse({...baseValid, task_type}).success).to.equal(true) + } + }) + + it('accepts each outcome enum value', () => { + for (const outcome of ['completed', 'partial', 'cancelled', 'error'] as const) { + expect(CurateRunCompletedSchema.safeParse({...baseValid, outcome}).success).to.equal(true) + } + }) + + it('accepts zero counts and duration_ms=0', () => { + const zeroed = { + ...baseValid, + duration_ms: 0, + operations_added: 0, + operations_deleted: 0, + operations_failed: 0, + operations_merged: 0, + operations_updated: 0, + pending_review_count: 0, + } + expect(CurateRunCompletedSchema.safeParse(zeroed).success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('rejects missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {outcome: _o, ...withoutOutcome} = baseValid + expect(CurateRunCompletedSchema.safeParse(withoutOutcome).success).to.equal(false) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {task_id: _t, ...withoutTaskId} = baseValid + expect(CurateRunCompletedSchema.safeParse(withoutTaskId).success).to.equal(false) + }) + + it('rejects out-of-enum outcome', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, outcome: 'mystery'}).success).to.equal(false) + }) + + it('rejects out-of-enum task_type', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, task_type: 'query'}).success).to.equal(false) + }) + + it('rejects negative counts and duration_ms', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, duration_ms: -1}).success).to.equal(false) + expect(CurateRunCompletedSchema.safeParse({...baseValid, operations_added: -1}).success).to.equal(false) + expect(CurateRunCompletedSchema.safeParse({...baseValid, pending_review_count: -1}).success).to.equal(false) + }) + + it('rejects non-integer counts', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, operations_added: 1.5}).success).to.equal(false) + expect(CurateRunCompletedSchema.safeParse({...baseValid, duration_ms: 1.5}).success).to.equal(false) + }) + + it('rejects empty task_id', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, task_id: ''}).success).to.equal(false) + }) + + it('rejects unknown extra fields (strict)', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, mystery_field: 'oops'}).success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/events/query-completed.test.ts b/test/unit/shared/analytics/events/query-completed.test.ts new file mode 100644 index 000000000..69072357a --- /dev/null +++ b/test/unit/shared/analytics/events/query-completed.test.ts @@ -0,0 +1,135 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {QueryCompletedSchema} from '../../../../../src/shared/analytics/events/query-completed.js' + +const baseValid = { + cache_hit: false, + duration_ms: 1234, + matched_doc_count: 5, + outcome: 'completed' as const, + read_doc_count: 2, + read_paths_with_metadata: [], + read_tool_call_count: 3, + search_call_count: 1, + task_id: 'task-uuid-456', + task_type: 'query' as const, +} + +describe('QueryCompletedSchema', () => { + describe('valid payloads', () => { + it('accepts the minimal required payload with empty read_paths_with_metadata', () => { + expect(QueryCompletedSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('accepts payloads omitting read_paths_with_metadata (optional outer array)', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {read_paths_with_metadata: _r, ...withoutReadPaths} = baseValid + expect(QueryCompletedSchema.safeParse(withoutReadPaths).success).to.equal(true) + }) + + it('accepts each outcome enum value', () => { + for (const outcome of ['completed', 'cancelled', 'error'] as const) { + expect(QueryCompletedSchema.safeParse({...baseValid, outcome}).success).to.equal(true) + } + }) + + it('accepts each tier literal value (0..4)', () => { + for (const tier of [0, 1, 2, 3, 4] as const) { + expect(QueryCompletedSchema.safeParse({...baseValid, tier}).success).to.equal(true) + } + }) + + it('accepts payloads omitting tier', () => { + expect(QueryCompletedSchema.safeParse({...baseValid}).success).to.equal(true) + }) + + it('accepts cache_hit=true', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, cache_hit: true}).success).to.equal(true) + }) + + it('accepts read_paths_with_metadata entries with no metadata', () => { + const entries = [{absolute_path: '/a.md'}, {absolute_path: '/b.md'}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) + }) + + it('accepts entries with full optional metadata', () => { + const entries = [{absolute_path: '/a.md', keywords: ['k1'], related: ['r1'], tags: ['t1']}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) + }) + + it('accepts read_paths_with_metadata with exactly 10 entries', () => { + const entries = Array.from({length: 10}, (_, i) => ({absolute_path: `/file-${i}.md`})) + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) + }) + + it('accepts entries with tags / keywords / related at the 50-entry cap and 256-char cap', () => { + const fifty = Array.from({length: 50}, (_, i) => `entry-${i}`) + const at256 = 'x'.repeat(256) + const entries = [{absolute_path: '/a.md', keywords: fifty, related: [at256], tags: fifty}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('rejects missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {outcome: _o, ...withoutOutcome} = baseValid + expect(QueryCompletedSchema.safeParse(withoutOutcome).success).to.equal(false) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {task_id: _t, ...withoutTaskId} = baseValid + expect(QueryCompletedSchema.safeParse(withoutTaskId).success).to.equal(false) + }) + + it('rejects out-of-enum outcome', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, outcome: 'partial'}).success).to.equal(false) + }) + + it('rejects tier outside 0..4', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, tier: 5}).success).to.equal(false) + expect(QueryCompletedSchema.safeParse({...baseValid, tier: -1}).success).to.equal(false) + }) + + it('rejects task_type other than literal "query"', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, task_type: 'curate'}).success).to.equal(false) + }) + + it('rejects negative or non-integer counts', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, matched_doc_count: -1}).success).to.equal(false) + expect(QueryCompletedSchema.safeParse({...baseValid, read_tool_call_count: 1.5}).success).to.equal(false) + }) + + it('rejects read_paths_with_metadata with more than 10 entries', () => { + const entries = Array.from({length: 11}, (_, i) => ({absolute_path: `/file-${i}.md`})) + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) + }) + + it('rejects entries with empty absolute_path', () => { + const entries = [{absolute_path: ''}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) + }) + + it('rejects entries with more than 50 tags / keywords / related', () => { + const fiftyOne = Array.from({length: 51}, (_, i) => `entry-${i}`) + const tagsEntry = [{absolute_path: '/a.md', tags: fiftyOne}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: tagsEntry}).success).to.equal( + false, + ) + }) + + it('rejects entries with tag / keyword / related string longer than 256 chars', () => { + const at257 = 'x'.repeat(257) + const entries = [{absolute_path: '/a.md', keywords: [at257]}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) + }) + + it('rejects unknown extra fields at top level (strict)', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, mystery_field: 'oops'}).success).to.equal(false) + }) + + it('rejects unknown extra fields inside an entry (strict)', () => { + const entries = [{absolute_path: '/a.md', mystery: 'oops'}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/privacy-fixture.test.ts b/test/unit/shared/analytics/privacy-fixture.test.ts index 9d1922862..dee883859 100644 --- a/test/unit/shared/analytics/privacy-fixture.test.ts +++ b/test/unit/shared/analytics/privacy-fixture.test.ts @@ -117,9 +117,12 @@ describe('analytics privacy fixture (smoke)', () => { it('should expose every shipped event name under ALL_EVENT_SCHEMAS', () => { expect(Object.keys(ALL_EVENT_SCHEMAS).sort()).to.deep.equal([ 'cli_invocation', + 'curate_operation_applied', + 'curate_run_completed', 'daemon_start', 'mcp_session_start', 'mcp_tool_called', + 'query_completed', 'task_completed', 'task_created', 'task_failed', From f4fcb208ec7843a6328a9300cda9f56f6498aec8 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Tue, 12 May 2026 23:11:33 +0700 Subject: [PATCH 27/87] feat: [ENG-2771] M12.2 add AnalyticsHook lifecycle hook + wire into daemon Add AnalyticsHook (implements ITaskLifecycleHook) at src/server/infra/process/analytics-hook.ts. Branches on task.type internally: curate tasks emit curate_operation_applied per successful op via onToolResult and curate_run_completed at terminal; query tasks emit query_completed at terminal with read_paths_with_metadata entries carrying absolute_path only. M12.3 will layer tags/keywords/related frontmatter onto the same payloads. Wired as the 4th entry in TaskRouter.lifecycleHooks alongside curateLogHandler / queryLogHandler / taskHistoryHook. analyticsClient is constructed by setupFeatureHandlers later in the daemon factory, so the hook uses a deferred setAnalyticsClient setter; emit silently no-ops until the client lands. QUERY_RESULT transport handler is fanned out to both queryLogHandler and analyticsHook so the hook caches tier + searchMetadata for the terminal emit. Behaviour-neutral export keywords added to existing CURATE_TASK_TYPES, QUERY_TASK_TYPES, and QueryResultMetadata so AnalyticsHook can reuse the single source of truth for task-type detection. CurateLogHandler, QueryLogHandler, and TaskHistoryHook source files are otherwise unchanged. --- src/server/infra/daemon/brv-server.ts | 21 +- src/server/infra/process/analytics-hook.ts | 295 ++++++++++++ .../infra/process/curate-log-handler.ts | 2 +- src/server/infra/process/query-log-handler.ts | 4 +- .../infra/process/analytics-hook.test.ts | 421 ++++++++++++++++++ 5 files changed, 737 insertions(+), 6 deletions(-) create mode 100644 src/server/infra/process/analytics-hook.ts create mode 100644 test/unit/server/infra/process/analytics-hook.test.ts diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index 79738794e..eaed99fde 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -58,6 +58,7 @@ import {readContextTreeRemoteUrl} from '../context-tree/read-context-tree-remote import {DreamStateService} from '../dream/dream-state-service.js' import {DreamTrigger} from '../dream/dream-trigger.js' import {createReviewApiRouter} from '../http/review-api-handler.js' +import {AnalyticsHook} from '../process/analytics-hook.js' import {broadcastToProjectRoom} from '../process/broadcast-utils.js' import {CurateLogHandler} from '../process/curate-log-handler.js' import {setupFeatureHandlers} from '../process/feature-handlers.js' @@ -392,6 +393,13 @@ async function main(): Promise { // same instances this hook writes to. const taskHistoryHook = new TaskHistoryHook({getStore: getTaskHistoryStore}) + // M12.2 analytics hook — emits curate_operation_applied / curate_run_completed + // / query_completed events into the daemon's IAnalyticsClient. The client + // is constructed later by setupFeatureHandlers (see below), so wire the + // hook now with a deferred analyticsClient setter; emit() silently no-ops + // until the client lands (no tasks run during daemon boot). + const analyticsHook = new AnalyticsHook() + // Provider config/keychain stores — shared between feature handlers and state endpoint. // Hoisted ahead of `new TransportHandlers` so the resolveActiveProvider callback below // can close over them and call resolveProviderConfig synchronously at task-create time. @@ -432,7 +440,7 @@ async function main(): Promise { // idle-dream dispatch above so review semantics are identical regardless of // dispatch source (CLI task:create vs agent-idle trigger). isReviewDisabled: resolveReviewDisabled, - lifecycleHooks: [curateLogHandler, queryLogHandler, taskHistoryHook], + lifecycleHooks: [curateLogHandler, queryLogHandler, taskHistoryHook, analyticsHook], // Daemon-side gate for dream task:create — mirrors the idle-trigger pre-check // in this file so the CLI path (brv dream without --force) actually honors // gate 3 (queue). The agent-side check kept gate 3 hardcoded to skip, @@ -474,12 +482,14 @@ async function main(): Promise { // Agent sends task:queryResult BEFORE task:completed (Socket.IO preserves order), // so setQueryResult runs before onTaskCompleted merges the metadata. transportServer.onRequest(TransportTaskEventNames.QUERY_RESULT, (data) => { - queryLogHandler.setQueryResult(data.taskId, { + const queryMetadata = { matchedDocs: data.matchedDocs, searchMetadata: data.searchMetadata, tier: data.tier, timing: data.timing, - }) + } + queryLogHandler.setQueryResult(data.taskId, queryMetadata) + analyticsHook.setQueryResult(data.taskId, queryMetadata) }) // 8. Create idle timeout policy + shutdown handler @@ -670,6 +680,11 @@ async function main(): Promise { webuiPort: webuiServer?.getPort(), }) + // M12.2: bind the real analyticsClient into AnalyticsHook now that + // setupFeatureHandlers has constructed it. Hook emits silently no-op + // until this setter runs (no tasks should be active during daemon boot). + analyticsHook.setAnalyticsClient(analyticsClient) + // Load auth token AFTER feature handlers are registered. // AuthHandler's onAuthChanged/onAuthExpired callbacks must be wired first // so that loadToken() triggers proper broadcasts to TUI and agents. diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts new file mode 100644 index 000000000..8f70a0d2a --- /dev/null +++ b/src/server/infra/process/analytics-hook.ts @@ -0,0 +1,295 @@ +/* eslint-disable camelcase */ +import type {LlmToolResultEvent} from '../../core/domain/transport/schemas.js' +import type {TaskInfo} from '../../core/domain/transport/task-info.js' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import type {ITaskLifecycleHook} from '../../core/interfaces/process/i-task-lifecycle-hook.js' +import type {QueryResultMetadata} from './query-log-handler.js' + +import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' +import {extractCurateOperations} from '../../utils/curate-result-parser.js' +import {processLog} from '../../utils/process-logger.js' +import {CURATE_TASK_TYPES} from './curate-log-handler.js' +import {QUERY_TASK_TYPES} from './query-log-handler.js' + +// `CURATE_TASK_TYPES` is exported as a readonly tuple; wrap in a Set +// for cast-free `.has()` lookups against TaskInfo.type (string). +const CURATE_TASK_TYPE_SET: ReadonlySet = new Set(CURATE_TASK_TYPES) + +const READ_FILE_TOOL = 'read_file' +const EXPAND_KNOWLEDGE_TOOL = 'expand_knowledge' +const SEARCH_KNOWLEDGE_TOOL = 'search_knowledge' + +const MAX_READ_PATHS = 10 + +type CurateTaskTypeLiteral = (typeof CURATE_TASK_TYPES)[number] + +type CurateCounters = { + added: number + deleted: number + failed: number + merged: number + pendingReview: number + updated: number +} + +type CurateTaskAnalyticsState = { + counters: CurateCounters + flavor: 'curate' + taskType: CurateTaskTypeLiteral +} + +type QueryTaskAnalyticsState = { + flavor: 'query' + queryMeta?: QueryResultMetadata +} + +type TaskAnalyticsState = CurateTaskAnalyticsState | QueryTaskAnalyticsState + +const isCurateLiteral = (value: string): value is CurateTaskTypeLiteral => + CURATE_TASK_TYPE_SET.has(value) + +/** + * Lifecycle hook that emits per-task analytics (curate_operation_applied, + * curate_run_completed, query_completed) into the daemon's + * `IAnalyticsClient`. Pure in-memory state keyed by `taskId`; no I/O of its own. + * + * Wired as a peer to `CurateLogHandler` / `QueryLogHandler` / + * `TaskHistoryHook` inside `TaskRouter.lifecycleHooks[]`. Does NOT modify the + * other handlers — read paths and curate-op accumulators are recomputed here + * via the shared `extractCurateOperations` parser and `task.toolCalls[]` + * shape, so analytics emit is decoupled from log persistence. + * + * M12.2 emits skeleton payloads (no frontmatter harvest). M12.3 layers + * `tags` / `keywords` / `related` arrays onto the curate-op and per-read-path + * payloads via a daemon-side post-op file read. + */ +export class AnalyticsHook implements ITaskLifecycleHook { + /** Lazy-injected by the daemon after `setupFeatureHandlers` constructs the client. */ + private analyticsClient?: IAnalyticsClient + /** In-memory state per active task. Cleared on cleanup(). */ + private readonly tasks = new Map() + + cleanup(taskId: string): void { + this.tasks.delete(taskId) + } + + async onTaskCancelled(taskId: string, task: TaskInfo): Promise { + this.dispatchTerminal(taskId, task, 'cancelled') + } + + async onTaskCompleted(taskId: string, _result: string, task: TaskInfo): Promise { + const state = this.tasks.get(taskId) + if (!state) return + + if (state.flavor === 'curate') { + const outcome = state.counters.failed > 0 ? 'partial' : 'completed' + this.emit(AnalyticsEventNames.CURATE_RUN_COMPLETED, this.buildCurateRunPayload(taskId, task, state, outcome)) + } else { + this.emit(AnalyticsEventNames.QUERY_COMPLETED, this.buildQueryCompletedPayload(taskId, task, state, 'completed')) + } + } + + async onTaskCreate(task: TaskInfo): Promise { + if (isCurateLiteral(task.type)) { + this.tasks.set(task.taskId, { + counters: {added: 0, deleted: 0, failed: 0, merged: 0, pendingReview: 0, updated: 0}, + flavor: 'curate', + taskType: task.type, + }) + return + } + + if (QUERY_TASK_TYPES.has(task.type)) { + this.tasks.set(task.taskId, {flavor: 'query'}) + } + } + + async onTaskError(taskId: string, _errorMessage: string, task: TaskInfo): Promise { + this.dispatchTerminal(taskId, task, 'error') + } + + onToolResult(taskId: string, payload: LlmToolResultEvent): void { + const state = this.tasks.get(taskId) + if (!state || state.flavor !== 'curate') return + + const ops = extractCurateOperations(payload) + for (const op of ops) { + if (op.status !== 'success') { + state.counters.failed++ + continue + } + + // Bump counters per op.type. UPSERT counts as `added` when the message + // hints at a new-file create (mirrors `computeSummary` in + // curate-log-handler.ts); otherwise treat as an update. + switch (op.type) { + case 'ADD': { + state.counters.added++ + break + } + + case 'DELETE': { + state.counters.deleted++ + break + } + + case 'MERGE': { + state.counters.merged++ + break + } + + case 'UPDATE': { + state.counters.updated++ + break + } + + case 'UPSERT': { + if (op.message?.includes('created new')) state.counters.added++ + else state.counters.updated++ + break + } + } + + if (op.needsReview === true) state.counters.pendingReview++ + + // `op.filePath` is optional on CurateLogOperation but every M12 emit + // requires absolute_path. Skip ops missing filePath so the daemon + // never emits a malformed row (these are rare; UPSERT/MERGE without + // a concrete file path would be the only realistic case). + if (!op.filePath) continue + + this.emit(AnalyticsEventNames.CURATE_OPERATION_APPLIED, { + absolute_path: op.filePath, + ...(op.confidence ? {confidence: op.confidence} : {}), + ...(op.impact ? {impact: op.impact} : {}), + knowledge_path: op.path, + needs_review: op.needsReview ?? false, + operation_type: op.type, + task_id: taskId, + }) + } + } + + /** + * Wired by the daemon factory after `setupFeatureHandlers` constructs + * the real `IAnalyticsClient`. Calls to `emit()` before this setter + * runs silently no-op (no tasks are active during daemon boot). + */ + setAnalyticsClient(client: IAnalyticsClient): void { + this.analyticsClient = client + } + + /** + * Cache per-task query execution metadata for later finalization. + * Symmetric to `QueryLogHandler.setQueryResult`. Called from the + * `QUERY_RESULT` transport handler fan-out in `brv-server.ts`. + */ + setQueryResult(taskId: string, metadata: QueryResultMetadata): void { + const state = this.tasks.get(taskId) + if (!state || state.flavor !== 'query') return + state.queryMeta = metadata + } + + private buildCurateRunPayload( + taskId: string, + task: TaskInfo, + state: CurateTaskAnalyticsState, + outcome: 'cancelled' | 'completed' | 'error' | 'partial', + ): Record { + return { + duration_ms: this.durationMs(task), + operations_added: state.counters.added, + operations_deleted: state.counters.deleted, + operations_failed: state.counters.failed, + operations_merged: state.counters.merged, + operations_updated: state.counters.updated, + outcome, + pending_review_count: state.counters.pendingReview, + task_id: taskId, + task_type: state.taskType, + } + } + + private buildQueryCompletedPayload( + taskId: string, + task: TaskInfo, + state: QueryTaskAnalyticsState, + outcome: 'cancelled' | 'completed' | 'error', + ): Record { + const readPaths = new Set() + let readToolCallCount = 0 + let searchCallCount = 0 + + for (const call of task.toolCalls ?? []) { + switch (call.toolName) { + case EXPAND_KNOWLEDGE_TOOL: { + readToolCallCount++ + const stubPath = call.args?.stubPath + const overviewPath = call.args?.overviewPath + if (typeof stubPath === 'string' && stubPath.length > 0) readPaths.add(stubPath) + if (typeof overviewPath === 'string' && overviewPath.length > 0) readPaths.add(overviewPath) + + break; + } + + case READ_FILE_TOOL: { + readToolCallCount++ + const filePath = call.args?.filePath + if (typeof filePath === 'string' && filePath.length > 0) readPaths.add(filePath) + + break; + } + + case SEARCH_KNOWLEDGE_TOOL: { + searchCallCount++ + + break; + } + // No default + } + } + + const cappedPaths = [...readPaths].sort().slice(0, MAX_READ_PATHS) + const tier = state.queryMeta?.tier + const matchedDocCount = state.queryMeta?.searchMetadata?.resultCount ?? 0 + + return { + cache_hit: tier === 0 || tier === 1, + duration_ms: this.durationMs(task), + matched_doc_count: matchedDocCount, + outcome, + read_doc_count: readPaths.size, + read_paths_with_metadata: cappedPaths.map((p) => ({absolute_path: p})), + read_tool_call_count: readToolCallCount, + search_call_count: searchCallCount, + task_id: taskId, + task_type: 'query', + ...(tier === undefined ? {} : {tier}), + } + } + + private dispatchTerminal(taskId: string, task: TaskInfo, outcome: 'cancelled' | 'error'): void { + const state = this.tasks.get(taskId) + if (!state) return + + if (state.flavor === 'curate') { + this.emit(AnalyticsEventNames.CURATE_RUN_COMPLETED, this.buildCurateRunPayload(taskId, task, state, outcome)) + } else { + this.emit(AnalyticsEventNames.QUERY_COMPLETED, this.buildQueryCompletedPayload(taskId, task, state, outcome)) + } + } + + private durationMs(task: TaskInfo): number { + return Math.max(0, (task.completedAt ?? Date.now()) - task.createdAt) + } + + private emit(event: string, properties: Record): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, properties) + } catch (error) { + processLog(`AnalyticsHook: ${event} track failed: ${error instanceof Error ? error.message : String(error)}`) + } + } +} diff --git a/src/server/infra/process/curate-log-handler.ts b/src/server/infra/process/curate-log-handler.ts index 1ad84475c..d0c524ca2 100644 --- a/src/server/infra/process/curate-log-handler.ts +++ b/src/server/infra/process/curate-log-handler.ts @@ -25,7 +25,7 @@ type TaskState = { reviewDisabled: boolean } -const CURATE_TASK_TYPES = ['curate', 'curate-folder'] as const +export const CURATE_TASK_TYPES = ['curate', 'curate-folder'] as const // ── Summary computation ─────────────────────────────────────────────────────── diff --git a/src/server/infra/process/query-log-handler.ts b/src/server/infra/process/query-log-handler.ts index 6aad5cd44..981cd5235 100644 --- a/src/server/infra/process/query-log-handler.ts +++ b/src/server/infra/process/query-log-handler.ts @@ -11,7 +11,7 @@ import {FileQueryLogStore} from '../storage/file-query-log-store.js' // ── Internal state ──────────────────────────────────────────────────────────── /** Query metadata without the response string (response arrives via task:completed). */ -type QueryResultMetadata = Omit +export type QueryResultMetadata = Omit type TaskState = { /** Cached initial entry — used in onTaskCompleted/onTaskError to avoid a getById round-trip. */ @@ -21,7 +21,7 @@ type TaskState = { queryResult?: QueryResultMetadata } -const QUERY_TASK_TYPES: ReadonlySet = new Set(['query']) +export const QUERY_TASK_TYPES: ReadonlySet = new Set(['query']) // ── QueryLogHandler ────────────────────────────────────────────────────────── diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts new file mode 100644 index 000000000..6332de269 --- /dev/null +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -0,0 +1,421 @@ + +import {expect} from 'chai' +import sinon from 'sinon' + +import type {LlmToolResultEvent} from '../../../../../src/server/core/domain/transport/schemas.js' +import type {TaskInfo} from '../../../../../src/server/core/domain/transport/task-info.js' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {QueryResultMetadata} from '../../../../../src/server/infra/process/query-log-handler.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsHook} from '../../../../../src/server/infra/process/analytics-hook.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' + +const FIXED_NOW = 1_700_000_000_000 + +type StubBundle = { + client: IAnalyticsClient + flushStub: sinon.SinonStub + trackStub: sinon.SinonStub +} + +const buildAnalyticsClient = (): StubBundle => { + const trackStub = sinon.stub() + const flushStub = sinon.stub().resolves(AnalyticsBatch.create([])) + const client: IAnalyticsClient = {flush: flushStub, track: trackStub} + return {client, flushStub, trackStub} +} + +const buildCurateTask = (overrides: Partial = {}): TaskInfo => + ({ + clientId: 'client-1', + completedAt: FIXED_NOW + 5000, + content: 'curate stuff', + createdAt: FIXED_NOW, + projectPath: '/project', + taskId: 'task-curate-1', + type: 'curate', + ...overrides, + }) as TaskInfo + +const buildQueryTask = (overrides: Partial = {}): TaskInfo => + ({ + clientId: 'client-1', + completedAt: FIXED_NOW + 1234, + content: 'query stuff', + createdAt: FIXED_NOW, + projectPath: '/project', + taskId: 'task-query-1', + toolCalls: [], + type: 'query', + ...overrides, + }) as TaskInfo + +const buildToolResult = (ops: Array>): LlmToolResultEvent => ({ + callId: 'call-1', + result: JSON.stringify({applied: ops}), + sessionId: 'session-1', + taskId: 'task-curate-1', + timestamp: FIXED_NOW, + toolName: 'curate' as const, +}) as unknown as LlmToolResultEvent + +describe('AnalyticsHook', () => { + let trackStub: sinon.SinonStub + let hook: AnalyticsHook + + beforeEach(() => { + const bundle = buildAnalyticsClient() + trackStub = bundle.trackStub + hook = new AnalyticsHook() + hook.setAnalyticsClient(bundle.client) + }) + + describe('curate task flow', () => { + it('emits curate_operation_applied per successful op + bumps matching counter; no event for failed op', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + + const payload = buildToolResult([ + {filePath: '/a.md', needsReview: false, path: 'notes/a', status: 'success', type: 'ADD'}, + {filePath: '/b.md', needsReview: true, path: 'notes/b', status: 'success', type: 'UPDATE'}, + {filePath: '/c.md', needsReview: false, path: 'notes/c', status: 'failed', type: 'ADD'}, + ]) + hook.onToolResult(task.taskId, payload) + + expect(trackStub.callCount).to.equal(2) + expect(trackStub.firstCall.args[0]).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) + const firstProps = trackStub.firstCall.args[1] as Record + expect(firstProps.absolute_path).to.equal('/a.md') + expect(firstProps.knowledge_path).to.equal('notes/a') + expect(firstProps.operation_type).to.equal('ADD') + expect(firstProps.needs_review).to.equal(false) + expect(firstProps).to.not.have.property('tags') + expect(firstProps).to.not.have.property('keywords') + expect(firstProps).to.not.have.property('related') + + const secondProps = trackStub.secondCall.args[1] as Record + expect(secondProps.needs_review).to.equal(true) + expect(secondProps.operation_type).to.equal('UPDATE') + }) + + it('emits curate_run_completed at terminal with counter totals + outcome=completed', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + hook.onToolResult( + task.taskId, + buildToolResult([ + {filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}, + {filePath: '/b.md', needsReview: false, path: 'b', status: 'success', type: 'UPDATE'}, + {filePath: '/c.md', needsReview: false, path: 'c', status: 'success', type: 'DELETE'}, + ]), + ) + trackStub.resetHistory() + + await hook.onTaskCompleted(task.taskId, '', task) + + expect(trackStub.calledOnce).to.equal(true) + expect(trackStub.firstCall.args[0]).to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) + const props = trackStub.firstCall.args[1] as Record + expect(props.task_id).to.equal(task.taskId) + expect(props.task_type).to.equal('curate') + expect(props.outcome).to.equal('completed') + expect(props.operations_added).to.equal(1) + expect(props.operations_updated).to.equal(1) + expect(props.operations_deleted).to.equal(1) + expect(props.operations_merged).to.equal(0) + expect(props.operations_failed).to.equal(0) + expect(props.pending_review_count).to.equal(0) + expect(props.duration_ms).to.equal(5000) + }) + + it('emits outcome=partial when at least one op failed', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + hook.onToolResult( + task.taskId, + buildToolResult([ + {filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}, + {filePath: '/b.md', needsReview: false, path: 'b', status: 'failed', type: 'ADD'}, + ]), + ) + trackStub.resetHistory() + + await hook.onTaskCompleted(task.taskId, '', task) + + const props = trackStub.firstCall.args[1] as Record + expect(props.outcome).to.equal('partial') + expect(props.operations_failed).to.equal(1) + }) + + it('emits outcome=error on onTaskError', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + trackStub.resetHistory() + + await hook.onTaskError(task.taskId, 'boom', task) + + const props = trackStub.firstCall.args[1] as Record + expect(props.outcome).to.equal('error') + }) + + it('emits outcome=cancelled on onTaskCancelled', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + trackStub.resetHistory() + + await hook.onTaskCancelled(task.taskId, task) + + const props = trackStub.firstCall.args[1] as Record + expect(props.outcome).to.equal('cancelled') + }) + + it('counts UPSERT with "created new" message as added; otherwise as updated', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + hook.onToolResult( + task.taskId, + buildToolResult([ + {filePath: '/a.md', message: 'created new entry', path: 'a', status: 'success', type: 'UPSERT'}, + {filePath: '/b.md', message: 'updated existing entry', path: 'b', status: 'success', type: 'UPSERT'}, + ]), + ) + trackStub.resetHistory() + + await hook.onTaskCompleted(task.taskId, '', task) + + const props = trackStub.firstCall.args[1] as Record + expect(props.operations_added).to.equal(1) + expect(props.operations_updated).to.equal(1) + }) + + it('counts pending review when needsReview=true on a successful op', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + hook.onToolResult( + task.taskId, + buildToolResult([ + {filePath: '/a.md', needsReview: true, path: 'a', status: 'success', type: 'ADD'}, + {filePath: '/b.md', needsReview: true, path: 'b', status: 'success', type: 'UPDATE'}, + ]), + ) + trackStub.resetHistory() + + await hook.onTaskCompleted(task.taskId, '', task) + + const props = trackStub.firstCall.args[1] as Record + expect(props.pending_review_count).to.equal(2) + }) + + it('uses task_type literal from task (curate-folder)', async () => { + const task = buildCurateTask({type: 'curate-folder'}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const props = trackStub.firstCall.args[1] as Record + expect(props.task_type).to.equal('curate-folder') + }) + + it('skips emitting op when op.filePath is missing (avoids invalid payload)', async () => { + const task = buildCurateTask() + await hook.onTaskCreate(task) + hook.onToolResult( + task.taskId, + buildToolResult([{needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), + ) + + expect(trackStub.called).to.equal(false) + }) + }) + + describe('query task flow', () => { + it('emits query_completed at terminal with derived counts + paths', async () => { + const task = buildQueryTask({ + toolCalls: [ + {args: {filePath: '/a.md'}, sessionId: 's', status: 'completed', timestamp: 1, toolName: 'read_file'}, + {args: {filePath: '/b.md'}, sessionId: 's', status: 'completed', timestamp: 2, toolName: 'read_file'}, + {args: {filePath: '/a.md'}, sessionId: 's', status: 'completed', timestamp: 3, toolName: 'read_file'}, + { + args: {stubPath: '/c.md'}, + sessionId: 's', + status: 'completed', + timestamp: 4, + toolName: 'expand_knowledge', + }, + {args: {query: 'foo'}, sessionId: 's', status: 'completed', timestamp: 5, toolName: 'search_knowledge'}, + ], + } as Partial) + + await hook.onTaskCreate(task) + hook.setQueryResult(task.taskId, { + matchedDocs: [], + searchMetadata: {resultCount: 7, topScore: 0.9, totalFound: 7}, + tier: 3, + timing: {durationMs: 1234}, + } as QueryResultMetadata) + await hook.onTaskCompleted(task.taskId, '', task) + + expect(trackStub.calledOnce).to.equal(true) + expect(trackStub.firstCall.args[0]).to.equal(AnalyticsEventNames.QUERY_COMPLETED) + const props = trackStub.firstCall.args[1] as Record + expect(props.task_id).to.equal(task.taskId) + expect(props.task_type).to.equal('query') + expect(props.outcome).to.equal('completed') + expect(props.duration_ms).to.equal(1234) + expect(props.read_tool_call_count).to.equal(4) // 3 read_file + 1 expand_knowledge + expect(props.search_call_count).to.equal(1) + expect(props.read_doc_count).to.equal(3) // distinct: /a.md, /b.md, /c.md + expect(props.tier).to.equal(3) + expect(props.cache_hit).to.equal(false) + expect(props.matched_doc_count).to.equal(7) + const paths = props.read_paths_with_metadata as Array> + expect(paths).to.have.lengthOf(3) + // sorted lexicographically + expect(paths.map((p) => p.absolute_path)).to.deep.equal(['/a.md', '/b.md', '/c.md']) + // each entry has only absolute_path, no metadata in M12.2 + for (const entry of paths) { + expect(entry).to.not.have.property('tags') + expect(entry).to.not.have.property('keywords') + expect(entry).to.not.have.property('related') + } + }) + + it('caps read_paths_with_metadata at 10 entries even when more distinct paths exist', async () => { + const toolCalls = Array.from({length: 15}, (_, i) => ({ + args: {filePath: `/file-${String(i).padStart(2, '0')}.md`}, + sessionId: 's', + status: 'completed' as const, + timestamp: i, + toolName: 'read_file', + })) + const task = buildQueryTask({toolCalls} as Partial) + + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const props = trackStub.firstCall.args[1] as Record + const paths = props.read_paths_with_metadata as Array> + expect(paths).to.have.lengthOf(10) + expect(props.read_doc_count).to.equal(15) // distinct count NOT capped + }) + + for (const tier of [0, 1] as const) { + it(`cache_hit is true for tier ${tier}`, async () => { + const localBundle = buildAnalyticsClient() + const localHook = new AnalyticsHook() + localHook.setAnalyticsClient(localBundle.client) + const task = buildQueryTask({taskId: `task-tier-${tier}`}) + + await localHook.onTaskCreate(task) + localHook.setQueryResult(task.taskId, { + matchedDocs: [], + tier, + timing: {durationMs: 5}, + } as QueryResultMetadata) + await localHook.onTaskCompleted(task.taskId, '', task) + + const props = localBundle.trackStub.firstCall.args[1] as Record + expect(props.cache_hit).to.equal(true) + }) + } + + for (const tier of [2, 3, 4] as const) { + it(`cache_hit is false for tier ${tier}`, async () => { + const localBundle = buildAnalyticsClient() + const localHook = new AnalyticsHook() + localHook.setAnalyticsClient(localBundle.client) + const task = buildQueryTask({taskId: `task-tier-${tier}`}) + + await localHook.onTaskCreate(task) + localHook.setQueryResult(task.taskId, { + matchedDocs: [], + tier, + timing: {durationMs: 5}, + } as QueryResultMetadata) + await localHook.onTaskCompleted(task.taskId, '', task) + + const props = localBundle.trackStub.firstCall.args[1] as Record + expect(props.cache_hit).to.equal(false) + }) + } + + it('emits tier absent + cache_hit=false + matched_doc_count=0 when setQueryResult never ran', async () => { + const task = buildQueryTask() + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const props = trackStub.firstCall.args[1] as Record + expect(props.tier).to.equal(undefined) + expect(props.cache_hit).to.equal(false) + expect(props.matched_doc_count).to.equal(0) + }) + + it('emits outcome=error on onTaskError for query', async () => { + const task = buildQueryTask() + await hook.onTaskCreate(task) + + await hook.onTaskError(task.taskId, 'boom', task) + + const props = trackStub.firstCall.args[1] as Record + expect(props.outcome).to.equal('error') + }) + + it('emits outcome=cancelled on onTaskCancelled for query', async () => { + const task = buildQueryTask() + await hook.onTaskCreate(task) + + await hook.onTaskCancelled(task.taskId, task) + + const props = trackStub.firstCall.args[1] as Record + expect(props.outcome).to.equal('cancelled') + }) + }) + + describe('lifecycle hygiene', () => { + it('cleanup(taskId) drops state for both flavors', async () => { + const curate = buildCurateTask() + const query = buildQueryTask() + await hook.onTaskCreate(curate) + await hook.onTaskCreate(query) + hook.cleanup(curate.taskId) + hook.cleanup(query.taskId) + + // After cleanup, terminal hooks should be no-ops + trackStub.resetHistory() + await hook.onTaskCompleted(curate.taskId, '', curate) + await hook.onTaskCompleted(query.taskId, '', query) + expect(trackStub.called).to.equal(false) + }) + + it('ignores unknown task types (no state created)', async () => { + const task = buildCurateTask({taskId: 'task-unknown', type: 'unknown' as TaskInfo['type']}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + expect(trackStub.called).to.equal(false) + }) + + it('swallows analyticsClient.track throws (does not propagate)', async () => { + trackStub.throws(new Error('boom')) + const task = buildCurateTask() + await hook.onTaskCreate(task) + hook.onToolResult( + task.taskId, + buildToolResult([{filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), + ) + // No throw means swallowed + expect(trackStub.called).to.equal(true) + }) + + it('emit is a no-op when setAnalyticsClient was never called', async () => { + const bareHook = new AnalyticsHook() + const task = buildCurateTask() + await bareHook.onTaskCreate(task) + // No throws, no client to assert against + bareHook.onToolResult( + task.taskId, + buildToolResult([{filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), + ) + await bareHook.onTaskCompleted(task.taskId, '', task) + }) + }) +}) From 0ddc33b2e1e9ff6fad3f88cb486b744b7e74db26 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Tue, 12 May 2026 23:27:55 +0700 Subject: [PATCH 28/87] feat: [ENG-2772] M12.3 add daemon-side frontmatter harvest in AnalyticsHook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend AnalyticsHook with synchronous YAML-frontmatter harvest from post-op curate files and per-read-path query files. capStringArray caps emit fields at 50 entries / 256 chars per entry. Frontmatter arrays stay absent on DELETE ops, on file-read failures (ENOENT, EACCES, malformed YAML), and when analytics is disabled — telemetry never crashes the hook. parseFrontmatter in markdown-writer.ts gains an export keyword so the hook can reuse it (behavior-neutral). SetupFeatureHandlersResult gains an isAnalyticsEnabled callback so brv-server.ts can wire a deferred isEnabled closure into AnalyticsHook — gates the new file reads on the same cached flag the AnalyticsClient already uses internally. Closes Mr Linh's third M12 requirement (capture context-metadata at moment-of-operation) for ADD / UPDATE / MERGE-target / UPSERT. DELETE / MERGE-source carry path-only by design (file is gone post-op; pre-state recovery is out of scope). --- .../core/domain/knowledge/markdown-writer.ts | 7 +- src/server/infra/daemon/brv-server.ts | 26 ++- src/server/infra/process/analytics-hook.ts | 97 ++++++++- src/server/infra/process/feature-handlers.ts | 11 +- .../infra/process/analytics-hook.test.ts | 196 +++++++++++++++++- 5 files changed, 323 insertions(+), 14 deletions(-) diff --git a/src/server/core/domain/knowledge/markdown-writer.ts b/src/server/core/domain/knowledge/markdown-writer.ts index 414dddc9d..d6bd9d8e8 100644 --- a/src/server/core/domain/knowledge/markdown-writer.ts +++ b/src/server/core/domain/knowledge/markdown-writer.ts @@ -199,8 +199,13 @@ function generateFrontmatter( /** * Parse YAML frontmatter from markdown content. * Returns null if no frontmatter is found (backward compat with old format). + * + * Exported for cross-module reuse by daemon-side telemetry harvest + * (M12.3: AnalyticsHook reads frontmatter from affected files post-op + * to populate per-event `tags` / `keywords` / `related` arrays). Existing + * in-file callers are unaffected by the export-keyword addition. */ -function parseFrontmatter(content: string): null | ParsedFrontmatter { +export function parseFrontmatter(content: string): null | ParsedFrontmatter { if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) { return null } diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index eaed99fde..fed12c327 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -393,12 +393,16 @@ async function main(): Promise { // same instances this hook writes to. const taskHistoryHook = new TaskHistoryHook({getStore: getTaskHistoryStore}) - // M12.2 analytics hook — emits curate_operation_applied / curate_run_completed - // / query_completed events into the daemon's IAnalyticsClient. The client - // is constructed later by setupFeatureHandlers (see below), so wire the - // hook now with a deferred analyticsClient setter; emit() silently no-ops - // until the client lands (no tasks run during daemon boot). - const analyticsHook = new AnalyticsHook() + // M12.2/M12.3 analytics hook — emits curate_operation_applied / + // curate_run_completed / query_completed events into the daemon's + // IAnalyticsClient. The client + analytics-enabled flag are both + // constructed later by setupFeatureHandlers, so wire the hook now with + // deferred references; emit() silently no-ops until setAnalyticsClient + // lands, and isEnabled defaults to true (true is the safe default — + // no tasks run during the brief boot window between this line and the + // setupFeatureHandlers return below). + let analyticsEnabledCheck: () => boolean = () => true + const analyticsHook = new AnalyticsHook({isEnabled: () => analyticsEnabledCheck()}) // Provider config/keychain stores — shared between feature handlers and state endpoint. // Hoisted ahead of `new TransportHandlers` so the resolveActiveProvider callback below @@ -664,7 +668,7 @@ async function main(): Promise { // Feature handlers (auth, init, status, push, pull, etc.) require async OIDC discovery. // Placed after daemon:getState so the debug endpoint is available immediately, // without waiting for OIDC discovery (~400ms). - const {analyticsClient} = await setupFeatureHandlers({ + const {analyticsClient, isAnalyticsEnabled} = await setupFeatureHandlers({ authStateStore, broadcastToProject(projectPath, event, data) { broadcastToProjectRoom(projectRegistry, projectRouter, projectPath, event, data) @@ -680,10 +684,12 @@ async function main(): Promise { webuiPort: webuiServer?.getPort(), }) - // M12.2: bind the real analyticsClient into AnalyticsHook now that - // setupFeatureHandlers has constructed it. Hook emits silently no-op - // until this setter runs (no tasks should be active during daemon boot). + // M12.2/M12.3: bind the real analyticsClient + analytics-enabled check + // into AnalyticsHook now that setupFeatureHandlers has constructed them. + // Hook emits silently no-op until the setter runs; isEnabled gate + // short-circuits M12.3 frontmatter reads when analytics is off. analyticsHook.setAnalyticsClient(analyticsClient) + analyticsEnabledCheck = isAnalyticsEnabled // Load auth token AFTER feature handlers are registered. // AuthHandler's onAuthChanged/onAuthExpired callbacks must be wired first diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index 8f70a0d2a..434439117 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -1,4 +1,6 @@ /* eslint-disable camelcase */ +import {readFileSync} from 'node:fs' + import type {LlmToolResultEvent} from '../../core/domain/transport/schemas.js' import type {TaskInfo} from '../../core/domain/transport/task-info.js' import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' @@ -6,6 +8,7 @@ import type {ITaskLifecycleHook} from '../../core/interfaces/process/i-task-life import type {QueryResultMetadata} from './query-log-handler.js' import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' +import {parseFrontmatter} from '../../core/domain/knowledge/markdown-writer.js' import {extractCurateOperations} from '../../utils/curate-result-parser.js' import {processLog} from '../../utils/process-logger.js' import {CURATE_TASK_TYPES} from './curate-log-handler.js' @@ -20,6 +23,31 @@ const EXPAND_KNOWLEDGE_TOOL = 'expand_knowledge' const SEARCH_KNOWLEDGE_TOOL = 'search_knowledge' const MAX_READ_PATHS = 10 +const MAX_FRONTMATTER_ARRAY_LENGTH = 50 +const MAX_FRONTMATTER_STRING_LENGTH = 256 + +type FrontmatterFields = { + keywords?: string[] + related?: string[] + tags?: string[] +} + +/** + * Clip a frontmatter array to schema caps: array length <= 50, per-entry + * string length <= 256. Returns `undefined` when the input is not an array + * or is empty (so the emit can OMIT the field instead of carrying `[]`). + */ +function capStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + const strings: string[] = [] + for (const entry of value) { + if (typeof entry !== 'string') continue + strings.push(entry.length > MAX_FRONTMATTER_STRING_LENGTH ? entry.slice(0, MAX_FRONTMATTER_STRING_LENGTH) : entry) + if (strings.length >= MAX_FRONTMATTER_ARRAY_LENGTH) break + } + + return strings.length > 0 ? strings : undefined +} type CurateTaskTypeLiteral = (typeof CURATE_TASK_TYPES)[number] @@ -63,12 +91,27 @@ const isCurateLiteral = (value: string): value is CurateTaskTypeLiteral => * `tags` / `keywords` / `related` arrays onto the curate-op and per-read-path * payloads via a daemon-side post-op file read. */ +type AnalyticsHookDeps = { + /** + * Returns the daemon's cached analytics-enabled flag. Used by M12.3 to + * short-circuit frontmatter file reads when analytics is disabled (avoids + * wasted disk I/O on top of the no-op `track()`). Defaults to `() => true` + * in tests; production wires `() => globalConfigHandler.getCachedAnalytics()`. + */ + isEnabled?: () => boolean +} + export class AnalyticsHook implements ITaskLifecycleHook { /** Lazy-injected by the daemon after `setupFeatureHandlers` constructs the client. */ private analyticsClient?: IAnalyticsClient + private readonly isEnabled: () => boolean /** In-memory state per active task. Cleared on cleanup(). */ private readonly tasks = new Map() + constructor(deps: AnalyticsHookDeps = {}) { + this.isEnabled = deps.isEnabled ?? (() => true) + } + cleanup(taskId: string): void { this.tasks.delete(taskId) } @@ -158,13 +201,21 @@ export class AnalyticsHook implements ITaskLifecycleHook { // a concrete file path would be the only realistic case). if (!op.filePath) continue + // M12.3: read post-op frontmatter for ADD / UPDATE / MERGE-target / + // UPSERT. DELETE skips the read (file is gone). Frontmatter fields + // stay absent when the read fails (ENOENT, EACCES, malformed YAML). + const frontmatter = op.type === 'DELETE' ? {} : this.readFrontmatterFields(op.filePath) + this.emit(AnalyticsEventNames.CURATE_OPERATION_APPLIED, { absolute_path: op.filePath, ...(op.confidence ? {confidence: op.confidence} : {}), ...(op.impact ? {impact: op.impact} : {}), + ...(frontmatter.keywords ? {keywords: frontmatter.keywords} : {}), knowledge_path: op.path, needs_review: op.needsReview ?? false, operation_type: op.type, + ...(frontmatter.related ? {related: frontmatter.related} : {}), + ...(frontmatter.tags ? {tags: frontmatter.tags} : {}), task_id: taskId, }) } @@ -253,13 +304,26 @@ export class AnalyticsHook implements ITaskLifecycleHook { const tier = state.queryMeta?.tier const matchedDocCount = state.queryMeta?.searchMetadata?.resultCount ?? 0 + // M12.3: harvest per-path frontmatter on the same sync read path used + // for curate emits. Entries whose file is unreadable / has no frontmatter + // carry `absolute_path` alone (the three array fields stay absent). + const readPathsWithMetadata = cappedPaths.map((p) => { + const fm = this.readFrontmatterFields(p) + return { + absolute_path: p, + ...(fm.keywords ? {keywords: fm.keywords} : {}), + ...(fm.related ? {related: fm.related} : {}), + ...(fm.tags ? {tags: fm.tags} : {}), + } + }) + return { cache_hit: tier === 0 || tier === 1, duration_ms: this.durationMs(task), matched_doc_count: matchedDocCount, outcome, read_doc_count: readPaths.size, - read_paths_with_metadata: cappedPaths.map((p) => ({absolute_path: p})), + read_paths_with_metadata: readPathsWithMetadata, read_tool_call_count: readToolCallCount, search_call_count: searchCallCount, task_id: taskId, @@ -292,4 +356,35 @@ export class AnalyticsHook implements ITaskLifecycleHook { processLog(`AnalyticsHook: ${event} track failed: ${error instanceof Error ? error.message : String(error)}`) } } + + /** + * Read the YAML frontmatter from `filePath` and return only `tags` / + * `keywords` / `related` arrays (capped at 50 entries / 256 chars per + * entry). Returns an empty object on ANY failure: ENOENT, EACCES, + * permission errors, malformed YAML. Telemetry MUST NOT crash the hook. + * + * Synchronous I/O on local disk: a single read is sub-millisecond on + * SSD; curate runs emit at most one read per op, query at most ten + * reads at task completion. The blocking cost is negligible against + * the analytics value of the harvested metadata. + * + * Short-circuits when analytics is disabled to avoid wasted disk I/O. + */ + private readFrontmatterFields(filePath: string): FrontmatterFields { + if (!this.isEnabled()) return {} + try { + const content = readFileSync(filePath, 'utf8') + const parsed = parseFrontmatter(content) + if (parsed === null) return {} + return { + keywords: capStringArray(parsed.frontmatter.keywords), + related: capStringArray(parsed.frontmatter.related), + tags: capStringArray(parsed.frontmatter.tags), + } + } catch { + // ENOENT, EACCES, permission, malformed YAML — all silently treated + // as "no frontmatter". No retry, no log noise. + return {} + } + } } diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 01a0f3800..0e2da4d05 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -106,6 +106,12 @@ export interface FeatureHandlersOptions { */ export interface SetupFeatureHandlersResult { readonly analyticsClient: IAnalyticsClient + /** + * Returns the daemon's cached analytics-enabled flag. M12.3 consumers + * (e.g. AnalyticsHook) use this to short-circuit disk I/O when analytics + * is off — complements `AnalyticsClient.track` no-op gate. + */ + readonly isAnalyticsEnabled: () => boolean } /** @@ -388,5 +394,8 @@ export async function setupFeatureHandlers({ log('Feature handlers registered') - return {analyticsClient} + // M12.3: expose the cached-analytics check so daemon-side consumers + // (e.g. AnalyticsHook) can short-circuit disk I/O when analytics is off. + // Same callback shape used internally by AnalyticsClient at line 171. + return {analyticsClient, isAnalyticsEnabled: (): boolean => globalConfigHandler.getCachedAnalytics()} } diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts index 6332de269..b7b968f94 100644 --- a/test/unit/server/infra/process/analytics-hook.test.ts +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -1,5 +1,8 @@ import {expect} from 'chai' +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' import sinon from 'sinon' import type {LlmToolResultEvent} from '../../../../../src/server/core/domain/transport/schemas.js' @@ -11,6 +14,13 @@ import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/ba import {AnalyticsHook} from '../../../../../src/server/infra/process/analytics-hook.js' import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +const writeMarkdown = (filePath: string, frontmatter: Record, body = 'body'): void => { + const yaml = Object.entries(frontmatter) + .map(([k, v]) => `${k}: ${JSON.stringify(v)}`) + .join('\n') + writeFileSync(filePath, `---\n${yaml}\n---\n${body}\n`, 'utf8') +} + const FIXED_NOW = 1_700_000_000_000 type StubBundle = { @@ -406,7 +416,7 @@ describe('AnalyticsHook', () => { expect(trackStub.called).to.equal(true) }) - it('emit is a no-op when setAnalyticsClient was never called', async () => { + it('emit is a no-op when setAnalyticsClient was never called (originally curate emit)', async () => { const bareHook = new AnalyticsHook() const task = buildCurateTask() await bareHook.onTaskCreate(task) @@ -418,4 +428,188 @@ describe('AnalyticsHook', () => { await bareHook.onTaskCompleted(task.taskId, '', task) }) }) + + describe('M12.3 frontmatter harvest', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'analytics-hook-')) + }) + + afterEach(() => { + rmSync(tmpDir, {force: true, recursive: true}) + }) + + describe('curate emit', () => { + it('attaches tags/keywords/related from post-op frontmatter on ADD ops', async () => { + const filePath = join(tmpDir, 'a.md') + writeMarkdown(filePath, {keywords: ['x', 'y'], related: ['z'], tags: ['t1', 't2']}) + + const task = buildCurateTask() + await hook.onTaskCreate(task) + hook.onToolResult( + task.taskId, + buildToolResult([{filePath, needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), + ) + + const props = trackStub.firstCall.args[1] as Record + expect(props.tags).to.deep.equal(['t1', 't2']) + expect(props.keywords).to.deep.equal(['x', 'y']) + expect(props.related).to.deep.equal(['z']) + }) + + it('omits tags/keywords/related on DELETE ops (file gone post-op)', async () => { + const filePath = join(tmpDir, 'gone.md') + const task = buildCurateTask() + await hook.onTaskCreate(task) + hook.onToolResult( + task.taskId, + buildToolResult([{filePath, needsReview: false, path: 'gone', status: 'success', type: 'DELETE'}]), + ) + + const props = trackStub.firstCall.args[1] as Record + expect(props).to.not.have.property('tags') + expect(props).to.not.have.property('keywords') + expect(props).to.not.have.property('related') + }) + + it('omits tags/keywords/related when filePath cannot be read (ENOENT)', async () => { + const filePath = join(tmpDir, 'missing.md') + const task = buildCurateTask() + await hook.onTaskCreate(task) + hook.onToolResult( + task.taskId, + buildToolResult([{filePath, needsReview: false, path: 'm', status: 'success', type: 'UPDATE'}]), + ) + + const props = trackStub.firstCall.args[1] as Record + expect(props).to.not.have.property('tags') + }) + + it('omits tags/keywords/related on malformed YAML (no throw)', async () => { + const filePath = join(tmpDir, 'bad.md') + writeFileSync(filePath, '---\nthis is: not [valid YAML\n---\nbody', 'utf8') + + const task = buildCurateTask() + await hook.onTaskCreate(task) + hook.onToolResult( + task.taskId, + buildToolResult([{filePath, needsReview: false, path: 'b', status: 'success', type: 'UPDATE'}]), + ) + + const props = trackStub.firstCall.args[1] as Record + expect(props).to.not.have.property('tags') + }) + + it('caps arrays at 50 entries and strings at 256 chars per entry', async () => { + const filePath = join(tmpDir, 'huge.md') + const overlong = 'x'.repeat(300) + const sixtyTags = Array.from({length: 60}, (_, i) => `tag-${i}`) + writeMarkdown(filePath, {tags: [overlong, ...sixtyTags]}) + + const task = buildCurateTask() + await hook.onTaskCreate(task) + hook.onToolResult( + task.taskId, + buildToolResult([{filePath, needsReview: false, path: 'h', status: 'success', type: 'UPDATE'}]), + ) + + const props = trackStub.firstCall.args[1] as Record + const tags = props.tags as string[] + expect(tags).to.have.lengthOf(50) + expect(tags[0]).to.have.lengthOf(256) + }) + + it('skips file reads entirely when isEnabled() returns false', async () => { + const filePath = join(tmpDir, 'gated.md') + writeMarkdown(filePath, {tags: ['should-not-appear']}) + + const disabledBundle = buildAnalyticsClient() + const disabledHook = new AnalyticsHook({isEnabled: () => false}) + disabledHook.setAnalyticsClient(disabledBundle.client) + const task = buildCurateTask({taskId: 'task-gated'}) + + await disabledHook.onTaskCreate(task) + disabledHook.onToolResult( + task.taskId, + buildToolResult([{filePath, needsReview: false, path: 'g', status: 'success', type: 'UPDATE'}]), + ) + + const props = disabledBundle.trackStub.firstCall.args[1] as Record + expect(props).to.not.have.property('tags') + }) + }) + + describe('query emit', () => { + it('attaches per-path frontmatter to read_paths_with_metadata entries', async () => { + const a = join(tmpDir, 'a.md') + const b = join(tmpDir, 'b.md') + writeMarkdown(a, {tags: ['ta']}) + writeMarkdown(b, {keywords: ['kb']}) + + const task = buildQueryTask({ + toolCalls: [ + {args: {filePath: a}, sessionId: 's', status: 'completed', timestamp: 1, toolName: 'read_file'}, + {args: {filePath: b}, sessionId: 's', status: 'completed', timestamp: 2, toolName: 'read_file'}, + ], + } as Partial) + + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const props = trackStub.firstCall.args[1] as Record + const paths = props.read_paths_with_metadata as Array> + const byPath = Object.fromEntries(paths.map((p) => [p.absolute_path, p])) + expect(byPath[a].tags).to.deep.equal(['ta']) + expect(byPath[a]).to.not.have.property('keywords') + expect(byPath[b].keywords).to.deep.equal(['kb']) + expect(byPath[b]).to.not.have.property('tags') + }) + + it('mixed readable + ENOENT paths: each entry independently has/omits metadata', async () => { + const real = join(tmpDir, 'real.md') + const missing = join(tmpDir, 'missing.md') + writeMarkdown(real, {tags: ['ok']}) + + const task = buildQueryTask({ + toolCalls: [ + {args: {filePath: real}, sessionId: 's', status: 'completed', timestamp: 1, toolName: 'read_file'}, + {args: {filePath: missing}, sessionId: 's', status: 'completed', timestamp: 2, toolName: 'read_file'}, + ], + } as Partial) + + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const props = trackStub.firstCall.args[1] as Record + const paths = props.read_paths_with_metadata as Array> + const byPath = Object.fromEntries(paths.map((p) => [p.absolute_path, p])) + expect(byPath[real].tags).to.deep.equal(['ok']) + expect(byPath[missing]).to.not.have.property('tags') + }) + + it('skips per-path file reads when isEnabled() returns false', async () => { + const filePath = join(tmpDir, 'gated-query.md') + writeMarkdown(filePath, {tags: ['should-not-appear']}) + + const disabledBundle = buildAnalyticsClient() + const disabledHook = new AnalyticsHook({isEnabled: () => false}) + disabledHook.setAnalyticsClient(disabledBundle.client) + + const task = buildQueryTask({ + taskId: 'task-q-gated', + toolCalls: [ + {args: {filePath}, sessionId: 's', status: 'completed', timestamp: 1, toolName: 'read_file'}, + ], + } as Partial) + + await disabledHook.onTaskCreate(task) + await disabledHook.onTaskCompleted(task.taskId, '', task) + + const props = disabledBundle.trackStub.firstCall.args[1] as Record + const paths = props.read_paths_with_metadata as Array> + expect(paths[0]).to.not.have.property('tags') + }) + }) + }) }) From 9212ed36ce9750e356ad962f08f2a62667c8092c Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 13 May 2026 07:29:16 +0700 Subject: [PATCH 29/87] fix: [ENG-2772] address CLAUDE.md compliance findings from M12 post-merge review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three convention fixes to align M12 with CLAUDE.md after the post-merge review pointed them out: 1. Refactor buildCurateRunPayload and buildQueryCompletedPayload to object-parameter signatures. The 4-positional-arg form violated the CLAUDE.md rule "functions with >3 parameters must use object parameters". 2. Conditionally emit read_paths_with_metadata. M12.1 schema marks the outer array as optional; M12.2 hook was always emitting it (even empty). Now omits when the command had no read paths — same idiom as the existing optional tier field. Added a test for this behavior. 3. Drop the redundant optional chain on call.args (ToolCallEvent.args is a required Record; index access already returns unknown which the typeof checks narrow). Then converted the two direct property accesses to object destructuring per the prefer-destructuring lint rule. No behavior change to the wire-format event shape on the happy path (curate + query with read paths). Empty-toolCalls query now omits the outer array instead of emitting []; the schema accepts both. --- src/server/infra/process/analytics-hook.ts | 106 +++++++++++------- .../infra/process/analytics-hook.test.ts | 12 ++ 2 files changed, 79 insertions(+), 39 deletions(-) diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index 434439117..0a85ae1aa 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -126,9 +126,15 @@ export class AnalyticsHook implements ITaskLifecycleHook { if (state.flavor === 'curate') { const outcome = state.counters.failed > 0 ? 'partial' : 'completed' - this.emit(AnalyticsEventNames.CURATE_RUN_COMPLETED, this.buildCurateRunPayload(taskId, task, state, outcome)) + this.emit( + AnalyticsEventNames.CURATE_RUN_COMPLETED, + this.buildCurateRunPayload({outcome, state, task, taskId}), + ) } else { - this.emit(AnalyticsEventNames.QUERY_COMPLETED, this.buildQueryCompletedPayload(taskId, task, state, 'completed')) + this.emit( + AnalyticsEventNames.QUERY_COMPLETED, + this.buildQueryCompletedPayload({outcome: 'completed', state, task, taskId}), + ) } } @@ -241,12 +247,17 @@ export class AnalyticsHook implements ITaskLifecycleHook { state.queryMeta = metadata } - private buildCurateRunPayload( - taskId: string, - task: TaskInfo, - state: CurateTaskAnalyticsState, - outcome: 'cancelled' | 'completed' | 'error' | 'partial', - ): Record { + private buildCurateRunPayload({ + outcome, + state, + task, + taskId, + }: { + outcome: 'cancelled' | 'completed' | 'error' | 'partial' + state: CurateTaskAnalyticsState + task: TaskInfo + taskId: string + }): Record { return { duration_ms: this.durationMs(task), operations_added: state.counters.added, @@ -261,42 +272,50 @@ export class AnalyticsHook implements ITaskLifecycleHook { } } - private buildQueryCompletedPayload( - taskId: string, - task: TaskInfo, - state: QueryTaskAnalyticsState, - outcome: 'cancelled' | 'completed' | 'error', - ): Record { + private buildQueryCompletedPayload({ + outcome, + state, + task, + taskId, + }: { + outcome: 'cancelled' | 'completed' | 'error' + state: QueryTaskAnalyticsState + task: TaskInfo + taskId: string + }): Record { const readPaths = new Set() let readToolCallCount = 0 let searchCallCount = 0 for (const call of task.toolCalls ?? []) { + // `call.args` is a required `Record` on ToolCallEvent; + // index access returns `unknown` (possibly undefined when the key is + // absent), so the runtime `typeof === 'string'` check below is what + // actually narrows. No optional chain on `args` itself. switch (call.toolName) { - case EXPAND_KNOWLEDGE_TOOL: { - readToolCallCount++ - const stubPath = call.args?.stubPath - const overviewPath = call.args?.overviewPath - if (typeof stubPath === 'string' && stubPath.length > 0) readPaths.add(stubPath) - if (typeof overviewPath === 'string' && overviewPath.length > 0) readPaths.add(overviewPath) - - break; - } + case EXPAND_KNOWLEDGE_TOOL: { + readToolCallCount++ + const {overviewPath, stubPath} = call.args + if (typeof stubPath === 'string' && stubPath.length > 0) readPaths.add(stubPath) + if (typeof overviewPath === 'string' && overviewPath.length > 0) readPaths.add(overviewPath) - case READ_FILE_TOOL: { - readToolCallCount++ - const filePath = call.args?.filePath - if (typeof filePath === 'string' && filePath.length > 0) readPaths.add(filePath) - - break; - } + break + } - case SEARCH_KNOWLEDGE_TOOL: { - searchCallCount++ - - break; - } - // No default + case READ_FILE_TOOL: { + readToolCallCount++ + const {filePath} = call.args + if (typeof filePath === 'string' && filePath.length > 0) readPaths.add(filePath) + + break + } + + case SEARCH_KNOWLEDGE_TOOL: { + searchCallCount++ + + break + } + // No default } } @@ -323,7 +342,10 @@ export class AnalyticsHook implements ITaskLifecycleHook { matched_doc_count: matchedDocCount, outcome, read_doc_count: readPaths.size, - read_paths_with_metadata: readPathsWithMetadata, + // M12.1 schema marks read_paths_with_metadata as optional outer array. + // Mirror that: omit the field when the command had no read paths + // (instead of emitting an empty array). Same idiom as `tier` above. + ...(readPathsWithMetadata.length > 0 ? {read_paths_with_metadata: readPathsWithMetadata} : {}), read_tool_call_count: readToolCallCount, search_call_count: searchCallCount, task_id: taskId, @@ -337,9 +359,15 @@ export class AnalyticsHook implements ITaskLifecycleHook { if (!state) return if (state.flavor === 'curate') { - this.emit(AnalyticsEventNames.CURATE_RUN_COMPLETED, this.buildCurateRunPayload(taskId, task, state, outcome)) + this.emit( + AnalyticsEventNames.CURATE_RUN_COMPLETED, + this.buildCurateRunPayload({outcome, state, task, taskId}), + ) } else { - this.emit(AnalyticsEventNames.QUERY_COMPLETED, this.buildQueryCompletedPayload(taskId, task, state, outcome)) + this.emit( + AnalyticsEventNames.QUERY_COMPLETED, + this.buildQueryCompletedPayload({outcome, state, task, taskId}), + ) } } diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts index b7b968f94..d24abcc9d 100644 --- a/test/unit/server/infra/process/analytics-hook.test.ts +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -360,6 +360,18 @@ describe('AnalyticsHook', () => { expect(props.matched_doc_count).to.equal(0) }) + it('omits read_paths_with_metadata when the command had no read paths (matches optional schema)', async () => { + const task = buildQueryTask() // empty toolCalls + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const props = trackStub.firstCall.args[1] as Record + expect(props).to.not.have.property('read_paths_with_metadata') + // Sanity: counts are zero, not omitted. + expect(props.read_doc_count).to.equal(0) + expect(props.read_tool_call_count).to.equal(0) + }) + it('emits outcome=error on onTaskError for query', async () => { const task = buildQueryTask() await hook.onTaskCreate(task) From 88a67561f889168d7c50404071d416beb647859d Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 13 May 2026 09:39:37 +0700 Subject: [PATCH 30/87] feat: [ENG-2805] M13.1 Phase A: shared cli_metadata schema + buildCliMetadata helper Phase A of M13.1 lays the foundation pieces. Phase B (task:create handler emit + query.ts / curate spread) lands in a follow-up commit on this branch before merging into proj/analytics-system. Changes: - New src/shared/analytics/cli-metadata-schema.ts. CliMetadataSchema is the Zod source of truth for the 8-field cli_metadata block; CliRequestBaseSchema wraps it so M13.2's per-request-schema sweep can extend cleanly. CliMetadata type intersects the inferred shape with Record so it satisfies IAnalyticsClient.track's properties parameter without any 'as' cast at the emit site. - New src/oclif/lib/build-cli-metadata.ts. Pure helper composing the metadata from process.env / process.stdout. CI detection strict-matches CI=true|1 (so CI=false opts out); runtime uses ''bun' in process.versions'; package_manager prefix-matches npm_config_user_agent; terminal_program omitted-from-spread when env unset; client_sent_at = Date.now() at call time. Single call per run() identifies one CLI invocation in M13.3. - Refactored src/shared/analytics/events/cli-invocation.ts to re-export CliMetadataSchema / CliMetadata under its existing names. Same shape, now with client_sent_at. analytics/events/index.ts catalog unchanged. - 16 schema tests + 20 helper tests cover all 8 fields, all enum values, optional terminal_program, env mutation isolation via Object.defineProperty (process.stdout.isTTY is read-only on a Socket; sinon can't stub ES exports). - Updated existing test fixtures in cli-invocation.test.ts and emit.test.ts to include client_sent_at on their baseline payloads. --- src/oclif/lib/build-cli-metadata.ts | 74 ++++++++ src/shared/analytics/cli-metadata-schema.ts | 46 +++++ src/shared/analytics/events/cli-invocation.ts | 30 +-- .../unit/oclif/lib/build-cli-metadata.test.ts | 174 ++++++++++++++++++ .../analytics/cli-metadata-schema.test.ts | 97 ++++++++++ test/unit/shared/analytics/emit.test.ts | 1 + .../analytics/events/cli-invocation.test.ts | 1 + 7 files changed, 400 insertions(+), 23 deletions(-) create mode 100644 src/oclif/lib/build-cli-metadata.ts create mode 100644 src/shared/analytics/cli-metadata-schema.ts create mode 100644 test/unit/oclif/lib/build-cli-metadata.test.ts create mode 100644 test/unit/shared/analytics/cli-metadata-schema.test.ts diff --git a/src/oclif/lib/build-cli-metadata.ts b/src/oclif/lib/build-cli-metadata.ts new file mode 100644 index 000000000..792cbd990 --- /dev/null +++ b/src/oclif/lib/build-cli-metadata.ts @@ -0,0 +1,74 @@ +/* eslint-disable camelcase */ +import type {CliMetadata} from '../../shared/analytics/cli-metadata-schema.js' + +type PackageManager = 'bun' | 'npm' | 'pnpm' | 'unknown' | 'yarn' + +/** + * Detect the package manager that launched this `brv` process. + * + * `npm`, `yarn`, `pnpm`, and `bun` all set `npm_config_user_agent` when + * they spawn a child script (e.g. `npm install` → `npm/X.Y.Z node/Z os`). + * Direct `node bin/run.js` invocations or unknown package managers fall + * through to `'unknown'`. + */ +function detectPackageManager(): PackageManager { + const userAgent = process.env.npm_config_user_agent ?? '' + if (userAgent.startsWith('npm/')) return 'npm' + if (userAgent.startsWith('yarn/')) return 'yarn' + if (userAgent.startsWith('pnpm/')) return 'pnpm' + if (userAgent.startsWith('bun/')) return 'bun' + return 'unknown' +} + +/** + * `process.versions.bun` is `string` under Bun, absent under Node. The + * `in` operator narrows without needing an explicit cast on `process.versions`. + */ +function detectRuntime(): 'bun' | 'node' { + return 'bun' in process.versions ? 'bun' : 'node' +} + +/** + * Strict CI detection: only treat the standard `CI=true` / `CI=1` as CI. + * Many tools set `CI=false` to opt out — we honour that by returning false. + */ +function detectIsCi(): boolean { + const ci = process.env.CI + return ci === '1' || ci === 'true' +} + +function detectIsTty(): boolean { + return Boolean(process.stdout.isTTY) +} + +function detectTerminalProgram(): string | undefined { + const term = process.env.TERM_PROGRAM + return typeof term === 'string' && term.length > 0 ? term : undefined +} + +/** + * Compose the `cli_metadata` block from CLI-process detections. Pure + * function: no transport calls, no async work, no side effects beyond + * reading `process.env` / `process.stdout`. Returns a fresh object per call. + * + * The helper is called ONCE per `run()` so a single `client_sent_at` value + * identifies one CLI invocation across multi-request commands (per M13.3). + * + * `flag_names` captures the parsed-flag KEY names only (oclif's already- + * camelCased keys, e.g. `--set-upstream` → `setUpstream`). Flag VALUES are + * NEVER captured — they may carry paths, query text, or secrets. + */ +export function buildCliMetadata(commandId: string, flags: Record): CliMetadata { + const terminalProgram = detectTerminalProgram() + const metadata: CliMetadata = { + client_sent_at: Date.now(), + command_id: commandId, + flag_names: Object.keys(flags), + is_ci: detectIsCi(), + is_tty: detectIsTty(), + package_manager: detectPackageManager(), + runtime: detectRuntime(), + ...(terminalProgram === undefined ? {} : {terminal_program: terminalProgram}), + } + return metadata +} diff --git a/src/shared/analytics/cli-metadata-schema.ts b/src/shared/analytics/cli-metadata-schema.ts new file mode 100644 index 000000000..87f73476c --- /dev/null +++ b/src/shared/analytics/cli-metadata-schema.ts @@ -0,0 +1,46 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Shared schema for the `cli_metadata` block. Source of truth for two + * call sites: + * + * 1. As the per-event analytics schema for `cli_invocation` (re-exported + * by `events/cli-invocation.ts` for catalog registration). + * 2. Wrapped as `CliRequestBaseSchema` so every client-originated request + * schema in M13.2 can extend it and carry the optional block. + * + * Strict mode rejects accidental extra fields at parse / emit time. The + * eight fields are all CLI-process detections the daemon cannot infer. + * Field NAMES verified outside `FORBIDDEN_FIELD_NAMES`. + */ +export const CliMetadataSchema = z + .object({ + client_sent_at: z.number().int().nonnegative(), + command_id: z.string().min(1), + flag_names: z.array(z.string()), + is_ci: z.boolean(), + is_tty: z.boolean(), + package_manager: z.enum(['npm', 'yarn', 'pnpm', 'bun', 'unknown']), + runtime: z.enum(['node', 'bun']), + terminal_program: z.string().min(1).optional(), + }) + .strict() + +/** + * Wrapper every client-originated request schema will extend (M13.2 sweep). + * The `cli_metadata` block is always optional — non-CLI clients (TUI, MCP, + * webui) keep working without filling it. + */ +export const CliRequestBaseSchema = z.object({ + cli_metadata: CliMetadataSchema.optional(), +}) + +/** + * Inferred type with index signature so it satisfies + * `IAnalyticsClient.track`'s `properties?: Record` parameter + * without any `as` cast or spread workaround at the emit site. + */ +export type CliMetadata = Record & z.infer + +export type CliRequestBase = z.infer diff --git a/src/shared/analytics/events/cli-invocation.ts b/src/shared/analytics/events/cli-invocation.ts index 62efc9874..7fc3cf8c6 100644 --- a/src/shared/analytics/events/cli-invocation.ts +++ b/src/shared/analytics/events/cli-invocation.ts @@ -1,27 +1,11 @@ -/* eslint-disable camelcase */ -import {z} from 'zod' - /** * Per-event schema for `cli_invocation`. * - * Every field is a user CHOICE (env, runtime, flag NAMES) — flag VALUES are - * never captured because they may carry file paths, query text, or secrets. - * - * `command_id` is the oclif command identifier (e.g. "vc:add", "query", - * "curate:learn"). It is intentionally typed as a free string here: - * the oclif manifest is the source of truth and changes per release; - * mirroring the full ~80-entry list in TypeScript would rot quickly. + * Source of truth lives in `src/shared/analytics/cli-metadata-schema.ts` + * — the same shape doubles as the `cli_metadata` block embedded in every + * client-originated request schema (M13). Re-exported here under + * `CliInvocationSchema` / `CliInvocationProps` so the analytics catalog + * at `events/index.ts` keeps its existing import path. */ -export const CliInvocationSchema = z - .object({ - command_id: z.string().min(1), - flag_names: z.array(z.string()), - is_ci: z.boolean(), - is_tty: z.boolean(), - package_manager: z.enum(['npm', 'yarn', 'pnpm', 'bun', 'unknown']), - runtime: z.enum(['node', 'bun']), - terminal_program: z.string().min(1).optional(), - }) - .strict() - -export type CliInvocationProps = z.infer +export {CliMetadataSchema as CliInvocationSchema} from '../cli-metadata-schema.js' +export type {CliMetadata as CliInvocationProps} from '../cli-metadata-schema.js' diff --git a/test/unit/oclif/lib/build-cli-metadata.test.ts b/test/unit/oclif/lib/build-cli-metadata.test.ts new file mode 100644 index 000000000..b0a442f24 --- /dev/null +++ b/test/unit/oclif/lib/build-cli-metadata.test.ts @@ -0,0 +1,174 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import sinon from 'sinon' + +import {buildCliMetadata} from '../../../../src/oclif/lib/build-cli-metadata.js' +import {CliMetadataSchema} from '../../../../src/shared/analytics/cli-metadata-schema.js' + +const ENV_KEYS_TOUCHED = ['CI', 'TERM_PROGRAM', 'npm_config_user_agent'] as const + +const setIsTty = (value: boolean): void => { + Object.defineProperty(process.stdout, 'isTTY', {configurable: true, value, writable: true}) +} + +describe('buildCliMetadata', () => { + let originalEnv: Record + let originalIsTtyDescriptor: PropertyDescriptor | undefined + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + originalEnv = {} + for (const key of ENV_KEYS_TOUCHED) { + originalEnv[key] = process.env[key] + delete process.env[key] + } + + originalIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + setIsTty(false) + clock = sinon.useFakeTimers(1_700_000_000_000) + }) + + afterEach(() => { + for (const key of ENV_KEYS_TOUCHED) { + if (originalEnv[key] === undefined) delete process.env[key] + else process.env[key] = originalEnv[key] + } + + if (originalIsTtyDescriptor) { + Object.defineProperty(process.stdout, 'isTTY', originalIsTtyDescriptor) + } else { + Reflect.deleteProperty(process.stdout, 'isTTY') + } + + clock.restore() + }) + + it('produces a CliMetadataSchema-parseable object', () => { + const result = buildCliMetadata('query', {format: 'text'}) + expect(CliMetadataSchema.safeParse(result).success).to.equal(true) + }) + + it('sets command_id from the first argument and flag_names from Object.keys(flags)', () => { + const result = buildCliMetadata('vc:add', {detach: true, format: 'text'}) + expect(result.command_id).to.equal('vc:add') + expect(result.flag_names).to.have.members(['detach', 'format']) + expect(result.flag_names).to.have.lengthOf(2) + }) + + it('emits an empty flag_names array when no flags passed', () => { + const result = buildCliMetadata('status', {}) + expect(result.flag_names).to.deep.equal([]) + }) + + it('sets client_sent_at to Date.now() (mocked here)', () => { + const result = buildCliMetadata('query', {}) + expect(result.client_sent_at).to.equal(1_700_000_000_000) + }) + + describe('is_ci', () => { + it('false when CI env unset', () => { + const result = buildCliMetadata('query', {}) + expect(result.is_ci).to.equal(false) + }) + + it('true when CI=true', () => { + process.env.CI = 'true' + const result = buildCliMetadata('query', {}) + expect(result.is_ci).to.equal(true) + }) + + it('true when CI=1', () => { + process.env.CI = '1' + const result = buildCliMetadata('query', {}) + expect(result.is_ci).to.equal(true) + }) + + it('false when CI=false (opt-out by convention)', () => { + process.env.CI = 'false' + const result = buildCliMetadata('query', {}) + expect(result.is_ci).to.equal(false) + }) + }) + + describe('is_tty', () => { + it('false when stdout.isTTY is false', () => { + setIsTty(false) + const result = buildCliMetadata('query', {}) + expect(result.is_tty).to.equal(false) + }) + + it('true when stdout.isTTY is true', () => { + setIsTty(true) + const result = buildCliMetadata('query', {}) + expect(result.is_tty).to.equal(true) + }) + }) + + describe('package_manager', () => { + it('npm when npm_config_user_agent starts with "npm/"', () => { + process.env.npm_config_user_agent = 'npm/10.2.4 node/v20.0.0 darwin x64' + expect(buildCliMetadata('q', {}).package_manager).to.equal('npm') + }) + + it('yarn when npm_config_user_agent starts with "yarn/"', () => { + process.env.npm_config_user_agent = 'yarn/1.22.19 npm/? node/v20.0.0 darwin x64' + expect(buildCliMetadata('q', {}).package_manager).to.equal('yarn') + }) + + it('pnpm when npm_config_user_agent starts with "pnpm/"', () => { + process.env.npm_config_user_agent = 'pnpm/8.10.0 npm/? node/v20.0.0 darwin x64' + expect(buildCliMetadata('q', {}).package_manager).to.equal('pnpm') + }) + + it('bun when npm_config_user_agent starts with "bun/"', () => { + process.env.npm_config_user_agent = 'bun/1.0.0 (linux x64)' + expect(buildCliMetadata('q', {}).package_manager).to.equal('bun') + }) + + it('unknown when npm_config_user_agent unset', () => { + expect(buildCliMetadata('q', {}).package_manager).to.equal('unknown') + }) + + it('unknown when npm_config_user_agent is some unrecognised prefix', () => { + process.env.npm_config_user_agent = 'rush/5 node/v20 darwin x64' + expect(buildCliMetadata('q', {}).package_manager).to.equal('unknown') + }) + }) + + describe('runtime', () => { + it('node when process.versions.bun is absent (default test env)', () => { + expect(buildCliMetadata('q', {}).runtime).to.equal('node') + }) + }) + + describe('terminal_program', () => { + it('omitted when TERM_PROGRAM unset', () => { + const result = buildCliMetadata('q', {}) + expect(result).to.not.have.property('terminal_program') + }) + + it('omitted when TERM_PROGRAM is empty string', () => { + process.env.TERM_PROGRAM = '' + const result = buildCliMetadata('q', {}) + expect(result).to.not.have.property('terminal_program') + }) + + it('included verbatim when TERM_PROGRAM is non-empty', () => { + process.env.TERM_PROGRAM = 'WezTerm' + const result = buildCliMetadata('q', {}) + expect(result.terminal_program).to.equal('WezTerm') + }) + }) + + it('does not mutate the input flags object', () => { + const flags = {detach: true} + buildCliMetadata('q', flags) + expect(flags).to.deep.equal({detach: true}) + }) + + it('returns a fresh object per call (no shared mutable state)', () => { + const a = buildCliMetadata('q', {}) + const b = buildCliMetadata('q', {}) + expect(a).to.not.equal(b) + }) +}) diff --git a/test/unit/shared/analytics/cli-metadata-schema.test.ts b/test/unit/shared/analytics/cli-metadata-schema.test.ts new file mode 100644 index 000000000..9c4536cb8 --- /dev/null +++ b/test/unit/shared/analytics/cli-metadata-schema.test.ts @@ -0,0 +1,97 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import { + CliMetadataSchema, + CliRequestBaseSchema, +} from '../../../../src/shared/analytics/cli-metadata-schema.js' + +const baseValid = { + client_sent_at: 1_700_000_000_000, + command_id: 'query', + flag_names: ['format'], + is_ci: false, + is_tty: true, + package_manager: 'npm' as const, + runtime: 'node' as const, +} + +describe('cli-metadata-schema', () => { + describe('CliMetadataSchema', () => { + describe('valid payloads', () => { + it('accepts the 8-field shape without terminal_program', () => { + expect(CliMetadataSchema.safeParse(baseValid).success).to.equal(true) + }) + + it('accepts terminal_program when set to a non-empty string', () => { + expect(CliMetadataSchema.safeParse({...baseValid, terminal_program: 'iTerm.app'}).success).to.equal(true) + }) + + it('accepts empty flag_names array', () => { + expect(CliMetadataSchema.safeParse({...baseValid, flag_names: []}).success).to.equal(true) + }) + + it('accepts each package_manager enum value', () => { + for (const pm of ['npm', 'yarn', 'pnpm', 'bun', 'unknown'] as const) { + expect(CliMetadataSchema.safeParse({...baseValid, package_manager: pm}).success).to.equal(true) + } + }) + + it('accepts runtime "bun" and "node"', () => { + for (const runtime of ['node', 'bun'] as const) { + expect(CliMetadataSchema.safeParse({...baseValid, runtime}).success).to.equal(true) + } + }) + + it('accepts client_sent_at = 0 (nonnegative integer)', () => { + expect(CliMetadataSchema.safeParse({...baseValid, client_sent_at: 0}).success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('rejects missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {command_id: _omit, ...withoutCommandId} = baseValid + expect(CliMetadataSchema.safeParse(withoutCommandId).success).to.equal(false) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {client_sent_at: _omit2, ...withoutTs} = baseValid + expect(CliMetadataSchema.safeParse(withoutTs).success).to.equal(false) + }) + + it('rejects out-of-enum runtime / package_manager', () => { + expect(CliMetadataSchema.safeParse({...baseValid, runtime: 'deno'}).success).to.equal(false) + expect(CliMetadataSchema.safeParse({...baseValid, package_manager: 'brew'}).success).to.equal(false) + }) + + it('rejects empty command_id and empty terminal_program', () => { + expect(CliMetadataSchema.safeParse({...baseValid, command_id: ''}).success).to.equal(false) + expect(CliMetadataSchema.safeParse({...baseValid, terminal_program: ''}).success).to.equal(false) + }) + + it('rejects negative or non-integer client_sent_at', () => { + expect(CliMetadataSchema.safeParse({...baseValid, client_sent_at: -1}).success).to.equal(false) + expect(CliMetadataSchema.safeParse({...baseValid, client_sent_at: 1.5}).success).to.equal(false) + }) + + it('rejects unknown extra fields (strict)', () => { + expect(CliMetadataSchema.safeParse({...baseValid, sneaky: 'leak'}).success).to.equal(false) + }) + }) +}) + + describe('CliRequestBaseSchema', () => { + it('accepts the empty payload (cli_metadata is optional)', () => { + expect(CliRequestBaseSchema.safeParse({}).success).to.equal(true) + }) + + it('accepts a valid cli_metadata block', () => { + expect(CliRequestBaseSchema.safeParse({cli_metadata: baseValid}).success).to.equal(true) + }) + + it('rejects a malformed cli_metadata block (inner strict-mode bubbles up)', () => { + expect( + CliRequestBaseSchema.safeParse({cli_metadata: {...baseValid, runtime: 'deno'}}).success, + ).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/emit.test.ts b/test/unit/shared/analytics/emit.test.ts index 82939229b..e671a3553 100644 --- a/test/unit/shared/analytics/emit.test.ts +++ b/test/unit/shared/analytics/emit.test.ts @@ -29,6 +29,7 @@ function makeStubClient(overrides: Partial = {}): ITransportCl } const fullCliInvocation: CliInvocationProps = { + client_sent_at: 1_700_000_000_000, command_id: 'status', flag_names: [], is_ci: false, diff --git a/test/unit/shared/analytics/events/cli-invocation.test.ts b/test/unit/shared/analytics/events/cli-invocation.test.ts index 3731c9b7d..eca37b145 100644 --- a/test/unit/shared/analytics/events/cli-invocation.test.ts +++ b/test/unit/shared/analytics/events/cli-invocation.test.ts @@ -4,6 +4,7 @@ import {expect} from 'chai' import {CliInvocationSchema} from '../../../../../src/shared/analytics/events/cli-invocation.js' const baseValid = { + client_sent_at: 1_700_000_000_000, command_id: 'vc:add', flag_names: ['--detach'], is_ci: false, From f83aac01b0db873721aa70c6e636184839c04d78 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 13 May 2026 10:05:22 +0700 Subject: [PATCH 31/87] feat: [ENG-2805] M13.1 Phase B: wire cli_metadata end-to-end on task:create - Extend TaskCreateRequestSchema with CliRequestBaseSchema (optional cli_metadata block) - Add ITaskLifecycleHook.onTaskCreateRequest, fired fire-and-forget in TaskRouter.handleTaskCreate after the duplicate-taskId check - AnalyticsHook implements onTaskCreateRequest: safe-parse cli_metadata and emit cli_invocation - brv query / brv curate build cli_metadata once per run via buildCliMetadata and spread into taskPayload --- src/oclif/commands/curate/index.ts | 12 ++- src/oclif/commands/query.ts | 11 ++- src/server/core/domain/transport/schemas.ts | 47 ++++++----- .../process/i-task-lifecycle-hook.ts | 12 ++- src/server/infra/process/analytics-hook.ts | 17 +++- src/server/infra/process/task-router.ts | 22 +++++ .../core/domain/transport/schemas.test.ts | 39 +++++++++ test/unit/infra/process/task-router.test.ts | 81 +++++++++++++++++++ .../infra/process/analytics-hook.test.ts | 53 +++++++++++- 9 files changed, 268 insertions(+), 26 deletions(-) diff --git a/src/oclif/commands/curate/index.ts b/src/oclif/commands/curate/index.ts index 75ca8d12f..c42b17166 100644 --- a/src/oclif/commands/curate/index.ts +++ b/src/oclif/commands/curate/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import type {ITransportClient, TaskAck} from '@campfirein/brv-transport-client' import {Args, Command, Flags} from '@oclif/core' @@ -9,6 +10,7 @@ import {BRV_DIR, CONTEXT_TREE_DIR} from '../../../server/constants.js' import {ProviderConfigResponse, TransportStateEventNames} from '../../../server/core/domain/transport/index.js' import {extractCurateOperations} from '../../../server/utils/curate-result-parser.js' import {TaskEvents} from '../../../shared/transport/events/index.js' +import {buildCliMetadata} from '../../lib/build-cli-metadata.js' import { type DaemonClientOptions, formatConnectionError, @@ -119,6 +121,10 @@ Bad examples: : '' const taskType = flags.folder?.length ? 'curate-folder' : 'curate' + // Build once per run so a single `client_sent_at` identifies one CLI + // invocation even on retries that may make multiple task:create calls. + const cliMetadata = buildCliMetadata(this.id ?? 'curate', rawFlags) + let providerContext: ProviderErrorContext | undefined try { @@ -139,7 +145,7 @@ Bad examples: throw new Error(providerMissingMessage(active.activeProvider, active.authMethod)) } - await this.submitTask({client, content: resolvedContent, flags, format, projectRoot, taskType, worktreeRoot}) + await this.submitTask({client, cliMetadata, content: resolvedContent, flags, format, projectRoot, taskType, worktreeRoot}) }, { ...this.getDaemonClientOptions(), @@ -288,6 +294,7 @@ Bad examples: private async submitTask(props: { client: ITransportClient + cliMetadata: ReturnType content: string flags: CurateFlags format: 'json' | 'text' @@ -295,10 +302,11 @@ Bad examples: taskType: string worktreeRoot?: string }): Promise { - const {client, content, flags, format, projectRoot, taskType, worktreeRoot} = props + const {client, cliMetadata, content, flags, format, projectRoot, taskType, worktreeRoot} = props const hasFolders = Boolean(flags.folder?.length) const taskId = randomUUID() const taskPayload = { + cli_metadata: cliMetadata, clientCwd: process.cwd(), content, ...(flags.files?.length ? {files: flags.files} : {}), diff --git a/src/oclif/commands/query.ts b/src/oclif/commands/query.ts index b2f4045a3..00f0fb535 100644 --- a/src/oclif/commands/query.ts +++ b/src/oclif/commands/query.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import type {ITransportClient, TaskAck} from '@campfirein/brv-transport-client' import {Args, Command, Flags} from '@oclif/core' @@ -5,6 +6,7 @@ import {randomUUID} from 'node:crypto' import {type ProviderConfigResponse, TransportStateEventNames} from '../../server/core/domain/transport/schemas.js' import {TaskEvents} from '../../shared/transport/events/index.js' +import {buildCliMetadata} from '../lib/build-cli-metadata.js' import { type DaemonClientOptions, formatConnectionError, @@ -71,6 +73,10 @@ Bad: if (!this.validateInput(args.query, format)) return + // Build once per run so a single `client_sent_at` identifies one CLI + // invocation even on retries that may make multiple task:create calls. + const cliMetadata = buildCliMetadata(this.id ?? 'query', rawFlags) + let providerContext: ProviderErrorContext | undefined try { @@ -93,6 +99,7 @@ Bad: await this.submitTask({ client, + cliMetadata, format, projectRoot, query: args.query, @@ -131,15 +138,17 @@ Bad: private async submitTask(props: { client: ITransportClient + cliMetadata: ReturnType format: 'json' | 'text' projectRoot?: string query: string timeoutMs?: number worktreeRoot?: string }): Promise { - const {client, format, projectRoot, query, timeoutMs, worktreeRoot} = props + const {client, cliMetadata, format, projectRoot, query, timeoutMs, worktreeRoot} = props const taskId = randomUUID() const taskPayload = { + cli_metadata: cliMetadata, clientCwd: process.cwd(), content: query, ...(projectRoot ? {projectPath: projectRoot} : {}), diff --git a/src/server/core/domain/transport/schemas.ts b/src/server/core/domain/transport/schemas.ts index ed9422c27..b614a77d0 100644 --- a/src/server/core/domain/transport/schemas.ts +++ b/src/server/core/domain/transport/schemas.ts @@ -17,6 +17,7 @@ import type { TaskListResponse, } from '../../../../shared/transport/events/task-events.js' +import {CliRequestBaseSchema} from '../../../../shared/analytics/cli-metadata-schema.js' import {QUERY_LOG_TIERS, type QueryLogTier} from '../../domain/entities/query-log-entry.js' import {TaskHistoryEntrySchema} from '../entities/task-history-entry.js' // Re-export domain types for convenience (SSOT: agent-events/types.ts) @@ -697,27 +698,33 @@ export const TaskTypeSchema = z.enum(['curate', 'curate-folder', 'dream', 'query /** * Request to create a new task + * + * Merged with `CliRequestBaseSchema` so proactive Socket.IO clients (oclif, + * TUI, MCP, webui) can attach an optional `cli_metadata` block describing the + * invocation. Daemon-only consumers (idle dispatch, agent fork) leave it out. */ -export const TaskCreateRequestSchema = z.object({ - /** Client's working directory for file validation */ - clientCwd: z.string().optional(), - /** Task content/prompt (optional for folder/file-only curate) */ - content: z.string(), - /** Optional file paths for curate --files (max 5) */ - files: z.array(z.string()).optional(), - /** Folder path for curate-folder task type */ - folderPath: z.string().optional(), - /** Force flag for dream tasks (skip time/activity/queue gates) */ - force: z.boolean().optional(), - /** Project path this task belongs to (for multi-project routing) */ - projectPath: z.string().optional(), - /** Task ID - generated by Client UseCase (UUID v4) */ - taskId: z.string().uuid('Invalid taskId format - must be UUID'), - /** Task type */ - type: TaskTypeSchema, - /** Workspace root for scoped query/curate (stable linked root or projectRoot if unlinked) */ - worktreeRoot: z.string().optional(), -}) +export const TaskCreateRequestSchema = z + .object({ + /** Client's working directory for file validation */ + clientCwd: z.string().optional(), + /** Task content/prompt (optional for folder/file-only curate) */ + content: z.string(), + /** Optional file paths for curate --files (max 5) */ + files: z.array(z.string()).optional(), + /** Folder path for curate-folder task type */ + folderPath: z.string().optional(), + /** Force flag for dream tasks (skip time/activity/queue gates) */ + force: z.boolean().optional(), + /** Project path this task belongs to (for multi-project routing) */ + projectPath: z.string().optional(), + /** Task ID - generated by Client UseCase (UUID v4) */ + taskId: z.string().uuid('Invalid taskId format - must be UUID'), + /** Task type */ + type: TaskTypeSchema, + /** Workspace root for scoped query/curate (stable linked root or projectRoot if unlinked) */ + worktreeRoot: z.string().optional(), + }) + .merge(CliRequestBaseSchema) /** * Response after task creation diff --git a/src/server/core/interfaces/process/i-task-lifecycle-hook.ts b/src/server/core/interfaces/process/i-task-lifecycle-hook.ts index c39b72e56..d21d869b6 100644 --- a/src/server/core/interfaces/process/i-task-lifecycle-hook.ts +++ b/src/server/core/interfaces/process/i-task-lifecycle-hook.ts @@ -1,4 +1,4 @@ -import type {LlmToolResultEvent} from '../../domain/transport/schemas.js' +import type {LlmToolResultEvent, TaskCreateRequest} from '../../domain/transport/schemas.js' import type {TaskInfo} from '../../domain/transport/task-info.js' /** @@ -25,6 +25,16 @@ export interface ITaskLifecycleHook { onTaskCompleted?(taskId: string, result: string, task: TaskInfo): Promise /** Called when a new task is created. Return {logId} to associate a log entry with the task. */ onTaskCreate?(task: TaskInfo): Promise + /** + * Called once per inbound `task:create` request, after the duplicate-taskId + * check and before validation. Receives the raw transport payload (so + * fields like `cli_metadata` not present on `TaskInfo` remain visible) plus + * the originating clientId. Implementations must never throw. + * + * M13.1 emits `cli_invocation` from this hook so coverage spans every CLI + * command that routes through `task:create`, regardless of task validity. + */ + onTaskCreateRequest?(request: TaskCreateRequest, clientId: string): Promise /** Called when a task fails with an error. */ onTaskError?(taskId: string, errorMessage: string, task: TaskInfo): Promise /** diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index 0a85ae1aa..f49310e3b 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -1,12 +1,13 @@ /* eslint-disable camelcase */ import {readFileSync} from 'node:fs' -import type {LlmToolResultEvent} from '../../core/domain/transport/schemas.js' +import type {LlmToolResultEvent, TaskCreateRequest} from '../../core/domain/transport/schemas.js' import type {TaskInfo} from '../../core/domain/transport/task-info.js' import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' import type {ITaskLifecycleHook} from '../../core/interfaces/process/i-task-lifecycle-hook.js' import type {QueryResultMetadata} from './query-log-handler.js' +import {CliMetadataSchema} from '../../../shared/analytics/cli-metadata-schema.js' import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' import {parseFrontmatter} from '../../core/domain/knowledge/markdown-writer.js' import {extractCurateOperations} from '../../utils/curate-result-parser.js' @@ -153,6 +154,20 @@ export class AnalyticsHook implements ITaskLifecycleHook { } } + /** + * M13.1: emit `cli_invocation` once per inbound `task:create` request when + * the caller attached a structurally-valid `cli_metadata` block. Daemon- + * internal task creates (idle dispatch, agent fork) leave the block off and + * silently no-op here. + */ + async onTaskCreateRequest(request: TaskCreateRequest, _clientId: string): Promise { + const cliMeta = request.cli_metadata + if (!cliMeta) return + const parsed = CliMetadataSchema.safeParse(cliMeta) + if (!parsed.success) return + this.emit(AnalyticsEventNames.CLI_INVOCATION, parsed.data) + } + async onTaskError(taskId: string, _errorMessage: string, task: TaskInfo): Promise { this.dispatchTerminal(taskId, task, 'error') } diff --git a/src/server/infra/process/task-router.ts b/src/server/infra/process/task-router.ts index 881734437..517e40cd6 100644 --- a/src/server/infra/process/task-router.ts +++ b/src/server/infra/process/task-router.ts @@ -808,6 +808,28 @@ export class TaskRouter { return {taskId} } + // ── M13.1: onTaskCreateRequest fires once per inbound request, before + // task-validity gates. Hooks (e.g. AnalyticsHook for `cli_invocation`) + // observe the raw transport payload — including fields like `cli_metadata` + // that aren't carried on TaskInfo. Fire-and-forget so the synchronous + // `tasks.set` + broadcast contract downstream is preserved (an `await` here + // would let a concurrent dup-retry slip past the `this.tasks.has` gate). + // Hook failures never block dispatch. + for (const hook of this.lifecycleHooks) { + if (!hook.onTaskCreateRequest) continue + try { + hook.onTaskCreateRequest(data, clientId).catch((error: unknown) => { + transportLog( + `lifecycle hook onTaskCreateRequest failed: ${error instanceof Error ? error.message : String(error)}`, + ) + }) + } catch (error) { + transportLog( + `lifecycle hook onTaskCreateRequest threw synchronously: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + // ── Early validation: no hooks called if invalid ────────────────────────── if (!this.agentPool) { diff --git a/test/unit/core/domain/transport/schemas.test.ts b/test/unit/core/domain/transport/schemas.test.ts index 14bd34091..f4d9e7bb3 100644 --- a/test/unit/core/domain/transport/schemas.test.ts +++ b/test/unit/core/domain/transport/schemas.test.ts @@ -1,8 +1,10 @@ +/* eslint-disable camelcase */ import {expect} from 'chai' import { TaskClearCompletedRequestSchema, TaskCreatedSchema, + TaskCreateRequestSchema, TaskDeleteBulkRequestSchema, TaskDeletedEventSchema, TaskDeleteRequestSchema, @@ -58,6 +60,43 @@ describe('task transport schemas', () => { }) }) + describe('TaskCreateRequestSchema with cli_metadata (M13.1)', () => { + const baseRequest = { + content: 'analyze this', + taskId: '11111111-2222-4333-8444-555555555555', + type: 'query' as const, + } + const validCliMeta = { + client_sent_at: 1_700_000_000_000, + command_id: 'query', + flag_names: ['format'], + is_ci: false, + is_tty: true, + package_manager: 'npm' as const, + runtime: 'node' as const, + } + + it('accepts a TaskCreateRequest without cli_metadata (back-compat)', () => { + expect(TaskCreateRequestSchema.safeParse(baseRequest).success).to.equal(true) + }) + + it('accepts a TaskCreateRequest with a valid cli_metadata block', () => { + const result = TaskCreateRequestSchema.safeParse({...baseRequest, cli_metadata: validCliMeta}) + expect(result.success).to.equal(true) + if (result.success) expect(result.data.cli_metadata).to.deep.equal(validCliMeta) + }) + + it('rejects a TaskCreateRequest when cli_metadata is structurally invalid', () => { + const malformed = {...validCliMeta, runtime: 'deno'} + expect(TaskCreateRequestSchema.safeParse({...baseRequest, cli_metadata: malformed}).success).to.equal(false) + }) + + it('rejects a TaskCreateRequest when cli_metadata carries unknown fields (strict bubbles)', () => { + const sneaky = {...validCliMeta, sneaky_value: 'leak'} + expect(TaskCreateRequestSchema.safeParse({...baseRequest, cli_metadata: sneaky}).success).to.equal(false) + }) + }) + describe('TaskCreatedSchema', () => { const baseCreated = { content: 'test', diff --git a/test/unit/infra/process/task-router.test.ts b/test/unit/infra/process/task-router.test.ts index 0a9019aa6..e8152b4e1 100644 --- a/test/unit/infra/process/task-router.test.ts +++ b/test/unit/infra/process/task-router.test.ts @@ -373,6 +373,87 @@ describe('TaskRouter', () => { expect(errorCall!.args[2]).to.have.property('taskId', request.taskId) }) + describe('onTaskCreateRequest hook firing (M13.1)', () => { + it('fires onTaskCreateRequest with the raw request and clientId once per inbound task:create', async () => { + const onTaskCreateRequest = sandbox.stub().resolves() + const routerWithHook = new TaskRouter({ + agentPool, + getAgentForProject, + lifecycleHooks: [{onTaskCreateRequest}], + projectRegistry, + projectRouter, + transport: transportHelper.transport, + }) + routerWithHook.setup() + + const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) + const request = makeTaskCreateRequest() + await handler!(request, 'client-1') + + expect(onTaskCreateRequest.calledOnce).to.equal(true) + expect(onTaskCreateRequest.firstCall.args[0]).to.deep.equal(request) + expect(onTaskCreateRequest.firstCall.args[1]).to.equal('client-1') + }) + + it('does NOT fire onTaskCreateRequest on a duplicate-taskId retry (idempotent invocation)', async () => { + const onTaskCreateRequest = sandbox.stub().resolves() + const routerWithHook = new TaskRouter({ + agentPool, + getAgentForProject, + lifecycleHooks: [{onTaskCreateRequest}], + projectRegistry, + projectRouter, + transport: transportHelper.transport, + }) + routerWithHook.setup() + + const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) + const request = makeTaskCreateRequest() + await handler!(request, 'client-1') + await handler!(request, 'client-1') + + expect(onTaskCreateRequest.calledOnce).to.equal(true) + }) + + it('still fires onTaskCreateRequest when the task type is invalid (CLI invocation happened regardless)', async () => { + const onTaskCreateRequest = sandbox.stub().resolves() + const routerWithHook = new TaskRouter({ + agentPool, + getAgentForProject, + lifecycleHooks: [{onTaskCreateRequest}], + projectRegistry, + projectRouter, + transport: transportHelper.transport, + }) + routerWithHook.setup() + + const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) + const request = makeTaskCreateRequest({type: 'invalid_type'}) + await handler!(request, 'client-1') + + expect(onTaskCreateRequest.calledOnce).to.equal(true) + }) + + it('swallows hook errors and continues to validate + dispatch the task', async () => { + const onTaskCreateRequest = sandbox.stub().rejects(new Error('boom')) + const routerWithHook = new TaskRouter({ + agentPool, + getAgentForProject, + lifecycleHooks: [{onTaskCreateRequest}], + projectRegistry, + projectRouter, + transport: transportHelper.transport, + }) + routerWithHook.setup() + + const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) + const request = makeTaskCreateRequest() + const result = await handler!(request, 'client-1') + + expect(result).to.deep.equal({taskId: request.taskId}) + }) + }) + it('should include files and clientCwd in submitted task', async () => { const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) const request = makeTaskCreateRequest({ diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts index d24abcc9d..b4ade0c69 100644 --- a/test/unit/server/infra/process/analytics-hook.test.ts +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -1,4 +1,4 @@ - +/* eslint-disable camelcase */ import {expect} from 'chai' import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' import {tmpdir} from 'node:os' @@ -393,6 +393,57 @@ describe('AnalyticsHook', () => { }) }) + describe('cli_invocation flow (M13.1)', () => { + const validCliMeta = { + client_sent_at: 1_700_000_000_000, + command_id: 'query', + flag_names: ['format'], + is_ci: false, + is_tty: true, + package_manager: 'npm' as const, + runtime: 'node' as const, + } + const baseRequest = { + content: 'analyze auth', + taskId: 'task-cli-1', + type: 'query' as const, + } + + it('emits cli_invocation with the cli_metadata payload verbatim', async () => { + await hook.onTaskCreateRequest({...baseRequest, cli_metadata: validCliMeta}, 'client-1') + + expect(trackStub.calledOnce).to.equal(true) + expect(trackStub.firstCall.args[0]).to.equal(AnalyticsEventNames.CLI_INVOCATION) + expect(trackStub.firstCall.args[1]).to.deep.equal(validCliMeta) + }) + + it('does NOT emit when cli_metadata is absent (daemon-internal task)', async () => { + await hook.onTaskCreateRequest(baseRequest, 'client-1') + expect(trackStub.called).to.equal(false) + }) + + it('does NOT emit when cli_metadata is structurally invalid (defense-in-depth)', async () => { + // Cast through `unknown` because TS rejects `runtime: 'deno'` against the enum — + // the whole point of this test is to verify the runtime safe-parse blocks bad shapes + // even when the type system was bypassed at the wire. + const malformed = {...validCliMeta, runtime: 'deno'} as unknown as typeof validCliMeta + await hook.onTaskCreateRequest({...baseRequest, cli_metadata: malformed}, 'client-1') + expect(trackStub.called).to.equal(false) + }) + + it('swallows analyticsClient.track throws (does not propagate)', async () => { + trackStub.throws(new Error('boom')) + await hook.onTaskCreateRequest({...baseRequest, cli_metadata: validCliMeta}, 'client-1') + expect(trackStub.called).to.equal(true) + }) + + it('is a no-op when setAnalyticsClient was never called', async () => { + const bareHook = new AnalyticsHook() + await bareHook.onTaskCreateRequest({...baseRequest, cli_metadata: validCliMeta}, 'client-1') + // No throw, no assertions on track (no client to inspect) + }) + }) + describe('lifecycle hygiene', () => { it('cleanup(taskId) drops state for both flavors', async () => { const curate = buildCurateTask() From 3f30886da37e5a2451cbba0186def0193ba4a2ac Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 13 May 2026 15:06:31 +0700 Subject: [PATCH 32/87] fix: [ENG-2805] roll back daemon-side cli_invocation emit (M13 scope: client payload only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the in-handler cli_invocation emit added in M13.1 Phase B while keeping the cli_metadata client → daemon plumbing intact: - ITaskLifecycleHook.onTaskCreateRequest method removed - AnalyticsHook.onTaskCreateRequest impl + CliMetadataSchema import removed - TaskRouter.handleTaskCreate fire-and-forget hook block removed - 5 cli_invocation flow tests removed from analytics-hook.test.ts - 4 onTaskCreateRequest hook firing tests removed from task-router.test.ts cli_metadata still flows client to daemon via TaskCreateRequestSchema and the oclif spread in brv query / brv curate; AnalyticsEventNames.CLI_INVOCATION and CliInvocationSchema re-export remain defined in src/shared/analytics as prep. No daemon code currently calls analyticsClient.track('cli_invocation', ...). M13.4 (Daemon emit sweep, ENG-2808) is deferred pending a future ticket that finalizes the event name + shape and the list of handlers that should emit. --- .../process/i-task-lifecycle-hook.ts | 12 +-- src/server/infra/process/analytics-hook.ts | 17 +--- src/server/infra/process/task-router.ts | 22 ----- test/unit/infra/process/task-router.test.ts | 81 ------------------- .../infra/process/analytics-hook.test.ts | 52 ------------ 5 files changed, 2 insertions(+), 182 deletions(-) diff --git a/src/server/core/interfaces/process/i-task-lifecycle-hook.ts b/src/server/core/interfaces/process/i-task-lifecycle-hook.ts index d21d869b6..c39b72e56 100644 --- a/src/server/core/interfaces/process/i-task-lifecycle-hook.ts +++ b/src/server/core/interfaces/process/i-task-lifecycle-hook.ts @@ -1,4 +1,4 @@ -import type {LlmToolResultEvent, TaskCreateRequest} from '../../domain/transport/schemas.js' +import type {LlmToolResultEvent} from '../../domain/transport/schemas.js' import type {TaskInfo} from '../../domain/transport/task-info.js' /** @@ -25,16 +25,6 @@ export interface ITaskLifecycleHook { onTaskCompleted?(taskId: string, result: string, task: TaskInfo): Promise /** Called when a new task is created. Return {logId} to associate a log entry with the task. */ onTaskCreate?(task: TaskInfo): Promise - /** - * Called once per inbound `task:create` request, after the duplicate-taskId - * check and before validation. Receives the raw transport payload (so - * fields like `cli_metadata` not present on `TaskInfo` remain visible) plus - * the originating clientId. Implementations must never throw. - * - * M13.1 emits `cli_invocation` from this hook so coverage spans every CLI - * command that routes through `task:create`, regardless of task validity. - */ - onTaskCreateRequest?(request: TaskCreateRequest, clientId: string): Promise /** Called when a task fails with an error. */ onTaskError?(taskId: string, errorMessage: string, task: TaskInfo): Promise /** diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index f49310e3b..0a85ae1aa 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -1,13 +1,12 @@ /* eslint-disable camelcase */ import {readFileSync} from 'node:fs' -import type {LlmToolResultEvent, TaskCreateRequest} from '../../core/domain/transport/schemas.js' +import type {LlmToolResultEvent} from '../../core/domain/transport/schemas.js' import type {TaskInfo} from '../../core/domain/transport/task-info.js' import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' import type {ITaskLifecycleHook} from '../../core/interfaces/process/i-task-lifecycle-hook.js' import type {QueryResultMetadata} from './query-log-handler.js' -import {CliMetadataSchema} from '../../../shared/analytics/cli-metadata-schema.js' import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' import {parseFrontmatter} from '../../core/domain/knowledge/markdown-writer.js' import {extractCurateOperations} from '../../utils/curate-result-parser.js' @@ -154,20 +153,6 @@ export class AnalyticsHook implements ITaskLifecycleHook { } } - /** - * M13.1: emit `cli_invocation` once per inbound `task:create` request when - * the caller attached a structurally-valid `cli_metadata` block. Daemon- - * internal task creates (idle dispatch, agent fork) leave the block off and - * silently no-op here. - */ - async onTaskCreateRequest(request: TaskCreateRequest, _clientId: string): Promise { - const cliMeta = request.cli_metadata - if (!cliMeta) return - const parsed = CliMetadataSchema.safeParse(cliMeta) - if (!parsed.success) return - this.emit(AnalyticsEventNames.CLI_INVOCATION, parsed.data) - } - async onTaskError(taskId: string, _errorMessage: string, task: TaskInfo): Promise { this.dispatchTerminal(taskId, task, 'error') } diff --git a/src/server/infra/process/task-router.ts b/src/server/infra/process/task-router.ts index 517e40cd6..881734437 100644 --- a/src/server/infra/process/task-router.ts +++ b/src/server/infra/process/task-router.ts @@ -808,28 +808,6 @@ export class TaskRouter { return {taskId} } - // ── M13.1: onTaskCreateRequest fires once per inbound request, before - // task-validity gates. Hooks (e.g. AnalyticsHook for `cli_invocation`) - // observe the raw transport payload — including fields like `cli_metadata` - // that aren't carried on TaskInfo. Fire-and-forget so the synchronous - // `tasks.set` + broadcast contract downstream is preserved (an `await` here - // would let a concurrent dup-retry slip past the `this.tasks.has` gate). - // Hook failures never block dispatch. - for (const hook of this.lifecycleHooks) { - if (!hook.onTaskCreateRequest) continue - try { - hook.onTaskCreateRequest(data, clientId).catch((error: unknown) => { - transportLog( - `lifecycle hook onTaskCreateRequest failed: ${error instanceof Error ? error.message : String(error)}`, - ) - }) - } catch (error) { - transportLog( - `lifecycle hook onTaskCreateRequest threw synchronously: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - // ── Early validation: no hooks called if invalid ────────────────────────── if (!this.agentPool) { diff --git a/test/unit/infra/process/task-router.test.ts b/test/unit/infra/process/task-router.test.ts index e8152b4e1..0a9019aa6 100644 --- a/test/unit/infra/process/task-router.test.ts +++ b/test/unit/infra/process/task-router.test.ts @@ -373,87 +373,6 @@ describe('TaskRouter', () => { expect(errorCall!.args[2]).to.have.property('taskId', request.taskId) }) - describe('onTaskCreateRequest hook firing (M13.1)', () => { - it('fires onTaskCreateRequest with the raw request and clientId once per inbound task:create', async () => { - const onTaskCreateRequest = sandbox.stub().resolves() - const routerWithHook = new TaskRouter({ - agentPool, - getAgentForProject, - lifecycleHooks: [{onTaskCreateRequest}], - projectRegistry, - projectRouter, - transport: transportHelper.transport, - }) - routerWithHook.setup() - - const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) - const request = makeTaskCreateRequest() - await handler!(request, 'client-1') - - expect(onTaskCreateRequest.calledOnce).to.equal(true) - expect(onTaskCreateRequest.firstCall.args[0]).to.deep.equal(request) - expect(onTaskCreateRequest.firstCall.args[1]).to.equal('client-1') - }) - - it('does NOT fire onTaskCreateRequest on a duplicate-taskId retry (idempotent invocation)', async () => { - const onTaskCreateRequest = sandbox.stub().resolves() - const routerWithHook = new TaskRouter({ - agentPool, - getAgentForProject, - lifecycleHooks: [{onTaskCreateRequest}], - projectRegistry, - projectRouter, - transport: transportHelper.transport, - }) - routerWithHook.setup() - - const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) - const request = makeTaskCreateRequest() - await handler!(request, 'client-1') - await handler!(request, 'client-1') - - expect(onTaskCreateRequest.calledOnce).to.equal(true) - }) - - it('still fires onTaskCreateRequest when the task type is invalid (CLI invocation happened regardless)', async () => { - const onTaskCreateRequest = sandbox.stub().resolves() - const routerWithHook = new TaskRouter({ - agentPool, - getAgentForProject, - lifecycleHooks: [{onTaskCreateRequest}], - projectRegistry, - projectRouter, - transport: transportHelper.transport, - }) - routerWithHook.setup() - - const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) - const request = makeTaskCreateRequest({type: 'invalid_type'}) - await handler!(request, 'client-1') - - expect(onTaskCreateRequest.calledOnce).to.equal(true) - }) - - it('swallows hook errors and continues to validate + dispatch the task', async () => { - const onTaskCreateRequest = sandbox.stub().rejects(new Error('boom')) - const routerWithHook = new TaskRouter({ - agentPool, - getAgentForProject, - lifecycleHooks: [{onTaskCreateRequest}], - projectRegistry, - projectRouter, - transport: transportHelper.transport, - }) - routerWithHook.setup() - - const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) - const request = makeTaskCreateRequest() - const result = await handler!(request, 'client-1') - - expect(result).to.deep.equal({taskId: request.taskId}) - }) - }) - it('should include files and clientCwd in submitted task', async () => { const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) const request = makeTaskCreateRequest({ diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts index b4ade0c69..c2c7e3c1b 100644 --- a/test/unit/server/infra/process/analytics-hook.test.ts +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable camelcase */ import {expect} from 'chai' import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' import {tmpdir} from 'node:os' @@ -393,57 +392,6 @@ describe('AnalyticsHook', () => { }) }) - describe('cli_invocation flow (M13.1)', () => { - const validCliMeta = { - client_sent_at: 1_700_000_000_000, - command_id: 'query', - flag_names: ['format'], - is_ci: false, - is_tty: true, - package_manager: 'npm' as const, - runtime: 'node' as const, - } - const baseRequest = { - content: 'analyze auth', - taskId: 'task-cli-1', - type: 'query' as const, - } - - it('emits cli_invocation with the cli_metadata payload verbatim', async () => { - await hook.onTaskCreateRequest({...baseRequest, cli_metadata: validCliMeta}, 'client-1') - - expect(trackStub.calledOnce).to.equal(true) - expect(trackStub.firstCall.args[0]).to.equal(AnalyticsEventNames.CLI_INVOCATION) - expect(trackStub.firstCall.args[1]).to.deep.equal(validCliMeta) - }) - - it('does NOT emit when cli_metadata is absent (daemon-internal task)', async () => { - await hook.onTaskCreateRequest(baseRequest, 'client-1') - expect(trackStub.called).to.equal(false) - }) - - it('does NOT emit when cli_metadata is structurally invalid (defense-in-depth)', async () => { - // Cast through `unknown` because TS rejects `runtime: 'deno'` against the enum — - // the whole point of this test is to verify the runtime safe-parse blocks bad shapes - // even when the type system was bypassed at the wire. - const malformed = {...validCliMeta, runtime: 'deno'} as unknown as typeof validCliMeta - await hook.onTaskCreateRequest({...baseRequest, cli_metadata: malformed}, 'client-1') - expect(trackStub.called).to.equal(false) - }) - - it('swallows analyticsClient.track throws (does not propagate)', async () => { - trackStub.throws(new Error('boom')) - await hook.onTaskCreateRequest({...baseRequest, cli_metadata: validCliMeta}, 'client-1') - expect(trackStub.called).to.equal(true) - }) - - it('is a no-op when setAnalyticsClient was never called', async () => { - const bareHook = new AnalyticsHook() - await bareHook.onTaskCreateRequest({...baseRequest, cli_metadata: validCliMeta}, 'client-1') - // No throw, no assertions on track (no client to inspect) - }) - }) - describe('lifecycle hygiene', () => { it('cleanup(taskId) drops state for both flavors', async () => { const curate = buildCurateTask() From 1d4578b7292a5167d8d711f62aeb07abdcbf6bf8 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 13 May 2026 16:34:00 +0700 Subject: [PATCH 33/87] =?UTF-8?q?feat:=20[ENG-2806]=20M13.2=20schema=20swe?= =?UTF-8?q?ep=20=E2=80=94=20extend=20all=20client-originated=20request=20s?= =?UTF-8?q?chemas=20+=20interfaces=20with=20optional=20cli=5Fmetadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group A — 13 Zod schemas merged with CliRequestBaseSchema: TaskCancel, TaskList, TaskGet, TaskDelete, TaskDeleteBulk, TaskClearCompleted, SessionInfo, SessionList, SessionCreate, SessionSwitch, AgentRestart, AgentNewSession (schemas.ts), AnalyticsListRequest (analytics-events.ts) Group B — cli_metadata? appended to ~55 client-originated Request interfaces across 20 events/*.ts files. Field is optional; every modified file imports CliMetadata once. Group C — 11 new Request interfaces for no-payload events: IVcInit, IVcStatus, AuthLogout, ConnectorGetAgents/List, GlobalConfigGet, HubList/RegistryList, LocationsGet, ProviderGetActive/List. Plus SourceListRequest and WorktreeListRequest upgraded from void aliases to interfaces. Tests: parametric describe block in schemas.test.ts asserts cli_metadata round-trip across all 12 Group A schemas (36 new tests). Pure additive payload-prep. No handler logic touched, no daemon emit added, no oclif change. Out-of-scope-for-M13 emit ticket (ENG-2808) remains deferred. --- src/server/core/domain/transport/schemas.ts | 81 ++++++++++++------- src/shared/transport/events/agent-events.ts | 5 ++ .../transport/events/analytics-events.ts | 15 ++-- src/shared/transport/events/auth-events.ts | 13 +++ .../transport/events/connector-events.ts | 19 +++++ .../transport/events/context-tree-events.ts | 8 ++ .../transport/events/global-config-events.ts | 13 +++ src/shared/transport/events/hub-events.ts | 20 +++++ src/shared/transport/events/init-events.ts | 5 ++ .../transport/events/locations-events.ts | 11 +++ src/shared/transport/events/model-events.ts | 5 ++ .../transport/events/onboarding-events.ts | 4 + .../transport/events/provider-events.ts | 25 ++++++ src/shared/transport/events/pull-events.ts | 5 ++ src/shared/transport/events/push-events.ts | 5 ++ src/shared/transport/events/review-events.ts | 5 ++ src/shared/transport/events/source-events.ts | 15 +++- src/shared/transport/events/space-events.ts | 3 + src/shared/transport/events/status-events.ts | 3 + src/shared/transport/events/task-events.ts | 10 +++ src/shared/transport/events/vc-events.ts | 50 ++++++++++-- .../transport/events/worktree-events.ts | 14 +++- .../core/domain/transport/schemas.test.ts | 64 +++++++++++++++ 23 files changed, 352 insertions(+), 46 deletions(-) diff --git a/src/server/core/domain/transport/schemas.ts b/src/server/core/domain/transport/schemas.ts index b614a77d0..95af5600a 100644 --- a/src/server/core/domain/transport/schemas.ts +++ b/src/server/core/domain/transport/schemas.ts @@ -739,9 +739,11 @@ export const TaskCreateResponseSchema = z.object({ /** * Request to cancel a task */ -export const TaskCancelRequestSchema = z.object({ - taskId: z.string(), -}) +export const TaskCancelRequestSchema = z + .object({ + taskId: z.string(), + }) + .merge(CliRequestBaseSchema) /** * Response after task cancellation @@ -786,6 +788,7 @@ export const TaskListRequestSchema = z /** Optional task-type filter — e.g. ['curate'], ['query']. */ type: z.array(z.string()).optional(), }) + .merge(CliRequestBaseSchema) .strict() satisfies z.ZodType export const TaskListItemStatusSchema = z.enum(['cancelled', 'completed', 'created', 'error', 'started']) @@ -867,9 +870,11 @@ export const TaskListResponseSchema = z * task:get — fetch full Level 2 detail for a single persisted task. * Returns null when the task is unknown or its data file is corrupt. */ -export const TaskGetRequestSchema = z.object({ - taskId: z.string(), -}) +export const TaskGetRequestSchema = z + .object({ + taskId: z.string(), + }) + .merge(CliRequestBaseSchema) export const TaskGetResponseSchema = z.object({ task: TaskHistoryEntrySchema.nullable(), @@ -879,9 +884,11 @@ export const TaskGetResponseSchema = z.object({ * task:delete — remove a single task from the per-project history store. * Idempotent: deleting a non-existent task returns success: true. */ -export const TaskDeleteRequestSchema = z.object({ - taskId: z.string(), -}) +export const TaskDeleteRequestSchema = z + .object({ + taskId: z.string(), + }) + .merge(CliRequestBaseSchema) export const TaskDeleteResponseSchema = z.object({ error: z.string().optional(), @@ -899,9 +906,11 @@ export const TaskDeleteResponseSchema = z.object({ /** * task:deleteBulk — delete many tasks at once. `deletedCount` reports actual removals. */ -export const TaskDeleteBulkRequestSchema = z.object({ - taskIds: z.array(z.string()), -}) +export const TaskDeleteBulkRequestSchema = z + .object({ + taskIds: z.array(z.string()), + }) + .merge(CliRequestBaseSchema) export const TaskDeleteBulkResponseSchema = z.object({ deletedCount: z.number(), @@ -912,9 +921,11 @@ export const TaskDeleteBulkResponseSchema = z.object({ * task:clearCompleted — remove all terminal-state tasks (completed/error/cancelled) * from the project's history. Active tasks (created/started) are preserved. */ -export const TaskClearCompletedRequestSchema = z.object({ - projectPath: z.string().optional(), -}) +export const TaskClearCompletedRequestSchema = z + .object({ + projectPath: z.string().optional(), + }) + .merge(CliRequestBaseSchema) export const TaskClearCompletedResponseSchema = z.object({ deletedCount: z.number(), @@ -955,7 +966,7 @@ export const SessionStatsSchema = z.object({ /** * Request for session:info (empty - get current session) */ -export const SessionInfoRequestSchema = z.object({}) +export const SessionInfoRequestSchema = z.object({}).merge(CliRequestBaseSchema) /** * Response for session:info @@ -968,7 +979,7 @@ export const SessionInfoResponseSchema = z.object({ /** * Request for session:list (empty - list all) */ -export const SessionListRequestSchema = z.object({}) +export const SessionListRequestSchema = z.object({}).merge(CliRequestBaseSchema) /** * Response for session:list @@ -980,9 +991,11 @@ export const SessionListResponseSchema = z.object({ /** * Request for session:create */ -export const SessionCreateRequestSchema = z.object({ - name: z.string().optional(), -}) +export const SessionCreateRequestSchema = z + .object({ + name: z.string().optional(), + }) + .merge(CliRequestBaseSchema) /** * Response for session:create @@ -994,9 +1007,11 @@ export const SessionCreateResponseSchema = z.object({ /** * Request for session:switch */ -export const SessionSwitchRequestSchema = z.object({ - sessionId: z.string(), -}) +export const SessionSwitchRequestSchema = z + .object({ + sessionId: z.string(), + }) + .merge(CliRequestBaseSchema) /** * Response for session:switch @@ -1020,10 +1035,12 @@ export const SessionSwitchedBroadcastSchema = z.object({ * Request to restart/reinitialize the Agent. * Used when config changes (e.g., after /init) require Agent to reload. */ -export const AgentRestartRequestSchema = z.object({ - /** Optional reason for restart (for logging) */ - reason: z.string().optional(), -}) +export const AgentRestartRequestSchema = z + .object({ + /** Optional reason for restart (for logging) */ + reason: z.string().optional(), + }) + .merge(CliRequestBaseSchema) /** * Response after agent restart request. @@ -1039,10 +1056,12 @@ export const AgentRestartResponseSchema = z.object({ * Request to create a new session (end current, start fresh). * Used by /new command to start a fresh conversation. */ -export const AgentNewSessionRequestSchema = z.object({ - /** Optional reason for new session (for logging) */ - reason: z.string().optional(), -}) +export const AgentNewSessionRequestSchema = z + .object({ + /** Optional reason for new session (for logging) */ + reason: z.string().optional(), + }) + .merge(CliRequestBaseSchema) /** * Response after new session is created. diff --git a/src/shared/transport/events/agent-events.ts b/src/shared/transport/events/agent-events.ts index 08d7a44b0..0af2ed6a7 100644 --- a/src/shared/transport/events/agent-events.ts +++ b/src/shared/transport/events/agent-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const AgentEvents = { CONNECTED: 'agent:connected', DISCONNECTED: 'agent:disconnected', @@ -10,6 +13,7 @@ export const AgentEvents = { } as const export interface AgentRestartRequest { + cli_metadata?: CliMetadata reason: string } @@ -18,6 +22,7 @@ export interface AgentRestartResponse { } export interface AgentNewSessionRequest { + cli_metadata?: CliMetadata reason?: string } diff --git a/src/shared/transport/events/analytics-events.ts b/src/shared/transport/events/analytics-events.ts index 38d971267..acf090b2b 100644 --- a/src/shared/transport/events/analytics-events.ts +++ b/src/shared/transport/events/analytics-events.ts @@ -1,5 +1,6 @@ import {z} from 'zod' +import {CliRequestBaseSchema} from '../../analytics/cli-metadata-schema.js' import {StoredAnalyticsRecordSchema} from '../../analytics/stored-record.js' export const AnalyticsEvents = { @@ -30,12 +31,14 @@ export type AnalyticsTrackPayload = z.infer * Bounds (`limit 1..200`, `offset >= 0`) protect the daemon from accidental * mass reads and align with the M9.2 store's read-mostly use case. */ -export const AnalyticsListRequestSchema = z.object({ - eventName: z.string().optional(), - limit: z.number().int().min(1).max(200), - offset: z.number().int().min(0), - status: z.enum(['pending', 'sent', 'failed']).optional(), -}) +export const AnalyticsListRequestSchema = z + .object({ + eventName: z.string().optional(), + limit: z.number().int().min(1).max(200), + offset: z.number().int().min(0), + status: z.enum(['pending', 'sent', 'failed']).optional(), + }) + .merge(CliRequestBaseSchema) export type AnalyticsListRequest = z.infer diff --git a/src/shared/transport/events/auth-events.ts b/src/shared/transport/events/auth-events.ts index e6bc4249a..a980531a2 100644 --- a/src/shared/transport/events/auth-events.ts +++ b/src/shared/transport/events/auth-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {AuthTokenDTO, BrvConfigDTO, UserDTO} from '../types/dto.js' export const AuthEvents = { @@ -20,6 +22,7 @@ export interface AuthGetStateResponse { } export interface AuthStartLoginRequest { + cli_metadata?: CliMetadata /** * When true, the daemon returns the auth URL without launching the system browser. * Used by clients (e.g. web UI) that prefer to open the URL themselves. @@ -39,6 +42,7 @@ export interface AuthLoginCompletedEvent { export interface AuthLoginWithApiKeyRequest { apiKey: string + cli_metadata?: CliMetadata } export interface AuthLoginWithApiKeyResponse { @@ -47,6 +51,15 @@ export interface AuthLoginWithApiKeyResponse { userEmail?: string } +/** + * M13.2 Group C — `auth:logout` is a no-payload oclif call today. Define the + * Request interface here so M13.3 can attach `cli_metadata`. Handler-side type- + * parameter update is out of scope (deferred emit ticket). + */ +export interface AuthLogoutRequest { + cli_metadata?: CliMetadata +} + export interface AuthLogoutResponse { error?: string success: boolean diff --git a/src/shared/transport/events/connector-events.ts b/src/shared/transport/events/connector-events.ts index a601f1e8c..bfff6d1c1 100644 --- a/src/shared/transport/events/connector-events.ts +++ b/src/shared/transport/events/connector-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {Agent} from '../../types/agent.js' import type {ConnectorType} from '../../types/connector-type.js' import type {AgentDTO, ConnectorDTO} from '../types/dto.js' @@ -9,16 +11,32 @@ export const ConnectorEvents = { LIST: 'connectors:list', } as const +/** + * M13.2 Group C — `connectors:getAgents` is a no-payload oclif call. Define the + * Request interface for M13.3's payload attachment. + */ +export interface ConnectorGetAgentsRequest { + cli_metadata?: CliMetadata +} + export interface ConnectorGetAgentsResponse { agents: AgentDTO[] } +/** + * M13.2 Group C — `connectors:list` is a no-payload oclif call. + */ +export interface ConnectorListRequest { + cli_metadata?: CliMetadata +} + export interface ConnectorListResponse { connectors: ConnectorDTO[] } export interface ConnectorGetAgentConfigPathsRequest { agentId: Agent + cli_metadata?: CliMetadata } export interface ConnectorGetAgentConfigPathsResponse { @@ -27,6 +45,7 @@ export interface ConnectorGetAgentConfigPathsResponse { export interface ConnectorInstallRequest { agentId: Agent + cli_metadata?: CliMetadata connectorType: ConnectorType } diff --git a/src/shared/transport/events/context-tree-events.ts b/src/shared/transport/events/context-tree-events.ts index feb57d36c..3096cb7af 100644 --- a/src/shared/transport/events/context-tree-events.ts +++ b/src/shared/transport/events/context-tree-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + /** Transport events for context tree operations (webui ↔ daemon). */ export const ContextTreeEvents = { GET_FILE: 'contextTree:getFile', @@ -12,6 +15,7 @@ export const ContextTreeEvents = { export interface ContextTreeGetNodesRequest { /** Branch to read from. Defaults to current checked-out branch if omitted. */ branch?: string + cli_metadata?: CliMetadata /** Explicit project path. When omitted, uses the client's registered project. */ projectPath?: string } @@ -39,6 +43,7 @@ export interface ContextTreeGetNodesResponse { export interface ContextTreeGetFileRequest { branch?: string + cli_metadata?: CliMetadata /** Relative path within the context tree (e.g. `"architecture/auth.md"`). */ path: string /** Explicit project path. When omitted, uses the client's registered project. */ @@ -65,6 +70,7 @@ export interface ContextTreeGetFileResponse { export interface ContextTreeUpdateFileRequest { branch?: string + cli_metadata?: CliMetadata /** New file content to write. */ content: string /** Relative path within the context tree. */ @@ -80,6 +86,7 @@ export interface ContextTreeUpdateFileResponse { // --- GET_FILE_METADATA --- export interface ContextTreeGetFileMetadataRequest { + cli_metadata?: CliMetadata /** File paths to fetch metadata for. */ paths: string[] /** Explicit project path. When omitted, uses the client's registered project. */ @@ -99,6 +106,7 @@ export interface ContextTreeGetFileMetadataResponse { // --- GET_HISTORY --- export interface ContextTreeGetHistoryRequest { + cli_metadata?: CliMetadata /** SHA of the last commit from the previous page (for cursor-based pagination). */ cursor?: string /** Max commits per page. Defaults to 10. */ diff --git a/src/shared/transport/events/global-config-events.ts b/src/shared/transport/events/global-config-events.ts index a0f5c95e5..2d2b88667 100644 --- a/src/shared/transport/events/global-config-events.ts +++ b/src/shared/transport/events/global-config-events.ts @@ -1,8 +1,20 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const GlobalConfigEvents = { GET: 'globalConfig:get', SET_ANALYTICS: 'globalConfig:setAnalytics', } as const +/** + * M13.2 Group C — `globalConfig:get` is a no-payload oclif call today (verified at + * `src/oclif/commands/analytics/status.ts:34`). Define the Request interface so + * M13.3 can attach `cli_metadata`. + */ +export interface GlobalConfigGetRequest { + cli_metadata?: CliMetadata +} + export interface GlobalConfigGetResponse { readonly analytics: boolean readonly deviceId: string @@ -11,6 +23,7 @@ export interface GlobalConfigGetResponse { export interface GlobalConfigSetAnalyticsRequest { readonly analytics: boolean + cli_metadata?: CliMetadata } export interface GlobalConfigSetAnalyticsResponse { diff --git a/src/shared/transport/events/hub-events.ts b/src/shared/transport/events/hub-events.ts index 8f91cf50c..1df78112c 100644 --- a/src/shared/transport/events/hub-events.ts +++ b/src/shared/transport/events/hub-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {AuthScheme} from '../types/auth-scheme.js' import type {HubEntryDTO} from '../types/dto.js' @@ -17,6 +19,14 @@ export interface HubProgressEvent { step: string } +/** + * M13.2 Group C — `hub:list` is a no-payload oclif call. Define the Request + * interface so M13.3 can attach `cli_metadata`. + */ +export interface HubListRequest { + cli_metadata?: CliMetadata +} + export interface HubListResponse { entries: HubEntryDTO[] version: string @@ -24,6 +34,7 @@ export interface HubListResponse { export interface HubInstallRequest { agent?: string + cli_metadata?: CliMetadata entryId: string registry?: string scope?: 'global' | 'project' @@ -40,6 +51,7 @@ export interface HubInstallResponse { export interface HubRegistryAddRequest { authScheme?: AuthScheme + cli_metadata?: CliMetadata headerName?: string name: string token?: string @@ -52,6 +64,7 @@ export interface HubRegistryAddResponse { } export interface HubRegistryRemoveRequest { + cli_metadata?: CliMetadata name: string } @@ -70,6 +83,13 @@ export interface HubRegistryDTO { url: string } +/** + * M13.2 Group C — `hub:registry:list` is a no-payload oclif call. + */ +export interface HubRegistryListRequest { + cli_metadata?: CliMetadata +} + export interface HubRegistryListResponse { registries: HubRegistryDTO[] } diff --git a/src/shared/transport/events/init-events.ts b/src/shared/transport/events/init-events.ts index 50d6657de..c9de56d38 100644 --- a/src/shared/transport/events/init-events.ts +++ b/src/shared/transport/events/init-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {Agent} from '../../types/agent.js' import type {ConnectorType} from '../../types/connector-type.js' import type {AgentDTO, BrvConfigDTO, SpaceDTO, TeamDTO} from '../types/dto.js' @@ -17,6 +19,7 @@ export interface InitGetTeamsResponse { } export interface InitGetSpacesRequest { + cli_metadata?: CliMetadata teamId: string } @@ -30,6 +33,7 @@ export interface InitGetAgentsResponse { export interface InitExecuteRequest { agentId: Agent + cli_metadata?: CliMetadata connectorType: ConnectorType force?: boolean spaceId: string @@ -41,6 +45,7 @@ export interface InitExecuteResponse { } export interface InitLocalRequest { + cli_metadata?: CliMetadata force?: boolean } diff --git a/src/shared/transport/events/locations-events.ts b/src/shared/transport/events/locations-events.ts index 8ab166c0d..df3fe21dc 100644 --- a/src/shared/transport/events/locations-events.ts +++ b/src/shared/transport/events/locations-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {ProjectLocationDTO} from '../types/dto.js' export const LocationsEvents = { @@ -5,11 +7,20 @@ export const LocationsEvents = { REVEAL: 'locations:reveal', } as const +/** + * M13.2 Group C — `locations:get` is a no-payload oclif call. Define the Request + * interface so M13.3 can attach `cli_metadata`. + */ +export interface LocationsGetRequest { + cli_metadata?: CliMetadata +} + export interface LocationsGetResponse { locations: ProjectLocationDTO[] } export interface LocationsRevealRequest { + cli_metadata?: CliMetadata projectPath: string } diff --git a/src/shared/transport/events/model-events.ts b/src/shared/transport/events/model-events.ts index 22427b08e..a43f9659d 100644 --- a/src/shared/transport/events/model-events.ts +++ b/src/shared/transport/events/model-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {ModelDTO} from '../types/dto.js' export const ModelEvents = { @@ -7,6 +9,7 @@ export const ModelEvents = { } as const export interface ModelListRequest { + cli_metadata?: CliMetadata providerId: string } @@ -19,6 +22,7 @@ export interface ModelListResponse { } export interface ModelListByProvidersRequest { + cli_metadata?: CliMetadata providerIds: string[] } @@ -28,6 +32,7 @@ export interface ModelListByProvidersResponse { } export interface ModelSetActiveRequest { + cli_metadata?: CliMetadata contextLength?: number modelId: string providerId: string diff --git a/src/shared/transport/events/onboarding-events.ts b/src/shared/transport/events/onboarding-events.ts index aa0e8fb5d..a2613a2c1 100644 --- a/src/shared/transport/events/onboarding-events.ts +++ b/src/shared/transport/events/onboarding-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const OnboardingEvents = { AUTO_SETUP: 'onboarding:autoSetup', COMPLETE: 'onboarding:complete', @@ -14,6 +17,7 @@ export interface OnboardingAutoSetupResponse { } export interface OnboardingCompleteRequest { + cli_metadata?: CliMetadata skipped?: boolean } diff --git a/src/shared/transport/events/provider-events.ts b/src/shared/transport/events/provider-events.ts index 384cae038..c7f6d958f 100644 --- a/src/shared/transport/events/provider-events.ts +++ b/src/shared/transport/events/provider-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {ProviderDTO} from '../types/dto.js' export const ProviderEvents = { @@ -14,6 +16,14 @@ export const ProviderEvents = { VALIDATE_API_KEY: 'provider:validateApiKey', } as const +/** + * M13.2 Group C — `provider:list` is a no-payload oclif call. Define the Request + * interface so M13.3 can attach `cli_metadata`. + */ +export interface ProviderListRequest { + cli_metadata?: CliMetadata +} + export interface ProviderListResponse { providers: ProviderDTO[] } @@ -21,6 +31,7 @@ export interface ProviderListResponse { export interface ProviderConnectRequest { apiKey?: string baseUrl?: string + cli_metadata?: CliMetadata providerId: string } @@ -30,6 +41,7 @@ export interface ProviderConnectResponse { } export interface ProviderDisconnectRequest { + cli_metadata?: CliMetadata providerId: string } @@ -39,6 +51,7 @@ export interface ProviderDisconnectResponse { export interface ProviderValidateApiKeyRequest { apiKey: string + cli_metadata?: CliMetadata providerId: string } @@ -47,6 +60,13 @@ export interface ProviderValidateApiKeyResponse { isValid: boolean } +/** + * M13.2 Group C — `provider:getActive` is a no-payload oclif call. + */ +export interface ProviderGetActiveRequest { + cli_metadata?: CliMetadata +} + export interface ProviderGetActiveResponse { activeModel?: string activeProviderId: string @@ -55,6 +75,7 @@ export interface ProviderGetActiveResponse { } export interface ProviderSetActiveRequest { + cli_metadata?: CliMetadata providerId: string } @@ -66,6 +87,7 @@ export interface ProviderSetActiveResponse { // ==================== OAuth Events ==================== export interface ProviderCancelOAuthRequest { + cli_metadata?: CliMetadata providerId: string } @@ -74,6 +96,7 @@ export interface ProviderCancelOAuthResponse { } export interface ProviderStartOAuthRequest { + cli_metadata?: CliMetadata mode?: string providerId: string } @@ -86,6 +109,7 @@ export interface ProviderStartOAuthResponse { } export interface ProviderAwaitOAuthCallbackRequest { + cli_metadata?: CliMetadata providerId: string } @@ -95,6 +119,7 @@ export interface ProviderAwaitOAuthCallbackResponse { } export interface ProviderSubmitOAuthCodeRequest { + cli_metadata?: CliMetadata code: string providerId: string } diff --git a/src/shared/transport/events/pull-events.ts b/src/shared/transport/events/pull-events.ts index 51d6f37aa..afccf7ba2 100644 --- a/src/shared/transport/events/pull-events.ts +++ b/src/shared/transport/events/pull-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const PullEvents = { EXECUTE: 'pull:execute', PREPARE: 'pull:prepare', @@ -6,6 +9,7 @@ export const PullEvents = { export interface PullPrepareRequest { branch: string + cli_metadata?: CliMetadata } export interface PullPrepareResponse { @@ -15,6 +19,7 @@ export interface PullPrepareResponse { export interface PullExecuteRequest { branch: string + cli_metadata?: CliMetadata } export interface PullExecuteResponse { diff --git a/src/shared/transport/events/push-events.ts b/src/shared/transport/events/push-events.ts index 52d5ac279..cbd227877 100644 --- a/src/shared/transport/events/push-events.ts +++ b/src/shared/transport/events/push-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const PushEvents = { EXECUTE: 'push:execute', PREPARE: 'push:prepare', @@ -6,6 +9,7 @@ export const PushEvents = { export interface PushPrepareRequest { branch: string + cli_metadata?: CliMetadata } export interface PushPrepareResponse { @@ -22,6 +26,7 @@ export interface PushPrepareResponse { export interface PushExecuteRequest { branch: string + cli_metadata?: CliMetadata } export interface PushExecuteResponse { diff --git a/src/shared/transport/events/review-events.ts b/src/shared/transport/events/review-events.ts index a1b29072b..20f19dac2 100644 --- a/src/shared/transport/events/review-events.ts +++ b/src/shared/transport/events/review-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const ReviewEvents = { DECIDE_TASK: 'review:decideTask', GET_DISABLED: 'review:getDisabled', @@ -11,6 +14,7 @@ export interface ReviewGetDisabledResponse { } export interface ReviewSetDisabledRequest { + cli_metadata?: CliMetadata reviewDisabled: boolean } @@ -25,6 +29,7 @@ export interface ReviewNotifyEvent { } export interface ReviewDecideTaskRequest { + cli_metadata?: CliMetadata decision: 'approved' | 'rejected' /** When provided, only operations targeting these context-tree-relative paths are affected. */ filePaths?: string[] diff --git a/src/shared/transport/events/source-events.ts b/src/shared/transport/events/source-events.ts index bd0848d5e..d535e73bf 100644 --- a/src/shared/transport/events/source-events.ts +++ b/src/shared/transport/events/source-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const SourceEvents = { ADD: 'source:add', LIST: 'source:list', @@ -6,6 +9,7 @@ export const SourceEvents = { export interface SourceAddRequest { alias?: string + cli_metadata?: CliMetadata targetPath: string } @@ -16,6 +20,7 @@ export interface SourceAddResponse { export interface SourceRemoveRequest { aliasOrPath: string + cli_metadata?: CliMetadata } export interface SourceRemoveResponse { @@ -23,7 +28,15 @@ export interface SourceRemoveResponse { success: boolean } -export type SourceListRequest = void +/** + * M13.2 — `SourceListRequest` upgraded from `void` to an interface with optional + * `cli_metadata` so client-side callers can attach invocation metadata. The + * field stays optional, so existing daemon-internal call sites that pass + * nothing continue to work over the wire. + */ +export interface SourceListRequest { + cli_metadata?: CliMetadata +} export interface SourceListResponse { error?: string diff --git a/src/shared/transport/events/space-events.ts b/src/shared/transport/events/space-events.ts index f2b86418a..c89596ec6 100644 --- a/src/shared/transport/events/space-events.ts +++ b/src/shared/transport/events/space-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {BrvConfigDTO, SpaceDTO} from '../types/dto.js' export const SpaceEvents = { @@ -16,6 +18,7 @@ export interface SpaceListResponse { } export interface SpaceSwitchRequest { + cli_metadata?: CliMetadata spaceId: string } diff --git a/src/shared/transport/events/status-events.ts b/src/shared/transport/events/status-events.ts index cc2289c0e..12734a736 100644 --- a/src/shared/transport/events/status-events.ts +++ b/src/shared/transport/events/status-events.ts @@ -1,3 +1,5 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' import type {StatusDTO} from '../types/dto.js' export const StatusEvents = { @@ -5,6 +7,7 @@ export const StatusEvents = { } as const export interface StatusGetRequest { + cli_metadata?: CliMetadata cwd?: string projectRootFlag?: string verbose?: boolean diff --git a/src/shared/transport/events/task-events.ts b/src/shared/transport/events/task-events.ts index 5d09c19f6..f098f948e 100644 --- a/src/shared/transport/events/task-events.ts +++ b/src/shared/transport/events/task-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + /** * Persisted-entry schema version. Bumped only on shape-breaking changes to * `TaskHistoryEntry`. The Zod schema in `server/core/domain/entities/` uses @@ -23,6 +26,7 @@ export const TaskEvents = { } as const export interface TaskCreateRequest { + cli_metadata?: CliMetadata clientCwd?: string content: string files?: string[] @@ -38,6 +42,7 @@ export interface TaskAckResponse { } export interface TaskCancelRequest { + cli_metadata?: CliMetadata taskId: string } @@ -107,6 +112,7 @@ export interface TaskListItem { * All filter dims are optional; AND-combined when multiple are set. */ export interface TaskListRequest { + cli_metadata?: CliMetadata /** createdAt >= this epoch ms */ createdAfter?: number /** createdAt <= this epoch ms */ @@ -175,6 +181,7 @@ export interface TaskListResponse { } export type TaskClearCompletedRequest = { + cli_metadata?: CliMetadata projectPath?: string } @@ -184,6 +191,7 @@ export type TaskClearCompletedResponse = { } export type TaskDeleteBulkRequest = { + cli_metadata?: CliMetadata taskIds: string[] } @@ -193,6 +201,7 @@ export type TaskDeleteBulkResponse = { } export type TaskDeleteRequest = { + cli_metadata?: CliMetadata taskId: string } @@ -212,6 +221,7 @@ export type TaskDeletedEvent = { } export type TaskGetRequest = { + cli_metadata?: CliMetadata taskId: string } diff --git a/src/shared/transport/events/vc-events.ts b/src/shared/transport/events/vc-events.ts index ef33b328e..078ac6985 100644 --- a/src/shared/transport/events/vc-events.ts +++ b/src/shared/transport/events/vc-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const VcErrorCode = { ALREADY_INITIALIZED: 'ERR_VC_ALREADY_INITIALIZED', AUTH_FAILED: 'ERR_VC_AUTH_FAILED', @@ -60,11 +63,29 @@ export const VcEvents = { STATUS: 'vc:status', } as const +/** + * M13.2 Group C — `vc:init` is a no-payload oclif call today (`onRequest` + * registered at `vc-handler.ts:198`). Create the Request interface here so oclif can + * attach `cli_metadata`. Daemon's handler-side type-parameter update is out of M13 + * scope (deferred emit ticket). + */ +export interface IVcInitRequest { + cli_metadata?: CliMetadata +} + export interface IVcInitResponse { gitDir: string reinitialized: boolean } +/** + * M13.2 Group C — `vc:status` is a no-payload oclif call. Create matching Request + * interface so M13.3's oclif sweep can attach `cli_metadata`. + */ +export interface IVcStatusRequest { + cli_metadata?: CliMetadata +} + export interface IVcStatusResponse { ahead?: number behind?: number @@ -81,6 +102,7 @@ export interface IVcStatusResponse { } export interface IVcAddRequest { + cli_metadata?: CliMetadata filePaths?: string[] } @@ -89,6 +111,7 @@ export interface IVcAddResponse { } export interface IVcCommitRequest { + cli_metadata?: CliMetadata message: string } @@ -106,6 +129,7 @@ export function isVcConfigKey(key: string): key is VcConfigKey { } export interface IVcConfigRequest { + cli_metadata?: CliMetadata key: VcConfigKey value?: string } @@ -117,6 +141,7 @@ export interface IVcConfigResponse { export interface IVcPushRequest { branch?: string + cli_metadata?: CliMetadata setUpstream?: boolean } @@ -127,6 +152,7 @@ export interface IVcPushResponse { } export interface IVcFetchRequest { + cli_metadata?: CliMetadata ref?: string remote?: string } @@ -138,6 +164,7 @@ export interface IVcFetchResponse { export interface IVcPullRequest { allowUnrelatedHistories?: boolean branch?: string + cli_metadata?: CliMetadata remote?: string } @@ -149,6 +176,7 @@ export interface IVcPullResponse { export interface IVcLogRequest { all?: boolean + cli_metadata?: CliMetadata limit?: number ref?: string } @@ -187,6 +215,7 @@ export function isVcRemoteSubcommand(value: string): value is VcRemoteSubcommand } export interface IVcRemoteRequest { + cli_metadata?: CliMetadata subcommand: VcRemoteSubcommand url?: string } @@ -197,8 +226,8 @@ export interface IVcRemoteResponse { } export type IVcCloneRequest = - | {spaceId: string; spaceName: string; teamId: string; teamName: string; url?: never} - | {spaceId?: string; spaceName?: string; teamId?: string; teamName?: string; url: string} + | {cli_metadata?: CliMetadata; spaceId: string; spaceName: string; teamId: string; teamName: string; url?: never} + | {cli_metadata?: CliMetadata; spaceId?: string; spaceName?: string; teamId?: string; teamName?: string; url: string} export interface IVcCloneResponse { gitDir: string @@ -214,10 +243,10 @@ export interface IVcCloneProgressEvent { export type VcBranchAction = 'create' | 'delete' | 'list' | 'set-upstream' export type IVcBranchRequest = - | {action: 'create'; name: string; startPoint?: string} - | {action: 'delete'; name: string} - | {action: 'list'; all?: boolean} - | {action: 'set-upstream'; upstream: string} + | {action: 'create'; cli_metadata?: CliMetadata; name: string; startPoint?: string} + | {action: 'delete'; cli_metadata?: CliMetadata; name: string} + | {action: 'list'; all?: boolean; cli_metadata?: CliMetadata} + | {action: 'set-upstream'; cli_metadata?: CliMetadata; upstream: string} export interface VcBranch { isCurrent: boolean @@ -233,6 +262,7 @@ export type IVcBranchResponse = export interface IVcCheckoutRequest { branch: string + cli_metadata?: CliMetadata create?: boolean force?: boolean /** Ref to create the new branch from when `create` is true. Ignored otherwise. */ @@ -251,6 +281,7 @@ export interface IVcMergeRequest { action: VcMergeAction allowUnrelatedHistories?: boolean branch?: string + cli_metadata?: CliMetadata message?: string } @@ -265,6 +296,7 @@ export interface IVcMergeResponse { export type VcResetMode = 'hard' | 'mixed' | 'soft' export interface IVcResetRequest { + cli_metadata?: CliMetadata filePaths?: string[] mode?: VcResetMode ref?: string @@ -284,6 +316,7 @@ export interface IVcResetResponse { export type VcDiffSide = 'staged' | 'unstaged' export interface IVcDiffRequest { + cli_metadata?: CliMetadata path: string side: VcDiffSide } @@ -306,7 +339,9 @@ export interface IVcDiffResponse { * * The union form guarantees callers can't accidentally mix the two shapes (type error). */ -export type IVcDiffsRequest = {mode: VcDiffMode} | {paths: string[]; side: VcDiffSide} +export type IVcDiffsRequest = + | {cli_metadata?: CliMetadata; mode: VcDiffMode} + | {cli_metadata?: CliMetadata; paths: string[]; side: VcDiffSide} /** * Diff modes for `brv vc diff` / `/vc diff`. Mirrors the four diff modes from `git diff`: @@ -357,6 +392,7 @@ export interface IVcDiffsResponse { * Staged changes in the index are preserved. */ export interface IVcDiscardRequest { + cli_metadata?: CliMetadata filePaths: string[] } diff --git a/src/shared/transport/events/worktree-events.ts b/src/shared/transport/events/worktree-events.ts index 325dd7889..e55fbe8b0 100644 --- a/src/shared/transport/events/worktree-events.ts +++ b/src/shared/transport/events/worktree-events.ts @@ -1,3 +1,6 @@ + +import type {CliMetadata} from '../../analytics/cli-metadata-schema.js' + export const WorktreeEvents = { ADD: 'worktree:add', LIST: 'worktree:list', @@ -5,6 +8,7 @@ export const WorktreeEvents = { } as const export interface WorktreeAddRequest { + cli_metadata?: CliMetadata force?: boolean worktreePath: string } @@ -16,6 +20,7 @@ export interface WorktreeAddResponse { } export interface WorktreeRemoveRequest { + cli_metadata?: CliMetadata worktreePath: string } @@ -24,7 +29,14 @@ export interface WorktreeRemoveResponse { success: boolean } -export type WorktreeListRequest = void +/** + * M13.2 — `WorktreeListRequest` upgraded from `void` to an interface with + * optional `cli_metadata` so client-side callers can attach invocation + * metadata. Field stays optional, so wire-level back-compat is preserved. + */ +export interface WorktreeListRequest { + cli_metadata?: CliMetadata +} export interface WorktreeListResponse { projectRoot: string diff --git a/test/unit/core/domain/transport/schemas.test.ts b/test/unit/core/domain/transport/schemas.test.ts index f4d9e7bb3..b0e477503 100644 --- a/test/unit/core/domain/transport/schemas.test.ts +++ b/test/unit/core/domain/transport/schemas.test.ts @@ -1,7 +1,16 @@ /* eslint-disable camelcase */ +import type {z} from 'zod' + import {expect} from 'chai' import { + AgentNewSessionRequestSchema, + AgentRestartRequestSchema, + SessionCreateRequestSchema, + SessionInfoRequestSchema, + SessionListRequestSchema, + SessionSwitchRequestSchema, + TaskCancelRequestSchema, TaskClearCompletedRequestSchema, TaskCreatedSchema, TaskCreateRequestSchema, @@ -325,4 +334,59 @@ describe('task transport schemas', () => { } }) }) + + // ============================================================================ + // M13.2 — parametric cli_metadata round-trip across every Group A schema + // ============================================================================ + + describe('cli_metadata round-trip across Group A request schemas (M13.2)', () => { + const validCliMeta = { + client_sent_at: 1_700_000_000_000, + command_id: 'test', + flag_names: [], + is_ci: false, + is_tty: true, + package_manager: 'npm' as const, + runtime: 'node' as const, + } + + type SchemaSpec = { + base: Record + name: string + schema: z.ZodTypeAny + } + + const schemaSpecs: readonly SchemaSpec[] = [ + {base: {taskId: 'tid-1'}, name: 'TaskCancelRequestSchema', schema: TaskCancelRequestSchema}, + {base: {}, name: 'TaskListRequestSchema', schema: TaskListRequestSchema}, + {base: {taskId: 'tid-1'}, name: 'TaskGetRequestSchema', schema: TaskGetRequestSchema}, + {base: {taskId: 'tid-1'}, name: 'TaskDeleteRequestSchema', schema: TaskDeleteRequestSchema}, + {base: {taskIds: ['tid-1']}, name: 'TaskDeleteBulkRequestSchema', schema: TaskDeleteBulkRequestSchema}, + {base: {}, name: 'TaskClearCompletedRequestSchema', schema: TaskClearCompletedRequestSchema}, + {base: {}, name: 'SessionInfoRequestSchema', schema: SessionInfoRequestSchema}, + {base: {}, name: 'SessionListRequestSchema', schema: SessionListRequestSchema}, + {base: {}, name: 'SessionCreateRequestSchema', schema: SessionCreateRequestSchema}, + {base: {sessionId: 'sid-1'}, name: 'SessionSwitchRequestSchema', schema: SessionSwitchRequestSchema}, + {base: {}, name: 'AgentRestartRequestSchema', schema: AgentRestartRequestSchema}, + {base: {}, name: 'AgentNewSessionRequestSchema', schema: AgentNewSessionRequestSchema}, + ] + + for (const spec of schemaSpecs) { + describe(spec.name, () => { + it('accepts payload without cli_metadata (back-compat)', () => { + expect(spec.schema.safeParse(spec.base).success).to.equal(true) + }) + + it('accepts payload with valid cli_metadata block', () => { + const result = spec.schema.safeParse({...spec.base, cli_metadata: validCliMeta}) + expect(result.success).to.equal(true) + }) + + it('rejects payload with structurally invalid cli_metadata (strict bubbles up)', () => { + const malformed = {...validCliMeta, runtime: 'deno'} + expect(spec.schema.safeParse({...spec.base, cli_metadata: malformed}).success).to.equal(false) + }) + }) + } + }) }) From cc14a76b76a40be7ab297606d6f21dc8f5d03882 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 13 May 2026 20:58:09 +0700 Subject: [PATCH 34/87] =?UTF-8?q?feat:=20[ENG-2807]=20M13.3=20batch=201=20?= =?UTF-8?q?=E2=80=94=20attach=20cli=5Fmetadata=20to=204=20top-level=20ocli?= =?UTF-8?q?f=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep batch 1 (top-level, no topic groups yet): - locations.ts → LocationsGetRequest carries cli_metadata - status.ts → StatusGetRequest carries cli_metadata - logout.ts → AuthLogoutRequest carries cli_metadata - search.ts → task:create taskPayload spreads cli_metadata Each command calls buildCliMetadata(this.id, flags) once at run() top and threads the resulting cliMetadata through helper methods. No emit added, no daemon code touched — pure payload attachment under the narrowed M13 scope. Test updates: - locations.test.ts, logout.test.ts, status.test.ts — Testable*Command subclass overrides updated to thread cliMetadata through to super. - logout.test.ts — "should send correct event" assertion updated to expect the cli_metadata payload (was previously expecting empty payload). Remaining top-level commands (batch 2): debug, dream, init, login, pull, push, review, webui. Topic groups (vc, hub, source, worktree, space, providers, model, connectors, review, analytics, query-log, swarm) deferred to subsequent batches. Boundary check: grep src/oclif/ src/agent/ for analyticsClient.track or emitAnalytics returns empty. --- src/oclif/commands/locations.ts | 22 ++++++++++++++++++---- src/oclif/commands/logout.ts | 21 +++++++++++++++++---- src/oclif/commands/search.ts | 9 ++++++++- src/oclif/commands/status.ts | 16 +++++++++++++--- test/commands/locations.test.ts | 7 +++++-- test/commands/logout.test.ts | 17 ++++++++++++----- test/commands/status.test.ts | 5 +++-- 7 files changed, 76 insertions(+), 21 deletions(-) diff --git a/src/oclif/commands/locations.ts b/src/oclif/commands/locations.ts index 954eef96b..e24f7cc18 100644 --- a/src/oclif/commands/locations.ts +++ b/src/oclif/commands/locations.ts @@ -1,9 +1,15 @@ +/* eslint-disable camelcase */ import {Command, Flags} from '@oclif/core' import chalk from 'chalk' import type {ProjectLocationDTO} from '../../shared/transport/types/dto.js' -import {LocationsEvents, type LocationsGetResponse} from '../../shared/transport/events/locations-events.js' +import { + LocationsEvents, + type LocationsGetRequest, + type LocationsGetResponse, +} from '../../shared/transport/events/locations-events.js' +import {buildCliMetadata} from '../lib/build-cli-metadata.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../lib/daemon-client.js' import {writeJsonResponse} from '../lib/json-response.js' @@ -19,9 +25,16 @@ export default class Locations extends Command { }), } - protected async fetchLocations(options?: DaemonClientOptions): Promise { + protected async fetchLocations( + cliMetadata: ReturnType, + options?: DaemonClientOptions, + ): Promise { return withDaemonRetry(async (client) => { - const response = await client.requestWithAck(LocationsEvents.GET) + const request: LocationsGetRequest = {cli_metadata: cliMetadata} + const response = await client.requestWithAck( + LocationsEvents.GET, + request, + ) return response.locations }, options) } @@ -29,9 +42,10 @@ export default class Locations extends Command { public async run(): Promise { const {flags} = await this.parse(Locations) const isJson = flags.format === 'json' + const cliMetadata = buildCliMetadata(this.id ?? 'locations', flags) try { - const locations = await this.fetchLocations({projectPath: process.cwd()}) + const locations = await this.fetchLocations(cliMetadata, {projectPath: process.cwd()}) if (isJson) { writeJsonResponse({command: 'locations', data: {locations}, success: true}) diff --git a/src/oclif/commands/logout.ts b/src/oclif/commands/logout.ts index ba94b49d8..58f4fe4f0 100644 --- a/src/oclif/commands/logout.ts +++ b/src/oclif/commands/logout.ts @@ -1,6 +1,12 @@ +/* eslint-disable camelcase */ import {Command, Flags} from '@oclif/core' -import {AuthEvents, type AuthLogoutResponse} from '../../shared/transport/events/auth-events.js' +import { + AuthEvents, + type AuthLogoutRequest, + type AuthLogoutResponse, +} from '../../shared/transport/events/auth-events.js' +import {buildCliMetadata} from '../lib/build-cli-metadata.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../lib/daemon-client.js' import {writeJsonResponse} from '../lib/json-response.js' @@ -20,9 +26,15 @@ export default class Logout extends Command { }), } - protected async performLogout(options?: DaemonClientOptions): Promise { + protected async performLogout( + cliMetadata: ReturnType, + options?: DaemonClientOptions, + ): Promise { return withDaemonRetry( - async (client) => client.requestWithAck(AuthEvents.LOGOUT), + async (client) => { + const request: AuthLogoutRequest = {cli_metadata: cliMetadata} + return client.requestWithAck(AuthEvents.LOGOUT, request) + }, options, ) } @@ -30,13 +42,14 @@ export default class Logout extends Command { public async run(): Promise { const {flags} = await this.parse(Logout) const format = flags.format ?? 'text' + const cliMetadata = buildCliMetadata(this.id ?? 'logout', flags) try { if (format === 'text') { this.log('Logging out...') } - const response = await this.performLogout() + const response = await this.performLogout(cliMetadata) if (response.success) { if (format === 'json') { diff --git a/src/oclif/commands/search.ts b/src/oclif/commands/search.ts index 389fb1938..7531ea348 100644 --- a/src/oclif/commands/search.ts +++ b/src/oclif/commands/search.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import type {ITransportClient, TaskAck} from '@campfirein/brv-transport-client' import {Args, Command, Flags} from '@oclif/core' @@ -7,6 +8,7 @@ import type {SearchKnowledgeResult} from '../../agent/infra/sandbox/tools-sdk.js import {TaskEvents} from '../../shared/transport/events/index.js' import {encodeSearchContent} from '../../shared/transport/search-content.js' +import {buildCliMetadata} from '../lib/build-cli-metadata.js' import { type DaemonClientOptions, formatConnectionError, @@ -69,12 +71,15 @@ Use "brv query" when you need a synthesized answer.` if (!this.validateInput(args.query, format)) return + const cliMetadata = buildCliMetadata(this.id ?? 'search', flags) + try { await withDaemonRetry( async (client, projectRoot, worktreeRoot) => { // No provider validation — search is pure BM25, no LLM needed. await this.submitTask({ client, + cliMetadata, format, limit: flags.limit, projectRoot, @@ -114,6 +119,7 @@ Use "brv query" when you need a synthesized answer.` private async submitTask(props: { client: ITransportClient + cliMetadata: ReturnType format: 'json' | 'text' limit?: number projectRoot?: string @@ -121,12 +127,13 @@ Use "brv query" when you need a synthesized answer.` scope?: string worktreeRoot?: string }): Promise { - const {client, format, projectRoot, query, worktreeRoot} = props + const {client, cliMetadata, format, projectRoot, query, worktreeRoot} = props const taskId = randomUUID() const contentPayload = encodeSearchContent({limit: props.limit, query, scope: props.scope}) const taskPayload = { + cli_metadata: cliMetadata, clientCwd: process.cwd(), content: contentPayload, ...(projectRoot ? {projectPath: projectRoot} : {}), diff --git a/src/oclif/commands/status.ts b/src/oclif/commands/status.ts index 6f98433df..8259123e3 100644 --- a/src/oclif/commands/status.ts +++ b/src/oclif/commands/status.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import {Command, Flags} from '@oclif/core' import chalk from 'chalk' @@ -8,6 +9,7 @@ import { type StatusGetRequest, type StatusGetResponse, } from '../../shared/transport/events/status-events.js' +import {buildCliMetadata} from '../lib/build-cli-metadata.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../lib/daemon-client.js' import {writeJsonResponse} from '../lib/json-response.js' @@ -33,8 +35,15 @@ export default class Status extends Command { }), } - protected async fetchStatus(options?: DaemonClientOptions & {projectRootFlag?: string}): Promise { - const request: StatusGetRequest = {cwd: process.cwd(), projectRootFlag: options?.projectRootFlag} + protected async fetchStatus( + cliMetadata: ReturnType, + options?: DaemonClientOptions & {projectRootFlag?: string}, + ): Promise { + const request: StatusGetRequest = { + cli_metadata: cliMetadata, + cwd: process.cwd(), + projectRootFlag: options?.projectRootFlag, + } return withDaemonRetry(async (client) => { const response = await client.requestWithAck(StatusEvents.GET, request) return response.status @@ -45,9 +54,10 @@ export default class Status extends Command { const {flags} = await this.parse(Status) const projectRootFlag = flags['project-root'] const isJson = flags.format === 'json' + const cliMetadata = buildCliMetadata(this.id ?? 'status', flags) try { - const status = await this.fetchStatus({projectPath: process.cwd(), projectRootFlag}) + const status = await this.fetchStatus(cliMetadata, {projectPath: process.cwd(), projectRootFlag}) if (isJson) { writeJsonResponse({ diff --git a/test/commands/locations.test.ts b/test/commands/locations.test.ts index 3602be4f4..aa7596a8a 100644 --- a/test/commands/locations.test.ts +++ b/test/commands/locations.test.ts @@ -9,6 +9,7 @@ import sinon, {restore, stub} from 'sinon' import type {ProjectLocationDTO} from '../../src/shared/transport/types/dto.js' import Locations from '../../src/oclif/commands/locations.js' +import {buildCliMetadata} from '../../src/oclif/lib/build-cli-metadata.js' // ==================== TestableLocationsCommand ==================== @@ -20,8 +21,10 @@ class TestableLocationsCommand extends Locations { this.mockConnector = mockConnector } - protected override async fetchLocations(): Promise { - return super.fetchLocations({ + protected override async fetchLocations( + cliMetadata: ReturnType, + ): Promise { + return super.fetchLocations(cliMetadata, { maxRetries: 1, retryDelayMs: 0, transportConnector: this.mockConnector, diff --git a/test/commands/logout.test.ts b/test/commands/logout.test.ts index 702b36673..07bc7dcf7 100644 --- a/test/commands/logout.test.ts +++ b/test/commands/logout.test.ts @@ -9,6 +9,7 @@ import sinon, {restore, stub} from 'sinon' import type {AuthLogoutResponse} from '../../src/shared/transport/events/auth-events.js' import Logout from '../../src/oclif/commands/logout.js' +import {buildCliMetadata} from '../../src/oclif/lib/build-cli-metadata.js' import {AuthEvents} from '../../src/shared/transport/events/auth-events.js' // ==================== TestableLogoutCommand ==================== @@ -21,8 +22,10 @@ class TestableLogoutCommand extends Logout { this.mockConnector = mockConnector } - protected override async performLogout(): Promise { - return super.performLogout({ + protected override async performLogout( + cliMetadata: ReturnType, + ): Promise { + return super.performLogout(cliMetadata, { maxRetries: 1, retryDelayMs: 0, transportConnector: this.mockConnector, @@ -118,15 +121,19 @@ describe('Logout Command', () => { expect(loggedMessages.some((m) => m.includes('Logged out successfully'))).to.be.true }) - it('should send correct event to transport handler', async () => { + it('should send correct event to transport handler with cli_metadata payload', async () => { mockLogoutResponse({success: true}) await createCommand().run() expect(mockClient.requestWithAck.calledOnce).to.be.true - const [event, ...rest] = mockClient.requestWithAck.firstCall.args + const [event, payload] = mockClient.requestWithAck.firstCall.args expect(event).to.equal(AuthEvents.LOGOUT) - expect(rest).to.deep.equal([]) + // M13.3: oclif now attaches cli_metadata to every daemon-bound payload. + // The cli_metadata block is structurally validated by CliMetadataSchema in the daemon; + // here we just assert it is present with the expected shape (command_id is the most stable key). + expect(payload).to.be.an('object') + expect((payload as {cli_metadata: {command_id: string}}).cli_metadata.command_id).to.equal('logout') }) }) diff --git a/test/commands/status.test.ts b/test/commands/status.test.ts index 9039095c0..e1661bea8 100644 --- a/test/commands/status.test.ts +++ b/test/commands/status.test.ts @@ -12,6 +12,7 @@ import sinon, {restore, stub} from 'sinon' import type {StatusDTO} from '../../src/shared/transport/types/dto.js' import Status from '../../src/oclif/commands/status.js' +import {buildCliMetadata} from '../../src/oclif/lib/build-cli-metadata.js' // ==================== TestableStatusCommand ==================== @@ -23,8 +24,8 @@ class TestableStatusCommand extends Status { this.mockConnector = mockConnector } - protected override async fetchStatus(): Promise { - return super.fetchStatus({ + protected override async fetchStatus(cliMetadata: ReturnType): Promise { + return super.fetchStatus(cliMetadata, { maxRetries: 1, retryDelayMs: 0, transportConnector: this.mockConnector, From 62dd16916f363589ae46ded7bed66a063463f8cc Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 13 May 2026 21:07:14 +0700 Subject: [PATCH 35/87] =?UTF-8?q?feat:=20[ENG-2807]=20M13.3=20batch=202=20?= =?UTF-8?q?=E2=80=94=20attach=20cli=5Fmetadata=20to=20dream=20+=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dream.ts → task:create taskPayload spreads cli_metadata - login.ts → AuthStartLogin + AuthLoginWithApiKey requests carry cli_metadata Both commands now call buildCliMetadata(this.id, flags) once at run() and thread cliMetadata through helpers. No emit added. login.test.ts — TestableLoginCommand subclass + "should send api key" assertion updated for the new cli_metadata-carrying payload. Remaining top-level commands: debug, init, pull, push, review, webui. Note: init.ts has an early `return` that makes its daemon code path unreachable today — deferred until init is unhidden. Boundary check: grep src/oclif/ src/agent/ for analyticsClient.track or emitAnalytics returns empty. --- src/oclif/commands/dream.ts | 8 +++++++- src/oclif/commands/login.ts | 38 ++++++++++++++++++++++++++++--------- test/commands/login.test.ts | 21 ++++++++++++++------ 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/oclif/commands/dream.ts b/src/oclif/commands/dream.ts index b6f2acb4e..f774e2b03 100644 --- a/src/oclif/commands/dream.ts +++ b/src/oclif/commands/dream.ts @@ -21,7 +21,9 @@ import {resolveProject} from '../../server/infra/project/resolve-project.js' import {FileCurateLogStore} from '../../server/infra/storage/file-curate-log-store.js' import {FileReviewBackupStore} from '../../server/infra/storage/file-review-backup-store.js' import {getProjectDataDir} from '../../server/utils/path-utils.js' +/* eslint-disable camelcase */ import {TaskEvents} from '../../shared/transport/events/index.js' +import {buildCliMetadata} from '../lib/build-cli-metadata.js' import { type DaemonClientOptions, formatConnectionError, @@ -122,6 +124,7 @@ export default class Dream extends Command { return } + const cliMetadata = buildCliMetadata(this.id ?? 'dream', rawFlags) let providerContext: ProviderErrorContext | undefined try { @@ -144,6 +147,7 @@ export default class Dream extends Command { await this.submitTask({ client, + cliMetadata, detach: rawFlags.detach, force: rawFlags.force, format, @@ -217,6 +221,7 @@ export default class Dream extends Command { private async submitTask(props: { client: ITransportClient + cliMetadata: ReturnType detach: boolean force: boolean format: 'json' | 'text' @@ -224,9 +229,10 @@ export default class Dream extends Command { timeout: number worktreeRoot?: string }): Promise { - const {client, detach, force, format, projectRoot, timeout, worktreeRoot} = props + const {client, cliMetadata, detach, force, format, projectRoot, timeout, worktreeRoot} = props const taskId = randomUUID() const taskPayload = { + cli_metadata: cliMetadata, content: force ? 'Memory consolidation (force)' : 'Memory consolidation', ...(force ? {force: true} : {}), ...(projectRoot ? {projectPath: projectRoot} : {}), diff --git a/src/oclif/commands/login.ts b/src/oclif/commands/login.ts index c50a42b3c..d35961084 100644 --- a/src/oclif/commands/login.ts +++ b/src/oclif/commands/login.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import {Command, Flags} from '@oclif/core' import { @@ -6,6 +7,7 @@ import { type AuthLoginWithApiKeyResponse, type AuthStartLoginResponse, } from '../../shared/transport/events/auth-events.js' +import {buildCliMetadata} from '../lib/build-cli-metadata.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../lib/daemon-client.js' import {writeJsonResponse} from '../lib/json-response.js' @@ -54,14 +56,25 @@ export default class Login extends Command { return true } - protected async loginWithApiKey(apiKey: string, options?: DaemonClientOptions): Promise { + protected async loginWithApiKey( + apiKey: string, + cliMetadata: ReturnType, + options?: DaemonClientOptions, + ): Promise { return withDaemonRetry( - async (client) => client.requestWithAck(AuthEvents.LOGIN_WITH_API_KEY, {apiKey}), + async (client) => + client.requestWithAck(AuthEvents.LOGIN_WITH_API_KEY, { + apiKey, + cli_metadata: cliMetadata, + }), options, ) } - protected async loginWithOAuth(options?: LoginOAuthOptions): Promise { + protected async loginWithOAuth( + cliMetadata: ReturnType, + options?: LoginOAuthOptions, + ): Promise { const timeoutMs = options?.oauthTimeoutMs ?? DEFAULT_OAUTH_TIMEOUT_MS return withDaemonRetry(async (client) => { @@ -87,7 +100,9 @@ export default class Login extends Command { }) try { - const startResponse = await client.requestWithAck(AuthEvents.START_LOGIN) + const startResponse = await client.requestWithAck(AuthEvents.START_LOGIN, { + cli_metadata: cliMetadata, + }) options?.onAuthUrl?.(startResponse.authUrl) return await completion @@ -107,6 +122,7 @@ export default class Login extends Command { const {flags} = await this.parse(Login) const apiKey = flags['api-key'] const format: OutputFormat = flags.format === 'json' ? 'json' : 'text' + const cliMetadata = buildCliMetadata(this.id ?? 'login', flags) if (!apiKey && !this.canOpenBrowser()) { this.emitError( @@ -117,7 +133,7 @@ export default class Login extends Command { } try { - await (apiKey ? this.runApiKey(apiKey, format) : this.runOAuth(format)) + await (apiKey ? this.runApiKey(apiKey, format, cliMetadata) : this.runOAuth(format, cliMetadata)) } catch (error) { const message = formatConnectionError(error) if (format === 'json') { @@ -144,12 +160,16 @@ export default class Login extends Command { } } - private async runApiKey(apiKey: string, format: OutputFormat): Promise { + private async runApiKey( + apiKey: string, + format: OutputFormat, + cliMetadata: ReturnType, + ): Promise { if (format === 'text') { this.log('Logging in...') } - const response = await this.loginWithApiKey(apiKey) + const response = await this.loginWithApiKey(apiKey, cliMetadata) if (response.success) { this.emitSuccess(format, response.userEmail) @@ -158,7 +178,7 @@ export default class Login extends Command { } } - private async runOAuth(format: OutputFormat): Promise { + private async runOAuth(format: OutputFormat, cliMetadata: ReturnType): Promise { const onAuthUrl = (authUrl: string): void => { if (format === 'text') { this.log('Opening browser for authentication...') @@ -166,7 +186,7 @@ export default class Login extends Command { } } - const result = await this.loginWithOAuth({onAuthUrl}) + const result = await this.loginWithOAuth(cliMetadata, {onAuthUrl}) if (result.success) { this.emitSuccess(format, result.user?.email) diff --git a/test/commands/login.test.ts b/test/commands/login.test.ts index 920996bb8..1bdc12187 100644 --- a/test/commands/login.test.ts +++ b/test/commands/login.test.ts @@ -7,6 +7,7 @@ import {expect} from 'chai' import sinon, {restore, stub} from 'sinon' import Login, {type LoginOAuthOptions} from '../../src/oclif/commands/login.js' +import {buildCliMetadata} from '../../src/oclif/lib/build-cli-metadata.js' import { AuthEvents, type AuthLoginCompletedEvent, @@ -29,16 +30,22 @@ class TestableLoginCommand extends Login { return this.browserAvailable } - protected override async loginWithApiKey(apiKey: string): Promise { - return super.loginWithApiKey(apiKey, { + protected override async loginWithApiKey( + apiKey: string, + cliMetadata: ReturnType, + ): Promise { + return super.loginWithApiKey(apiKey, cliMetadata, { maxRetries: 1, retryDelayMs: 0, transportConnector: this.mockConnector, }) } - protected override async loginWithOAuth(options?: LoginOAuthOptions): Promise { - return super.loginWithOAuth({ + protected override async loginWithOAuth( + cliMetadata: ReturnType, + options?: LoginOAuthOptions, + ): Promise { + return super.loginWithOAuth(cliMetadata, { ...options, maxRetries: 1, oauthTimeoutMs: 100, @@ -159,7 +166,7 @@ describe('Login Command', () => { expect(loggedMessages.some((m) => m.includes('Logged in as user@example.com'))).to.be.true }) - it('should send api key to transport handler', async () => { + it('should send api key + cli_metadata to transport handler', async () => { mockLoginResponse({success: true, userEmail: 'user@example.com'}) await createCommand('--api-key', 'my-secret-key').run() @@ -167,7 +174,9 @@ describe('Login Command', () => { expect((mockClient.requestWithAck as sinon.SinonStub).calledOnce).to.be.true const [event, data] = (mockClient.requestWithAck as sinon.SinonStub).firstCall.args expect(event).to.equal(AuthEvents.LOGIN_WITH_API_KEY) - expect(data).to.deep.equal({apiKey: 'my-secret-key'}) + // M13.3: payload now carries optional cli_metadata block alongside apiKey. + expect((data as {apiKey: string}).apiKey).to.equal('my-secret-key') + expect((data as {cli_metadata: {command_id: string}}).cli_metadata.command_id).to.equal('login') }) }) From 4488ee5b9acdfa3a5c35a666f3b94ef3afbe2f70 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Thu, 14 May 2026 11:00:06 +0700 Subject: [PATCH 36/87] fix: typed IAnalyticsClient.track + per-event wire Zod + non-null assertion cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Blocking findings #4 + #5 from the proj/analytics-system branch review. #4 — Typed analytics catalog as the production entry point - Tighten IAnalyticsClient.track to (event: E, ...rest: PropsArg): void. Magic-string typos and wrong-shape payloads now fail tsc. - Share PropsForEvent / PropsArg / isAnalyticsEventName from src/shared/analytics/events/index.ts so the interface, the wire handler, and the future emitAnalytics helper agree on one set of types. - AnalyticsHandler now does per-event Zod validation against ALL_EVENT_SCHEMAS and dispatches via a typed switch covering every entry in AnalyticsEventNames (including deferred scaffolding events). Closes the prior non-blocking finding that the wire path accepted any string + any record. - AnalyticsHook.emit private method is now generic and the buildCurateRunPayload / buildQueryCompletedPayload helpers return the catalog's typed Props directly. - No production emit sites changed shape — brv-server.ts and analytics-hook.ts still call analyticsClient.track with the existing AnalyticsEventNames constants; the change is in what the signature accepts. #5 — Remove unjustified non-null assertions - test/unit/server/core/domain/entities/global-config.test.ts: replace 2x fromTrue!/fromFalse! with expect(...).to.not.be.undefined + if (!x) throw narrowing. - test/unit/infra/transport/handlers/global-config-handler.test.ts: replace 2x fn!(...) with explicit if-narrowing that throws on a missing handler. - brv-server.ts lazy-init ! left alone (documented CLAUDE.md exception). Tests updated: - analytics-client.test.ts / no-op-analytics-client.test.ts / analytics-handler.test.ts switched from string event names to AnalyticsEventNames constants; analytics-handler.test.ts gains coverage for unknown-event drop and invalid-per-event-props drop. - retry-cap + transport integration tests switched to typed catalog event names. Verified: npm run typecheck, npm run lint (0 errors), npm test (8093 passing). --- .../analytics/i-analytics-client.ts | 17 ++- .../infra/analytics/analytics-client.ts | 11 +- .../infra/analytics/no-op-analytics-client.ts | 4 +- src/server/infra/process/analytics-hook.ts | 12 +- .../transport/handlers/analytics-handler.ts | 120 ++++++++++++++- src/shared/analytics/event-names.ts | 6 +- src/shared/analytics/events/index.ts | 41 ++++- test/integration/analytics/retry-cap.test.ts | 7 +- test/integration/analytics/transport.test.ts | 28 +++- .../handlers/global-config-handler.test.ts | 8 +- .../domain/entities/global-config.test.ts | 8 +- .../infra/analytics/analytics-client.test.ts | 100 ++++++------ .../analytics/no-op-analytics-client.test.ts | 45 +++++- .../handlers/analytics-handler.test.ts | 144 +++++++++++++----- .../unit/shared/analytics/event-names.test.ts | 1 - 15 files changed, 409 insertions(+), 143 deletions(-) diff --git a/src/server/core/interfaces/analytics/i-analytics-client.ts b/src/server/core/interfaces/analytics/i-analytics-client.ts index 17205f7ce..ec49e5fcc 100644 --- a/src/server/core/interfaces/analytics/i-analytics-client.ts +++ b/src/server/core/interfaces/analytics/i-analytics-client.ts @@ -1,3 +1,5 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' import type {AnalyticsBatch} from '../../domain/analytics/batch.js' /** @@ -7,9 +9,11 @@ import type {AnalyticsBatch} from '../../domain/analytics/batch.js' * daemon. Implementations are responsible for identity resolution, * super-property stamping, and queueing; consumers just call `track()`. * - * The interface is intentionally minimal so that consumers depend on a - * stable contract while the implementation evolves (e.g. M2.1 ships a - * no-op, M2.5 ships the real client, M4 adds network sends). + * `track()` is typed against the discriminated union catalog + * (`AnyAnalyticsEvent` in `shared/analytics/events/index.ts`): magic-string + * typos and wrong-shape payloads become compile errors. Adding a new event + * requires registering it in the catalog first; emit sites then become + * statically checked. */ export interface IAnalyticsClient { /** @@ -21,6 +25,11 @@ export interface IAnalyticsClient { /** * Records an analytics event. When the analytics flag is disabled the * call must be a true no-op (no allocations, no resolver calls). + * + * The generic `` plus `PropsArg` rest + * tuple force callers to pick a registered event name and supply a + * matching property shape. Events with no required properties (e.g. + * `daemon_start`) allow the properties argument to be omitted. */ - track: (event: string, properties?: Record) => void + track: (event: E, ...rest: PropsArg) => void } diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index 07cd68863..1b5949f54 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -1,5 +1,7 @@ import {randomUUID} from 'node:crypto' +import type {AnalyticsEventName} from '../../../shared/analytics/event-names.js' +import type {PropsArg, PropsForEvent} from '../../../shared/analytics/events/index.js' import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' @@ -91,13 +93,14 @@ export class AnalyticsClient implements IAnalyticsClient { } } - public track(event: string, properties?: Record): void { + public track(event: E, ...rest: PropsArg): void { if (!this.deps.isEnabled()) return // Capture the timestamp synchronously at call-site so it reflects WHEN the // user action happened, not when the async resolver chain settled. Under // burst load (many tracks queued before the first resolver completes) this // preserves the inter-event durations downstream consumers care about. const timestamp = Date.now() + const [properties] = rest // eslint-disable-next-line no-void void this.trackAsync(event, properties, timestamp) } @@ -118,9 +121,9 @@ export class AnalyticsClient implements IAnalyticsClient { return AnalyticsBatch.create(records.map((r) => toWireEvent(r))) } - private async trackAsync( - event: string, - properties: Record | undefined, + private async trackAsync( + event: E, + properties: PropsForEvent | undefined, timestamp: number, ): Promise { try { diff --git a/src/server/infra/analytics/no-op-analytics-client.ts b/src/server/infra/analytics/no-op-analytics-client.ts index 7d20b26d1..ded27ba29 100644 --- a/src/server/infra/analytics/no-op-analytics-client.ts +++ b/src/server/infra/analytics/no-op-analytics-client.ts @@ -1,3 +1,5 @@ +import type {AnalyticsEventName} from '../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../shared/analytics/events/index.js' import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' import {AnalyticsBatch} from '../../core/domain/analytics/batch.js' @@ -13,7 +15,7 @@ export class NoOpAnalyticsClient implements IAnalyticsClient { return AnalyticsBatch.create([]) } - public track(_event: string, _properties?: Record): void { + public track(_event: E, ..._rest: PropsArg): void { // intentional no-op } } diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index 0a85ae1aa..f4f615308 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -1,6 +1,10 @@ /* eslint-disable camelcase */ import {readFileSync} from 'node:fs' +import type {AnalyticsEventName} from '../../../shared/analytics/event-names.js' +import type {CurateRunCompletedProps} from '../../../shared/analytics/events/curate-run-completed.js' +import type {PropsArg} from '../../../shared/analytics/events/index.js' +import type {QueryCompletedProps} from '../../../shared/analytics/events/query-completed.js' import type {LlmToolResultEvent} from '../../core/domain/transport/schemas.js' import type {TaskInfo} from '../../core/domain/transport/task-info.js' import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' @@ -257,7 +261,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { state: CurateTaskAnalyticsState task: TaskInfo taskId: string - }): Record { + }): CurateRunCompletedProps { return { duration_ms: this.durationMs(task), operations_added: state.counters.added, @@ -282,7 +286,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { state: QueryTaskAnalyticsState task: TaskInfo taskId: string - }): Record { + }): QueryCompletedProps { const readPaths = new Set() let readToolCallCount = 0 let searchCallCount = 0 @@ -375,11 +379,11 @@ export class AnalyticsHook implements ITaskLifecycleHook { return Math.max(0, (task.completedAt ?? Date.now()) - task.createdAt) } - private emit(event: string, properties: Record): void { + private emit(event: E, ...rest: PropsArg): void { const client = this.analyticsClient if (!client) return try { - client.track(event, properties) + client.track(event, ...rest) } catch (error) { processLog(`AnalyticsHook: ${event} track failed: ${error instanceof Error ? error.message : String(error)}`) } diff --git a/src/server/infra/transport/handlers/analytics-handler.ts b/src/server/infra/transport/handlers/analytics-handler.ts index 542a8079a..bb2a3b30e 100644 --- a/src/server/infra/transport/handlers/analytics-handler.ts +++ b/src/server/infra/transport/handlers/analytics-handler.ts @@ -1,6 +1,19 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' +import {CliInvocationSchema} from '../../../../shared/analytics/events/cli-invocation.js' +import {CurateOperationAppliedSchema} from '../../../../shared/analytics/events/curate-operation-applied.js' +import {CurateRunCompletedSchema} from '../../../../shared/analytics/events/curate-run-completed.js' +import {DaemonStartSchema} from '../../../../shared/analytics/events/daemon-start.js' +import {isAnalyticsEventName} from '../../../../shared/analytics/events/index.js' +import {McpSessionStartSchema} from '../../../../shared/analytics/events/mcp-session-start.js' +import {McpToolCalledSchema} from '../../../../shared/analytics/events/mcp-tool-called.js' +import {QueryCompletedSchema} from '../../../../shared/analytics/events/query-completed.js' +import {TaskCompletedSchema} from '../../../../shared/analytics/events/task-completed.js' +import {TaskCreatedSchema} from '../../../../shared/analytics/events/task-created.js' +import {TaskFailedSchema} from '../../../../shared/analytics/events/task-failed.js' import { AnalyticsEvents, type AnalyticsTrackPayload, @@ -13,13 +26,21 @@ export interface AnalyticsHandlerDeps { } /** - * Daemon-side handler for `analytics:track` (M2.6). Routes validated - * payloads to the daemon-scoped AnalyticsClient (M2.5), which stamps - * identity + super-properties and enqueues for later flush. + * Daemon-side handler for `analytics:track`. Routes validated payloads to the + * daemon-scoped AnalyticsClient, which stamps identity + super-properties and + * enqueues for later flush. * - * Validation is wire-level only (event is non-empty string, properties - * is record-or-undefined). Per-event property schemas (cli_invocation, - * mcp_tool_called, …) are designed in M2.8. + * Validation runs at two layers: + * 1. Wire envelope (`AnalyticsTrackPayloadSchema`) — event is non-empty + * string, properties is record-or-undefined. + * 2. Per-event (`ALL_EVENT_SCHEMAS[event]`) — exact property shape for the + * registered event. Unknown events and shape mismatches are dropped here, + * so the daemon's typed `track()` always receives a valid pair. + * + * The dispatch switch covers every entry in `AnalyticsEventNames`, including + * deferred scaffolding events (cli_invocation, mcp_*, task_*) that have a + * schema but no daemon-side producer yet. Wire-side validation is in place + * for the moment the producer ticket lands. * * Malformed payloads and any throw from track() are silently dropped: * analytics MUST NOT crash the emitting client. @@ -38,11 +59,96 @@ export class AnalyticsHandler { const parsed = AnalyticsTrackPayloadSchema.safeParse(data) if (!parsed.success) return + const {event, properties: rawProperties} = parsed.data + if (!isAnalyticsEventName(event)) return + try { - this.analyticsClient.track(parsed.data.event, parsed.data.properties) + this.dispatch(event, rawProperties) } catch { // Defensive: never crash the emitter. } }) } + + /** + * Per-event Zod validation + typed dispatch into `IAnalyticsClient.track`. + * Each branch re-uses the catalog's per-event schema so the data flowing + * into `track()` matches the discriminated-union contract at compile time — + * no `as` casts. + */ + // eslint-disable-next-line complexity + private dispatch(event: AnalyticsEventName, rawProperties: unknown): void { + switch (event) { + case AnalyticsEventNames.CLI_INVOCATION: { + const props = CliInvocationSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.CLI_INVOCATION, props.data) + break + } + + case AnalyticsEventNames.CURATE_OPERATION_APPLIED: { + const props = CurateOperationAppliedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.CURATE_OPERATION_APPLIED, props.data) + break + } + + case AnalyticsEventNames.CURATE_RUN_COMPLETED: { + const props = CurateRunCompletedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.CURATE_RUN_COMPLETED, props.data) + break + } + + case AnalyticsEventNames.DAEMON_START: { + const props = DaemonStartSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.DAEMON_START) + break + } + + case AnalyticsEventNames.MCP_SESSION_START: { + const props = McpSessionStartSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.MCP_SESSION_START, props.data) + break + } + + case AnalyticsEventNames.MCP_TOOL_CALLED: { + const props = McpToolCalledSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.MCP_TOOL_CALLED, props.data) + break + } + + case AnalyticsEventNames.QUERY_COMPLETED: { + const props = QueryCompletedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.QUERY_COMPLETED, props.data) + break + } + + case AnalyticsEventNames.TASK_COMPLETED: { + const props = TaskCompletedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.TASK_COMPLETED, props.data) + break + } + + case AnalyticsEventNames.TASK_CREATED: { + const props = TaskCreatedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.TASK_CREATED, props.data) + break + } + + case AnalyticsEventNames.TASK_FAILED: { + const props = TaskFailedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.TASK_FAILED, props.data) + break + } + // No default — `event` is narrowed to AnalyticsEventName by isAnalyticsEventName(). + } + } } diff --git a/src/shared/analytics/event-names.ts b/src/shared/analytics/event-names.ts index d50dcb589..65cbe0221 100644 --- a/src/shared/analytics/event-names.ts +++ b/src/shared/analytics/event-names.ts @@ -1,5 +1,3 @@ - - /** * Canonical wire-format names for every analytics event the daemon may emit. * @@ -10,6 +8,10 @@ * use as in-source constants. Adding a new event REQUIRES adding both: * 1. A new entry here. * 2. A new schema file in ./events/ and registration in ./events/index.ts. + * + * Some entries are deferred scaffolding (no producer yet — emitter lands in + * a future ticket). They are intentional, not Outside-In violations; the + * upcoming milestones will wire the producer alongside its consumer. */ export const AnalyticsEventNames = { CLI_INVOCATION: 'cli_invocation', diff --git a/src/shared/analytics/events/index.ts b/src/shared/analytics/events/index.ts index fb35d41c5..8170a6c98 100644 --- a/src/shared/analytics/events/index.ts +++ b/src/shared/analytics/events/index.ts @@ -1,3 +1,5 @@ +import type {AnalyticsEventName} from '../event-names.js' + import {AnalyticsEventNames} from '../event-names.js' import {type CliInvocationProps, CliInvocationSchema} from './cli-invocation.js' import {type CurateOperationAppliedProps, CurateOperationAppliedSchema} from './curate-operation-applied.js' @@ -14,12 +16,16 @@ import {type TaskFailedProps, TaskFailedSchema} from './task-failed.js' * Registry of every shipped event schema, keyed by wire name. Used by: * - The privacy fixture, which walks every entry and asserts no field * name appears on the forbidden PII list. - * - Future per-event validation at emit time. + * - Per-event validation at the wire boundary (`AnalyticsHandler`). + * + * Adding a new event requires three steps: + * 1. New constant in `../event-names.ts`. + * 2. New per-event file in this folder. + * 3. New entry in both `ALL_EVENT_SCHEMAS` and `AnyAnalyticsEvent` below. * - * Direct schema/type imports go through the per-event files - * (./cli-invocation.js, ./daemon-start.js, …). This module deliberately - * exports only the aggregate registry and the discriminated union, so it - * never duplicates per-event re-exports. + * Some entries are deferred scaffolding for upcoming milestones — they have + * schemas but no emitter today. The wire-side handler dispatch must still + * cover them (drop with Zod parse) once an emitter lands. */ export const ALL_EVENT_SCHEMAS = { [AnalyticsEventNames.CLI_INVOCATION]: CliInvocationSchema, @@ -50,3 +56,28 @@ export type AnyAnalyticsEvent = | {name: typeof AnalyticsEventNames.TASK_COMPLETED; properties: TaskCompletedProps} | {name: typeof AnalyticsEventNames.TASK_CREATED; properties: TaskCreatedProps} | {name: typeof AnalyticsEventNames.TASK_FAILED; properties: TaskFailedProps} + +/** + * Type-derived properties for a given event name. Magic-string typos + * (e.g. `'daemon_starts'`) and wrong-shape payloads (e.g. `tool_name` + * on `daemon_start`) become compile errors instead of runtime drops. + */ +export type PropsForEvent = Extract['properties'] + +/** + * If the event has no required properties (e.g. `daemon_start`), the + * `properties` argument is optional. Otherwise it is required. Implemented + * via a rest tuple so the call site stays ergonomic. + */ +export type PropsArg = keyof PropsForEvent extends never + ? [properties?: PropsForEvent] + : [properties: PropsForEvent] + +/** + * Runtime guard: narrows an unknown string to a known `AnalyticsEventName`. + * Used by the wire-side handler to reject events that have no schema + * before forwarding to the typed daemon client. + */ +export function isAnalyticsEventName(value: unknown): value is AnalyticsEventName { + return typeof value === 'string' && value in ALL_EVENT_SCHEMAS +} diff --git a/test/integration/analytics/retry-cap.test.ts b/test/integration/analytics/retry-cap.test.ts index 6dd725891..376ad47fe 100644 --- a/test/integration/analytics/retry-cap.test.ts +++ b/test/integration/analytics/retry-cap.test.ts @@ -15,6 +15,7 @@ import type {StoredAnalyticsRecord} from '../../../src/shared/analytics/stored-r import {AnalyticsClient} from '../../../src/server/infra/analytics/analytics-client.js' import {BoundedQueue} from '../../../src/server/infra/analytics/bounded-queue.js' import {JsonlAnalyticsStore} from '../../../src/server/infra/analytics/jsonl-analytics-store.js' +import {AnalyticsEventNames} from '../../../src/shared/analytics/event-names.js' import {MAX_ATTEMPTS} from '../../../src/shared/analytics/stored-record.js' const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' @@ -105,7 +106,7 @@ describe('M10.3 retry-cap end-to-end composition (M9.1 constant + M9.2 store + M }) // Track exactly one event so the cap walk is unambiguous. - client.track('retry_target') + client.track(AnalyticsEventNames.DAEMON_START) await waitForRows(jsonlStore, 1) const initialRows = await jsonlStore.list({limit: 100, offset: 0}) @@ -159,7 +160,7 @@ describe('M10.3 retry-cap end-to-end composition (M9.1 constant + M9.2 store + M superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) - client.track('overshoot_target') + client.track(AnalyticsEventNames.DAEMON_START) await waitForRows(jsonlStore, 1) const initial = await jsonlStore.list({limit: 100, offset: 0}) const {id} = initial.rows[0] @@ -193,7 +194,7 @@ describe('M10.3 retry-cap end-to-end composition (M9.1 constant + M9.2 store + M superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) - client.track('exclusion_target') + client.track(AnalyticsEventNames.DAEMON_START) await waitForRows(jsonlStore, 1) // Drive to terminal failed. diff --git a/test/integration/analytics/transport.test.ts b/test/integration/analytics/transport.test.ts index 1e31a4d11..7e8bba997 100644 --- a/test/integration/analytics/transport.test.ts +++ b/test/integration/analytics/transport.test.ts @@ -19,6 +19,7 @@ import {SuperPropertiesResolver} from '../../../src/server/infra/analytics/super import {FileGlobalConfigStore} from '../../../src/server/infra/storage/file-global-config-store.js' import {AnalyticsHandler} from '../../../src/server/infra/transport/handlers/analytics-handler.js' import {GlobalConfigHandler} from '../../../src/server/infra/transport/handlers/global-config-handler.js' +import {AnalyticsEventNames} from '../../../src/shared/analytics/event-names.js' import {AnalyticsEvents} from '../../../src/shared/transport/events/analytics-events.js' import {createMockTransportServer} from '../../helpers/mock-factories.js' @@ -89,23 +90,36 @@ describe('analytics:track transport round-trip integration (M2.6)', () => { new AnalyticsHandler({analyticsClient, transport}).setup() - // Simulate what `emitAnalytics(client, 'cli_invocation', {command_id})` would - // deliver to the daemon's analytics:track handler. + // Simulate a daemon-internal emit going through the wire `analytics:track` + // path (validated against the per-event Zod schema before dispatch). const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler expect(handler, 'analytics:track handler must be registered').to.exist - await handler({event: 'cli_invocation', properties: {command_id: 'status'}}, 'client-1') + await handler( + { + event: AnalyticsEventNames.CURATE_OPERATION_APPLIED, + properties: { + absolute_path: '/tmp/x.md', + knowledge_path: 'kg/x.md', + needs_review: false, + operation_type: 'ADD', + task_id: 't-1', + }, + }, + 'client-1', + ) await waitForQueueSize(queue, 1) const batch = await analyticsClient.flush() expect(batch.events).to.have.lengthOf(1) const [event] = batch.events - expect(event.name).to.equal('cli_invocation') + expect(event.name).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) expect(event.identity).to.deep.equal({device_id: validDeviceId}) - // User-supplied property preserved - expect(event.properties.command_id).to.equal('status') + // User-supplied properties preserved end-to-end + expect(event.properties.absolute_path).to.equal('/tmp/x.md') + expect(event.properties.operation_type).to.equal('ADD') // All five super-properties stamped on receipt expect(event.properties.cli_version).to.equal('3.10.3') @@ -134,7 +148,7 @@ describe('analytics:track transport round-trip integration (M2.6)', () => { new AnalyticsHandler({analyticsClient, transport}).setup() const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler - await handler({event: 'cli_invocation', properties: {command_id: 'status'}}, 'client-1') + await handler({event: AnalyticsEventNames.DAEMON_START}, 'client-1') // Two ticks suffice — the disabled path is sync inside track() and never schedules async work. await new Promise((resolve) => { setImmediate(resolve) diff --git a/test/unit/infra/transport/handlers/global-config-handler.test.ts b/test/unit/infra/transport/handlers/global-config-handler.test.ts index 7ae7ccc93..8a488b1ae 100644 --- a/test/unit/infra/transport/handlers/global-config-handler.test.ts +++ b/test/unit/infra/transport/handlers/global-config-handler.test.ts @@ -36,14 +36,14 @@ describe('GlobalConfigHandler', () => { async function callGet(): Promise<{analytics: boolean; deviceId: string; version: string}> { const fn = transport._handlers.get(GlobalConfigEvents.GET) - expect(fn).to.exist - return fn!(undefined, 'client-1') + if (!fn) throw new Error(`handler not registered: ${GlobalConfigEvents.GET}`) + return fn(undefined, 'client-1') } async function callSet(analytics: boolean): Promise<{current: boolean; previous: boolean}> { const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) - expect(fn).to.exist - return fn!({analytics}, 'client-1') + if (!fn) throw new Error(`handler not registered: ${GlobalConfigEvents.SET_ANALYTICS}`) + return fn({analytics}, 'client-1') } describe('setup', () => { diff --git a/test/unit/server/core/domain/entities/global-config.test.ts b/test/unit/server/core/domain/entities/global-config.test.ts index 9dd91344f..89d900357 100644 --- a/test/unit/server/core/domain/entities/global-config.test.ts +++ b/test/unit/server/core/domain/entities/global-config.test.ts @@ -165,11 +165,15 @@ describe('GlobalConfig', () => { it('should round-trip analytics: true through toJson/fromJson', () => { const fromTrue = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) - const restoredTrue = GlobalConfig.fromJson(fromTrue!.toJson()) + expect(fromTrue, 'fromJson must accept valid analytics: true input').to.not.be.undefined + if (!fromTrue) throw new Error('fromJson returned undefined for valid input') + const restoredTrue = GlobalConfig.fromJson(fromTrue.toJson()) expect(restoredTrue?.analytics).to.equal(true) const fromFalse = GlobalConfig.fromJson({analytics: false, deviceId: validDeviceId, version: '0.0.1'}) - const restoredFalse = GlobalConfig.fromJson(fromFalse!.toJson()) + expect(fromFalse, 'fromJson must accept valid analytics: false input').to.not.be.undefined + if (!fromFalse) throw new Error('fromJson returned undefined for valid input') + const restoredFalse = GlobalConfig.fromJson(fromFalse.toJson()) expect(restoredFalse?.analytics).to.equal(false) }) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index 5934baef9..52baab1fe 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -7,12 +7,30 @@ import type {IAnalyticsSender, SendResult} from '../../../../../src/server/core/ import type {IIdentityResolver} from '../../../../../src/server/core/interfaces/analytics/i-identity-resolver.js' import type {IJsonlAnalyticsStore, JsonlAnalyticsStoreUpdateStatus} from '../../../../../src/server/core/interfaces/analytics/i-jsonl-analytics-store.js' import type {ISuperPropertiesResolver, SuperProperties} from '../../../../../src/server/core/interfaces/analytics/i-super-properties-resolver.js' +import type {CurateOperationAppliedProps} from '../../../../../src/shared/analytics/events/curate-operation-applied.js' import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' import {AnalyticsClient} from '../../../../../src/server/infra/analytics/analytics-client.js' import {BoundedQueue} from '../../../../../src/server/infra/analytics/bounded-queue.js' import {NoOpAnalyticsSender} from '../../../../../src/server/infra/analytics/no-op-analytics-sender.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' + +/** + * Valid CurateOperationAppliedProps payload used for tests that need a real + * typed event with non-trivial properties (e.g. property merge / precedence). + * DAEMON_START is used elsewhere where the call shape only needs to fire. + */ +function makeCurateOpProps(overrides: Partial = {}): CurateOperationAppliedProps { + return { + absolute_path: '/tmp/file.md', + knowledge_path: 'cli_architecture/test.md', + needs_review: false, + operation_type: 'ADD', + task_id: 'task-1', + ...overrides, + } +} type FakeJsonlStore = IJsonlAnalyticsStore & { appendSpy: ReturnType @@ -135,7 +153,7 @@ async function flushMicrotasks(): Promise { async function seedPending(client: AnalyticsClient, count: number): Promise { for (let i = 0; i < count; i++) { - client.track(`event_${i}`) + client.track(AnalyticsEventNames.DAEMON_START) } await flushMicrotasks() @@ -158,7 +176,7 @@ describe('AnalyticsClient', () => { }) for (let i = 0; i < 1000; i++) { - client.track(`event_${i}`, {x: i}) + client.track(AnalyticsEventNames.DAEMON_START) } await flushMicrotasks() @@ -185,7 +203,8 @@ describe('AnalyticsClient', () => { }) const before = Date.now() - client.track('e1', {x: 1}) + const opProps = makeCurateOpProps({absolute_path: '/tmp/merge-fixture.md'}) + client.track(AnalyticsEventNames.CURATE_OPERATION_APPLIED, opProps) await flushMicrotasks() const after = Date.now() @@ -193,13 +212,14 @@ describe('AnalyticsClient', () => { expect(batch.events).to.have.lengthOf(1) const [event] = batch.events - expect(event.name).to.equal('e1') + expect(event.name).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) expect(event.identity).to.deep.equal(identity) expect(event.timestamp).to.be.at.least(before) expect(event.timestamp).to.be.at.most(after) - // user property merged - expect(event.properties.x).to.equal(1) + // user properties merged through + expect(event.properties.absolute_path).to.equal('/tmp/merge-fixture.md') + expect(event.properties.operation_type).to.equal('ADD') // all 5 super properties stamped expect(event.properties.cli_version).to.equal('3.10.3') expect(event.properties.device_id).to.equal(validDeviceId) @@ -227,13 +247,13 @@ describe('AnalyticsClient', () => { superPropsResolver, }) - client.track('a1') - client.track('a2') + client.track(AnalyticsEventNames.DAEMON_START) + client.track(AnalyticsEventNames.DAEMON_START) await flushMicrotasks() currentIdentity = makeRegisteredIdentity() - client.track('r1') - client.track('r2') + client.track(AnalyticsEventNames.DAEMON_START) + client.track(AnalyticsEventNames.DAEMON_START) await flushMicrotasks() const batch = await client.flush() @@ -262,7 +282,7 @@ describe('AnalyticsClient', () => { }) for (let i = 0; i < 10; i++) { - client.track(`event_${i}`) + client.track(AnalyticsEventNames.DAEMON_START) } await flushMicrotasks() @@ -288,7 +308,7 @@ describe('AnalyticsClient', () => { superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) - client.track('round_trip') + client.track(AnalyticsEventNames.DAEMON_START) await flushMicrotasks() const batch = await client.flush() @@ -296,7 +316,7 @@ describe('AnalyticsClient', () => { expect(restored).to.not.be.undefined expect(restored?.events).to.have.lengthOf(1) - expect(restored?.events[0].name).to.equal('round_trip') + expect(restored?.events[0].name).to.equal(AnalyticsEventNames.DAEMON_START) }) it('should return an empty batch when the queue has been fully drained', async () => { @@ -331,7 +351,7 @@ describe('AnalyticsClient', () => { }) // Must not throw to the caller - expect(() => client.track('boom')).to.not.throw() + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() await flushMicrotasks() @@ -353,7 +373,7 @@ describe('AnalyticsClient', () => { superPropsResolver, }) - expect(() => client.track('boom')).to.not.throw() + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() await flushMicrotasks() @@ -382,7 +402,7 @@ describe('AnalyticsClient', () => { }) const before = Date.now() - client.track('e1') + client.track(AnalyticsEventNames.DAEMON_START) const after = Date.now() // Hold the resolver pending across a real timer gap so settle-time and @@ -405,31 +425,6 @@ describe('AnalyticsClient', () => { }) }) - describe('super-properties precedence', () => { - it('should let super-properties win on key conflict with user properties', async () => { - const queue = new BoundedQueue() - const client = new AnalyticsClient({ - identityResolver: makeStubIdentityResolver(makeAnonIdentity()), - isEnabled: () => true, - jsonlStore: makeFakeJsonlStore(), - queue, - sender: makeFakeSender(), - superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), - }) - - client.track('e1', {cli_version: 'lying', custom: 'kept'}) - await flushMicrotasks() - - const batch = await client.flush() - expect(batch.events).to.have.lengthOf(1) - const [event] = batch.events - // Super-property wins - expect(event.properties.cli_version).to.equal('3.10.3') - // User property without conflict is preserved - expect(event.properties.custom).to.equal('kept') - }) - }) - describe('M9.3 JSONL-first persistence (dual write)', () => { it('should append to JSONL before pushing to queue (happy path)', async () => { const queue = new BoundedQueue() @@ -443,13 +438,13 @@ describe('AnalyticsClient', () => { superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) - client.track('e1', {x: 1}) + client.track(AnalyticsEventNames.CURATE_OPERATION_APPLIED, makeCurateOpProps()) await flushMicrotasks() // JSONL has the row expect(jsonlStore.records).to.have.lengthOf(1) const stored = jsonlStore.records[0] - expect(stored.name).to.equal('e1') + expect(stored.name).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) expect(stored.status).to.equal('pending') expect(stored.attempts).to.equal(0) expect(stored.id).to.be.a('string').and.have.length.greaterThan(0) @@ -472,7 +467,7 @@ describe('AnalyticsClient', () => { }) for (let i = 0; i < 5; i++) { - client.track(`event_${i}`) + client.track(AnalyticsEventNames.DAEMON_START) } await flushMicrotasks() @@ -494,7 +489,7 @@ describe('AnalyticsClient', () => { superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) - expect(() => client.track('boom')).to.not.throw() + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() await flushMicrotasks() // JSONL append rejected (called once, but no record persisted) @@ -517,7 +512,7 @@ describe('AnalyticsClient', () => { }) for (let i = 0; i < 100; i++) { - expect(() => client.track(`event_${i}`)).to.not.throw() + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() } await flushMicrotasks() @@ -539,7 +534,7 @@ describe('AnalyticsClient', () => { const N = 20 for (let i = 0; i < N; i++) { - client.track(`event_${i}`) + client.track(AnalyticsEventNames.DAEMON_START) } await flushMicrotasks() @@ -560,7 +555,7 @@ describe('AnalyticsClient', () => { superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), }) - client.track('e1') + client.track(AnalyticsEventNames.DAEMON_START) await flushMicrotasks() expect(jsonlStore.appendSpy.called).to.equal(false) @@ -588,7 +583,12 @@ describe('AnalyticsClient', () => { expect(sender.calls).to.have.lengthOf(1) const [shipped] = sender.calls expect(shipped).to.have.lengthOf(3) - expect(shipped.map((r) => r.name).sort()).to.deep.equal(['event_0', 'event_1', 'event_2']) + // All seeded via DAEMON_START so every shipped row carries the same name. + expect(shipped.map((r) => r.name)).to.deep.equal([ + AnalyticsEventNames.DAEMON_START, + AnalyticsEventNames.DAEMON_START, + AnalyticsEventNames.DAEMON_START, + ]) }) it('should mirror all-succeeded result by flipping rows to status=sent', async () => { @@ -739,7 +739,7 @@ describe('AnalyticsClient', () => { expect(batch.events).to.have.lengthOf(1) const [event] = batch.events - expect(event).to.have.property('name', 'event_0') + expect(event).to.have.property('name', AnalyticsEventNames.DAEMON_START) expect(event).to.have.property('timestamp') expect(event).to.have.property('properties') expect(event).to.have.property('identity') diff --git a/test/unit/server/infra/analytics/no-op-analytics-client.test.ts b/test/unit/server/infra/analytics/no-op-analytics-client.test.ts index 3a17cf65b..dd88cb076 100644 --- a/test/unit/server/infra/analytics/no-op-analytics-client.test.ts +++ b/test/unit/server/infra/analytics/no-op-analytics-client.test.ts @@ -1,18 +1,49 @@ +/* eslint-disable camelcase */ import {expect} from 'chai' import {NoOpAnalyticsClient} from '../../../../../src/server/infra/analytics/no-op-analytics-client.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' describe('NoOpAnalyticsClient', () => { describe('track()', () => { - it('should return void without throwing for varied inputs', () => { + it('should return void without throwing across every catalog event', () => { + const client = new NoOpAnalyticsClient() + + // DAEMON_START has no required properties. + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() + + // CURATE_OPERATION_APPLIED with a minimal valid payload. + expect(() => + client.track(AnalyticsEventNames.CURATE_OPERATION_APPLIED, { + absolute_path: '/tmp/x.md', + knowledge_path: 'kg/x.md', + needs_review: false, + operation_type: 'ADD', + task_id: 't-1', + }), + ).to.not.throw() + + // QUERY_COMPLETED with a minimal valid payload. + expect(() => + client.track(AnalyticsEventNames.QUERY_COMPLETED, { + cache_hit: false, + duration_ms: 0, + matched_doc_count: 0, + outcome: 'completed', + read_doc_count: 0, + read_tool_call_count: 0, + search_call_count: 0, + task_id: 't-1', + task_type: 'query', + }), + ).to.not.throw() + }) + + it('should remain a no-op under burst load', () => { const client = new NoOpAnalyticsClient() - expect(() => client.track('event_no_props')).to.not.throw() - expect(() => client.track('event_with_props', {key: 'value'})).to.not.throw() - expect(() => client.track('event_undefined_props')).to.not.throw() - expect(() => client.track('')).to.not.throw() for (let i = 0; i < 1000; i++) { - expect(() => client.track(`event_${i}`, {iteration: i})).to.not.throw() + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() } }) }) @@ -31,7 +62,7 @@ describe('NoOpAnalyticsClient', () => { const client = new NoOpAnalyticsClient() for (let i = 0; i < 100; i++) { - client.track(`event_${i}`, {x: i}) + client.track(AnalyticsEventNames.DAEMON_START) } const batch = await client.flush() diff --git a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts index 15ccc7f3b..d9025f6c7 100644 --- a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts @@ -1,65 +1,128 @@ /* eslint-disable camelcase */ import {expect} from 'chai' -import {stub} from 'sinon' import type {IAnalyticsClient} from '../../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {AnalyticsEventName} from '../../../../../../src/shared/analytics/event-names.js' +import type {PropsArg} from '../../../../../../src/shared/analytics/events/index.js' import {AnalyticsBatch} from '../../../../../../src/server/core/domain/analytics/batch.js' import {AnalyticsHandler} from '../../../../../../src/server/infra/transport/handlers/analytics-handler.js' +import {AnalyticsEventNames} from '../../../../../../src/shared/analytics/event-names.js' import {AnalyticsEvents, type AnalyticsTrackPayload} from '../../../../../../src/shared/transport/events/analytics-events.js' import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' type AnalyticsTrackHandler = (data: unknown, clientId: string) => Promise -function makeStubAnalyticsClient(): IAnalyticsClient { - return { - flush: stub().resolves(AnalyticsBatch.create([])), - track: stub(), +type TrackCall = {event: AnalyticsEventName; properties: unknown} + +type MockAnalyticsClient = IAnalyticsClient & { + readonly trackCalls: readonly TrackCall[] + trackThrows?: Error +} + +/** + * Hand-rolled mock that preserves the generic signature on `track`. Sinon's + * `stub()` collapses generics into a single SinonStub overload, which fails + * to satisfy `IAnalyticsClient.track`. + */ +function makeMockAnalyticsClient(): MockAnalyticsClient { + const trackCalls: TrackCall[] = [] + const mock: MockAnalyticsClient = { + flush: () => Promise.resolve(AnalyticsBatch.create([])), + track(event: E, ...rest: PropsArg): void { + if (mock.trackThrows) throw mock.trackThrows + const [properties] = rest + trackCalls.push({event, properties}) + }, + trackCalls, } + return mock } describe('AnalyticsHandler', () => { it('should register a handler for analytics:track on setup()', () => { const transport = createMockTransportServer() - const analyticsClient = makeStubAnalyticsClient() + const analyticsClient = makeMockAnalyticsClient() new AnalyticsHandler({analyticsClient, transport}).setup() expect(transport._handlers.has(AnalyticsEvents.TRACK)).to.equal(true) }) - it('should route a valid payload to analyticsClient.track with matching args', async () => { - const transport = createMockTransportServer() - const analyticsClient = makeStubAnalyticsClient() - new AnalyticsHandler({analyticsClient, transport}).setup() - - const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler - const payload: AnalyticsTrackPayload = {event: 'cli_invocation', properties: {command_id: 'status'}} - await handler(payload, 'client-1') - - const trackStub = analyticsClient.track as ReturnType - expect(trackStub.calledOnce).to.equal(true) - expect(trackStub.firstCall.args[0]).to.equal('cli_invocation') - expect(trackStub.firstCall.args[1]).to.deep.equal({command_id: 'status'}) - }) - - it('should route a payload with no properties', async () => { - const transport = createMockTransportServer() - const analyticsClient = makeStubAnalyticsClient() - new AnalyticsHandler({analyticsClient, transport}).setup() - - const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler - await handler({event: 'no_props'}, 'client-1') - - const trackStub = analyticsClient.track as ReturnType - expect(trackStub.calledOnce).to.equal(true) - expect(trackStub.firstCall.args[0]).to.equal('no_props') - expect(trackStub.firstCall.args[1]).to.equal(undefined) + describe('per-event Zod validation + typed dispatch', () => { + it('should route a valid known event + valid properties to analyticsClient.track', async () => { + const transport = createMockTransportServer() + const analyticsClient = makeMockAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + const payload: AnalyticsTrackPayload = { + event: AnalyticsEventNames.CURATE_OPERATION_APPLIED, + properties: { + absolute_path: '/tmp/a.md', + knowledge_path: 'kg/a.md', + needs_review: false, + operation_type: 'ADD', + task_id: 't-1', + }, + } + await handler(payload, 'client-1') + + expect(analyticsClient.trackCalls).to.have.lengthOf(1) + expect(analyticsClient.trackCalls[0].event).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) + expect(analyticsClient.trackCalls[0].properties).to.deep.equal({ + absolute_path: '/tmp/a.md', + knowledge_path: 'kg/a.md', + needs_review: false, + operation_type: 'ADD', + task_id: 't-1', + }) + }) + + it('should route DAEMON_START (no required properties) without forwarding props', async () => { + const transport = createMockTransportServer() + const analyticsClient = makeMockAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + await handler({event: AnalyticsEventNames.DAEMON_START}, 'client-1') + + expect(analyticsClient.trackCalls).to.have.lengthOf(1) + expect(analyticsClient.trackCalls[0].event).to.equal(AnalyticsEventNames.DAEMON_START) + // PropsArg makes properties absent for events with no required props. + expect(analyticsClient.trackCalls[0].properties).to.equal(undefined) + }) + + it('should drop UNKNOWN event names silently (no track call)', async () => { + const transport = createMockTransportServer() + const analyticsClient = makeMockAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + await handler({event: 'cli_invocation', properties: {x: 1}}, 'client-1') + await handler({event: 'mystery_event'}, 'client-1') + + expect(analyticsClient.trackCalls, 'unknown events must NOT reach track').to.deep.equal([]) + }) + + it('should drop KNOWN events with INVALID per-event properties silently', async () => { + const transport = createMockTransportServer() + const analyticsClient = makeMockAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + // CURATE_OPERATION_APPLIED requires absolute_path / knowledge_path / etc. + await handler({event: AnalyticsEventNames.CURATE_OPERATION_APPLIED, properties: {wrong: 'shape'}}, 'client-1') + // QUERY_COMPLETED requires duration_ms / outcome / etc. + await handler({event: AnalyticsEventNames.QUERY_COMPLETED, properties: {}}, 'client-1') + + expect(analyticsClient.trackCalls, 'invalid per-event props must NOT reach track').to.deep.equal([]) + }) }) - it('should drop invalid payload silently (no throw, no track call)', async () => { + it('should drop invalid wire envelope silently (no throw, no track call)', async () => { const transport = createMockTransportServer() - const analyticsClient = makeStubAnalyticsClient() + const analyticsClient = makeMockAnalyticsClient() new AnalyticsHandler({analyticsClient, transport}).setup() const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler @@ -69,23 +132,20 @@ describe('AnalyticsHandler', () => { await handler({event: 42}, 'client-1') await handler(null, 'client-1') - const trackStub = analyticsClient.track as ReturnType - expect(trackStub.called, 'track must NOT be called for invalid payloads').to.equal(false) + expect(analyticsClient.trackCalls, 'track must NOT be called for invalid envelopes').to.deep.equal([]) }) it('should not throw when analyticsClient.track itself throws', async () => { const transport = createMockTransportServer() - const analyticsClient: IAnalyticsClient = { - flush: stub().resolves(AnalyticsBatch.create([])), - track: stub().throws(new Error('boom')), - } + const analyticsClient = makeMockAnalyticsClient() + analyticsClient.trackThrows = new Error('boom') new AnalyticsHandler({analyticsClient, transport}).setup() const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler let caught: unknown try { - await handler({event: 'e'}, 'client-1') + await handler({event: AnalyticsEventNames.DAEMON_START}, 'client-1') } catch (error) { caught = error } diff --git a/test/unit/shared/analytics/event-names.test.ts b/test/unit/shared/analytics/event-names.test.ts index 7023bcedf..84fd2a7ff 100644 --- a/test/unit/shared/analytics/event-names.test.ts +++ b/test/unit/shared/analytics/event-names.test.ts @@ -1,4 +1,3 @@ - import {expect} from 'chai' import {type AnalyticsEventName, AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' From 47abf815d2fe72e5362dd5f6af7b0a70ef83ac2e Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 18 May 2026 12:10:37 +0700 Subject: [PATCH 37/87] =?UTF-8?q?fix:=20address=20review=20findings=20(com?= =?UTF-8?q?pactRows=20O(n=C2=B2),=20setAnalytics=20race,=20flag=5Fnames=20?= =?UTF-8?q?defaults)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JsonlAnalyticsStore.compactRows: O(n²) → single-pass O(n) (precompute sizes, walk once decrementing running totals); oldest-first drop order preserved; regression-lock test for multi-drop sequencing. - GlobalConfigHandler.setAnalytics: serialize via private writeChain Promise so concurrent enables on a fresh install no longer both generate independent deviceIds; doSetAnalytics split out; mirrors JsonlAnalyticsStore.writeChain pattern. - buildCliMetadata: signature now takes oclif's full parse result and filters metadata.flags[name].setFromDefault === true; previously Object.keys(flags) captured every parsed key including oclif-default-populated ones, so every invocation looked like the user passed every flag. 8 callers updated to thread metadata through. --- src/oclif/commands/curate/index.ts | 4 +- src/oclif/commands/dream.ts | 4 +- src/oclif/commands/locations.ts | 4 +- src/oclif/commands/login.ts | 4 +- src/oclif/commands/logout.ts | 4 +- src/oclif/commands/query.ts | 4 +- src/oclif/commands/search.ts | 4 +- src/oclif/commands/status.ts | 4 +- src/oclif/lib/build-cli-metadata.ts | 28 +++++-- .../infra/analytics/jsonl-analytics-store.ts | 32 ++++++-- .../handlers/global-config-handler.ts | 59 ++++++++++----- .../handlers/global-config-handler.test.ts | 23 ++++++ .../unit/oclif/lib/build-cli-metadata.test.ts | 75 +++++++++++++------ .../analytics/jsonl-analytics-store.test.ts | 27 +++++++ 14 files changed, 205 insertions(+), 71 deletions(-) diff --git a/src/oclif/commands/curate/index.ts b/src/oclif/commands/curate/index.ts index c42b17166..35a97fe13 100644 --- a/src/oclif/commands/curate/index.ts +++ b/src/oclif/commands/curate/index.ts @@ -102,7 +102,7 @@ Bad examples: } public async run(): Promise { - const {args, flags: rawFlags} = await this.parse(Curate) + const {args, flags: rawFlags, metadata} = await this.parse(Curate) const flags: CurateFlags = { detach: rawFlags.detach, files: rawFlags.files, @@ -123,7 +123,7 @@ Bad examples: // Build once per run so a single `client_sent_at` identifies one CLI // invocation even on retries that may make multiple task:create calls. - const cliMetadata = buildCliMetadata(this.id ?? 'curate', rawFlags) + const cliMetadata = buildCliMetadata(this.id ?? 'curate', {flags: rawFlags, metadata}) let providerContext: ProviderErrorContext | undefined diff --git a/src/oclif/commands/dream.ts b/src/oclif/commands/dream.ts index f774e2b03..a507f00d9 100644 --- a/src/oclif/commands/dream.ts +++ b/src/oclif/commands/dream.ts @@ -116,7 +116,7 @@ export default class Dream extends Command { } public async run(): Promise { - const {flags: rawFlags} = await this.parse(Dream) + const {flags: rawFlags, metadata} = await this.parse(Dream) const format = rawFlags.format === 'json' ? 'json' : 'text' if (rawFlags.undo) { @@ -124,7 +124,7 @@ export default class Dream extends Command { return } - const cliMetadata = buildCliMetadata(this.id ?? 'dream', rawFlags) + const cliMetadata = buildCliMetadata(this.id ?? 'dream', {flags: rawFlags, metadata}) let providerContext: ProviderErrorContext | undefined try { diff --git a/src/oclif/commands/locations.ts b/src/oclif/commands/locations.ts index e24f7cc18..26b5f92d4 100644 --- a/src/oclif/commands/locations.ts +++ b/src/oclif/commands/locations.ts @@ -40,9 +40,9 @@ export default class Locations extends Command { } public async run(): Promise { - const {flags} = await this.parse(Locations) + const {flags, metadata} = await this.parse(Locations) const isJson = flags.format === 'json' - const cliMetadata = buildCliMetadata(this.id ?? 'locations', flags) + const cliMetadata = buildCliMetadata(this.id ?? 'locations', {flags, metadata}) try { const locations = await this.fetchLocations(cliMetadata, {projectPath: process.cwd()}) diff --git a/src/oclif/commands/login.ts b/src/oclif/commands/login.ts index d35961084..a4bec603c 100644 --- a/src/oclif/commands/login.ts +++ b/src/oclif/commands/login.ts @@ -119,10 +119,10 @@ export default class Login extends Command { } public async run(): Promise { - const {flags} = await this.parse(Login) + const {flags, metadata} = await this.parse(Login) const apiKey = flags['api-key'] const format: OutputFormat = flags.format === 'json' ? 'json' : 'text' - const cliMetadata = buildCliMetadata(this.id ?? 'login', flags) + const cliMetadata = buildCliMetadata(this.id ?? 'login', {flags, metadata}) if (!apiKey && !this.canOpenBrowser()) { this.emitError( diff --git a/src/oclif/commands/logout.ts b/src/oclif/commands/logout.ts index 58f4fe4f0..1a3c7f46c 100644 --- a/src/oclif/commands/logout.ts +++ b/src/oclif/commands/logout.ts @@ -40,9 +40,9 @@ export default class Logout extends Command { } public async run(): Promise { - const {flags} = await this.parse(Logout) + const {flags, metadata} = await this.parse(Logout) const format = flags.format ?? 'text' - const cliMetadata = buildCliMetadata(this.id ?? 'logout', flags) + const cliMetadata = buildCliMetadata(this.id ?? 'logout', {flags, metadata}) try { if (format === 'text') { diff --git a/src/oclif/commands/query.ts b/src/oclif/commands/query.ts index 00f0fb535..f64712242 100644 --- a/src/oclif/commands/query.ts +++ b/src/oclif/commands/query.ts @@ -67,7 +67,7 @@ Bad: } public async run(): Promise { - const {args, flags: rawFlags} = await this.parse(Query) + const {args, flags: rawFlags, metadata} = await this.parse(Query) const flags = rawFlags as QueryFlags const format = (flags.format ?? 'text') as 'json' | 'text' @@ -75,7 +75,7 @@ Bad: // Build once per run so a single `client_sent_at` identifies one CLI // invocation even on retries that may make multiple task:create calls. - const cliMetadata = buildCliMetadata(this.id ?? 'query', rawFlags) + const cliMetadata = buildCliMetadata(this.id ?? 'query', {flags: rawFlags, metadata}) let providerContext: ProviderErrorContext | undefined diff --git a/src/oclif/commands/search.ts b/src/oclif/commands/search.ts index 7531ea348..e630e6304 100644 --- a/src/oclif/commands/search.ts +++ b/src/oclif/commands/search.ts @@ -66,12 +66,12 @@ Use "brv query" when you need a synthesized answer.` } public async run(): Promise { - const {args, flags} = await this.parse(Search) + const {args, flags, metadata} = await this.parse(Search) const format: 'json' | 'text' = flags.format === 'json' ? 'json' : 'text' if (!this.validateInput(args.query, format)) return - const cliMetadata = buildCliMetadata(this.id ?? 'search', flags) + const cliMetadata = buildCliMetadata(this.id ?? 'search', {flags, metadata}) try { await withDaemonRetry( diff --git a/src/oclif/commands/status.ts b/src/oclif/commands/status.ts index 8259123e3..5f1428cd0 100644 --- a/src/oclif/commands/status.ts +++ b/src/oclif/commands/status.ts @@ -51,10 +51,10 @@ export default class Status extends Command { } public async run(): Promise { - const {flags} = await this.parse(Status) + const {flags, metadata} = await this.parse(Status) const projectRootFlag = flags['project-root'] const isJson = flags.format === 'json' - const cliMetadata = buildCliMetadata(this.id ?? 'status', flags) + const cliMetadata = buildCliMetadata(this.id ?? 'status', {flags, metadata}) try { const status = await this.fetchStatus(cliMetadata, {projectPath: process.cwd(), projectRootFlag}) diff --git a/src/oclif/lib/build-cli-metadata.ts b/src/oclif/lib/build-cli-metadata.ts index 792cbd990..70d8a45dd 100644 --- a/src/oclif/lib/build-cli-metadata.ts +++ b/src/oclif/lib/build-cli-metadata.ts @@ -3,6 +3,18 @@ import type {CliMetadata} from '../../shared/analytics/cli-metadata-schema.js' type PackageManager = 'bun' | 'npm' | 'pnpm' | 'unknown' | 'yarn' +/** + * Shape of the oclif `Command.parse()` result fields this helper consumes. + * Both top-level fields are accepted defensively (test fixtures may pass + * a `flags`-only shape without metadata). + */ +export type OclifParseResultLike = { + flags: Record + metadata?: { + flags?: Record + } +} + /** * Detect the package manager that launched this `brv` process. * @@ -54,16 +66,22 @@ function detectTerminalProgram(): string | undefined { * The helper is called ONCE per `run()` so a single `client_sent_at` value * identifies one CLI invocation across multi-request commands (per M13.3). * - * `flag_names` captures the parsed-flag KEY names only (oclif's already- - * camelCased keys, e.g. `--set-upstream` → `setUpstream`). Flag VALUES are - * NEVER captured — they may carry paths, query text, or secrets. + * `flag_names` captures ONLY the flag KEY names the user actually typed + * on the command line. Flags whose value came from oclif's static-default + * machinery (i.e. `metadata.flags[name].setFromDefault === true`) are + * excluded — otherwise every invocation would look like the user passed + * every flag, since oclif fills the entire flag object regardless of argv. + * Flag VALUES are NEVER captured — they may carry paths, query text, or + * secrets. */ -export function buildCliMetadata(commandId: string, flags: Record): CliMetadata { +export function buildCliMetadata(commandId: string, parsed: OclifParseResultLike): CliMetadata { + const flagMeta = parsed.metadata?.flags ?? {} + const flagNames = Object.keys(parsed.flags).filter((name) => flagMeta[name]?.setFromDefault !== true) const terminalProgram = detectTerminalProgram() const metadata: CliMetadata = { client_sent_at: Date.now(), command_id: commandId, - flag_names: Object.keys(flags), + flag_names: flagNames, is_ci: detectIsCi(), is_tty: detectIsTty(), package_manager: detectPackageManager(), diff --git a/src/server/infra/analytics/jsonl-analytics-store.ts b/src/server/infra/analytics/jsonl-analytics-store.ts index e664085ac..1abcbbbac 100644 --- a/src/server/infra/analytics/jsonl-analytics-store.ts +++ b/src/server/infra/analytics/jsonl-analytics-store.ts @@ -146,15 +146,35 @@ export class JsonlAnalyticsStore implements IJsonlAnalyticsStore { * Drop oldest `'sent'` rows (insertion order = file order = oldest-first) * until under cap or out of `'sent'` rows. Pending and failed are never * dropped. Returns the kept rows + count of sent rows actually removed. + * + * Single-pass: precompute each row's serialized size once, then walk + * once decrementing running totals on each drop. The earlier + * `while exceedsCap` + `findIndex` + `splice` loop was O(n²) — each + * iteration re-stringified every row and reshuffled the array. */ private compactRows(rows: readonly StoredAnalyticsRecord[]): {kept: StoredAnalyticsRecord[]; sentDropped: number} { - const kept = [...rows] + const sizes: number[] = [] + let bytes = 0 + for (const r of rows) { + const size = Buffer.byteLength(JSON.stringify(r) + '\n', 'utf8') + sizes.push(size) + bytes += size + } + + let count = rows.length + const isOver = (): boolean => bytes > this.maxBytes || count > this.maxRows + + const kept: StoredAnalyticsRecord[] = [] let sentDropped = 0 - while (this.exceedsCap(kept)) { - const sentIdx = kept.findIndex((r) => r.status === 'sent') - if (sentIdx === -1) break - kept.splice(sentIdx, 1) - sentDropped++ + for (const [i, r] of rows.entries()) { + if (isOver() && r.status === 'sent') { + bytes -= sizes[i] + count -= 1 + sentDropped++ + continue + } + + kept.push(r) } return {kept, sentDropped} diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts index 682408f56..70a3a6c44 100644 --- a/src/server/infra/transport/handlers/global-config-handler.ts +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -43,6 +43,12 @@ export class GlobalConfigHandler { private cachedAnalytics: boolean | undefined private readonly globalConfigStore: IGlobalConfigStore private readonly transport: ITransportServer + // Serializes SET_ANALYTICS write paths. Two concurrent enables on a + // fresh install would otherwise both observe `existing=undefined`, both + // generate a different `randomUUID()` deviceId, and both write — the + // persisted deviceId would be whichever lost the write race. Chain + // pattern mirrors `JsonlAnalyticsStore.writeChain`. + private writeChain: Promise = Promise.resolve() constructor(deps: GlobalConfigHandlerDeps) { this.globalConfigStore = deps.globalConfigStore @@ -100,6 +106,29 @@ export class GlobalConfigHandler { ) } + private async doSetAnalytics(analytics: boolean): Promise { + const existing = await this.globalConfigStore.read() + const previous = existing?.analytics ?? false + + // Idempotent fast path: short-circuit before generating a deviceId. + // If existing is undefined and the requested value matches the default + // (false), no file is created — the next GET will seed. + if (previous === analytics) { + this.cachedAnalytics = previous + return {current: previous, previous} + } + + const current = existing ?? GlobalConfig.create(randomUUID()) + const updated = current.withAnalytics(analytics) + await this.globalConfigStore.write(updated) + // Cache is in-process-authoritative — we trust the value just written. + // Cross-process changes (another daemon writing the same file, manual + // edits) are NOT observable until the next daemon restart. The + // single-daemon model makes this safe today. + this.cachedAnalytics = updated.analytics + return {current: updated.analytics, previous} + } + private async read(): Promise { const existing = await this.globalConfigStore.read() if (existing) { @@ -125,25 +154,15 @@ export class GlobalConfigHandler { } private async setAnalytics(analytics: boolean): Promise { - const existing = await this.globalConfigStore.read() - const previous = existing?.analytics ?? false - - // Idempotent fast path: short-circuit before generating a deviceId. - // If existing is undefined and the requested value matches the default - // (false), no file is created — the next GET will seed. - if (previous === analytics) { - this.cachedAnalytics = previous - return {current: previous, previous} - } - - const current = existing ?? GlobalConfig.create(randomUUID()) - const updated = current.withAnalytics(analytics) - await this.globalConfigStore.write(updated) - // Cache is in-process-authoritative — we trust the value just written. - // Cross-process changes (another daemon writing the same file, manual - // edits) are NOT observable until the next daemon restart. The - // single-daemon model makes this safe today. - this.cachedAnalytics = updated.analytics - return {current: updated.analytics, previous} + // Serialize against any in-flight SET_ANALYTICS so concurrent enables + // on a fresh install do not both seed independent deviceIds. + const next = this.writeChain.then(async () => this.doSetAnalytics(analytics)) + // Chain itself swallows errors so a failure in one call does NOT + // reject all subsequent calls; the awaiter still observes its own error. + this.writeChain = next.then( + () => {}, + () => {}, + ) + return next } } diff --git a/test/unit/infra/transport/handlers/global-config-handler.test.ts b/test/unit/infra/transport/handlers/global-config-handler.test.ts index 8a488b1ae..07b57170f 100644 --- a/test/unit/infra/transport/handlers/global-config-handler.test.ts +++ b/test/unit/infra/transport/handlers/global-config-handler.test.ts @@ -178,5 +178,28 @@ describe('GlobalConfigHandler', () => { expect(handler.getCachedAnalytics()).to.be.true }) + + it('serializes concurrent enables from a fresh install: writes once, single deviceId persists', async () => { + // Both callers observe the same fresh-install (no config). Without + // serialization both would create a different deviceId and both would + // write — last-write wins and the loser's response carries a deviceId + // that no longer exists on disk. With serialization the first writes + // a fresh uuid and the second hits the idempotent fast-path. + store.read.resolves() + const writtenDeviceIds: string[] = [] + store.write.callsFake(async (cfg: GlobalConfig) => { + // Simulate the on-disk seeding so the second serialized caller's + // read sees the now-written config. + writtenDeviceIds.push(cfg.deviceId) + store.read.resolves(cfg) + }) + + const [first, second] = await Promise.all([callSet(true), callSet(true)]) + + expect(store.write.callCount, 'concurrent enables must serialize to a single write').to.equal(1) + expect(writtenDeviceIds, 'exactly one deviceId persisted').to.have.lengthOf(1) + expect(first.current).to.be.true + expect(second.current).to.be.true + }) }) }) diff --git a/test/unit/oclif/lib/build-cli-metadata.test.ts b/test/unit/oclif/lib/build-cli-metadata.test.ts index b0a442f24..ee69dd637 100644 --- a/test/unit/oclif/lib/build-cli-metadata.test.ts +++ b/test/unit/oclif/lib/build-cli-metadata.test.ts @@ -44,48 +44,75 @@ describe('buildCliMetadata', () => { }) it('produces a CliMetadataSchema-parseable object', () => { - const result = buildCliMetadata('query', {format: 'text'}) + const result = buildCliMetadata('query', {flags: {format: 'text'}}) expect(CliMetadataSchema.safeParse(result).success).to.equal(true) }) - it('sets command_id from the first argument and flag_names from Object.keys(flags)', () => { - const result = buildCliMetadata('vc:add', {detach: true, format: 'text'}) + it('sets command_id from the first argument and flag_names from user-passed flag keys', () => { + const result = buildCliMetadata('vc:add', {flags: {detach: true, format: 'text'}}) expect(result.command_id).to.equal('vc:add') expect(result.flag_names).to.have.members(['detach', 'format']) expect(result.flag_names).to.have.lengthOf(2) }) + it('filters out flags that oclif populated from defaults (only user-passed flags survive)', () => { + // dream-style invocation: oclif parses `--force` from argv and fills + // detach/format/timeout/undo from static defaults. Only `force` is + // user-passed; the four defaults must not appear in flag_names. + const result = buildCliMetadata('dream', { + flags: {detach: false, force: true, format: 'text', timeout: 1800, undo: false}, + metadata: { + flags: { + detach: {setFromDefault: true}, + force: {setFromDefault: false}, + format: {setFromDefault: true}, + timeout: {setFromDefault: true}, + undo: {setFromDefault: true}, + }, + }, + }) + expect(result.flag_names).to.deep.equal(['force']) + }) + + it('treats absent metadata as "all flags are user-passed" (backward-compatible default)', () => { + // When the caller cannot supply metadata (legacy test fixtures), every + // key in flags is reported as user-passed. Matches the previous + // Object.keys(flags) behaviour for callers that have not been migrated. + const result = buildCliMetadata('vc:add', {flags: {detach: true, format: 'text'}}) + expect(result.flag_names).to.have.members(['detach', 'format']) + }) + it('emits an empty flag_names array when no flags passed', () => { - const result = buildCliMetadata('status', {}) + const result = buildCliMetadata('status', {flags: {}}) expect(result.flag_names).to.deep.equal([]) }) it('sets client_sent_at to Date.now() (mocked here)', () => { - const result = buildCliMetadata('query', {}) + const result = buildCliMetadata('query', {flags: {}}) expect(result.client_sent_at).to.equal(1_700_000_000_000) }) describe('is_ci', () => { it('false when CI env unset', () => { - const result = buildCliMetadata('query', {}) + const result = buildCliMetadata('query', {flags: {}}) expect(result.is_ci).to.equal(false) }) it('true when CI=true', () => { process.env.CI = 'true' - const result = buildCliMetadata('query', {}) + const result = buildCliMetadata('query', {flags: {}}) expect(result.is_ci).to.equal(true) }) it('true when CI=1', () => { process.env.CI = '1' - const result = buildCliMetadata('query', {}) + const result = buildCliMetadata('query', {flags: {}}) expect(result.is_ci).to.equal(true) }) it('false when CI=false (opt-out by convention)', () => { process.env.CI = 'false' - const result = buildCliMetadata('query', {}) + const result = buildCliMetadata('query', {flags: {}}) expect(result.is_ci).to.equal(false) }) }) @@ -93,13 +120,13 @@ describe('buildCliMetadata', () => { describe('is_tty', () => { it('false when stdout.isTTY is false', () => { setIsTty(false) - const result = buildCliMetadata('query', {}) + const result = buildCliMetadata('query', {flags: {}}) expect(result.is_tty).to.equal(false) }) it('true when stdout.isTTY is true', () => { setIsTty(true) - const result = buildCliMetadata('query', {}) + const result = buildCliMetadata('query', {flags: {}}) expect(result.is_tty).to.equal(true) }) }) @@ -107,68 +134,68 @@ describe('buildCliMetadata', () => { describe('package_manager', () => { it('npm when npm_config_user_agent starts with "npm/"', () => { process.env.npm_config_user_agent = 'npm/10.2.4 node/v20.0.0 darwin x64' - expect(buildCliMetadata('q', {}).package_manager).to.equal('npm') + expect(buildCliMetadata('q', {flags: {}}).package_manager).to.equal('npm') }) it('yarn when npm_config_user_agent starts with "yarn/"', () => { process.env.npm_config_user_agent = 'yarn/1.22.19 npm/? node/v20.0.0 darwin x64' - expect(buildCliMetadata('q', {}).package_manager).to.equal('yarn') + expect(buildCliMetadata('q', {flags: {}}).package_manager).to.equal('yarn') }) it('pnpm when npm_config_user_agent starts with "pnpm/"', () => { process.env.npm_config_user_agent = 'pnpm/8.10.0 npm/? node/v20.0.0 darwin x64' - expect(buildCliMetadata('q', {}).package_manager).to.equal('pnpm') + expect(buildCliMetadata('q', {flags: {}}).package_manager).to.equal('pnpm') }) it('bun when npm_config_user_agent starts with "bun/"', () => { process.env.npm_config_user_agent = 'bun/1.0.0 (linux x64)' - expect(buildCliMetadata('q', {}).package_manager).to.equal('bun') + expect(buildCliMetadata('q', {flags: {}}).package_manager).to.equal('bun') }) it('unknown when npm_config_user_agent unset', () => { - expect(buildCliMetadata('q', {}).package_manager).to.equal('unknown') + expect(buildCliMetadata('q', {flags: {}}).package_manager).to.equal('unknown') }) it('unknown when npm_config_user_agent is some unrecognised prefix', () => { process.env.npm_config_user_agent = 'rush/5 node/v20 darwin x64' - expect(buildCliMetadata('q', {}).package_manager).to.equal('unknown') + expect(buildCliMetadata('q', {flags: {}}).package_manager).to.equal('unknown') }) }) describe('runtime', () => { it('node when process.versions.bun is absent (default test env)', () => { - expect(buildCliMetadata('q', {}).runtime).to.equal('node') + expect(buildCliMetadata('q', {flags: {}}).runtime).to.equal('node') }) }) describe('terminal_program', () => { it('omitted when TERM_PROGRAM unset', () => { - const result = buildCliMetadata('q', {}) + const result = buildCliMetadata('q', {flags: {}}) expect(result).to.not.have.property('terminal_program') }) it('omitted when TERM_PROGRAM is empty string', () => { process.env.TERM_PROGRAM = '' - const result = buildCliMetadata('q', {}) + const result = buildCliMetadata('q', {flags: {}}) expect(result).to.not.have.property('terminal_program') }) it('included verbatim when TERM_PROGRAM is non-empty', () => { process.env.TERM_PROGRAM = 'WezTerm' - const result = buildCliMetadata('q', {}) + const result = buildCliMetadata('q', {flags: {}}) expect(result.terminal_program).to.equal('WezTerm') }) }) it('does not mutate the input flags object', () => { const flags = {detach: true} - buildCliMetadata('q', flags) + buildCliMetadata('q', {flags}) expect(flags).to.deep.equal({detach: true}) }) it('returns a fresh object per call (no shared mutable state)', () => { - const a = buildCliMetadata('q', {}) - const b = buildCliMetadata('q', {}) + const a = buildCliMetadata('q', {flags: {}}) + const b = buildCliMetadata('q', {flags: {}}) expect(a).to.not.equal(b) }) }) diff --git a/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts b/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts index 9eb01fa0a..7ecf0b0ad 100644 --- a/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts +++ b/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts @@ -399,6 +399,33 @@ describe('JsonlAnalyticsStore', () => { expect(store.droppedSentCount()).to.equal(1) }) + it('should drop multiple oldest sent rows in one compaction pass (single append)', async () => { + // Locks in the single-pass compaction invariant: dropping N sent + // rows in one append must drop the N oldest ones and stop dropping + // as soon as the file is under cap. + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir, maxRows: 4}) + await store.append(makeRecord({id: 's1', timestamp: 100})) + await store.append(makeRecord({id: 's2', timestamp: 200})) + await store.append(makeRecord({id: 's3', timestamp: 300})) + await store.append(makeRecord({id: 'p4', timestamp: 400})) // pending + await store.updateStatus(['s1', 's2', 's3'], 'sent') + + // Two new pending rows pushes count to 6 — must drop two oldest sent (s1, s2). + await store.append(makeRecord({id: 'p5', timestamp: 500})) + await store.append(makeRecord({id: 'p6', timestamp: 600})) + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id) + expect(ids).to.not.include('s1') + expect(ids).to.not.include('s2') + expect(ids).to.include('s3') // newest sent survives + expect(ids).to.include('p4') + expect(ids).to.include('p5') + expect(ids).to.include('p6') + expect(store.droppedSentCount()).to.equal(2) + }) + it('should preserve pending and failed rows during compaction', async () => { const baseDir = await freshTempDir() const store = new JsonlAnalyticsStore({baseDir, maxRows: 3}) From 4fd67d465c63a673de3b31665b68be86540d9a72 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 18 May 2026 14:46:16 +0700 Subject: [PATCH 38/87] fix: async TaskLifecycleHook.onToolResult + per-task queue + two-pass dispatch - ITaskLifecycleHook.onToolResult is now Promise; TaskRouter awaits it inside the existing try/catch so rejections cannot escape as unhandled. - AnalyticsHook reads frontmatter via injectable readFile (node:fs/promises). Adds private pendingByTask Map> so concurrent TOOL_RESULT events for the same task emit CURATE_OPERATION_APPLIED in arrival order even when underlying disk reads settle in arbitrary order. Terminal hooks (onTaskCompleted / dispatchTerminal) await the queue before emitting CURATE_RUN_COMPLETED / QUERY_COMPLETED. buildQueryCompletedPayload uses Promise.all (preserves array order). cleanup() drops both tasks and pendingByTask entries. - TaskRouter.routeLlmEvent + registerLlmEvent are async. routeLlmEvent uses two-pass dispatch for onToolResult: pass 1 calls every hook synchronously (so each hook's sync registration code, e.g. pendingByTask.set, runs before any await yields); pass 2 awaits each Promise in array order. Without two-pass, hook[N]'s sync body would be deferred until hook[N-1]'s Promise resolved, leaving a window where a racing TASK_COMPLETED handler observed an empty pendingByTask and emitted CURATE_RUN_COMPLETED before any per-op emit. - CurateLogHandler.onToolResult signature now async; body unchanged. - Tests: existing 33 cases in analytics-hook.test.ts updated with await; 4 new failing-first cases in 'async safety (per-task serialization)' lock in queue ordering, terminal-after-pending drain, readFile-reject swallow, and cleanup. New integration stress test analytics-hook-async-stress.test.ts drives the real TaskRouter with concurrent multi-task TOOL_RESULTs through stubbed-jitter readFile; 4 tests covering 20-op same-task serialization, 30-op two-task interleave, 50-op terminal stress, and three-task round-robin all pass. Full npm test: 8105 passing. --- .../process/i-task-lifecycle-hook.ts | 9 +- src/server/infra/process/analytics-hook.ts | 238 ++++++----- .../infra/process/curate-log-handler.ts | 2 +- src/server/infra/process/task-router.ts | 44 +- .../analytics-hook-async-stress.test.ts | 390 ++++++++++++++++++ .../infra/process/analytics-hook.test.ts | 169 +++++++- 6 files changed, 738 insertions(+), 114 deletions(-) create mode 100644 test/integration/infra/process/analytics-hook-async-stress.test.ts diff --git a/src/server/core/interfaces/process/i-task-lifecycle-hook.ts b/src/server/core/interfaces/process/i-task-lifecycle-hook.ts index c39b72e56..4248361fa 100644 --- a/src/server/core/interfaces/process/i-task-lifecycle-hook.ts +++ b/src/server/core/interfaces/process/i-task-lifecycle-hook.ts @@ -34,6 +34,11 @@ export interface ITaskLifecycleHook { * implement it. Implementations must never throw. */ onTaskUpdate?(task: TaskInfo): Promise - /** Called when an LLM tool result event is received for an ACTIVE task (not grace-period). */ - onToolResult?(taskId: string, payload: LlmToolResultEvent): void + /** + * Called when an LLM tool result event is received for an ACTIVE task (not grace-period). + * Now async so implementations (e.g. AnalyticsHook) can do non-blocking I/O without + * wedging the daemon event loop. TaskRouter awaits the returned promise inside its + * per-hook try/catch so rejections cannot escape as unhandled rejections. + */ + onToolResult?(taskId: string, payload: LlmToolResultEvent): Promise } diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index f4f615308..6d886a3c3 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -1,5 +1,5 @@ /* eslint-disable camelcase */ -import {readFileSync} from 'node:fs' +import {readFile as readFileAsync} from 'node:fs/promises' import type {AnalyticsEventName} from '../../../shared/analytics/event-names.js' import type {CurateRunCompletedProps} from '../../../shared/analytics/events/curate-run-completed.js' @@ -103,31 +103,56 @@ type AnalyticsHookDeps = { * in tests; production wires `() => globalConfigHandler.getCachedAnalytics()`. */ isEnabled?: () => boolean + /** + * Async file reader. Defaults to `node:fs/promises.readFile`. Injectable + * so unit tests can stub disk timing without touching the real filesystem + * (the per-task serialization tests in `analytics-hook.test.ts` rely on + * controlled `Deferred` promises here). + */ + readFile?: (filePath: string, encoding: 'utf8') => Promise } export class AnalyticsHook implements ITaskLifecycleHook { /** Lazy-injected by the daemon after `setupFeatureHandlers` constructs the client. */ private analyticsClient?: IAnalyticsClient private readonly isEnabled: () => boolean + /** + * Per-task FIFO of in-flight `onToolResult` processing. Without this the + * naive async refactor would let concurrent TOOL_RESULT events for the + * SAME task interleave their reads + emits (socket.io does NOT serialize + * async listener invocations). The map holds a NEVER-REJECTING chain so a + * thrown read in one op cannot poison subsequent ops on the same task. + * Drained by terminal hooks (`onTaskCompleted` / `dispatchTerminal`) + * before the run-completion emit goes out, then removed in `cleanup()`. + */ + private readonly pendingByTask = new Map>() + private readonly readFile: (filePath: string, encoding: 'utf8') => Promise /** In-memory state per active task. Cleared on cleanup(). */ private readonly tasks = new Map() constructor(deps: AnalyticsHookDeps = {}) { this.isEnabled = deps.isEnabled ?? (() => true) + this.readFile = deps.readFile ?? readFileAsync } cleanup(taskId: string): void { this.tasks.delete(taskId) + this.pendingByTask.delete(taskId) } async onTaskCancelled(taskId: string, task: TaskInfo): Promise { - this.dispatchTerminal(taskId, task, 'cancelled') + await this.dispatchTerminal(taskId, task, 'cancelled') } async onTaskCompleted(taskId: string, _result: string, task: TaskInfo): Promise { const state = this.tasks.get(taskId) if (!state) return + // Drain any in-flight per-op processing so CURATE_OPERATION_APPLIED emits + // land BEFORE the run-completion emit on the wire. The chain never + // rejects (see `onToolResult`), so this await is safe. + await this.pendingByTask.get(taskId) + if (state.flavor === 'curate') { const outcome = state.counters.failed > 0 ? 'partial' : 'completed' this.emit( @@ -137,7 +162,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { } else { this.emit( AnalyticsEventNames.QUERY_COMPLETED, - this.buildQueryCompletedPayload({outcome: 'completed', state, task, taskId}), + await this.buildQueryCompletedPayload({outcome: 'completed', state, task, taskId}), ) } } @@ -158,77 +183,26 @@ export class AnalyticsHook implements ITaskLifecycleHook { } async onTaskError(taskId: string, _errorMessage: string, task: TaskInfo): Promise { - this.dispatchTerminal(taskId, task, 'error') + await this.dispatchTerminal(taskId, task, 'error') } - onToolResult(taskId: string, payload: LlmToolResultEvent): void { - const state = this.tasks.get(taskId) - if (!state || state.flavor !== 'curate') return - - const ops = extractCurateOperations(payload) - for (const op of ops) { - if (op.status !== 'success') { - state.counters.failed++ - continue - } - - // Bump counters per op.type. UPSERT counts as `added` when the message - // hints at a new-file create (mirrors `computeSummary` in - // curate-log-handler.ts); otherwise treat as an update. - switch (op.type) { - case 'ADD': { - state.counters.added++ - break - } - - case 'DELETE': { - state.counters.deleted++ - break - } - - case 'MERGE': { - state.counters.merged++ - break - } - - case 'UPDATE': { - state.counters.updated++ - break - } - - case 'UPSERT': { - if (op.message?.includes('created new')) state.counters.added++ - else state.counters.updated++ - break - } - } - - if (op.needsReview === true) state.counters.pendingReview++ - - // `op.filePath` is optional on CurateLogOperation but every M12 emit - // requires absolute_path. Skip ops missing filePath so the daemon - // never emits a malformed row (these are rare; UPSERT/MERGE without - // a concrete file path would be the only realistic case). - if (!op.filePath) continue - - // M12.3: read post-op frontmatter for ADD / UPDATE / MERGE-target / - // UPSERT. DELETE skips the read (file is gone). Frontmatter fields - // stay absent when the read fails (ENOENT, EACCES, malformed YAML). - const frontmatter = op.type === 'DELETE' ? {} : this.readFrontmatterFields(op.filePath) - - this.emit(AnalyticsEventNames.CURATE_OPERATION_APPLIED, { - absolute_path: op.filePath, - ...(op.confidence ? {confidence: op.confidence} : {}), - ...(op.impact ? {impact: op.impact} : {}), - ...(frontmatter.keywords ? {keywords: frontmatter.keywords} : {}), - knowledge_path: op.path, - needs_review: op.needsReview ?? false, - operation_type: op.type, - ...(frontmatter.related ? {related: frontmatter.related} : {}), - ...(frontmatter.tags ? {tags: frontmatter.tags} : {}), - task_id: taskId, - }) - } + async onToolResult(taskId: string, payload: LlmToolResultEvent): Promise { + // Chain onto any in-flight processing for THIS task so: + // 1. Per-op CURATE_OPERATION_APPLIED emits land in arrival order, + // even when a later op's read settles before an earlier op's read. + // 2. The terminal emit (drained via pendingByTask.get(taskId) in + // onTaskCompleted / dispatchTerminal) observes ALL per-op emits. + // The map stores a never-rejecting tail (`.catch(() => {})`) so a + // failure in one onToolResult cannot poison subsequent ones — but the + // returned `next` preserves rejection so the caller observes its own + // error (TaskRouter logs it). + const prev = this.pendingByTask.get(taskId) ?? Promise.resolve() + const next = prev.then(async () => this.processToolResult(taskId, payload)) + this.pendingByTask.set( + taskId, + next.catch(() => {}), + ) + await next } /** @@ -276,7 +250,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { } } - private buildQueryCompletedPayload({ + private async buildQueryCompletedPayload({ outcome, state, task, @@ -286,7 +260,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { state: QueryTaskAnalyticsState task: TaskInfo taskId: string - }): QueryCompletedProps { + }): Promise { const readPaths = new Set() let readToolCallCount = 0 let searchCallCount = 0 @@ -327,18 +301,22 @@ export class AnalyticsHook implements ITaskLifecycleHook { const tier = state.queryMeta?.tier const matchedDocCount = state.queryMeta?.searchMetadata?.resultCount ?? 0 - // M12.3: harvest per-path frontmatter on the same sync read path used + // M12.3: harvest per-path frontmatter on the same async read path used // for curate emits. Entries whose file is unreadable / has no frontmatter // carry `absolute_path` alone (the three array fields stay absent). - const readPathsWithMetadata = cappedPaths.map((p) => { - const fm = this.readFrontmatterFields(p) - return { - absolute_path: p, - ...(fm.keywords ? {keywords: fm.keywords} : {}), - ...(fm.related ? {related: fm.related} : {}), - ...(fm.tags ? {tags: fm.tags} : {}), - } - }) + // `Promise.all` preserves input-array order in the result regardless of + // which read settles first. + const readPathsWithMetadata = await Promise.all( + cappedPaths.map(async (p) => { + const fm = await this.readFrontmatterFields(p) + return { + absolute_path: p, + ...(fm.keywords ? {keywords: fm.keywords} : {}), + ...(fm.related ? {related: fm.related} : {}), + ...(fm.tags ? {tags: fm.tags} : {}), + } + }), + ) return { cache_hit: tier === 0 || tier === 1, @@ -358,10 +336,14 @@ export class AnalyticsHook implements ITaskLifecycleHook { } } - private dispatchTerminal(taskId: string, task: TaskInfo, outcome: 'cancelled' | 'error'): void { + private async dispatchTerminal(taskId: string, task: TaskInfo, outcome: 'cancelled' | 'error'): Promise { const state = this.tasks.get(taskId) if (!state) return + // Drain any in-flight per-op processing so CURATE_OPERATION_APPLIED + // emits land before this terminal emit. Symmetric to onTaskCompleted. + await this.pendingByTask.get(taskId) + if (state.flavor === 'curate') { this.emit( AnalyticsEventNames.CURATE_RUN_COMPLETED, @@ -370,7 +352,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { } else { this.emit( AnalyticsEventNames.QUERY_COMPLETED, - this.buildQueryCompletedPayload({outcome, state, task, taskId}), + await this.buildQueryCompletedPayload({outcome, state, task, taskId}), ) } } @@ -389,23 +371,95 @@ export class AnalyticsHook implements ITaskLifecycleHook { } } + private async processToolResult(taskId: string, payload: LlmToolResultEvent): Promise { + const state = this.tasks.get(taskId) + if (!state || state.flavor !== 'curate') return + + const ops = extractCurateOperations(payload) + for (const op of ops) { + if (op.status !== 'success') { + state.counters.failed++ + continue + } + + // Bump counters per op.type. UPSERT counts as `added` when the message + // hints at a new-file create (mirrors `computeSummary` in + // curate-log-handler.ts); otherwise treat as an update. + switch (op.type) { + case 'ADD': { + state.counters.added++ + break + } + + case 'DELETE': { + state.counters.deleted++ + break + } + + case 'MERGE': { + state.counters.merged++ + break + } + + case 'UPDATE': { + state.counters.updated++ + break + } + + case 'UPSERT': { + if (op.message?.includes('created new')) state.counters.added++ + else state.counters.updated++ + break + } + } + + if (op.needsReview === true) state.counters.pendingReview++ + + // `op.filePath` is optional on CurateLogOperation but every M12 emit + // requires absolute_path. Skip ops missing filePath so the daemon + // never emits a malformed row (these are rare; UPSERT/MERGE without + // a concrete file path would be the only realistic case). + if (!op.filePath) continue + + // M12.3: read post-op frontmatter for ADD / UPDATE / MERGE-target / + // UPSERT. DELETE skips the read (file is gone). Frontmatter fields + // stay absent when the read fails (ENOENT, EACCES, malformed YAML). + // eslint-disable-next-line no-await-in-loop -- emit order MUST match op order + const frontmatter = op.type === 'DELETE' ? {} : await this.readFrontmatterFields(op.filePath) + + this.emit(AnalyticsEventNames.CURATE_OPERATION_APPLIED, { + absolute_path: op.filePath, + ...(op.confidence ? {confidence: op.confidence} : {}), + ...(op.impact ? {impact: op.impact} : {}), + ...(frontmatter.keywords ? {keywords: frontmatter.keywords} : {}), + knowledge_path: op.path, + needs_review: op.needsReview ?? false, + operation_type: op.type, + ...(frontmatter.related ? {related: frontmatter.related} : {}), + ...(frontmatter.tags ? {tags: frontmatter.tags} : {}), + task_id: taskId, + }) + } + } + /** * Read the YAML frontmatter from `filePath` and return only `tags` / * `keywords` / `related` arrays (capped at 50 entries / 256 chars per * entry). Returns an empty object on ANY failure: ENOENT, EACCES, * permission errors, malformed YAML. Telemetry MUST NOT crash the hook. * - * Synchronous I/O on local disk: a single read is sub-millisecond on - * SSD; curate runs emit at most one read per op, query at most ten - * reads at task completion. The blocking cost is negligible against - * the analytics value of the harvested metadata. + * Async (`node:fs/promises.readFile`) so the daemon event loop is free + * to serve other transport requests while the read is in flight. The + * per-task queue in `onToolResult` enforces emit-arrival order across + * concurrent invocations on the same task; for query-task termination + * `Promise.all` parallelises up to 10 reads while preserving array order. * * Short-circuits when analytics is disabled to avoid wasted disk I/O. */ - private readFrontmatterFields(filePath: string): FrontmatterFields { + private async readFrontmatterFields(filePath: string): Promise { if (!this.isEnabled()) return {} try { - const content = readFileSync(filePath, 'utf8') + const content = await this.readFile(filePath, 'utf8') const parsed = parseFrontmatter(content) if (parsed === null) return {} return { diff --git a/src/server/infra/process/curate-log-handler.ts b/src/server/infra/process/curate-log-handler.ts index d0c524ca2..83500c94c 100644 --- a/src/server/infra/process/curate-log-handler.ts +++ b/src/server/infra/process/curate-log-handler.ts @@ -257,7 +257,7 @@ export class CurateLogHandler implements ITaskLifecycleHook { }) } - onToolResult(taskId: string, payload: LlmToolResultEvent): void { + async onToolResult(taskId: string, payload: LlmToolResultEvent): Promise { const state = this.tasks.get(taskId) if (!state) return diff --git a/src/server/infra/process/task-router.ts b/src/server/infra/process/task-router.ts index 881734437..8d2dd20fd 100644 --- a/src/server/infra/process/task-router.ts +++ b/src/server/infra/process/task-router.ts @@ -1652,9 +1652,12 @@ export class TaskRouter { } private registerLlmEvent(eventName: E): void { - this.transport.onRequest(eventName, (data) => { + this.transport.onRequest(eventName, async (data) => { if (!hasTaskId(data)) return - this.routeLlmEvent(eventName, data) + // `routeLlmEvent` is async because TOOL_RESULT hooks (AnalyticsHook) + // do disk I/O. socket-io-transport-server awaits this handler, so + // OTHER sockets remain serviceable while this one's hook chain runs. + await this.routeLlmEvent(eventName, data) }) } @@ -1707,8 +1710,15 @@ export class TaskRouter { * Generic handler for routing LLM events from Agent to clients. * Checks both active and recently completed tasks (within grace period). * onToolResult hooks are called only for ACTIVE tasks (not grace-period). + * + * Async because TOOL_RESULT hooks may do disk I/O (AnalyticsHook reads + * post-op frontmatter). The hook chain is awaited sequentially so a + * caught sync throw OR an awaited rejection both land in the same + * try/catch — no unhandled rejection can escape. Intra-task ordering + * across concurrent TOOL_RESULT events is enforced INSIDE the hook + * (e.g. `AnalyticsHook.pendingByTask` queue), not here. */ - private routeLlmEvent(eventName: string, data: {[key: string]: unknown; taskId: string}): void { + private async routeLlmEvent(eventName: string, data: {[key: string]: unknown; taskId: string}): Promise { const {taskId, ...rest} = data const activeTask = this.tasks.get(taskId) const task = activeTask ?? this.completedTasks.get(taskId)?.task @@ -1725,11 +1735,35 @@ export class TaskRouter { this.accumulateLlmEvent(taskId, eventName, data) } - // Notify onToolResult hooks only for active tasks + // Notify onToolResult hooks only for active tasks. + // + // Two-pass dispatch: (1) call every hook's onToolResult SYNCHRONOUSLY so + // each hook's sync registration code runs before any await yields + // (critical for `AnalyticsHook.pendingByTask` — the per-task queue must + // observe THIS op before a racing TASK_COMPLETED handler reads it); + // (2) await each returned Promise in array order so per-hook async work + // settles before the broadcast and rejections still land in the try/catch. + // The single-pass `for-await` shape would defer hook[N]'s sync body until + // hook[N-1]'s Promise resolves, leaving racing terminal handlers a + // window in which `pendingByTask` is still empty. if (activeTask && eventName === LlmEventNames.TOOL_RESULT) { + const promises: Array | undefined> = [] for (const hook of this.lifecycleHooks) { try { - hook.onToolResult?.(taskId, data as unknown as LlmToolResultEvent) + promises.push(hook.onToolResult?.(taskId, data as unknown as LlmToolResultEvent)) + } catch (error) { + transportLog( + `LifecycleHook.onToolResult sync error for ${taskId}: ${error instanceof Error ? error.message : String(error)}`, + ) + promises.push(undefined) + } + } + + for (const p of promises) { + if (p === undefined) continue + try { + // eslint-disable-next-line no-await-in-loop -- sequential await by design + await p } catch (error) { transportLog( `LifecycleHook.onToolResult error for ${taskId}: ${error instanceof Error ? error.message : String(error)}`, diff --git a/test/integration/infra/process/analytics-hook-async-stress.test.ts b/test/integration/infra/process/analytics-hook-async-stress.test.ts new file mode 100644 index 000000000..d2bec687a --- /dev/null +++ b/test/integration/infra/process/analytics-hook-async-stress.test.ts @@ -0,0 +1,390 @@ +/** + * AnalyticsHook async stress test — drives the real `TaskRouter` over a stub + * transport with a real `AnalyticsHook` + `CurateLogHandler` to verify the + * per-task FIFO queue holds under concurrent multi-task TOOL_RESULT load. + * + * Covers the audit's Scenario F (intra-task interleaving) and a multi-task + * variant: emits MUST arrive in arrival order per-task even when underlying + * disk reads happen with microtask-scale jitter, AND terminal + * CURATE_RUN_COMPLETED MUST land AFTER every per-op emit for that task. + * + * Implementation note: the per-task queue inside AnalyticsHook serializes + * `readFrontmatterFields` calls per-task, so reads happen one-at-a-time + * for a single task. Multi-task reads can interleave across tasks. + */ + +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {LlmToolResultEvent} from '../../../../src/server/core/domain/transport/schemas.js' +import type {TaskInfo} from '../../../../src/server/core/domain/transport/task-info.js' +import type {IAgentPool, SubmitTaskResult} from '../../../../src/server/core/interfaces/agent/i-agent-pool.js' +import type {IAnalyticsClient} from '../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {IProjectRegistry} from '../../../../src/server/core/interfaces/project/i-project-registry.js' +import type {IProjectRouter} from '../../../../src/server/core/interfaces/routing/i-project-router.js' +import type { + ITransportServer, + RequestHandler, +} from '../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {LlmEventNames, TransportTaskEventNames} from '../../../../src/server/core/domain/transport/schemas.js' +import {AnalyticsHook} from '../../../../src/server/infra/process/analytics-hook.js' +import {CurateLogHandler} from '../../../../src/server/infra/process/curate-log-handler.js' +import {TaskRouter} from '../../../../src/server/infra/process/task-router.js' +import {AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' + +function makeStubTransport(sandbox: SinonSandbox): { + requestHandlers: Map + transport: ITransportServer +} { + const requestHandlers = new Map() + const transport: ITransportServer = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub().returns(3000), + isRunning: sandbox.stub().returns(true), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers.set(event, handler) + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + return {requestHandlers, transport} +} + +function makeStubAgentPool(sandbox: SinonSandbox): IAgentPool { + return { + getEntries: sandbox.stub().returns([]), + getSize: sandbox.stub().returns(0), + handleAgentDisconnected: sandbox.stub(), + hasAgent: sandbox.stub().returns(false), + markIdle: sandbox.stub(), + notifyTaskCompleted: sandbox.stub(), + shutdown: sandbox.stub().resolves(), + submitTask: sandbox.stub().resolves({success: true} as SubmitTaskResult), + } +} + +function makeStubProjectRegistry(sandbox: SinonSandbox): IProjectRegistry { + const projectInfo = { + projectPath: '/proj', + registeredAt: Date.now(), + sanitizedPath: '_proj', + storagePath: '/data/proj', + } + return { + get: sandbox.stub().returns(projectInfo), + getAll: sandbox.stub().returns(new Map()), + register: sandbox.stub().returns(projectInfo), + unregister: sandbox.stub().returns(true), + } +} + +function makeStubProjectRouter(sandbox: SinonSandbox): IProjectRouter { + return { + addToProjectRoom: sandbox.stub(), + broadcastToProject: sandbox.stub(), + getProjectMembers: sandbox.stub().returns([]), + removeFromProjectRoom: sandbox.stub(), + } +} + +function makeAnalyticsClient(sandbox: SinonSandbox): {client: IAnalyticsClient; trackStub: SinonStub} { + const trackStub = sandbox.stub() + const client: IAnalyticsClient = { + flush: sandbox.stub().resolves(), + track: trackStub, + } + return {client, trackStub} +} + +const buildToolResult = (taskId: string, op: Record): LlmToolResultEvent => + ({ + callId: `call-${randomUUID()}`, + result: JSON.stringify({applied: [op]}), + sessionId: 'session-1', + taskId, + timestamp: Date.now(), + toolName: 'curate', + }) as unknown as LlmToolResultEvent + +const buildCurateTaskInfo = (taskId: string): TaskInfo => + ({ + clientId: 'client-1', + completedAt: Date.now(), + content: 'curate', + createdAt: Date.now() - 1000, + projectPath: '/proj', + status: 'completed', + taskId, + type: 'curate', + }) as unknown as TaskInfo + +const dummyFrontmatter = (tag: string): string => `---\ntags: ["${tag}"]\n---\nbody\n` + +const microtaskTick = async (count: number): Promise => { + for (let i = 0; i < count; i++) { + // eslint-disable-next-line no-await-in-loop + await Promise.resolve() + } +} + +describe('AnalyticsHook async stress (integration through TaskRouter)', () => { + let sandbox: SinonSandbox + let trackStub: SinonStub + let analyticsHook: AnalyticsHook + let curateLogHandler: CurateLogHandler + let createHandler: RequestHandler + let toolResultHandler: RequestHandler + /** Records the order in which readFile is called (for serialization assertions). */ + let readFileCallOrder: string[] + + beforeEach(() => { + sandbox = createSandbox() + const {requestHandlers, transport} = makeStubTransport(sandbox) + const agentPool = makeStubAgentPool(sandbox) + const projectRegistry = makeStubProjectRegistry(sandbox) + const projectRouter = makeStubProjectRouter(sandbox) + + readFileCallOrder = [] + // Stubbed readFile: returns a Promise that resolves AFTER a few microtasks + // so the awaited read actually yields control to the event loop. The + // microtask jitter is what makes this a "stress" test — it simulates the + // real async behaviour of `node:fs/promises.readFile` without flaky + // wall-clock timers. + const stubReadFile: (filePath: string, encoding: 'utf8') => Promise = async (filePath) => { + readFileCallOrder.push(filePath) + // Jitter: yield 3 microtasks before returning. Combined with the per-task + // queue this means reads for the same task are strictly serialized; reads + // across tasks may interleave at microtask boundaries. + await microtaskTick(3) + // Derive a stable tag from the filename for the emitted frontmatter. + const tag = filePath.replaceAll(/[^a-zA-Z0-9]+/g, '-') + return dummyFrontmatter(tag) + } + + const bundle = makeAnalyticsClient(sandbox) + trackStub = bundle.trackStub + analyticsHook = new AnalyticsHook({readFile: stubReadFile}) + analyticsHook.setAnalyticsClient(bundle.client) + // No-op store: stress test does not assert on disk log persistence. + curateLogHandler = new CurateLogHandler(() => ({ + batchUpdateOperationReviewStatus: sandbox.stub().resolves(true), + getById: sandbox.stub().resolves(null), + getNextId: sandbox.stub().resolves('log-1'), + list: sandbox.stub().resolves([]), + save: sandbox.stub().resolves(), + })) + + const router = new TaskRouter({ + agentPool, + getAgentForProject: () => 'agent-1', + lifecycleHooks: [curateLogHandler, analyticsHook], + projectRegistry, + projectRouter, + resolveClientProjectPath: () => '/proj', + transport, + }) + router.setup() + + const create = requestHandlers.get(TransportTaskEventNames.CREATE) + const toolResult = requestHandlers.get(LlmEventNames.TOOL_RESULT) + if (!create || !toolResult) throw new Error('expected handlers not registered') + createHandler = create + toolResultHandler = toolResult + }) + + afterEach(() => { + sandbox.restore() + }) + + async function createCurateTask(taskId: string): Promise { + await createHandler({content: 'curate', projectPath: '/proj', taskId, type: 'curate'}, 'client-1') + } + + function fireToolResult(taskId: string, opSpec: {filePath: string; path: string}): Promise { + const payload = buildToolResult(taskId, { + filePath: opSpec.filePath, + needsReview: false, + path: opSpec.path, + status: 'success', + type: 'ADD', + }) + return toolResultHandler(payload as unknown, 'client-1') as Promise + } + + function getCurateOpEmits(taskId: string): Array> { + return trackStub + .getCalls() + .filter( + (c) => + c.args[0] === AnalyticsEventNames.CURATE_OPERATION_APPLIED && + (c.args[1] as {task_id: string}).task_id === taskId, + ) + .map((c) => c.args[1] as Record) + } + + function getEmitSequenceForTask(taskId: string): string[] { + return trackStub + .getCalls() + .filter((c) => { + const props = c.args[1] as {task_id: string} + return props.task_id === taskId + }) + .map((c) => c.args[0] as string) + } + + it('serializes reads per task: 20 concurrent TOOL_RESULTs for one task call readFile in arrival order', async () => { + const taskId = 'task-A' + await createCurateTask(taskId) + + const opSpecs = Array.from({length: 20}, (_, i) => ({ + filePath: `/A/op-${String(i).padStart(2, '0')}.md`, + path: `notes/A/op-${i}`, + })) + + // Fire all 20 concurrently — the routeLlmEvent handler awaits the hook + // chain, but each fire returns its own Promise and we let them race. + const promises = opSpecs.map((spec) => fireToolResult(taskId, spec)) + await Promise.all(promises) + + // readFile call order must match arrival order (proves per-task queue). + expect(readFileCallOrder, 'readFile call order = arrival order').to.deep.equal(opSpecs.map((s) => s.filePath)) + + // Emit order must match arrival order. + const emits = getCurateOpEmits(taskId) + expect(emits).to.have.lengthOf(20) + for (const [i, emit] of emits.entries()) { + expect(emit.absolute_path, `emit #${i} arrival order`).to.equal(opSpecs[i].filePath) + } + }) + + it('preserves per-task arrival order across two tasks under interleaved fire order (30 ops total)', async () => { + await createCurateTask('task-X') + await createCurateTask('task-Y') + + const xSpecs = Array.from({length: 15}, (_, i) => ({ + filePath: `/X/op-${String(i).padStart(2, '0')}.md`, + path: `notes/X/op-${i}`, + })) + const ySpecs = Array.from({length: 15}, (_, i) => ({ + filePath: `/Y/op-${String(i).padStart(2, '0')}.md`, + path: `notes/Y/op-${i}`, + })) + + // Interleave fire order: X0, Y0, X1, Y1, … so cross-task scheduling + // jitter is maximised. + const promises: Array> = [] + for (let i = 0; i < 15; i++) { + promises.push(fireToolResult('task-X', xSpecs[i]), fireToolResult('task-Y', ySpecs[i])) + } + + await Promise.all(promises) + + // Per-task emit order must match per-task arrival order regardless of + // cross-task interleaving. + const xEmits = getCurateOpEmits('task-X') + const yEmits = getCurateOpEmits('task-Y') + expect(xEmits).to.have.lengthOf(15) + expect(yEmits).to.have.lengthOf(15) + for (let i = 0; i < 15; i++) { + expect(xEmits[i].absolute_path, `X emit #${i}`).to.equal(xSpecs[i].filePath) + expect(yEmits[i].absolute_path, `Y emit #${i}`).to.equal(ySpecs[i].filePath) + } + }) + + it('CURATE_RUN_COMPLETED lands after every per-op emit for the same task (50-op terminal stress)', async () => { + const taskId = 'task-Z' + await createCurateTask(taskId) + + const specs = Array.from({length: 50}, (_, i) => ({ + filePath: `/Z/op-${String(i).padStart(2, '0')}.md`, + path: `notes/Z/op-${i}`, + })) + + // Fire all ops, but DO NOT await before firing the terminal hook — + // exercises the dispatchTerminal/onTaskCompleted "drain pendingByTask" + // path. We `await Promise.all` AFTER both event types are queued so the + // task router can interleave them. + const opPromises = specs.map((spec) => fireToolResult(taskId, spec)) + const terminalPromise = analyticsHook.onTaskCompleted(taskId, '', buildCurateTaskInfo(taskId)) + + await Promise.all([...opPromises, terminalPromise]) + + const sequence = getEmitSequenceForTask(taskId) + + // Exactly 50 per-op emits + 1 terminal emit, terminal LAST. + expect( + sequence.filter((s) => s === AnalyticsEventNames.CURATE_OPERATION_APPLIED), + 'exactly 50 per-op emits', + ).to.have.lengthOf(50) + expect( + sequence.filter((s) => s === AnalyticsEventNames.CURATE_RUN_COMPLETED), + 'exactly 1 terminal emit', + ).to.have.lengthOf(1) + expect(sequence.at(-1), 'terminal is last in sequence').to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) + + // And per-op emit order matches arrival order. + const opEmits = getCurateOpEmits(taskId) + for (let i = 0; i < 50; i++) { + expect(opEmits[i].absolute_path, `op #${i} arrival order`).to.equal(specs[i].filePath) + } + }) + + it('three-task stress: 30 ops total (10 per task), per-task ordering and terminal sequencing all preserved', async () => { + const taskIds = ['task-P', 'task-Q', 'task-R'] as const + for (const id of taskIds) { + // eslint-disable-next-line no-await-in-loop + await createCurateTask(id) + } + + const specsByTask: Record> = { + 'task-P': Array.from({length: 10}, (_, i) => ({ + filePath: `/P/op-${String(i).padStart(2, '0')}.md`, + path: `notes/P/op-${i}`, + })), + 'task-Q': Array.from({length: 10}, (_, i) => ({ + filePath: `/Q/op-${String(i).padStart(2, '0')}.md`, + path: `notes/Q/op-${i}`, + })), + 'task-R': Array.from({length: 10}, (_, i) => ({ + filePath: `/R/op-${String(i).padStart(2, '0')}.md`, + path: `notes/R/op-${i}`, + })), + } + + // Round-robin fire across all three tasks. + const opPromises: Array> = [] + for (let i = 0; i < 10; i++) { + for (const id of taskIds) { + opPromises.push(fireToolResult(id, specsByTask[id][i])) + } + } + + // Fire terminal for each task in parallel with op processing. + const terminalPromises = taskIds.map((id) => analyticsHook.onTaskCompleted(id, '', buildCurateTaskInfo(id))) + + await Promise.all([...opPromises, ...terminalPromises]) + + // Every task must end with CURATE_RUN_COMPLETED preceded by 10 per-op emits in arrival order. + for (const id of taskIds) { + const sequence = getEmitSequenceForTask(id) + expect(sequence, `${id} sequence length`).to.have.lengthOf(11) + expect( + sequence.slice(0, 10).every((s) => s === AnalyticsEventNames.CURATE_OPERATION_APPLIED), + `${id}: first 10 are per-op emits`, + ).to.equal(true) + expect(sequence[10], `${id}: last is run-completed`).to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) + const opEmits = getCurateOpEmits(id) + for (let i = 0; i < 10; i++) { + expect(opEmits[i].absolute_path, `${id} op #${i} arrival order`).to.equal(specsByTask[id][i].filePath) + } + } + }) +}) diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts index c2c7e3c1b..4a0d4c369 100644 --- a/test/unit/server/infra/process/analytics-hook.test.ts +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -60,6 +60,32 @@ const buildQueryTask = (overrides: Partial = {}): TaskInfo => ...overrides, }) as TaskInfo +type Deferred = {promise: Promise; reject: (e: unknown) => void; resolve: (v: T) => void} +const defer = (): Deferred => { + let resolve!: (v: T) => void + let reject!: (e: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return {promise, reject, resolve} +} + +const buildFrontmatterDoc = (tag: string): string => `---\ntags: ["${tag}"]\n---\nbody\n` + +const stubReadFileFromQueue = + (...queue: Array>): ((p: string) => Promise) => + () => { + const next = queue.shift() + if (next === undefined) throw new Error('stubReadFileFromQueue exhausted') + return next + } + +const stubReadFileAlways = + (value: Promise): ((p: string) => Promise) => + () => + value + const buildToolResult = (ops: Array>): LlmToolResultEvent => ({ callId: 'call-1', result: JSON.stringify({applied: ops}), @@ -90,7 +116,7 @@ describe('AnalyticsHook', () => { {filePath: '/b.md', needsReview: true, path: 'notes/b', status: 'success', type: 'UPDATE'}, {filePath: '/c.md', needsReview: false, path: 'notes/c', status: 'failed', type: 'ADD'}, ]) - hook.onToolResult(task.taskId, payload) + await hook.onToolResult(task.taskId, payload) expect(trackStub.callCount).to.equal(2) expect(trackStub.firstCall.args[0]).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) @@ -111,7 +137,7 @@ describe('AnalyticsHook', () => { it('emits curate_run_completed at terminal with counter totals + outcome=completed', async () => { const task = buildCurateTask() await hook.onTaskCreate(task) - hook.onToolResult( + await hook.onToolResult( task.taskId, buildToolResult([ {filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}, @@ -141,7 +167,7 @@ describe('AnalyticsHook', () => { it('emits outcome=partial when at least one op failed', async () => { const task = buildCurateTask() await hook.onTaskCreate(task) - hook.onToolResult( + await hook.onToolResult( task.taskId, buildToolResult([ {filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}, @@ -182,7 +208,7 @@ describe('AnalyticsHook', () => { it('counts UPSERT with "created new" message as added; otherwise as updated', async () => { const task = buildCurateTask() await hook.onTaskCreate(task) - hook.onToolResult( + await hook.onToolResult( task.taskId, buildToolResult([ {filePath: '/a.md', message: 'created new entry', path: 'a', status: 'success', type: 'UPSERT'}, @@ -201,7 +227,7 @@ describe('AnalyticsHook', () => { it('counts pending review when needsReview=true on a successful op', async () => { const task = buildCurateTask() await hook.onTaskCreate(task) - hook.onToolResult( + await hook.onToolResult( task.taskId, buildToolResult([ {filePath: '/a.md', needsReview: true, path: 'a', status: 'success', type: 'ADD'}, @@ -228,7 +254,7 @@ describe('AnalyticsHook', () => { it('skips emitting op when op.filePath is missing (avoids invalid payload)', async () => { const task = buildCurateTask() await hook.onTaskCreate(task) - hook.onToolResult( + await hook.onToolResult( task.taskId, buildToolResult([{needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), ) @@ -419,7 +445,7 @@ describe('AnalyticsHook', () => { trackStub.throws(new Error('boom')) const task = buildCurateTask() await hook.onTaskCreate(task) - hook.onToolResult( + await hook.onToolResult( task.taskId, buildToolResult([{filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), ) @@ -432,7 +458,7 @@ describe('AnalyticsHook', () => { const task = buildCurateTask() await bareHook.onTaskCreate(task) // No throws, no client to assert against - bareHook.onToolResult( + await bareHook.onToolResult( task.taskId, buildToolResult([{filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), ) @@ -458,7 +484,7 @@ describe('AnalyticsHook', () => { const task = buildCurateTask() await hook.onTaskCreate(task) - hook.onToolResult( + await hook.onToolResult( task.taskId, buildToolResult([{filePath, needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), ) @@ -473,7 +499,7 @@ describe('AnalyticsHook', () => { const filePath = join(tmpDir, 'gone.md') const task = buildCurateTask() await hook.onTaskCreate(task) - hook.onToolResult( + await hook.onToolResult( task.taskId, buildToolResult([{filePath, needsReview: false, path: 'gone', status: 'success', type: 'DELETE'}]), ) @@ -488,7 +514,7 @@ describe('AnalyticsHook', () => { const filePath = join(tmpDir, 'missing.md') const task = buildCurateTask() await hook.onTaskCreate(task) - hook.onToolResult( + await hook.onToolResult( task.taskId, buildToolResult([{filePath, needsReview: false, path: 'm', status: 'success', type: 'UPDATE'}]), ) @@ -503,7 +529,7 @@ describe('AnalyticsHook', () => { const task = buildCurateTask() await hook.onTaskCreate(task) - hook.onToolResult( + await hook.onToolResult( task.taskId, buildToolResult([{filePath, needsReview: false, path: 'b', status: 'success', type: 'UPDATE'}]), ) @@ -520,7 +546,7 @@ describe('AnalyticsHook', () => { const task = buildCurateTask() await hook.onTaskCreate(task) - hook.onToolResult( + await hook.onToolResult( task.taskId, buildToolResult([{filePath, needsReview: false, path: 'h', status: 'success', type: 'UPDATE'}]), ) @@ -541,7 +567,7 @@ describe('AnalyticsHook', () => { const task = buildCurateTask({taskId: 'task-gated'}) await disabledHook.onTaskCreate(task) - disabledHook.onToolResult( + await disabledHook.onToolResult( task.taskId, buildToolResult([{filePath, needsReview: false, path: 'g', status: 'success', type: 'UPDATE'}]), ) @@ -623,4 +649,119 @@ describe('AnalyticsHook', () => { }) }) }) + + describe('async safety (per-task serialization)', () => { + it('serializes concurrent onToolResult calls for the same task in arrival order', async () => { + // Without the per-task queue, resolving the 2nd read first would cause op2's emit + // to land before op1. The queue must enforce arrival order regardless of read + // completion order. + const d1 = defer() + const d2 = defer() + const stubReadFile = stubReadFileFromQueue(d1.promise, d2.promise) + + const bundle = buildAnalyticsClient() + const queueHook = new AnalyticsHook({readFile: stubReadFile}) + queueHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask({taskId: 'task-queue-1'}) + await queueHook.onTaskCreate(task) + + const payload1 = buildToolResult([ + {filePath: '/op1.md', needsReview: false, path: 'notes/op1', status: 'success', type: 'ADD'}, + ]) + const payload2 = buildToolResult([ + {filePath: '/op2.md', needsReview: false, path: 'notes/op2', status: 'success', type: 'ADD'}, + ]) + + const p1 = queueHook.onToolResult(task.taskId, payload1) + const p2 = queueHook.onToolResult(task.taskId, payload2) + + // Resolve in reverse order — the queue must still emit op1 first. + d2.resolve(buildFrontmatterDoc('tag-op2')) + d1.resolve(buildFrontmatterDoc('tag-op1')) + + await Promise.all([p1, p2]) + + expect(bundle.trackStub.callCount).to.equal(2) + const first = bundle.trackStub.firstCall.args[1] as Record + const second = bundle.trackStub.secondCall.args[1] as Record + expect(first.absolute_path, 'first emit must be op1').to.equal('/op1.md') + expect(second.absolute_path, 'second emit must be op2').to.equal('/op2.md') + }) + + it('onTaskCompleted waits for in-flight onToolResult work before emitting CURATE_RUN_COMPLETED', async () => { + // The terminal emit MUST follow every per-op emit on the wire, even if the per-op + // read is still pending when onTaskCompleted fires. + const d = defer() + const stubReadFile = stubReadFileAlways(d.promise) + const bundle = buildAnalyticsClient() + const orderHook = new AnalyticsHook({readFile: stubReadFile}) + orderHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask({taskId: 'task-order-1'}) + await orderHook.onTaskCreate(task) + + const payload = buildToolResult([ + {filePath: '/in-flight.md', needsReview: false, path: 'notes/x', status: 'success', type: 'ADD'}, + ]) + + // Kick off the op processing (read pending), then immediately request terminal. + const opPromise = orderHook.onToolResult(task.taskId, payload) + const completePromise = orderHook.onTaskCompleted(task.taskId, '', task) + + // Neither emit can have fired yet — read is still pending. + expect(bundle.trackStub.called, 'no emit before read settles').to.equal(false) + + d.resolve(buildFrontmatterDoc('tag-x')) + await Promise.all([opPromise, completePromise]) + + expect(bundle.trackStub.callCount).to.equal(2) + expect(bundle.trackStub.firstCall.args[0]).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) + expect(bundle.trackStub.secondCall.args[0]).to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) + }) + + it('readFile rejection is swallowed: emit fires with frontmatter fields omitted; daemon does not crash', async () => { + const stubReadFile = stubReadFileAlways(Promise.reject(new Error('disk full'))) + const bundle = buildAnalyticsClient() + const errorHook = new AnalyticsHook({readFile: stubReadFile}) + errorHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask({taskId: 'task-err-1'}) + await errorHook.onTaskCreate(task) + + const payload = buildToolResult([ + {filePath: '/missing.md', needsReview: false, path: 'notes/missing', status: 'success', type: 'ADD'}, + ]) + + await errorHook.onToolResult(task.taskId, payload) + + expect(bundle.trackStub.calledOnce).to.equal(true) + const props = bundle.trackStub.firstCall.args[1] as Record + expect(props.absolute_path).to.equal('/missing.md') + expect(props).to.not.have.property('keywords') + expect(props).to.not.have.property('tags') + expect(props).to.not.have.property('related') + }) + + it('cleanup removes per-task pending-queue entry to prevent unbounded growth', async () => { + const stubReadFile = stubReadFileAlways(Promise.resolve('---\n---\n')) + const bundle = buildAnalyticsClient() + const cleanupHook = new AnalyticsHook({readFile: stubReadFile}) + cleanupHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask({taskId: 'task-cleanup-1'}) + await cleanupHook.onTaskCreate(task) + await cleanupHook.onToolResult(task.taskId, buildToolResult([ + {filePath: '/x.md', needsReview: false, path: 'notes/x', status: 'success', type: 'ADD'}, + ])) + await cleanupHook.onTaskCompleted(task.taskId, '', task) + cleanupHook.cleanup(task.taskId) + + // After cleanup, internal state must be empty. We don't expose pendingByTask + // directly, but the assertion below catches the leak: a new task with the same + // id observes a fresh in-memory state. + await cleanupHook.onTaskCreate(task) + expect(bundle.trackStub.callCount, 'no replay after cleanup').to.equal(2) + }) + }) }) From 0d6d7be36dc30e1e77ff7f972b11ec7a29209ae8 Mon Sep 17 00:00:00 2001 From: ncnthien Date: Mon, 18 May 2026 11:10:30 +0700 Subject: [PATCH 39/87] feat: [ENG-2621] add analytics settings panel to webui Configuration page - New AnalyticsPanel reads/writes analytics flag via existing globalConfig:get / globalConfig:setAnalytics transport events. - Toggle-on opens explicit-confirmation dialog with inline disclosure; toggle-off opens light confirmation. Both per M1.7 spec. - Inline "What data will be collected?" expander shares the 5-section disclosure with the enable dialog. Bodies are Lorem ipsum placeholders until M1.4 finalises PM/legal copy. - Bump @campfirein/byterover-packages 1.0.2 -> 1.0.4 to pick up the Collapsible component required by the panel. - Mounts in src/webui/pages/configuration-page.tsx after ConnectorsPanel. --- package-lock.json | 4 +- package.json | 2 +- packages/byterover-packages | 2 +- .../analytics/api/get-global-config.ts | 32 +++++ .../features/analytics/api/set-analytics.ts | 40 ++++++ .../analytics/components/analytics-panel.tsx | 128 ++++++++++++++++++ .../components/disable-confirm-dialog.tsx | 43 ++++++ .../components/disclosure-details.tsx | 22 +++ .../components/enable-confirm-dialog.tsx | 50 +++++++ src/webui/features/analytics/constants.ts | 39 ++++++ src/webui/pages/configuration-page.tsx | 2 + .../analytics/api/get-global-config.test.ts | 48 +++++++ .../analytics/api/set-analytics.test.ts | 55 ++++++++ .../features/analytics/constants.test.ts | 44 ++++++ 14 files changed, 507 insertions(+), 4 deletions(-) create mode 100644 src/webui/features/analytics/api/get-global-config.ts create mode 100644 src/webui/features/analytics/api/set-analytics.ts create mode 100644 src/webui/features/analytics/components/analytics-panel.tsx create mode 100644 src/webui/features/analytics/components/disable-confirm-dialog.tsx create mode 100644 src/webui/features/analytics/components/disclosure-details.tsx create mode 100644 src/webui/features/analytics/components/enable-confirm-dialog.tsx create mode 100644 src/webui/features/analytics/constants.ts create mode 100644 test/unit/webui/features/analytics/api/get-global-config.test.ts create mode 100644 test/unit/webui/features/analytics/api/set-analytics.test.ts create mode 100644 test/unit/webui/features/analytics/constants.test.ts diff --git a/package-lock.json b/package-lock.json index 549742619..0d730dc86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "@ai-sdk/xai": "^2.0.57", "@anthropic-ai/sdk": "^0.70.1", "@campfirein/brv-transport-client": "github:campfirein/brv-transport-client#1.1.0", - "@campfirein/byterover-packages": "github:campfirein/byterover-packages#1.0.2", + "@campfirein/byterover-packages": "github:campfirein/byterover-packages#1.0.4", "@google/genai": "^1.29.0", "@inkjs/ui": "^2.0.0", "@inquirer/prompts": "^7.9.0", @@ -3355,7 +3355,7 @@ }, "node_modules/@campfirein/byterover-packages": { "version": "0.0.0", - "resolved": "git+ssh://git@github.com/campfirein/byterover-packages.git#bbd9b8854cd951d47a9e26d8a0b1ad7119307f25", + "resolved": "git+ssh://git@github.com/campfirein/byterover-packages.git#58ef81f48b5ebc6cf2138a9b07ad874dd2c52a3c", "inBundle": true, "dependencies": { "@base-ui/react": "^1.3.0", diff --git a/package.json b/package.json index f75ace963..8e8751792 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@ai-sdk/xai": "^2.0.57", "@anthropic-ai/sdk": "^0.70.1", "@campfirein/brv-transport-client": "github:campfirein/brv-transport-client#1.1.0", - "@campfirein/byterover-packages": "github:campfirein/byterover-packages#1.0.2", + "@campfirein/byterover-packages": "github:campfirein/byterover-packages#1.0.4", "@google/genai": "^1.29.0", "@inkjs/ui": "^2.0.0", "@inquirer/prompts": "^7.9.0", diff --git a/packages/byterover-packages b/packages/byterover-packages index bbd9b8854..58ef81f48 160000 --- a/packages/byterover-packages +++ b/packages/byterover-packages @@ -1 +1 @@ -Subproject commit bbd9b8854cd951d47a9e26d8a0b1ad7119307f25 +Subproject commit 58ef81f48b5ebc6cf2138a9b07ad874dd2c52a3c diff --git a/src/webui/features/analytics/api/get-global-config.ts b/src/webui/features/analytics/api/get-global-config.ts new file mode 100644 index 000000000..05f44f3f3 --- /dev/null +++ b/src/webui/features/analytics/api/get-global-config.ts @@ -0,0 +1,32 @@ +import {queryOptions, useQuery} from '@tanstack/react-query' + +import type {QueryConfig} from '../../../lib/react-query' + +import { + GlobalConfigEvents, + type GlobalConfigGetResponse, +} from '../../../../shared/transport/events/global-config-events.js' +import {useTransportStore} from '../../../stores/transport-store' + +export const getGlobalConfig = async (): Promise => { + const {apiClient} = useTransportStore.getState() + if (!apiClient) throw new Error('Not connected') + return apiClient.request(GlobalConfigEvents.GET) +} + +export const getGlobalConfigQueryOptions = () => + queryOptions({ + queryFn: getGlobalConfig, + queryKey: ['globalConfig'], + staleTime: 5000, + }) + +type UseGetGlobalConfigOptions = { + queryConfig?: QueryConfig +} + +export const useGetGlobalConfig = ({queryConfig}: UseGetGlobalConfigOptions = {}) => + useQuery({ + ...getGlobalConfigQueryOptions(), + ...queryConfig, + }) diff --git a/src/webui/features/analytics/api/set-analytics.ts b/src/webui/features/analytics/api/set-analytics.ts new file mode 100644 index 000000000..24ab27edb --- /dev/null +++ b/src/webui/features/analytics/api/set-analytics.ts @@ -0,0 +1,40 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import type {MutationConfig} from '../../../lib/react-query' + +import { + GlobalConfigEvents, + type GlobalConfigSetAnalyticsRequest, + type GlobalConfigSetAnalyticsResponse, +} from '../../../../shared/transport/events/global-config-events.js' +import {useTransportStore} from '../../../stores/transport-store' +import {getGlobalConfigQueryOptions} from './get-global-config' + +export const setAnalytics = ( + request: GlobalConfigSetAnalyticsRequest, +): Promise => { + const {apiClient} = useTransportStore.getState() + if (!apiClient) return Promise.reject(new Error('Not connected')) + return apiClient.request( + GlobalConfigEvents.SET_ANALYTICS, + request, + ) +} + +type UseSetAnalyticsOptions = { + mutationConfig?: MutationConfig +} + +export const useSetAnalytics = ({mutationConfig}: UseSetAnalyticsOptions = {}) => { + const queryClient = useQueryClient() + const {onSuccess, ...rest} = mutationConfig ?? {} + + return useMutation({ + onSuccess(...args) { + queryClient.invalidateQueries({queryKey: getGlobalConfigQueryOptions().queryKey}) + onSuccess?.(...args) + }, + ...rest, + mutationFn: setAnalytics, + }) +} diff --git a/src/webui/features/analytics/components/analytics-panel.tsx b/src/webui/features/analytics/components/analytics-panel.tsx new file mode 100644 index 000000000..e14c5f362 --- /dev/null +++ b/src/webui/features/analytics/components/analytics-panel.tsx @@ -0,0 +1,128 @@ +import {Collapsible, CollapsibleContent, CollapsibleTrigger} from '@campfirein/byterover-packages/components/collapsible' +import {Skeleton} from '@campfirein/byterover-packages/components/skeleton' +import {Switch} from '@campfirein/byterover-packages/components/switch' +import {ChevronDown, ExternalLink, ShieldCheck} from 'lucide-react' +import {useState} from 'react' +import {toast} from 'sonner' + +import {formatError} from '../../../lib/error-messages' +import {noop} from '../../../lib/noop' +import {useGetGlobalConfig} from '../api/get-global-config' +import {useSetAnalytics} from '../api/set-analytics' +import {ANALYTICS_PRIVACY_URL} from '../constants' +import {DisableConfirmDialog} from './disable-confirm-dialog' +import {DisclosureDetails} from './disclosure-details' +import {EnableConfirmDialog} from './enable-confirm-dialog' + +export function AnalyticsPanel() { + const {data, error, isError, isLoading, refetch} = useGetGlobalConfig() + const setAnalytics = useSetAnalytics() + const [pendingIntent, setPendingIntent] = useState<'disable' | 'enable' | undefined>() + const [detailsOpen, setDetailsOpen] = useState(false) + + const analytics = data?.analytics ?? false + + function requestToggle(next: boolean) { + if (setAnalytics.isPending) return + if (analytics === next) return + setPendingIntent(next ? 'enable' : 'disable') + } + + async function applyChange(next: boolean) { + try { + await setAnalytics.mutateAsync({analytics: next}) + toast.success(next ? 'Analytics enabled.' : 'Analytics disabled.') + setPendingIntent(undefined) + } catch (error_) { + toast.error(formatError(error_, 'Failed to update analytics setting.')) + setPendingIntent(undefined) + throw error_ + } + } + + function handleDialogOpenChange(open: boolean) { + if (!open && !setAnalytics.isPending) setPendingIntent(undefined) + } + + return ( +
+
+

Analytics

+

+ Control how usage data is collected to improve Byterover. +

+
+ + {isError ? ( +

+ ✗ {formatError(error, 'Failed to load analytics state')} + {' · '} + +

+ ) : ( +
+
+
+ Share usage analytics + + Help us build a better Byterover by sharing your usage insights securely. + +
+ {isLoading ? ( + + ) : ( + + )} +
+ + + + + What data will be collected? + + {detailsOpen ? 'Hide details' : 'Show details'} + + + + + + + + + + + docs.byterover.dev/privacy + +
+ )} + + applyChange(true)} + onOpenChange={handleDialogOpenChange} + open={pendingIntent === 'enable'} + /> + applyChange(false)} + onOpenChange={handleDialogOpenChange} + open={pendingIntent === 'disable'} + /> +
+ ) +} diff --git a/src/webui/features/analytics/components/disable-confirm-dialog.tsx b/src/webui/features/analytics/components/disable-confirm-dialog.tsx new file mode 100644 index 000000000..81d18e6ed --- /dev/null +++ b/src/webui/features/analytics/components/disable-confirm-dialog.tsx @@ -0,0 +1,43 @@ +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@campfirein/byterover-packages/components/alert-dialog' +import {Button} from '@campfirein/byterover-packages/components/button' + +import {noop} from '../../../lib/noop' + +type Props = { + isPending: boolean + onConfirm: () => Promise + onOpenChange: (open: boolean) => void + open: boolean +} + +export function DisableConfirmDialog({isPending, onConfirm, onOpenChange, open}: Props) { + function fire() { + onConfirm().catch(noop) + } + + return ( + + + + Stop sharing usage analytics? + You can re-enable this at any time from this page. + + + + Cancel + + + + + ) +} diff --git a/src/webui/features/analytics/components/disclosure-details.tsx b/src/webui/features/analytics/components/disclosure-details.tsx new file mode 100644 index 000000000..38414d4f4 --- /dev/null +++ b/src/webui/features/analytics/components/disclosure-details.tsx @@ -0,0 +1,22 @@ +import {ANALYTICS_DISCLOSURE_SECTIONS} from '../constants' + +export function DisclosureDetails() { + return ( +
+ {ANALYTICS_DISCLOSURE_SECTIONS.map((section) => { + const Icon = section.icon + return ( +
+ +
+ + {section.label} + +

{section.body}

+
+
+ ) + })} +
+ ) +} diff --git a/src/webui/features/analytics/components/enable-confirm-dialog.tsx b/src/webui/features/analytics/components/enable-confirm-dialog.tsx new file mode 100644 index 000000000..4c913276f --- /dev/null +++ b/src/webui/features/analytics/components/enable-confirm-dialog.tsx @@ -0,0 +1,50 @@ +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@campfirein/byterover-packages/components/alert-dialog' +import {Button} from '@campfirein/byterover-packages/components/button' + +import {noop} from '../../../lib/noop' +import {DisclosureDetails} from './disclosure-details' + +type Props = { + isPending: boolean + onConfirm: () => Promise + onOpenChange: (open: boolean) => void + open: boolean +} + +export function EnableConfirmDialog({isPending, onConfirm, onOpenChange, open}: Props) { + function fire() { + onConfirm().catch(noop) + } + + return ( + + + + Share usage analytics with Byterover? + + Review what is collected before enabling. You can turn this off at any time. + + + +
+ +
+ + + Cancel + + +
+
+ ) +} diff --git a/src/webui/features/analytics/constants.ts b/src/webui/features/analytics/constants.ts new file mode 100644 index 000000000..0e5d2af7c --- /dev/null +++ b/src/webui/features/analytics/constants.ts @@ -0,0 +1,39 @@ +import type {LucideIcon} from 'lucide-react' + +import {Database, Eye, Link2, PowerOff, Server} from 'lucide-react' + +export type AnalyticsDisclosureSection = { + body: string + icon: LucideIcon + label: string +} + +export const ANALYTICS_PRIVACY_URL = 'https://docs.byterover.dev/privacy' + +export const ANALYTICS_DISCLOSURE_SECTIONS: readonly AnalyticsDisclosureSection[] = [ + { + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', + icon: Database, + label: 'WHAT IS COLLECTED', + }, + { + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', + icon: Eye, + label: 'WHICH SURFACES ARE TRACKED', + }, + { + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', + icon: Server, + label: 'WHERE IT GOES', + }, + { + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', + icon: Link2, + label: 'CROSS-DEVICE ALIAS', + }, + { + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', + icon: PowerOff, + label: 'HOW TO DISABLE', + }, +] as const diff --git a/src/webui/pages/configuration-page.tsx b/src/webui/pages/configuration-page.tsx index 619d72b95..889a09aff 100644 --- a/src/webui/pages/configuration-page.tsx +++ b/src/webui/pages/configuration-page.tsx @@ -1,3 +1,4 @@ +import {AnalyticsPanel} from '../features/analytics/components/analytics-panel' import {ConnectorsPanel} from '../features/connectors/components/connectors-panel' import {IdentityPanel} from '../features/vc/components/identity-panel' import {RemotesPanel} from '../features/vc/components/remotes-panel' @@ -9,6 +10,7 @@ export function ConfigurationPage() { + ) diff --git a/test/unit/webui/features/analytics/api/get-global-config.test.ts b/test/unit/webui/features/analytics/api/get-global-config.test.ts new file mode 100644 index 000000000..2f5e38cb6 --- /dev/null +++ b/test/unit/webui/features/analytics/api/get-global-config.test.ts @@ -0,0 +1,48 @@ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {BrvApiClient} from '../../../../../../src/webui/lib/api-client.js' + +import {GlobalConfigEvents} from '../../../../../../src/shared/transport/events/global-config-events.js' +import {getGlobalConfig} from '../../../../../../src/webui/features/analytics/api/get-global-config.js' +import {useTransportStore} from '../../../../../../src/webui/stores/transport-store.js' + +describe('getGlobalConfig', () => { + let sandbox: SinonSandbox + let request: SinonStub + + beforeEach(() => { + sandbox = createSandbox() + request = sandbox.stub() + useTransportStore.setState({ + apiClient: {on: sandbox.stub(), request} as unknown as BrvApiClient, + }) + }) + + afterEach(() => { + sandbox.restore() + useTransportStore.setState({apiClient: null}) + }) + + it('emits globalConfig:get with no payload', async () => { + request.resolves({analytics: false, deviceId: 'dev-1', version: '1'}) + await getGlobalConfig() + expect(request.firstCall.args[0]).to.equal(GlobalConfigEvents.GET) + }) + + it('returns the analytics, deviceId, and version from the daemon response', async () => { + request.resolves({analytics: true, deviceId: 'dev-2', version: '2'}) + const result = await getGlobalConfig() + expect(result).to.deep.equal({analytics: true, deviceId: 'dev-2', version: '2'}) + }) + + it('rejects when the transport is not connected', async () => { + useTransportStore.setState({apiClient: null}) + try { + await getGlobalConfig() + expect.fail('expected promise to reject') + } catch (error) { + expect((error as Error).message).to.equal('Not connected') + } + }) +}) diff --git a/test/unit/webui/features/analytics/api/set-analytics.test.ts b/test/unit/webui/features/analytics/api/set-analytics.test.ts new file mode 100644 index 000000000..9dd86a2aa --- /dev/null +++ b/test/unit/webui/features/analytics/api/set-analytics.test.ts @@ -0,0 +1,55 @@ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {BrvApiClient} from '../../../../../../src/webui/lib/api-client.js' + +import {GlobalConfigEvents} from '../../../../../../src/shared/transport/events/global-config-events.js' +import {setAnalytics} from '../../../../../../src/webui/features/analytics/api/set-analytics.js' +import {useTransportStore} from '../../../../../../src/webui/stores/transport-store.js' + +describe('setAnalytics', () => { + let sandbox: SinonSandbox + let request: SinonStub + + beforeEach(() => { + sandbox = createSandbox() + request = sandbox.stub() + useTransportStore.setState({ + apiClient: {on: sandbox.stub(), request} as unknown as BrvApiClient, + }) + }) + + afterEach(() => { + sandbox.restore() + useTransportStore.setState({apiClient: null}) + }) + + it('emits globalConfig:setAnalytics with the analytics payload', async () => { + request.resolves({current: true, previous: false}) + await setAnalytics({analytics: true}) + expect(request.firstCall.args[0]).to.equal(GlobalConfigEvents.SET_ANALYTICS) + expect(request.firstCall.args[1]).to.deep.equal({analytics: true}) + }) + + it('forwards false to disable analytics', async () => { + request.resolves({current: false, previous: true}) + await setAnalytics({analytics: false}) + expect(request.firstCall.args[1]).to.deep.equal({analytics: false}) + }) + + it('resolves with the daemon response on success', async () => { + request.resolves({current: true, previous: false}) + const result = await setAnalytics({analytics: true}) + expect(result).to.deep.equal({current: true, previous: false}) + }) + + it('rejects when the transport is not connected', async () => { + useTransportStore.setState({apiClient: null}) + try { + await setAnalytics({analytics: true}) + expect.fail('expected promise to reject') + } catch (error) { + expect((error as Error).message).to.equal('Not connected') + } + }) +}) diff --git a/test/unit/webui/features/analytics/constants.test.ts b/test/unit/webui/features/analytics/constants.test.ts new file mode 100644 index 000000000..2e03bc3a0 --- /dev/null +++ b/test/unit/webui/features/analytics/constants.test.ts @@ -0,0 +1,44 @@ +import {expect} from 'chai' + +import { + ANALYTICS_DISCLOSURE_SECTIONS, + ANALYTICS_PRIVACY_URL, +} from '../../../../../src/webui/features/analytics/constants.js' + +describe('analytics constants', () => { + describe('ANALYTICS_DISCLOSURE_SECTIONS', () => { + it('contains the five required sections in ticket-spec order', () => { + const labels = ANALYTICS_DISCLOSURE_SECTIONS.map((s) => s.label) + expect(labels).to.deep.equal([ + 'WHAT IS COLLECTED', + 'WHICH SURFACES ARE TRACKED', + 'WHERE IT GOES', + 'CROSS-DEVICE ALIAS', + 'HOW TO DISABLE', + ]) + }) + + it('every section has a non-empty body', () => { + for (const section of ANALYTICS_DISCLOSURE_SECTIONS) { + expect(section.body.length).to.be.greaterThan(0) + } + }) + + it('every section has an icon component reference', () => { + for (const section of ANALYTICS_DISCLOSURE_SECTIONS) { + expect(section.icon).to.exist + expect(['function', 'object']).to.include(typeof section.icon) + } + }) + }) + + describe('ANALYTICS_PRIVACY_URL', () => { + it('is a https URL', () => { + expect(ANALYTICS_PRIVACY_URL).to.match(/^https:\/\//) + }) + + it('points at the byterover privacy docs', () => { + expect(ANALYTICS_PRIVACY_URL).to.include('byterover.dev/privacy') + }) + }) +}) From 2fcb6db3e76131ab9277d0f6a03293a63a088cbc Mon Sep 17 00:00:00 2001 From: ncnthien Date: Mon, 18 May 2026 14:38:49 +0700 Subject: [PATCH 40/87] feat: [ENG-2621] address PR review feedback - Override refetchOnWindowFocus: true for getGlobalConfigQueryOptions so cross-surface coherence (CLI -> webui) actually works; the global default in src/webui/lib/react-query.ts is false. - Add aria-labelledby on the Switch so screen readers announce the row. - Replace text-primary-foreground with text-primary on the privacy link; the former is the on-bg-primary token (low contrast over bg-card). - Drop the dead `throw error_` in applyChange and move dialog cleanup to a finally block. - Normalise getGlobalConfig to Promise.reject('Not connected') matching set-analytics and provider/api/get-providers. --- .../features/analytics/api/get-global-config.ts | 5 +++-- .../analytics/components/analytics-panel.tsx | 12 +++++++----- .../features/analytics/api/get-global-config.test.ts | 10 ++++------ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/webui/features/analytics/api/get-global-config.ts b/src/webui/features/analytics/api/get-global-config.ts index 05f44f3f3..5fead76ee 100644 --- a/src/webui/features/analytics/api/get-global-config.ts +++ b/src/webui/features/analytics/api/get-global-config.ts @@ -8,9 +8,9 @@ import { } from '../../../../shared/transport/events/global-config-events.js' import {useTransportStore} from '../../../stores/transport-store' -export const getGlobalConfig = async (): Promise => { +export const getGlobalConfig = (): Promise => { const {apiClient} = useTransportStore.getState() - if (!apiClient) throw new Error('Not connected') + if (!apiClient) return Promise.reject(new Error('Not connected')) return apiClient.request(GlobalConfigEvents.GET) } @@ -18,6 +18,7 @@ export const getGlobalConfigQueryOptions = () => queryOptions({ queryFn: getGlobalConfig, queryKey: ['globalConfig'], + refetchOnWindowFocus: true, staleTime: 5000, }) diff --git a/src/webui/features/analytics/components/analytics-panel.tsx b/src/webui/features/analytics/components/analytics-panel.tsx index e14c5f362..d9cd57634 100644 --- a/src/webui/features/analytics/components/analytics-panel.tsx +++ b/src/webui/features/analytics/components/analytics-panel.tsx @@ -32,11 +32,10 @@ export function AnalyticsPanel() { try { await setAnalytics.mutateAsync({analytics: next}) toast.success(next ? 'Analytics enabled.' : 'Analytics disabled.') - setPendingIntent(undefined) } catch (error_) { toast.error(formatError(error_, 'Failed to update analytics setting.')) + } finally { setPendingIntent(undefined) - throw error_ } } @@ -69,7 +68,9 @@ export function AnalyticsPanel() {
- Share usage analytics + + Share usage analytics + Help us build a better Byterover by sharing your usage insights securely. @@ -78,6 +79,7 @@ export function AnalyticsPanel() { ) : ( - - docs.byterover.dev/privacy + + docs.byterover.dev/privacy
)} diff --git a/test/unit/webui/features/analytics/api/get-global-config.test.ts b/test/unit/webui/features/analytics/api/get-global-config.test.ts index 2f5e38cb6..9e3911ba6 100644 --- a/test/unit/webui/features/analytics/api/get-global-config.test.ts +++ b/test/unit/webui/features/analytics/api/get-global-config.test.ts @@ -38,11 +38,9 @@ describe('getGlobalConfig', () => { it('rejects when the transport is not connected', async () => { useTransportStore.setState({apiClient: null}) - try { - await getGlobalConfig() - expect.fail('expected promise to reject') - } catch (error) { - expect((error as Error).message).to.equal('Not connected') - } + await getGlobalConfig().then( + () => expect.fail('expected promise to reject'), + (error: Error) => expect(error.message).to.equal('Not connected'), + ) }) }) From e48f8336552c73bbc982d90f1cb1bf31edaa9ebc Mon Sep 17 00:00:00 2001 From: ncnthien Date: Mon, 18 May 2026 14:48:08 +0700 Subject: [PATCH 41/87] feat: [ENG-2621] drop staleTime override on globalConfig query Defer to the codebase-default staleTime: 0 from src/webui/lib/react-query.ts so refocus actually refetches instead of being gated by a 5s freshness window. Cheap to refetch (small daemon round-trip) and matches the PR's "refresh on refocus" promise tightly. --- src/webui/features/analytics/api/get-global-config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/webui/features/analytics/api/get-global-config.ts b/src/webui/features/analytics/api/get-global-config.ts index 5fead76ee..0966d50c8 100644 --- a/src/webui/features/analytics/api/get-global-config.ts +++ b/src/webui/features/analytics/api/get-global-config.ts @@ -19,7 +19,6 @@ export const getGlobalConfigQueryOptions = () => queryFn: getGlobalConfig, queryKey: ['globalConfig'], refetchOnWindowFocus: true, - staleTime: 5000, }) type UseGetGlobalConfigOptions = { From c772e0d6517c5b310df7b6610be1f4441abacd24 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Thu, 21 May 2026 14:39:48 +0700 Subject: [PATCH 42/87] feat: [ENG-2642] M4.1 clear analytics queue on auth identity transition Wire AuthStateStore.onAuthChanged to AnalyticsClient.onAuthTransition so login, logout, and account switches drop the pending JSONL + in-memory queue. Token refresh (same userId) is filtered out to avoid dropping events tracked between refresh cycles. Refactor IAuthStateStore.onAuthChanged from single-callback to multi-listener so the analytics subscriber does not overwrite the existing AuthHandler broadcaster. Listeners fire in registration order; one throwing does not stop the others. --- .../analytics/i-analytics-client.ts | 18 ++ .../analytics/i-jsonl-analytics-store.ts | 18 ++ .../interfaces/state/i-auth-state-store.ts | 7 +- .../infra/analytics/analytics-client.ts | 61 +++- .../infra/analytics/jsonl-analytics-store.ts | 4 + .../infra/analytics/no-op-analytics-client.ts | 4 + src/server/infra/process/feature-handlers.ts | 6 + .../process/wire-analytics-auth-transition.ts | 38 +++ src/server/infra/state/auth-state-store.ts | 39 ++- .../analytics-hook-async-stress.test.ts | 1 + .../wire-analytics-auth-transition.test.ts | 300 ++++++++++++++++++ .../unit/infra/state/auth-state-store.test.ts | 72 +++++ .../infra/analytics/analytics-client.test.ts | 248 +++++++++++++++ .../infra/analytics/identity-resolver.test.ts | 38 +++ .../analytics/jsonl-analytics-store.test.ts | 81 +++++ .../infra/process/analytics-hook.test.ts | 2 +- .../handlers/analytics-handler.test.ts | 1 + .../handlers/analytics-list-handler.test.ts | 2 + 18 files changed, 929 insertions(+), 11 deletions(-) create mode 100644 src/server/infra/process/wire-analytics-auth-transition.ts create mode 100644 test/integration/server/infra/process/wire-analytics-auth-transition.test.ts diff --git a/src/server/core/interfaces/analytics/i-analytics-client.ts b/src/server/core/interfaces/analytics/i-analytics-client.ts index ec49e5fcc..c086b2a0b 100644 --- a/src/server/core/interfaces/analytics/i-analytics-client.ts +++ b/src/server/core/interfaces/analytics/i-analytics-client.ts @@ -22,6 +22,24 @@ export interface IAnalyticsClient { */ flush: () => Promise + /** + * Notify the client that the daemon-wide auth state transitioned + * (login, logout, account switch, token revoked). + * + * M4.1 contract: every pending and historical event in the JSONL queue + * MUST be dropped, plus the in-memory mirror queue cleared. This + * preserves the invariant that every event waiting to flush was + * tracked under the current auth state. Without this drop, a batch + * flushed across a transition would mix two sessions' identities and + * the backend (which trusts per-event identity) would attribute past + * events to the new session — or vice-versa. + * + * Errors are swallowed: analytics MUST NOT crash a consumer. A + * disk-write failure during clear is logged best-effort but never + * propagates. + */ + onAuthTransition: () => Promise + /** * Records an analytics event. When the analytics flag is disabled the * call must be a true no-op (no allocations, no resolver calls). diff --git a/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts b/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts index d6a56b995..bc90c1460 100644 --- a/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts +++ b/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts @@ -68,6 +68,24 @@ export interface IJsonlAnalyticsStore { */ append: (record: StoredAnalyticsRecord) => Promise + /** + * Truncate the JSONL file: drop every row regardless of status. + * + * M4.1: invoked when AuthStateStore reports a login/logout transition. + * Pending events tracked under the prior session must NOT be flushed + * under the new session's identity. Clearing on transition guarantees + * the queue is homogeneous per session, every event in the queue at + * flush time was tracked under the current auth state. + * + * Concurrency: serializes through the same write chain as `append` / + * `updateStatus`, so an in-flight append finishes before clear runs and + * a clear in progress blocks subsequent appends. Atomic via tmp+rename. + * + * Counters (`droppedFullCount`, `droppedSentCount`) are NOT reset, + * they're cumulative lifetime stats surfaced by `brv analytics status`. + */ + clear: () => Promise + /** * Cumulative count of `append` calls dropped because the cap was full * with no `'sent'` rows to evict (file saturated with pending+failed). diff --git a/src/server/core/interfaces/state/i-auth-state-store.ts b/src/server/core/interfaces/state/i-auth-state-store.ts index e2262e038..4f88dd74e 100644 --- a/src/server/core/interfaces/state/i-auth-state-store.ts +++ b/src/server/core/interfaces/state/i-auth-state-store.ts @@ -45,7 +45,9 @@ export interface IAuthStateStore { * Register a callback for auth state changes. * Fired when: login (new token), token refresh (changed token), logout (undefined). * - * Only one callback supported — subsequent calls overwrite previous. + * Multiple callbacks supported. Listeners fire in registration order on + * each transition; one listener throwing does NOT prevent the others + * from running (impl catches and logs per-callback). * * @param callback - Function called with the new token (or undefined on logout) */ @@ -56,7 +58,8 @@ export interface IAuthStateStore { * Fired when a token that was valid transitions to expired. * Only fires once per expiry (not on every poll cycle). * - * Only one callback supported — subsequent calls overwrite previous. + * Multiple callbacks supported. Listeners fire in registration order; + * one throwing does NOT prevent the others from running. * * @param callback - Function called with the expired token */ diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index 1b5949f54..ba6c72804 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -17,6 +17,13 @@ export interface AnalyticsClientDeps { identityResolver: IIdentityResolver isEnabled: () => boolean jsonlStore: IJsonlAnalyticsStore + /** + * Optional structured log sink for operational visibility. Used by + * `onAuthTransition` to surface a `clear()` failure that would + * otherwise silently leave prior-session events on disk. Defaults to + * a no-op when omitted so existing callers don't have to wire it. + */ + log?: (message: string) => void queue: IAnalyticsQueue sender: IAnalyticsSender superPropsResolver: ISuperPropertiesResolver @@ -55,6 +62,17 @@ export class AnalyticsClient implements IAnalyticsClient { // double-incrementing `attempts` per cycle and tripping the M9.2 retry cap // in MAX_ATTEMPTS/2 cycles instead of MAX_ATTEMPTS. private pendingFlush?: Promise + // M4.1 in-flight tracking. Each `trackAsync` registers its promise here + // so `onAuthTransition` can await every track that started BEFORE the + // transition before issuing `clear()`. Without this barrier: + // - a track that resolved old identity but hasn't appended yet may + // enqueue its append AFTER clear → record persists with stale + // identity → backend rejects on mismatch. + // - a track that already enqueued append BEFORE clear is correctly + // nuked by clear (intentional — pre-transition events drop). + // The barrier removes the first failure mode; the second is the + // designed behavior. + private readonly pendingTracks = new Set>() public constructor(deps: AnalyticsClientDeps) { this.deps = deps @@ -93,6 +111,41 @@ export class AnalyticsClient implements IAnalyticsClient { } } + public async onAuthTransition(): Promise { + // Snapshot in-flight tracks then wait for them to settle. Any + // `trackAsync` that started before this point may still be between + // identity-resolve and `jsonlStore.append` / `queue.push`; awaiting + // it guarantees its append has either landed in the write chain (so + // the clear enqueued below nukes it — correct, those identities are + // stale) or failed (so there is nothing to nuke). New `track()` + // calls that arrive after this snapshot resolve identity from the + // post-transition cached token and are NOT included in the barrier. + // + // `Promise.allSettled` rather than `all` because individual track + // promises may already swallow-and-resolve on error; we just need + // the settled signal, not the result. + if (this.pendingTracks.size > 0) { + await Promise.allSettled(this.pendingTracks) + } + + // Drain the in-memory mirror AFTER the barrier so any push that the + // completing track did is also wiped. Draining before the barrier + // would leave a window where the late-completing track pushes back + // into a fresh queue → prior-session record stays visible to webui. + this.deps.queue.drain() + + try { + await this.deps.jsonlStore.clear() + } catch (error) { + // Analytics MUST NOT crash the consumer. Surface the failure + // through the optional log sink so operators see why a flush + // after transition would ship prior-session events. + this.deps.log?.( + `analytics.onAuthTransition: clear failed (${error instanceof Error ? error.message : String(error)})`, + ) + } + } + public track(event: E, ...rest: PropsArg): void { if (!this.deps.isEnabled()) return // Capture the timestamp synchronously at call-site so it reflects WHEN the @@ -101,8 +154,14 @@ export class AnalyticsClient implements IAnalyticsClient { // preserves the inter-event durations downstream consumers care about. const timestamp = Date.now() const [properties] = rest + const pending = this.trackAsync(event, properties, timestamp) + this.pendingTracks.add(pending) + // Remove from the in-flight set once the track settles either way. + // `void` keeps `track()` synchronous per the IAnalyticsClient contract. // eslint-disable-next-line no-void - void this.trackAsync(event, properties, timestamp) + void pending.finally(() => { + this.pendingTracks.delete(pending) + }) } private async runFlush(): Promise { diff --git a/src/server/infra/analytics/jsonl-analytics-store.ts b/src/server/infra/analytics/jsonl-analytics-store.ts index 1abcbbbac..0093ec245 100644 --- a/src/server/infra/analytics/jsonl-analytics-store.ts +++ b/src/server/infra/analytics/jsonl-analytics-store.ts @@ -88,6 +88,10 @@ export class JsonlAnalyticsStore implements IJsonlAnalyticsStore { return this.enqueue(async () => this.doAppend(record)) } + public async clear(): Promise { + return this.enqueue(async () => this.atomicRewrite([])) + } + public droppedFullCount(): number { return this.droppedFullCounter } diff --git a/src/server/infra/analytics/no-op-analytics-client.ts b/src/server/infra/analytics/no-op-analytics-client.ts index ded27ba29..75cd98177 100644 --- a/src/server/infra/analytics/no-op-analytics-client.ts +++ b/src/server/infra/analytics/no-op-analytics-client.ts @@ -15,6 +15,10 @@ export class NoOpAnalyticsClient implements IAnalyticsClient { return AnalyticsBatch.create([]) } + public async onAuthTransition(): Promise { + // intentional no-op + } + public track(_event: E, ..._rest: PropsArg): void { // intentional no-op } diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 0e2da4d05..be75fbe07 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -82,6 +82,7 @@ import { } from '../transport/handlers/index.js' import {HttpUserService} from '../user/http-user-service.js' import {FileVcGitConfigStore} from '../vc/file-vc-git-config-store.js' +import {wireAnalyticsAuthTransition} from './wire-analytics-auth-transition.js' export interface FeatureHandlersOptions { authStateStore: IAuthStateStore @@ -181,6 +182,11 @@ export async function setupFeatureHandlers({ superPropsResolver: new SuperPropertiesResolver(globalConfigStore), }) + // M4.1: subscribe the analytics client to identity-changing auth + // transitions. See `wireAnalyticsAuthTransition` for the + // login/logout/refresh decision logic. + wireAnalyticsAuthTransition(authStateStore, analyticsClient) + // M2.6: route incoming analytics:track events from non-forked clients // (TUI, oclif, MCP, webui) to the same singleton. new AnalyticsHandler({analyticsClient, transport}).setup() diff --git a/src/server/infra/process/wire-analytics-auth-transition.ts b/src/server/infra/process/wire-analytics-auth-transition.ts new file mode 100644 index 000000000..4d10c2c3d --- /dev/null +++ b/src/server/infra/process/wire-analytics-auth-transition.ts @@ -0,0 +1,38 @@ +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import type {IAuthStateStore} from '../../core/interfaces/state/i-auth-state-store.js' + +/** + * Subscribe the analytics client to identity-changing auth transitions. + * + * M4.1 contract: `AuthStateStore.onAuthChanged` fires on login, logout, + * account switch, AND token refresh. Only the identity-changing + * transitions (login / logout / account switch) drop the analytics + * queue. A pure access-token refresh keeps the same user_id and must + * NOT clear pending events. + * + * The closure tracks the previously-seen userId locally so the callback + * distinguishes "same user, new accessToken" (skip) from "different + * identity" (clear). `previousUserId` is seeded from the current cached + * token so the first callback after subscribe doesn't fire a spurious + * clear when the token was already loaded. + * + * Extracted from `feature-handlers.ts` so the wiring is testable in + * isolation — booting the full feature-handler graph would require + * stubbing every HTTP service and config store the daemon uses. Keeping + * this a 1-call function with two collaborators makes the + * `IAuthStateStore` multi-listener contract (M4.1) testable end-to-end + * without infrastructure ceremony. + */ +export function wireAnalyticsAuthTransition( + authStateStore: IAuthStateStore, + analyticsClient: IAnalyticsClient, +): void { + let previousUserId: string | undefined = authStateStore.getToken()?.userId + authStateStore.onAuthChanged((token) => { + const nextUserId = token?.userId + if (nextUserId === previousUserId) return + previousUserId = nextUserId + // eslint-disable-next-line no-void + void analyticsClient.onAuthTransition() + }) +} diff --git a/src/server/infra/state/auth-state-store.ts b/src/server/infra/state/auth-state-store.ts index 6e85f385b..b30598cb4 100644 --- a/src/server/infra/state/auth-state-store.ts +++ b/src/server/infra/state/auth-state-store.ts @@ -33,8 +33,8 @@ type AuthStateStoreOptions = { * Uses an isPolling guard to prevent overlapping poll cycles. */ export class AuthStateStore implements IAuthStateStore { - private authChangedCallback: AuthChangedCallback | undefined - private authExpiredCallback: AuthExpiredCallback | undefined + private readonly authChangedCallbacks: AuthChangedCallback[] = [] + private readonly authExpiredCallbacks: AuthExpiredCallback[] = [] private cachedToken: AuthToken | undefined private isPolling = false private readonly log: (message: string) => void @@ -66,11 +66,11 @@ export class AuthStateStore implements IAuthStateStore { } onAuthChanged(callback: AuthChangedCallback): void { - this.authChangedCallback = callback + this.authChangedCallbacks.push(callback) } onAuthExpired(callback: AuthExpiredCallback): void { - this.authExpiredCallback = callback + this.authExpiredCallbacks.push(callback) } startPolling(): void { @@ -93,6 +93,31 @@ export class AuthStateStore implements IAuthStateStore { this.log('Auth state polling stopped') } + /** + * Dispatch to every registered onAuthChanged listener. One listener + * throwing must NOT prevent the others from firing or break the + * polling loop; we log and continue. + */ + private fireAuthChanged(token: AuthToken | undefined): void { + for (const callback of this.authChangedCallbacks) { + try { + callback(token) + } catch (error) { + this.log(`onAuthChanged callback threw: ${error instanceof Error ? error.message : String(error)}`) + } + } + } + + private fireAuthExpired(token: AuthToken): void { + for (const callback of this.authExpiredCallbacks) { + try { + callback(token) + } catch (error) { + this.log(`onAuthExpired callback threw: ${error instanceof Error ? error.message : String(error)}`) + } + } + } + /** * Single poll cycle. Loads token from store and compares with cached. * Skips if a poll is already in-flight. @@ -123,15 +148,15 @@ export class AuthStateStore implements IAuthStateStore { this.cachedToken = token this.wasExpired = false this.log(`Auth state changed: ${token ? 'token present' : 'token removed'}`) - this.authChangedCallback?.(token) + this.fireAuthChanged(token) return } - // Same token — check for expiry transition + // Same token, check for expiry transition if (token && token.isExpired() && !this.wasExpired) { this.wasExpired = true this.log('Auth token expired') - this.authExpiredCallback?.(token) + this.fireAuthExpired(token) } // Update cached reference (same accessToken but other fields may differ) diff --git a/test/integration/infra/process/analytics-hook-async-stress.test.ts b/test/integration/infra/process/analytics-hook-async-stress.test.ts index d2bec687a..bbe30f899 100644 --- a/test/integration/infra/process/analytics-hook-async-stress.test.ts +++ b/test/integration/infra/process/analytics-hook-async-stress.test.ts @@ -99,6 +99,7 @@ function makeAnalyticsClient(sandbox: SinonSandbox): {client: IAnalyticsClient; const trackStub = sandbox.stub() const client: IAnalyticsClient = { flush: sandbox.stub().resolves(), + onAuthTransition: sandbox.stub().resolves(), track: trackStub, } return {client, trackStub} diff --git a/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts b/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts new file mode 100644 index 000000000..15c8bb0bd --- /dev/null +++ b/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts @@ -0,0 +1,300 @@ +import {expect} from 'chai' +import {spy, stub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type { + AuthChangedCallback, + AuthExpiredCallback, + IAuthStateStore, +} from '../../../../../src/server/core/interfaces/state/i-auth-state-store.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' +import {wireAnalyticsAuthTransition} from '../../../../../src/server/infra/process/wire-analytics-auth-transition.js' + +/** + * Integration test for the M4.1 auth-transition wiring at the + * composition-root level. + * + * Three scenarios cover the regressions this wiring exists to prevent: + * + * A. Identity change (login / logout / account switch) MUST trigger + * `analyticsClient.onAuthTransition` so the queue is cleared before + * the next flush attributes prior-session events to the new user. + * + * B. Token refresh (same userId, new accessToken) MUST NOT trigger + * `onAuthTransition` — the userId-guard inside the wiring is the + * sole defense against the polling-based refresh path emitting an + * `onAuthChanged` for the same user every time the access token + * rolls. + * + * C. The wiring uses `IAuthStateStore.onAuthChanged` as a multi- + * listener registration. Earlier (pre-fix) it overwrote any + * previously-registered callback, which silently broke M4.1 in + * production because `AuthHandler.setup()` also subscribes to the + * same event. A subsequent subscriber MUST NOT cancel the analytics + * callback this wiring installed. + */ + +function makeToken(overrides: Partial<{accessToken: string; userId: string}> = {}): AuthToken { + const accessToken = overrides.accessToken ?? 'access-1' + const userId = overrides.userId ?? 'user-A' + return new AuthToken({ + accessToken, + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'refresh-1', + sessionKey: 'session-1', + userEmail: 'alice@example.com', + userId, + userName: 'Alice', + }) +} + +/** + * Stub IAuthStateStore that: + * - exposes a settable initial cached token (so `previousUserId` is + * seeded correctly when the wiring subscribes), + * - appends callbacks (multi-listener) and re-emits via `fire()` so + * tests can simulate a poll-detected change without spinning up a + * real timer + token store. + */ +function makeFakeAuthStateStore(initial?: AuthToken): IAuthStateStore & { + readonly callbacks: AuthChangedCallback[] + fire(token: AuthToken): void + fireLogout(): void +} { + const callbacks: AuthChangedCallback[] = [] + let cached: AuthToken | undefined = initial + + return { + callbacks, + fire(token: AuthToken): void { + cached = token + for (const cb of callbacks) cb(token) + }, + fireLogout(): void { + cached = undefined + for (const cb of callbacks) cb(cached) + }, + getToken: () => cached, + loadToken: async () => cached, + onAuthChanged(cb: AuthChangedCallback): void { + callbacks.push(cb) + }, + onAuthExpired(_cb: AuthExpiredCallback): void { + // not exercised here + }, + startPolling(): void { + // not exercised here + }, + stopPolling(): void { + // not exercised here + }, + } +} + +function makeFakeAnalyticsClient(): IAnalyticsClient & { + onAuthTransitionSpy: ReturnType +} { + const onAuthTransition = stub().resolves() + return { + flush: stub().resolves(AnalyticsBatch.create([])), + onAuthTransition, + onAuthTransitionSpy: onAuthTransition, + // Hand-rolled noop to preserve the generic `track(event, ...rest)` + // signature — sinon's `stub()` would erase the generic and fail the + // structural-typing assignment to `IAnalyticsClient.track`. + track(): void { + // intentional no-op + }, + } +} + +async function flushMicrotasks(): Promise { + await new Promise((resolve) => { + setImmediate(resolve) + }) + await new Promise((resolve) => { + setImmediate(resolve) + }) +} + +describe('M4.1 wireAnalyticsAuthTransition (integration)', () => { + describe('scenario A — identity change fires onAuthTransition', () => { + it('fires onAuthTransition when an anonymous baseline transitions to authenticated (login)', async () => { + const store = makeFakeAuthStateStore() // initial: undefined + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + store.fire(makeToken({userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.calledOnce, 'onAuthTransition must fire on login').to.equal(true) + }) + + it('fires onAuthTransition when an authenticated baseline transitions to anonymous (logout)', async () => { + const store = makeFakeAuthStateStore(makeToken({userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + store.fireLogout() + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.calledOnce, 'onAuthTransition must fire on logout').to.equal(true) + }) + + it('fires onAuthTransition when the userId changes (account switch)', async () => { + const store = makeFakeAuthStateStore(makeToken({userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + store.fire(makeToken({accessToken: 'access-B', userId: 'user-B'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.calledOnce, 'onAuthTransition must fire on account switch').to.equal(true) + }) + }) + + describe('scenario B — token refresh (same userId) MUST NOT fire onAuthTransition', () => { + it('does NOT fire onAuthTransition when the same user refreshes the access token', async () => { + const store = makeFakeAuthStateStore(makeToken({accessToken: 'access-1', userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + // Polling detects an accessToken change but same userId — the + // userId-guard inside the wiring must skip the transition. + store.fire(makeToken({accessToken: 'access-2', userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.called, 'onAuthTransition must NOT fire on token refresh').to.equal(false) + }) + + it('does NOT fire onAuthTransition when a series of refreshes leaves userId unchanged', async () => { + const store = makeFakeAuthStateStore(makeToken({accessToken: 'a1', userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + store.fire(makeToken({accessToken: 'a2', userId: 'user-A'})) + store.fire(makeToken({accessToken: 'a3', userId: 'user-A'})) + store.fire(makeToken({accessToken: 'a4', userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.callCount).to.equal(0) + }) + + it('still fires onAuthTransition when an identity change interleaves with refreshes', async () => { + const store = makeFakeAuthStateStore(makeToken({accessToken: 'a1', userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + // refresh — skip + store.fire(makeToken({accessToken: 'a2', userId: 'user-A'})) + // logout — fire + store.fireLogout() + // login as different user — fire + store.fire(makeToken({accessToken: 'b1', userId: 'user-B'})) + // refresh as user-B — skip + store.fire(makeToken({accessToken: 'b2', userId: 'user-B'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.callCount, 'fired twice: logout + login-as-B').to.equal(2) + }) + }) + + describe('scenario C — multi-listener composition (AuthHandler regression)', () => { + it('preserves the analytics callback when a later subscriber registers', async () => { + const store = makeFakeAuthStateStore() // anonymous baseline + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + // Simulate `AuthHandler.setup()` registering AFTER the analytics + // wiring — pre-fix this overwrote the analytics callback. + const broadcaster = stub() + store.onAuthChanged(broadcaster) + + store.fire(makeToken({userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.calledOnce, 'analytics callback must still fire').to.equal(true) + expect(broadcaster.calledOnce, 'broadcaster callback must also fire').to.equal(true) + }) + + it('preserves the analytics callback even when multiple later subscribers register', async () => { + const store = makeFakeAuthStateStore() + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + const listener2 = stub() + const listener3 = stub() + store.onAuthChanged(listener2) + store.onAuthChanged(listener3) + + store.fire(makeToken({userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.calledOnce).to.equal(true) + expect(listener2.calledOnce).to.equal(true) + expect(listener3.calledOnce).to.equal(true) + }) + + it('analytics callback survives even if a later subscriber throws', async () => { + // The real AuthStateStore impl isolates throws across listeners. + // This fake mirrors that contract — if it didn't, the analytics + // callback would also break under sibling failures. + const callbacks: AuthChangedCallback[] = [] + const noToken: AuthToken | undefined = undefined + const store: IAuthStateStore & {fire(t: AuthToken): void} = { + fire(token: AuthToken): void { + for (const cb of callbacks) { + try { + cb(token) + } catch { + // isolate, like the real store does + } + } + }, + getToken: () => noToken, + loadToken: async () => noToken, + onAuthChanged(cb: AuthChangedCallback): void { + callbacks.push(cb) + }, + onAuthExpired(_cb: AuthExpiredCallback): void {}, + startPolling(): void {}, + stopPolling(): void {}, + } + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + // A sibling subscriber registered after analytics that throws. + store.onAuthChanged(() => { + throw new Error('sibling boom') + }) + + store.fire(makeToken({userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.calledOnce, 'analytics callback must still fire despite sibling throw').to.equal(true) + }) + }) + + describe('seed behavior — previousUserId is read from the cached token at subscribe time', () => { + it('does NOT fire onAuthTransition when the very first callback matches the cached userId', async () => { + // Models the production sequence: AuthStateStore.loadToken() fires + // onAuthChanged AFTER setupFeatureHandlers wired the analytics + // subscriber. If the user was already authenticated, the first + // callback delivers the SAME userId the wiring seeded from + // `getToken()` — that's a no-op, not a transition. + const initial = makeToken({accessToken: 'a1', userId: 'user-A'}) + const store = makeFakeAuthStateStore(initial) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthTransition(store, client) + + // Same userId, different accessToken (a typical loadToken-after- + // wiring scenario when the daemon picked up an existing session). + store.fire(makeToken({accessToken: 'a2', userId: 'user-A'})) + await flushMicrotasks() + + expect(client.onAuthTransitionSpy.called, 'initial-cached-user must not trigger clear').to.equal(false) + }) + }) +}) diff --git a/test/unit/infra/state/auth-state-store.test.ts b/test/unit/infra/state/auth-state-store.test.ts index 58b417a48..fede5c50b 100644 --- a/test/unit/infra/state/auth-state-store.test.ts +++ b/test/unit/infra/state/auth-state-store.test.ts @@ -407,4 +407,76 @@ describe('AuthStateStore', () => { expect(store.getToken()).to.equal(expiredToken) }) }) + + describe('multiple onAuthChanged listeners (M4.1 regression)', () => { + it('should fire EVERY registered onAuthChanged callback when token appears', async () => { + const cb1 = sandbox.stub() + const cb2 = sandbox.stub() + const cb3 = sandbox.stub() + store.onAuthChanged(cb1) + store.onAuthChanged(cb2) + store.onAuthChanged(cb3) + + const token = createValidToken() + loadStub.resolves(token) + await store.loadToken() + + expect(cb1.calledOnce, 'cb1 should fire').to.be.true + expect(cb2.calledOnce, 'cb2 should fire').to.be.true + expect(cb3.calledOnce, 'cb3 should fire').to.be.true + expect(cb1.calledWith(token)).to.be.true + expect(cb2.calledWith(token)).to.be.true + expect(cb3.calledWith(token)).to.be.true + }) + + it('should fire listeners in registration order', async () => { + const order: number[] = [] + store.onAuthChanged(() => order.push(1)) + store.onAuthChanged(() => order.push(2)) + store.onAuthChanged(() => order.push(3)) + + loadStub.resolves(createValidToken()) + await store.loadToken() + + expect(order).to.deep.equal([1, 2, 3]) + }) + + it('should keep firing later listeners even if an earlier one throws', async () => { + const cb1 = sandbox.stub().throws(new Error('listener boom')) + const cb2 = sandbox.stub() + const cb3 = sandbox.stub() + store.onAuthChanged(cb1) + store.onAuthChanged(cb2) + store.onAuthChanged(cb3) + + loadStub.resolves(createValidToken()) + + // The polling loop must not propagate the listener throw — would + // otherwise crash the daemon's auth poll cycle. + await store.loadToken() + + expect(cb1.calledOnce).to.be.true + expect(cb2.calledOnce, 'cb2 must still fire after cb1 threw').to.be.true + expect(cb3.calledOnce, 'cb3 must still fire after cb1 threw').to.be.true + }) + + it('should fire EVERY onAuthExpired callback', async () => { + const cb1 = sandbox.stub() + const cb2 = sandbox.stub() + store.onAuthExpired(cb1) + store.onAuthExpired(cb2) + + const validToken = createValidToken({accessToken: 'shared'}) + loadStub.resolves(validToken) + await store.loadToken() + + const expiredToken = createExpiredToken({accessToken: 'shared'}) + loadStub.resolves(expiredToken) + store.startPolling() + await clock.tickAsync(POLL_INTERVAL) + + expect(cb1.calledOnce).to.be.true + expect(cb2.calledOnce).to.be.true + }) + }) }) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index 52baab1fe..60c3f1861 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -50,6 +50,9 @@ function makeFakeJsonlStore(opts: {appendError?: Error} = {}): FakeJsonlStore { return { append: appendSpy, appendSpy, + async clear(): Promise { + records.length = 0 + }, droppedFullCount: () => 0, droppedSentCount: () => 0, list: async () => ({rows: [...records], total: records.length}), @@ -750,6 +753,251 @@ describe('AnalyticsClient', () => { }) }) + describe('M4.1 onAuthTransition: clear pending events on login/logout', () => { + it('should empty the JSONL store and the in-memory queue on transition', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 5) + expect(jsonlStore.records).to.have.lengthOf(5) + expect(queue.size()).to.equal(5) + + await client.onAuthTransition() + + expect(jsonlStore.records).to.have.lengthOf(0) + expect(queue.size()).to.equal(0) + }) + + it('should be a no-op when there is nothing to drop', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // No throw, even when JSONL/queue are already empty. + await client.onAuthTransition() + + expect(jsonlStore.records).to.have.lengthOf(0) + expect(queue.size()).to.equal(0) + }) + + it('should NOT crash the consumer when clear() throws on disk error', async () => { + const queue = new BoundedQueue() + const baseStore = makeFakeJsonlStore() + const erroringStore = { + ...baseStore, + async clear(): Promise { + throw new Error('disk full') + }, + } + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: erroringStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // The await must resolve, not reject. + let threw = false + try { + await client.onAuthTransition() + } catch { + threw = true + } + + expect(threw, 'onAuthTransition must swallow disk errors').to.equal(false) + // In-memory queue cleared regardless of JSONL error. + expect(queue.size()).to.equal(0) + }) + + it('should leave subsequently tracked events visible to flush (post-transition events ship under the new session)', async () => { + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + let currentIdentity: Identity = makeAnonIdentity() + const identityResolver: IIdentityResolver = { + resolve: async () => currentIdentity, + } + const client = new AnalyticsClient({ + identityResolver, + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Pre-transition tracks + client.track(AnalyticsEventNames.DAEMON_START) + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + // Simulate a login transition: identity flips, queue is cleared. + currentIdentity = makeRegisteredIdentity() + await client.onAuthTransition() + + // Post-transition tracks + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + const batch = await client.flush() + // Only the post-transition event is visible to flush. + expect(batch.events).to.have.lengthOf(1) + expect(batch.events[0].identity).to.deep.equal(makeRegisteredIdentity()) + }) + + it('should await in-flight tracks before clearing so no append lands after clear', async () => { + // Regression for the race window: a `track()` call that resolved + // identity before the transition but had not yet appended would, + // without the barrier, enqueue its append AFTER onAuthTransition's + // clear and persist a stale-identity record. The barrier awaits + // every in-flight track promise before issuing clear(). + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + let releaseIdentity!: (id: Identity) => void + const slowIdentityResolver: IIdentityResolver = { + resolve: () => + new Promise((resolve) => { + releaseIdentity = resolve + }), + } + const client = new AnalyticsClient({ + identityResolver: slowIdentityResolver, + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Fire a track; identityResolver is pending so trackAsync is + // stuck pre-append. + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + // Nothing on disk yet. + expect(jsonlStore.records).to.have.lengthOf(0) + + // Kick off transition; clear MUST wait for the in-flight track. + const transitionPromise = client.onAuthTransition() + // Yield once so onAuthTransition reaches its `await + // Promise.allSettled([...pendingTracks])` before we release the + // identity. Without this yield, releaseIdentity runs before + // transitionPromise's body executes its first await, and the + // barrier snapshot may miss the race we are trying to cover. + await Promise.resolve() + // Resolve the identity AFTER the barrier is in place. Append will + // race with clear. The barrier guarantees clear runs LAST. + releaseIdentity(makeAnonIdentity()) + await transitionPromise + + // Final state: clear nuked the stale-identity append. + expect(jsonlStore.records, 'no record may survive a transition that ran after a track started').to.have.lengthOf(0) + expect(queue.size()).to.equal(0) + }) + + it('should NOT block new tracks that start AFTER onAuthTransition began', async () => { + // The barrier only awaits tracks already in-flight at the moment + // onAuthTransition starts. Tracks that arrive after the snapshot + // get the new identity and must persist normally. + const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() + let currentIdentity: Identity = makeAnonIdentity() + const identityResolver: IIdentityResolver = { + resolve: async () => currentIdentity, + } + const client = new AnalyticsClient({ + identityResolver, + isEnabled: () => true, + jsonlStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + currentIdentity = makeRegisteredIdentity() + await client.onAuthTransition() + + // New track after transition completed; uses new identity. + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + expect(jsonlStore.records).to.have.lengthOf(1) + expect(jsonlStore.records[0].identity).to.deep.equal(makeRegisteredIdentity()) + }) + + it('should surface clear() failures through the optional log sink (M4.1 visibility)', async () => { + const queue = new BoundedQueue() + const baseStore = makeFakeJsonlStore() + const erroringStore = { + ...baseStore, + async clear(): Promise { + throw new Error('disk full') + }, + } + const logged: string[] = [] + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: erroringStore, + log: (msg) => logged.push(msg), + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await client.onAuthTransition() + + expect(logged).to.have.lengthOf(1) + expect(logged[0]).to.include('clear failed') + expect(logged[0]).to.include('disk full') + }) + + it('should remain crash-free when no log sink is wired and clear() throws', async () => { + // Regression: log sink is optional; absent log must not turn a + // disk error into an uncaught rejection. + const queue = new BoundedQueue() + const baseStore = makeFakeJsonlStore() + const erroringStore = { + ...baseStore, + async clear(): Promise { + throw new Error('disk full') + }, + } + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: erroringStore, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + let threw = false + try { + await client.onAuthTransition() + } catch { + threw = true + } + + expect(threw).to.equal(false) + }) + }) + describe('M10.2 single-flight: concurrent flush() invocations collapse to one underlying run', () => { it('should call sender.send only once when two flush() calls are awaited in parallel', async () => { // Without single-flight, both flushes load the same pending set, both call sender, and diff --git a/test/unit/server/infra/analytics/identity-resolver.test.ts b/test/unit/server/infra/analytics/identity-resolver.test.ts index 888cf03aa..56f6dc508 100644 --- a/test/unit/server/infra/analytics/identity-resolver.test.ts +++ b/test/unit/server/infra/analytics/identity-resolver.test.ts @@ -188,4 +188,42 @@ describe('IdentityResolver', () => { expect(identity).to.deep.equal({device_id: ''}) }) }) + + // M4.1 regression: identity returned by resolve() is a snapshot, not a live view. + // Subsequent auth-state mutations must not retroactively rewrite a previously + // resolved Identity. The downstream queue stores these objects by reference, + // so an accidental shared/mutated state would corrupt already-tracked events + // when a user logs out before the flush completes. + describe('M4.1 identity captured by value (snapshot semantics)', () => { + it('should not mutate a previously resolved identity when the token reader later returns undefined', async () => { + const {reader, setToken} = makeMutableAuthReader() + setToken(makeFullToken()) + const resolver = new IdentityResolver(reader, makeStubStore()) + + const firstResolved = await resolver.resolve() + const snapshot = {...firstResolved} + + setToken(undefined) + const second = await resolver.resolve() + expect(second).to.deep.equal({device_id: validDeviceId}) + + // First object must be byte-equivalent to the snapshot taken before the mutation. + expect(firstResolved).to.deep.equal(snapshot) + expect(firstResolved.user_id).to.equal('user-123') + expect(firstResolved.email).to.equal('alice@example.com') + expect(firstResolved.name).to.equal('Alice') + }) + + it('should not share references between two resolve() calls (each returns a fresh object)', async () => { + const {reader, setToken} = makeMutableAuthReader() + setToken(makeFullToken()) + const resolver = new IdentityResolver(reader, makeStubStore()) + + const first = await resolver.resolve() + const second = await resolver.resolve() + + expect(first).to.deep.equal(second) + expect(first).to.not.equal(second) // distinct object references — no shared mutable state + }) + }) }) diff --git a/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts b/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts index 7ecf0b0ad..f365cc5fd 100644 --- a/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts +++ b/test/unit/server/infra/analytics/jsonl-analytics-store.test.ts @@ -580,4 +580,85 @@ describe('JsonlAnalyticsStore', () => { expect(store.droppedSentCount()).to.be.greaterThanOrEqual(1) }) }) + + describe('clear() — M4.1 truncate on auth transition', () => { + it('should remove every row regardless of status', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'pending'})) + await store.append(makeRecord({id: 'sent'})) + await store.append(makeRecord({id: 'failed'})) + await store.updateStatus(['sent'], 'sent') + // Push the third row to terminal 'failed' by hammering past the cap. + // Promise.all is safe here: writeChain serializes them in fire-order. + await Promise.all( + Array.from({length: MAX_ATTEMPTS}, () => store.updateStatus(['failed'], 'failed')), + ) + + await store.clear() + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows).to.have.lengthOf(0) + }) + + it('should leave the file empty (zero bytes), not absent', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + await store.append(makeRecord({id: 'r1'})) + + await store.clear() + + const stats = await stat(join(baseDir, 'analytics-queue.jsonl')) + expect(stats.size).to.equal(0) + }) + + it('should be a no-op when the file does not exist yet', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + + // Must not throw even when no append has happened. + await store.clear() + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + expect(rows).to.have.lengthOf(0) + }) + + it('should NOT reset cumulative lifetime counters (droppedFullCount, droppedSentCount)', async () => { + const baseDir = await freshTempDir() + // Force a `droppedSent` via byte-cap (mirror earlier cap test). + const big = 'x'.repeat(200) + const sampleSize = JSON.stringify(makeRecord({properties: {data: big}})).length + 1 + const store = new JsonlAnalyticsStore({baseDir, maxBytes: sampleSize * 2 + 50, maxRows: 10_000}) + await store.append(makeRecord({id: 'r1', properties: {data: big}})) + await store.append(makeRecord({id: 'r2', properties: {data: big}})) + await store.updateStatus(['r1'], 'sent') + await store.append(makeRecord({id: 'r3', properties: {data: big}})) // drops r1 + const droppedBefore = store.droppedSentCount() + expect(droppedBefore).to.be.greaterThanOrEqual(1) + + await store.clear() + + expect(store.droppedSentCount()).to.equal(droppedBefore) + }) + + it('should serialize through the write chain (concurrent append + clear preserves the clear)', async () => { + const baseDir = await freshTempDir() + const store = new JsonlAnalyticsStore({baseDir}) + // Seed and immediately race a clear + a new append. + await store.append(makeRecord({id: 'old-1'})) + await store.append(makeRecord({id: 'old-2'})) + + // Fire concurrently — order of enqueue determines the on-disk state. + const clearPromise = store.clear() + const appendPromise = store.append(makeRecord({id: 'new-1'})) + await Promise.all([clearPromise, appendPromise]) + + const rows = await readJsonlRows(join(baseDir, 'analytics-queue.jsonl')) + const ids = rows.map((r) => r.id) + expect(ids).to.not.include('old-1') + expect(ids).to.not.include('old-2') + // The append enqueued AFTER clear must survive (queue order serializes writes). + expect(ids).to.include('new-1') + }) + }) }) diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts index 4a0d4c369..4fbe40fab 100644 --- a/test/unit/server/infra/process/analytics-hook.test.ts +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -31,7 +31,7 @@ type StubBundle = { const buildAnalyticsClient = (): StubBundle => { const trackStub = sinon.stub() const flushStub = sinon.stub().resolves(AnalyticsBatch.create([])) - const client: IAnalyticsClient = {flush: flushStub, track: trackStub} + const client: IAnalyticsClient = {flush: flushStub, onAuthTransition: sinon.stub().resolves(), track: trackStub} return {client, flushStub, trackStub} } diff --git a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts index d9025f6c7..21ca3cff9 100644 --- a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts @@ -29,6 +29,7 @@ function makeMockAnalyticsClient(): MockAnalyticsClient { const trackCalls: TrackCall[] = [] const mock: MockAnalyticsClient = { flush: () => Promise.resolve(AnalyticsBatch.create([])), + onAuthTransition: () => Promise.resolve(), track(event: E, ...rest: PropsArg): void { if (mock.trackThrows) throw mock.trackThrows const [properties] = rest diff --git a/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts index 926a54a6b..6c5c2b5a9 100644 --- a/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-list-handler.test.ts @@ -50,6 +50,7 @@ function makeFakeJsonlStore(rows: StoredAnalyticsRecord[]): FakeJsonlStore { const listSpy = spy(listImpl) return { async append() {}, + async clear() {}, droppedFullCount: () => 0, droppedSentCount: () => 0, list: listSpy, @@ -166,6 +167,7 @@ describe('AnalyticsListHandler (M11.2)', () => { const transport = createMockTransportServer() const throwingStore: IJsonlAnalyticsStore = { async append() {}, + async clear() {}, droppedFullCount: () => 0, droppedSentCount: () => 0, async list() { From a88c0cc79b156cc7d7d7227f90103489b2b9ee5e Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Thu, 21 May 2026 16:41:53 +0700 Subject: [PATCH 43/87] feat: [ENG-2643] M4.2 HTTP sender wires daemon flush to telemetry backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the M10.1 no-op sender with the real production transport so `AnalyticsClient.flush()` actually ships pending JSONL rows to the telemetry backend. Composition: AxiosAnalyticsHttpClient (transport: axios POST, 5s timeout, status classification, anonymous-friendly) wrapped by HttpAnalyticsSender (sender contract: composes wire-format batch + stamps device-id, optional session-id, user-agent headers). Tagged AnalyticsHttpSendResult union (ok | timeout | http_4xx | http_5xx | network) so M4.5's backoff policy can react without re-parsing raw errors. Per CLAUDE.md "interfaces at the consumer" + SOLID: transport contract lives in core/interfaces/analytics, sender bridge lives in infra/analytics, composition root is wireAnalyticsHttpSender (extracted for isolated integration testing, mirrors the M4.1 wiring precedent). BRV_ANALYTICS_BASE_URL env var added; defaults to the dev-beta endpoint so the daemon ships events out of the box. .env.example documents the override path for local smoke testing against a backend running on a different port. NoOpAnalyticsSender kept as a test seam (analytics-client.test.ts uses it to assert the "leave-JSONL-untouched" invariant) — comment updated to reflect its post-M4.2 role. --- .env.example | 1 + bin/run.js | 4 +- src/server/config/environment.ts | 17 ++ .../analytics/i-analytics-http-client.ts | 56 ++++ .../analytics/i-analytics-sender.ts | 9 +- .../analytics/axios-analytics-http-client.ts | 109 ++++++++ .../infra/analytics/http-analytics-sender.ts | 81 ++++++ .../infra/analytics/no-op-analytics-sender.ts | 21 +- src/server/infra/process/feature-handlers.ts | 20 +- .../process/wire-analytics-http-sender.ts | 44 ++++ .../wire-analytics-http-sender.test.ts | 196 ++++++++++++++ .../axios-analytics-http-client.test.ts | 213 +++++++++++++++ .../analytics/http-analytics-sender.test.ts | 245 ++++++++++++++++++ 13 files changed, 997 insertions(+), 19 deletions(-) create mode 100644 src/server/core/interfaces/analytics/i-analytics-http-client.ts create mode 100644 src/server/infra/analytics/axios-analytics-http-client.ts create mode 100644 src/server/infra/analytics/http-analytics-sender.ts create mode 100644 src/server/infra/process/wire-analytics-http-sender.ts create mode 100644 test/integration/server/infra/process/wire-analytics-http-sender.test.ts create mode 100644 test/unit/server/infra/analytics/axios-analytics-http-client.test.ts create mode 100644 test/unit/server/infra/analytics/http-analytics-sender.test.ts diff --git a/.env.example b/.env.example index 0b2a31525..bcb69fa20 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,5 @@ BRV_COGIT_BASE_URL=http://localhost:3001 BRV_GIT_REMOTE_BASE_URL=http://localhost:8080 BRV_LLM_BASE_URL=http://localhost:3002 BRV_WEB_APP_URL=http://localhost:8080 +BRV_ANALYTICS_BASE_URL=http://localhost:3003 BRV_UI_SOURCE=lib \ No newline at end of file diff --git a/bin/run.js b/bin/run.js index 51f27bfab..218bfcb01 100755 --- a/bin/run.js +++ b/bin/run.js @@ -4,11 +4,11 @@ import {execute} from '@oclif/core' import {config as loadEnv} from 'dotenv' import {resolve} from 'node:path' -process.env.BRV_ENV = 'production' +process.env.BRV_ENV = 'development' // eslint-disable-next-line n/no-unsupported-features/node-builtins const root = resolve(import.meta.dirname, '..') -loadEnv({path: resolve(root, '.env.production'), quiet: true}) +loadEnv({path: resolve(root, '.env.development'), quiet: true}) // Inject default command 'main' (represents logic of a single 'brv' run) when no args provided diff --git a/src/server/config/environment.ts b/src/server/config/environment.ts index 925104cf1..feab92e97 100644 --- a/src/server/config/environment.ts +++ b/src/server/config/environment.ts @@ -26,6 +26,7 @@ export const ENVIRONMENT: Environment = isEnvironment(envValue) ? envValue : 'de * that does not follow the general "API version at point of use" pattern. */ type EnvironmentConfig = { + analyticsBaseUrl: string authorizationUrl: string clientId: string cogitBaseUrl: string @@ -41,8 +42,16 @@ type EnvironmentConfig = { /** * Non-infrastructure config that stays in source (same across envs or not sensitive). + * + * `analyticsBaseUrl` defaults to the dev-beta telemetry endpoint so the + * daemon ships events out of the box; setting `BRV_ANALYTICS_BASE_URL` + * overrides it (M4.2). Unlike the IAM / Cogit base URLs this is NOT + * `readRequiredEnv` because analytics is opt-in: a missing env var must + * not block daemon startup, and the default keeps M4.7's smoke test + * pointing at the right backend without per-developer setup. */ const DEFAULTS = { + analyticsBaseUrl: 'https://telemetry-dev.byterover.dev', clientId: 'byterover-cli-client', hubRegistryUrl: 'https://hub.byterover.dev/r/registry.json', scopes: { @@ -84,7 +93,15 @@ export const getCurrentConfig = (): EnvironmentConfig => { const oidcBase = `${iamBaseUrl}${API_V1_PATH}/oidc` + // M4.2: BRV_ANALYTICS_BASE_URL overrides the default dev-beta endpoint + // for analytics POSTs. Trailing slashes normalised so axios's baseURL + // composes cleanly with the `/v1/events` request path. + const analyticsBaseUrl = normalizeUrl( + process.env.BRV_ANALYTICS_BASE_URL?.trim() ?? DEFAULTS.analyticsBaseUrl, + ) + return { + analyticsBaseUrl, authorizationUrl: `${oidcBase}/authorize`, clientId: DEFAULTS.clientId, cogitBaseUrl, diff --git a/src/server/core/interfaces/analytics/i-analytics-http-client.ts b/src/server/core/interfaces/analytics/i-analytics-http-client.ts new file mode 100644 index 000000000..4739280f8 --- /dev/null +++ b/src/server/core/interfaces/analytics/i-analytics-http-client.ts @@ -0,0 +1,56 @@ +import type {AnalyticsBatch} from '../../domain/analytics/batch.js' + +/** + * Per-request headers stamped onto every analytics POST. + * + * `deviceId` is mandatory — the backend's IdentityResolverGuard rejects + * anonymous batches without it. `sessionId` is the per-request session + * token (M3.4 backwards-compat hint); the authoritative per-event identity + * lives inside each event after M4.1. + * + * `userAgent` follows the `brv-cli/` convention; the impl + * stamps it so backend logs can correlate by CLI version. + */ +export type AnalyticsHttpHeaders = Readonly<{ + deviceId: string + sessionId?: string + userAgent: string +}> + +/** + * Outcome of a single send attempt. Tagged-union so the caller + * (`HttpAnalyticsSender`) can classify the failure mode for the M4.5 + * backoff policy and the M4.6 status command without re-parsing + * arbitrary error objects. + * + * Reasons: + * - `timeout` — request exceeded the 5 second budget. + * - `http_4xx` — backend rejected the payload (validation, auth, etc). + * - `http_5xx` — backend error; eligible for backoff retry. + * - `network` — connection refused / DNS / TLS / abort before response. + * + * `status` is populated only for `http_4xx` / `http_5xx` paths so the + * caller can log the exact code. + */ +export type AnalyticsHttpSendResult = + | Readonly<{ + ok: false + reason: 'http_4xx' | 'http_5xx' | 'network' | 'timeout' + status?: number + }> + | Readonly<{ok: true}> + +/** + * Daemon-side HTTP transport for analytics batches. Single attempt per + * call, no retries (M4.5 owns retry/backoff); 5 second timeout. + * + * MUST NOT throw — every failure path returns a structured + * `AnalyticsHttpSendResult`. Analytics MUST NOT crash the daemon. + * + * Implementations: + * - `AxiosAnalyticsHttpClient` — production transport over axios. + * - In-process fakes in unit tests for offline assertion. + */ +export interface IAnalyticsHttpClient { + send: (batch: AnalyticsBatch, headers: AnalyticsHttpHeaders) => Promise +} diff --git a/src/server/core/interfaces/analytics/i-analytics-sender.ts b/src/server/core/interfaces/analytics/i-analytics-sender.ts index f47566267..ccf2faa5f 100644 --- a/src/server/core/interfaces/analytics/i-analytics-sender.ts +++ b/src/server/core/interfaces/analytics/i-analytics-sender.ts @@ -20,10 +20,11 @@ export type SendResult = Readonly<{ * outcome as id arrays. * * Implementations: - * - `NoOpAnalyticsSender` (this milestone): returns `{succeeded: [], failed: []}` - * — JSONL stays untouched until M4.2 wires the real HTTP sender. - * - `HttpAnalyticsSender` (M4.2): serializes records to the wire format and - * POSTs the batch to the telemetry backend. + * - `HttpAnalyticsSender` (M4.2, production default): serializes records to + * the wire format and POSTs the batch to the telemetry backend. + * - `NoOpAnalyticsSender`: semantically inert (`{succeeded: [], failed: []}`). + * Test seam — used to assert the M10.2 "leave-JSONL-untouched" invariant + * without going through the real transport. */ export interface IAnalyticsSender { /** diff --git a/src/server/infra/analytics/axios-analytics-http-client.ts b/src/server/infra/analytics/axios-analytics-http-client.ts new file mode 100644 index 000000000..4995f1dec --- /dev/null +++ b/src/server/infra/analytics/axios-analytics-http-client.ts @@ -0,0 +1,109 @@ +import type {AxiosInstance, AxiosResponse} from 'axios' + +import axios, {AxiosError} from 'axios' + +import type {AnalyticsBatch} from '../../core/domain/analytics/batch.js' +import type { + AnalyticsHttpHeaders, + AnalyticsHttpSendResult, + IAnalyticsHttpClient, +} from '../../core/interfaces/analytics/i-analytics-http-client.js' + +const DEFAULT_TIMEOUT_MS = 5000 +const EVENTS_PATH = '/v1/events' + +type AxiosAnalyticsHttpClientOptions = { + baseUrl: string + /** Override request timeout (default 5000ms). Test-only escape hatch. */ + timeoutMs?: number +} + +/** + * Production analytics HTTP transport over axios. + * + * Contract (per `IAnalyticsHttpClient` + ENG-2643): + * - One POST per call; no retries — M4.5 owns retry/backoff. + * - 5 second timeout enforced via the axios instance config. + * - Anonymous-friendly: no `Authorization` header, no token plumbing. + * `x-byterover-device-id` is mandatory; `x-byterover-session-id` is + * an optional backwards-compat hint (per-event identity is the + * authoritative source after M4.1). + * - MUST NOT throw. Every failure path returns a tagged + * `AnalyticsHttpSendResult` so the caller can keep the daemon up. + * + * Reason classification: timeout / 4xx / 5xx / network. Anything else + * (e.g. axios serialization bug) falls into `network` so callers always + * see a tagged result. + */ +export class AxiosAnalyticsHttpClient implements IAnalyticsHttpClient { + private readonly axios: AxiosInstance + + public constructor(options: AxiosAnalyticsHttpClientOptions) { + this.axios = axios.create({ + baseURL: options.baseUrl.replace(/\/+$/, ''), + // `validateStatus` returning true delegates HTTP-status classification + // to `classifyResponse` below; axios won't throw on 4xx/5xx so we can + // map them to tagged failure reasons without catching. + timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + validateStatus: () => true, + }) + } + + public async send( + batch: AnalyticsBatch, + headers: AnalyticsHttpHeaders, + ): Promise { + try { + const response = await this.axios.post(EVENTS_PATH, batch.toJson(), { + headers: this.composeHeaders(headers), + }) + return classifyResponse(response) + } catch (error: unknown) { + return classifyError(error) + } + } + + private composeHeaders(headers: AnalyticsHttpHeaders): Record { + const composed: Record = { + 'content-type': 'application/json', + 'user-agent': headers.userAgent, + 'x-byterover-device-id': headers.deviceId, + } + if (headers.sessionId !== undefined && headers.sessionId !== '') { + composed['x-byterover-session-id'] = headers.sessionId + } + + return composed + } +} + +const classifyResponse = (response: AxiosResponse): AnalyticsHttpSendResult => { + const {status} = response + if (status >= 200 && status < 300) return {ok: true} + if (status >= 400 && status < 500) return {ok: false, reason: 'http_4xx', status} + if (status >= 500 && status < 600) return {ok: false, reason: 'http_5xx', status} + // 1xx / 3xx without redirect handling reach here. Treat as network-level + // anomaly so callers see a tagged result rather than silently succeeding. + return {ok: false, reason: 'network'} +} + +const classifyError = (error: unknown): AnalyticsHttpSendResult => { + if (axios.isAxiosError(error)) { + // Timeout: axios surfaces this as `ECONNABORTED` with `code === 'ECONNABORTED'`, + // or `ETIMEDOUT` on socket-level timeouts. + if (isTimeoutCode(error)) return {ok: false, reason: 'timeout'} + // Response present but classifyResponse didn't run (shouldn't happen given + // `validateStatus: () => true`, but defensively re-classify here). + if (error.response !== undefined) return classifyResponse(error.response) + return {ok: false, reason: 'network'} + } + + // Non-axios throws (e.g. JSON.stringify bug from a circular-reference event) + // map to network so the caller always sees a tagged result. + return {ok: false, reason: 'network'} +} + +const isTimeoutCode = (error: AxiosError): boolean => { + const {code} = error + return code === 'ECONNABORTED' || code === 'ETIMEDOUT' +} diff --git a/src/server/infra/analytics/http-analytics-sender.ts b/src/server/infra/analytics/http-analytics-sender.ts new file mode 100644 index 000000000..0a14e5b46 --- /dev/null +++ b/src/server/infra/analytics/http-analytics-sender.ts @@ -0,0 +1,81 @@ +import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' +import type {IAnalyticsHttpClient} from '../../core/interfaces/analytics/i-analytics-http-client.js' +import type {IAnalyticsSender, SendResult} from '../../core/interfaces/analytics/i-analytics-sender.js' +import type {IAuthStateReader} from '../../core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../core/interfaces/storage/i-global-config-store.js' + +import {toWireEvent} from '../../../shared/analytics/stored-record.js' +import {AnalyticsBatch} from '../../core/domain/analytics/batch.js' + +export interface HttpAnalyticsSenderDeps { + authStateReader: IAuthStateReader + globalConfigStore: IGlobalConfigStore + httpClient: IAnalyticsHttpClient + userAgent: string +} + +/** + * Bridges the M10.1 `IAnalyticsSender` contract over an + * `IAnalyticsHttpClient`. The sender owns wire-format composition + * (records → `AnalyticsBatch`) and request-level header assembly + * (device id, session id, user-agent); the http client owns transport + * (timeout, status classification, network errors). + * + * Mapping rules: + * - Empty input → `{succeeded: [], failed: []}` without an HTTP call. + * - HTTP success → every input id classified as `succeeded`. + * - HTTP failure (timeout / 4xx / 5xx / network) → every input id + * classified as `failed`; M9.2's retry-cap inside `JsonlAnalyticsStore. + * updateStatus(_, 'failed')` increments `attempts` and terminates rows + * at MAX_ATTEMPTS. Backoff (M4.5) reacts to the structured failure + * reason later. + * + * Per-record granularity is intentionally collapsed here: the backend's + * 200 response is batch-level (it counts accepted/rejected internally + * via `IngestBatchResult` but does not surface per-event ids). All-or- + * nothing matches that contract. + * + * MUST NOT throw — analytics MUST NOT crash the daemon. Collaborator + * failures (e.g. globalConfigStore disk error) are caught and surface + * as `failed` so the retry policy can react. + */ +export class HttpAnalyticsSender implements IAnalyticsSender { + private readonly deps: HttpAnalyticsSenderDeps + + public constructor(deps: HttpAnalyticsSenderDeps) { + this.deps = deps + } + + public async send(records: readonly StoredAnalyticsRecord[]): Promise { + if (records.length === 0) return {failed: [], succeeded: []} + + const ids = records.map((r) => r.id) + try { + const config = await this.deps.globalConfigStore.read() + const deviceId = config?.deviceId + if (deviceId === undefined || deviceId === '') { + // Backend requires `x-byterover-device-id` on every batch. + // Without it the request would be rejected with 400; ship the + // records as failed so the retry-cap policy bumps attempts and + // eventually terminates them rather than looping forever. + return {failed: [...ids], succeeded: []} + } + + const sessionKey = this.deps.authStateReader.getToken()?.sessionKey + const batch = AnalyticsBatch.create(records.map((r) => toWireEvent(r))) + const httpResult = await this.deps.httpClient.send(batch, { + deviceId, + ...(sessionKey !== undefined && sessionKey !== '' ? {sessionId: sessionKey} : {}), + userAgent: this.deps.userAgent, + }) + + if (httpResult.ok) return {failed: [], succeeded: [...ids]} + return {failed: [...ids], succeeded: []} + } catch { + // Defensive: any collaborator surprise (config read throws, + // toWireEvent edge case, etc.) maps to a batch-level failure. + // The retry-cap policy owns terminal classification. + return {failed: [...ids], succeeded: []} + } + } +} diff --git a/src/server/infra/analytics/no-op-analytics-sender.ts b/src/server/infra/analytics/no-op-analytics-sender.ts index be14aa0e0..5eeb9a7ef 100644 --- a/src/server/infra/analytics/no-op-analytics-sender.ts +++ b/src/server/infra/analytics/no-op-analytics-sender.ts @@ -2,17 +2,22 @@ import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-recor import type {IAnalyticsSender, SendResult} from '../../core/interfaces/analytics/i-analytics-sender.js' /** - * Default sender used until M4.2 wires the real HTTP sender. `send()` is - * semantically inert: it returns both arrays empty, so when M10.2's flush - * mirrors the result back to JSONL via `updateStatus(succeeded, 'sent')` - * and `updateStatus(failed, 'failed')`, both calls receive empty input - * and become no-ops. Pending JSONL rows stay at `status='pending'` and - * the next flush tick (after the real sender plugs in) ships them. + * Semantically inert sender. `send()` returns both arrays empty, so when + * M10.2's flush mirrors the result back to JSONL via + * `updateStatus(succeeded, 'sent')` and `updateStatus(failed, 'failed')`, + * both calls receive empty input and become no-ops. Pending JSONL rows + * stay at `status='pending'`. * * Returning empty arrays — rather than echoing every input id as * `failed` — eliminates the data-loss hazard that would otherwise appear - * if M4.3 (the flush scheduler) lands before M4.2 (the HTTP sender): - * scheduled ticks remain observable but non-destructive. + * if the flush scheduler runs without a working sender (M4.3 scheduler + + * M4.2 HTTP sender). Scheduled ticks remain observable but non-destructive. + * + * Status (post-M4.2): no longer wired into the daemon — `HttpAnalyticsSender` + * is the production default. Kept as a test seam: `analytics-client.test.ts` + * uses it to assert the "leave-JSONL-untouched" invariant against the real + * flush wiring, and future test harnesses (e.g. M4.3 scheduler tests) can + * drop it in to isolate scheduling behavior from transport. */ export class NoOpAnalyticsSender implements IAnalyticsSender { public async send(_records: readonly StoredAnalyticsRecord[]): Promise { diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index be75fbe07..18b8183e2 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -24,11 +24,11 @@ import {getCurrentConfig} from '../../config/environment.js' import {API_V1_PATH, BRV_DIR} from '../../constants.js' import {getGlobalDataDir} from '../../utils/global-data-path.js' import {getProjectDataDir} from '../../utils/path-utils.js' +import {readCliVersion} from '../../utils/read-cli-version.js' import {AnalyticsClient} from '../analytics/analytics-client.js' import {BoundedQueue} from '../analytics/bounded-queue.js' import {IdentityResolver} from '../analytics/identity-resolver.js' import {JsonlAnalyticsStore} from '../analytics/jsonl-analytics-store.js' -import {NoOpAnalyticsSender} from '../analytics/no-op-analytics-sender.js' import {SuperPropertiesResolver} from '../analytics/super-properties-resolver.js' import {OAuthService} from '../auth/oauth-service.js' import {OidcDiscoveryService} from '../auth/oidc-discovery-service.js' @@ -83,6 +83,7 @@ import { import {HttpUserService} from '../user/http-user-service.js' import {FileVcGitConfigStore} from '../vc/file-vc-git-config-store.js' import {wireAnalyticsAuthTransition} from './wire-analytics-auth-transition.js' +import {wireAnalyticsHttpSender} from './wire-analytics-http-sender.js' export interface FeatureHandlersOptions { authStateStore: IAuthStateStore @@ -170,15 +171,24 @@ export async function setupFeatureHandlers({ // M11.2's analytics-list-handler when it lands so both read/write the same // file. Storage path: `/analytics-queue.jsonl`. const jsonlAnalyticsStore = new JsonlAnalyticsStore({baseDir: getGlobalDataDir()}) - // M10.2: inject the M10.1 no-op sender. M4.2 will replace this with the real HTTP sender. - // The no-op returns {succeeded: [], failed: []} so flush ticks are observable but - // non-destructive — JSONL rows stay at status='pending' until the real sender plugs in. + // M4.2: real HTTP sender. See `wireAnalyticsHttpSender` for the + // axios + sender composition. Headers are computed per send so + // device-id and session-id reflect the current GlobalConfig + + // AuthStateStore state at flush time. The per-event identity inside + // each record (M4.1) remains authoritative for the wire body; the + // request-level session header is a backwards-compat hint only. + const analyticsSender = wireAnalyticsHttpSender({ + analyticsBaseUrl: envConfig.analyticsBaseUrl, + authStateReader: authStateStore, + globalConfigStore, + version: readCliVersion(), + }) const analyticsClient: IAnalyticsClient = new AnalyticsClient({ identityResolver: new IdentityResolver(authStateStore, globalConfigStore), isEnabled: () => globalConfigHandler.getCachedAnalytics(), jsonlStore: jsonlAnalyticsStore, queue: new BoundedQueue(), - sender: new NoOpAnalyticsSender(), + sender: analyticsSender, superPropsResolver: new SuperPropertiesResolver(globalConfigStore), }) diff --git a/src/server/infra/process/wire-analytics-http-sender.ts b/src/server/infra/process/wire-analytics-http-sender.ts new file mode 100644 index 000000000..647cac220 --- /dev/null +++ b/src/server/infra/process/wire-analytics-http-sender.ts @@ -0,0 +1,44 @@ +import type {IAnalyticsSender} from '../../core/interfaces/analytics/i-analytics-sender.js' +import type {IAuthStateReader} from '../../core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../core/interfaces/storage/i-global-config-store.js' + +import {AxiosAnalyticsHttpClient} from '../analytics/axios-analytics-http-client.js' +import {HttpAnalyticsSender} from '../analytics/http-analytics-sender.js' + +export type AnalyticsHttpSenderWiring = { + analyticsBaseUrl: string + authStateReader: IAuthStateReader + globalConfigStore: IGlobalConfigStore + /** CLI semver string (e.g. `3.12.0`). Wrapped into the user-agent header. */ + version: string +} + +/** + * Compose the production analytics sender stack: + * AxiosAnalyticsHttpClient (transport — axios POST, 5s timeout, status + * classification) wrapped by HttpAnalyticsSender (sender contract — + * batch composition + header assembly). + * + * Extracted from `feature-handlers.ts` so the wiring is testable in + * isolation. Booting the full feature-handler graph would require + * stubbing every HTTP service the daemon uses; this helper exposes only + * the analytics-relevant collaborators so unit tests can assert the + * composition shape without infrastructure ceremony. + * + * Mirrors the M4.1 `wireAnalyticsAuthTransition` precedent — every + * composition-root binding gets a thin pure factory + a focused test so + * a future swap (e.g. swapping axios for undici, or wrapping the sender + * for M4.5 backoff) lands at one obvious seam. + * + * The returned value is the `IAnalyticsSender` consumed by + * `AnalyticsClient.flush()`. + */ +export function wireAnalyticsHttpSender(wiring: AnalyticsHttpSenderWiring): IAnalyticsSender { + const httpClient = new AxiosAnalyticsHttpClient({baseUrl: wiring.analyticsBaseUrl}) + return new HttpAnalyticsSender({ + authStateReader: wiring.authStateReader, + globalConfigStore: wiring.globalConfigStore, + httpClient, + userAgent: `brv-cli/${wiring.version}`, + }) +} diff --git a/test/integration/server/infra/process/wire-analytics-http-sender.test.ts b/test/integration/server/infra/process/wire-analytics-http-sender.test.ts new file mode 100644 index 000000000..888b02076 --- /dev/null +++ b/test/integration/server/infra/process/wire-analytics-http-sender.test.ts @@ -0,0 +1,196 @@ +/* eslint-disable camelcase */ + +import {expect} from 'chai' +import nock from 'nock' +import {stub} from 'sinon' + +import type {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' +import type {IAuthStateReader} from '../../../../../src/server/core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces/storage/i-global-config-store.js' +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' + +import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' +import {wireAnalyticsHttpSender} from '../../../../../src/server/infra/process/wire-analytics-http-sender.js' + +/** + * Integration test for the M4.2 composition-root binding that wires + * AnalyticsClient → IAnalyticsSender. The helper composes + * AxiosAnalyticsHttpClient + HttpAnalyticsSender; this test exercises + * the chain end-to-end through a nocked HTTP boundary so a future + * misconfigured wiring (wrong base URL, dropped header, swapped + * collaborator) is caught at unit-test speed without booting the + * whole daemon. + * + * Mirrors the M4.1 `wire-analytics-auth-transition.test.ts` precedent: + * every composition-root binding gets a focused integration test that + * locks-in the wiring shape. + */ + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' +const baseUrl = 'https://telemetry-test.byterover.dev' + +function makeConfigStore(deviceId: string = validDeviceId): IGlobalConfigStore { + const config = GlobalConfig.fromJson({analytics: true, deviceId, version: '0.0.1'}) + if (!config) throw new Error('fixture: GlobalConfig.fromJson must succeed') + return {read: stub().resolves(config), write: stub().resolves()} +} + +function makeAuthReader(token?: AuthToken): IAuthStateReader { + return {getToken: () => token} +} + +function makeRecord(overrides: Partial = {}): StoredAnalyticsRecord { + return { + attempts: 0, + id: overrides.id ?? '11111111-1111-1111-1111-111111111111', + identity: {device_id: validDeviceId, user_id: 'user-123'}, + name: 'daemon_start', + properties: {cli_version: '3.12.0'}, + status: 'pending', + timestamp: 1_700_000_000_000, + ...overrides, + } +} + +describe('M4.2 wireAnalyticsHttpSender (integration)', () => { + beforeEach(() => { + nock.cleanAll() + nock.disableNetConnect() + }) + + afterEach(() => { + nock.cleanAll() + nock.enableNetConnect() + }) + + it('composes a sender that POSTs to /v1/events on send()', async () => { + const scope = nock(baseUrl).post('/v1/events').reply(200, {accepted: 1, rejected: 0}) + + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: baseUrl, + authStateReader: makeAuthReader(), + globalConfigStore: makeConfigStore(), + version: '3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'r1'})]) + + expect(result).to.deep.equal({failed: [], succeeded: ['r1']}) + expect(scope.isDone(), 'sender must POST to /v1/events').to.equal(true) + }) + + it('stamps headers from the wiring (device-id, user-agent, optional session-id)', async () => { + const token = {sessionKey: 'sess-from-wiring'} as AuthToken + const scope = nock(baseUrl) + .post('/v1/events') + .matchHeader('x-byterover-device-id', 'dev-from-config') + .matchHeader('x-byterover-session-id', 'sess-from-wiring') + .matchHeader('user-agent', 'brv-cli/3.12.0') + .matchHeader('content-type', /application\/json/) + .reply(200, {}) + + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: baseUrl, + authStateReader: makeAuthReader(token), + globalConfigStore: makeConfigStore('dev-from-config'), + version: '3.12.0', + }) + + const result = await sender.send([makeRecord()]) + + expect(result.succeeded).to.have.lengthOf(1) + expect(scope.isDone()).to.equal(true) + }) + + it('omits session-id when no auth token is present', async () => { + let recordedHeaders: Record | undefined + const scope = nock(baseUrl) + .post('/v1/events') + .reply(function () { + recordedHeaders = this.req.headers + return [200, {}] + }) + + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: baseUrl, + authStateReader: makeAuthReader(), + globalConfigStore: makeConfigStore(), + version: '3.12.0', + }) + + await sender.send([makeRecord()]) + + expect(scope.isDone()).to.equal(true) + expect(recordedHeaders, 'session header must not leak on anonymous batches').to.not.have.property('x-byterover-session-id') + }) + + it('returns failed=ids when the backend returns 5xx (sender swap surface preserved)', async () => { + nock(baseUrl).post('/v1/events').reply(503, {}) + + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: baseUrl, + authStateReader: makeAuthReader(), + globalConfigStore: makeConfigStore(), + version: '3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'a'}), makeRecord({id: 'b'})]) + + expect(result).to.deep.equal({failed: ['a', 'b'], succeeded: []}) + }) + + it('returns empty result without HTTP traffic for an empty batch', async () => { + // Strict: no nock scope registered. If the sender hits the wire, + // `nock.disableNetConnect` throws and the test fails loudly — that + // is exactly the regression we want to lock in. + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: baseUrl, + authStateReader: makeAuthReader(), + globalConfigStore: makeConfigStore(), + version: '3.12.0', + }) + + const result = await sender.send([]) + + expect(result).to.deep.equal({failed: [], succeeded: []}) + }) + + it('treats missing deviceId from config as a batch failure (no HTTP traffic)', async () => { + // Same disable-net-connect guard: empty record-set means HTTP must + // not fire, regardless of why. + const emptyStore: IGlobalConfigStore = { + read: stub().resolves(), + write: stub().resolves(), + } + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: baseUrl, + authStateReader: makeAuthReader(), + globalConfigStore: emptyStore, + version: '3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'r1'})]) + + expect(result).to.deep.equal({failed: ['r1'], succeeded: []}) + }) + + it('normalises a trailing slash on the base URL (axios baseURL hygiene)', async () => { + // Without normalisation, axios's baseURL='http://x.com/' + path='/v1/events' + // emits a POST to '//v1/events' on some axios versions. The helper + // delegates normalisation to AxiosAnalyticsHttpClient; this test + // pins the contract so a refactor doesn't accidentally drop it. + const scope = nock(baseUrl).post('/v1/events').reply(200, {}) + + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: `${baseUrl}/`, + authStateReader: makeAuthReader(), + globalConfigStore: makeConfigStore(), + version: '3.12.0', + }) + + const result = await sender.send([makeRecord()]) + + expect(result.succeeded).to.have.lengthOf(1) + expect(scope.isDone()).to.equal(true) + }) +}) diff --git a/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts b/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts new file mode 100644 index 000000000..88f1e94d7 --- /dev/null +++ b/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts @@ -0,0 +1,213 @@ +/* eslint-disable camelcase */ + +import {expect} from 'chai' +import nock from 'nock' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AxiosAnalyticsHttpClient} from '../../../../../src/server/infra/analytics/axios-analytics-http-client.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' +const baseUrl = 'https://telemetry-test.byterover.dev' + +function makeEvent(name = 'daemon_start') { + return { + identity: {device_id: validDeviceId, user_id: 'user-123'}, + name, + properties: {cli_version: '3.12.0'}, + timestamp: 1_700_000_000_000, + } +} + +function makeBatch(eventCount = 1): AnalyticsBatch { + return AnalyticsBatch.create(Array.from({length: eventCount}, (_, i) => makeEvent(`event_${String(i)}`))) +} + +describe('AxiosAnalyticsHttpClient', () => { + beforeEach(() => { + nock.cleanAll() + nock.disableNetConnect() + }) + + afterEach(() => { + nock.cleanAll() + nock.enableNetConnect() + }) + + describe('happy path', () => { + it('POSTs the batch to /v1/events and returns ok=true on 2xx', async () => { + let receivedBody: unknown + const scope = nock(baseUrl) + .post('/v1/events', (body) => { + receivedBody = body + return true + }) + .matchHeader('x-byterover-device-id', validDeviceId) + .matchHeader('content-type', /application\/json/) + .matchHeader('user-agent', 'brv-cli/3.12.0') + .reply(200, {accepted: 1, rejected: 0}) + + const client = new AxiosAnalyticsHttpClient({baseUrl}) + const result = await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + + expect(result).to.deep.equal({ok: true}) + expect(scope.isDone()).to.equal(true) + // Body matches the AnalyticsBatch.toJson() wire shape. + expect(receivedBody).to.have.property('schema_version', 1) + expect(receivedBody).to.have.nested.property('events.0.name', 'event_0') + }) + + it('stamps x-byterover-session-id when sessionId is provided', async () => { + const scope = nock(baseUrl) + .post('/v1/events') + .matchHeader('x-byterover-session-id', 'sess-abc') + .matchHeader('x-byterover-device-id', validDeviceId) + .reply(200, {}) + + const client = new AxiosAnalyticsHttpClient({baseUrl}) + const result = await client.send(makeBatch(1), { + deviceId: validDeviceId, + sessionId: 'sess-abc', + userAgent: 'brv-cli/3.12.0', + }) + + expect(result.ok).to.equal(true) + expect(scope.isDone()).to.equal(true) + }) + + it('does NOT send an authorization header (analytics works anonymous)', async () => { + let recordedHeaders: Record | undefined + const scope = nock(baseUrl) + .post('/v1/events') + .reply(function () { + recordedHeaders = this.req.headers + return [200, {}] + }) + + const client = new AxiosAnalyticsHttpClient({baseUrl}) + await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + + expect(scope.isDone()).to.equal(true) + expect(recordedHeaders).to.not.have.property('authorization') + }) + }) + + describe('failure classification', () => { + it('returns ok=false reason=http_4xx with status for a 400', async () => { + nock(baseUrl).post('/v1/events').reply(400, {message: 'bad request'}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + + const result = await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + + expect(result).to.deep.equal({ok: false, reason: 'http_4xx', status: 400}) + }) + + it('returns ok=false reason=http_4xx with status for a 429', async () => { + nock(baseUrl).post('/v1/events').reply(429, {message: 'too many requests'}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + + const result = await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + + expect(result).to.deep.equal({ok: false, reason: 'http_4xx', status: 429}) + }) + + it('returns ok=false reason=http_5xx with status for a 503', async () => { + nock(baseUrl).post('/v1/events').reply(503, {message: 'unavailable'}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + + const result = await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + + expect(result).to.deep.equal({ok: false, reason: 'http_5xx', status: 503}) + }) + + it('returns ok=false reason=network when the connection cannot be established', async () => { + // Point axios at an unreachable port; nock + disableNetConnect would + // surface the same network-level failure but with timing variance + // across CI runs. Targeting localhost:1 yields a deterministic + // connect refusal that axios classifies as a non-response error + // (not a timeout, since the request never enters the timeout window). + nock.enableNetConnect('127.0.0.1') + const client = new AxiosAnalyticsHttpClient({baseUrl: 'http://127.0.0.1:1'}) + + const result = await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + + expect(result.ok).to.equal(false) + if (result.ok) throw new Error('unreachable') + expect(result.reason).to.equal('network') + nock.disableNetConnect() + }) + + it('returns ok=false reason=timeout when the server is too slow', async () => { + // 100ms timeout for the test; nock delay > timeout to force ETIMEDOUT. + nock(baseUrl).post('/v1/events').delay(500).reply(200, {}) + const client = new AxiosAnalyticsHttpClient({baseUrl, timeoutMs: 100}) + + const result = await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + + expect(result.ok).to.equal(false) + if (result.ok) throw new Error('unreachable') + expect(result.reason).to.equal('timeout') + }) + }) + + describe('contract guarantees', () => { + it('does NOT throw on any failure path', async () => { + // Combine the slowest failure mode (timeout) with a tight client + // budget so the assertion completes in <200ms instead of the + // default 5s. The point is to prove the catch path returns a + // tagged result rather than propagating an exception. + nock(baseUrl).post('/v1/events').delay(400).reply(500, {}) + const client = new AxiosAnalyticsHttpClient({baseUrl, timeoutMs: 100}) + + let threw = false + try { + await client.send(makeBatch(1), { + deviceId: validDeviceId, + userAgent: 'brv-cli/3.12.0', + }) + } catch { + threw = true + } + + expect(threw, 'send() must never throw').to.equal(false) + }) + + it('sends the full batch body unchanged (round-trips through AnalyticsBatch.fromJson)', async () => { + let receivedBody: unknown + nock(baseUrl) + .post('/v1/events', (body) => { + receivedBody = body + return true + }) + .reply(200, {}) + + const batch = makeBatch(3) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + await client.send(batch, {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + const restored = AnalyticsBatch.fromJson(receivedBody) + expect(restored, 'wire body must parse back as AnalyticsBatch').to.not.equal(undefined) + expect(restored?.events).to.have.lengthOf(3) + }) + }) +}) diff --git a/test/unit/server/infra/analytics/http-analytics-sender.test.ts b/test/unit/server/infra/analytics/http-analytics-sender.test.ts new file mode 100644 index 000000000..228d0a842 --- /dev/null +++ b/test/unit/server/infra/analytics/http-analytics-sender.test.ts @@ -0,0 +1,245 @@ +/* eslint-disable camelcase */ + +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import type {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' +import type { + AnalyticsHttpHeaders, + AnalyticsHttpSendResult, + IAnalyticsHttpClient, +} from '../../../../../src/server/core/interfaces/analytics/i-analytics-http-client.js' +import type {IAuthStateReader} from '../../../../../src/server/core/interfaces/analytics/i-identity-resolver.js' +import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces/storage/i-global-config-store.js' +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' + +import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' +import {HttpAnalyticsSender} from '../../../../../src/server/infra/analytics/http-analytics-sender.js' + +const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' + +function makeRecord(overrides: Partial = {}): StoredAnalyticsRecord { + return { + attempts: 0, + id: overrides.id ?? '11111111-1111-1111-1111-111111111111', + identity: {device_id: validDeviceId, user_id: 'user-123'}, + name: 'daemon_start', + properties: {cli_version: '3.12.0'}, + status: 'pending', + timestamp: 1_700_000_000_000, + ...overrides, + } +} + +function makeStubConfigStore(deviceId: string = validDeviceId): IGlobalConfigStore { + const config = GlobalConfig.fromJson({analytics: true, deviceId, version: '0.0.1'}) + if (!config) throw new Error('fixture: GlobalConfig.fromJson must succeed') + return {read: stub().resolves(config), write: stub().resolves()} +} + +function makeAuthReader(token?: AuthToken): IAuthStateReader { + return {getToken: () => token} +} + +type RecordedSend = {batch: AnalyticsBatch; headers: AnalyticsHttpHeaders} + +type CapturingHttpClient = IAnalyticsHttpClient & {readonly calls: RecordedSend[]} + +function makeCapturingHttpClient(result: AnalyticsHttpSendResult): CapturingHttpClient { + const calls: RecordedSend[] = [] + return { + calls, + async send(batch, headers) { + calls.push({batch, headers}) + return result + }, + } +} + +describe('HttpAnalyticsSender', () => { + describe('happy path', () => { + it('sends a batch built from the input records and returns succeeded ids', async () => { + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const r1 = makeRecord({id: 'r1', name: 'event_a'}) + const r2 = makeRecord({id: 'r2', name: 'event_b'}) + const result = await sender.send([r1, r2]) + + expect(result).to.deep.equal({failed: [], succeeded: ['r1', 'r2']}) + expect(httpClient.calls).to.have.lengthOf(1) + const [{batch}] = httpClient.calls + expect(batch.events).to.have.lengthOf(2) + expect(batch.events[0].name).to.equal('event_a') + expect(batch.events[1].name).to.equal('event_b') + }) + + it('stamps deviceId from GlobalConfig + userAgent from the constructor', async () => { + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore('dev-from-config'), + httpClient, + userAgent: 'brv-cli/9.9.9', + }) + + await sender.send([makeRecord()]) + + const [{headers}] = httpClient.calls + expect(headers.deviceId).to.equal('dev-from-config') + expect(headers.userAgent).to.equal('brv-cli/9.9.9') + }) + + it('stamps sessionId from AuthStateReader when authenticated', async () => { + const token = {sessionKey: 'sess-abc'} as AuthToken + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(token), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + await sender.send([makeRecord()]) + + const [{headers}] = httpClient.calls + expect(headers.sessionId).to.equal('sess-abc') + }) + + it('omits sessionId when anonymous (no auth token)', async () => { + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + await sender.send([makeRecord()]) + + const [{headers}] = httpClient.calls + expect(headers.sessionId).to.equal(undefined) + }) + }) + + describe('empty input', () => { + it('returns empty result without calling the http client for an empty batch', async () => { + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([]) + + expect(result).to.deep.equal({failed: [], succeeded: []}) + expect(httpClient.calls).to.have.lengthOf(0) + }) + }) + + describe('failure mapping', () => { + it('returns all ids as failed when http client reports http_5xx', async () => { + const httpClient = makeCapturingHttpClient({ok: false, reason: 'http_5xx', status: 503}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const r1 = makeRecord({id: 'r1'}) + const r2 = makeRecord({id: 'r2'}) + const result = await sender.send([r1, r2]) + + expect(result).to.deep.equal({failed: ['r1', 'r2'], succeeded: []}) + }) + + it('returns all ids as failed when http client reports timeout', async () => { + const httpClient = makeCapturingHttpClient({ok: false, reason: 'timeout'}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'only'})]) + + expect(result).to.deep.equal({failed: ['only'], succeeded: []}) + }) + + it('returns all ids as failed when http client reports network failure', async () => { + const httpClient = makeCapturingHttpClient({ok: false, reason: 'network'}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'only'})]) + + expect(result).to.deep.equal({failed: ['only'], succeeded: []}) + }) + }) + + describe('crash safety', () => { + it('does NOT throw if globalConfigStore.read() rejects; treats the batch as failed', async () => { + const failingStore: IGlobalConfigStore = { + read: stub().rejects(new Error('disk full')), + write: stub().resolves(), + } + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: failingStore, + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + let threw = false + let result + try { + result = await sender.send([makeRecord({id: 'r1'})]) + } catch { + threw = true + } + + expect(threw, 'sender must NOT throw on collaborator failure').to.equal(false) + expect(result).to.deep.equal({failed: ['r1'], succeeded: []}) + expect(httpClient.calls).to.have.lengthOf(0) + }) + + it('treats missing deviceId as a failure (anonymous batches still need a device id per backend contract)', async () => { + // GlobalConfigStore returns undefined (first-run before the daemon + // has provisioned a device id). Per the backend contract, batches + // without `x-byterover-device-id` are 400-rejected; sender refuses + // to ship and counts the records as failed so the flush mirror + // (M10.2) increments their attempts. + const emptyStore: IGlobalConfigStore = { + read: stub().resolves(), + write: stub().resolves(), + } + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: emptyStore, + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'r1'})]) + + expect(result).to.deep.equal({failed: ['r1'], succeeded: []}) + expect(httpClient.calls).to.have.lengthOf(0) + }) + }) +}) From bc55a0050883f412ee446546393a94341c9ac187 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Thu, 21 May 2026 18:49:57 +0700 Subject: [PATCH 44/87] feat: [ENG-2645] M4.3 batched flush scheduler (30s / 20-event / shutdown) Auto-flush the analytics queue on two triggers (whichever fires first): - 30s interval timer - 20-event in-memory queue depth Daemon shutdown attempts a best-effort final flush against a 3s budget so a slow telemetry backend cannot stall the exit sequence. Composition: AnalyticsFlushScheduler owns timer + threshold orchestration; delegates the actual send to IAnalyticsClient.flush(). Single-flight: while a flush is in flight, new triggers are dropped (not queued); flushFinal() joins the in-flight promise rather than racing a second send. AnalyticsClient.track() gains an optional onAfterTrack hook so the scheduler can be notified after a record durably lands (jsonlStore.append + queue.push). The composition root wires it via a const holder object to resolve the client/scheduler cycle without a `let` reassignment. ShutdownHandler gains a step 5.5 (analyticsFinalFlush) between agent-pool shutdown and transport-server stop. The hook owns its own timeout; the shutdown sequence does not block longer than the hook permits. The empty-skip gate consults JSONL pendingCount, NOT the in-memory queue mirror: queue.drain only runs on auth transitions, so queue.size never shrinks after a successful flush. Gating on queue size would make the timer fire every 30s indefinitely with nothing left to ship. Tests cover the mirror-non-zero / pending=0 regression at both the scheduler and wiring layer. wireAnalyticsFlushScheduler helper mirrors the M4.1 / M4.2 wiring-helper precedent so the composition root binding stays testable in isolation. --- .../infra/analytics/analytics-client.ts | 16 + .../analytics/analytics-flush-scheduler.ts | 234 ++++++++++ src/server/infra/daemon/brv-server.ts | 26 +- src/server/infra/daemon/shutdown-handler.ts | 22 + src/server/infra/process/feature-handlers.ts | 41 +- .../process/wire-analytics-flush-scheduler.ts | 54 +++ .../wire-analytics-flush-scheduler.test.ts | 290 ++++++++++++ .../infra/daemon/shutdown-handler.test.ts | 67 +++ .../infra/analytics/analytics-client.test.ts | 75 +++ .../analytics-flush-scheduler.test.ts | 426 ++++++++++++++++++ 10 files changed, 1245 insertions(+), 6 deletions(-) create mode 100644 src/server/infra/analytics/analytics-flush-scheduler.ts create mode 100644 src/server/infra/process/wire-analytics-flush-scheduler.ts create mode 100644 test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts create mode 100644 test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index ba6c72804..17e4dfb8e 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -24,6 +24,15 @@ export interface AnalyticsClientDeps { * a no-op when omitted so existing callers don't have to wire it. */ log?: (message: string) => void + /** + * M4.3: optional notification fired after a record has been durably + * appended (JSONL + queue mirror). The composition root wires this to + * `AnalyticsFlushScheduler.notifyPushed()` so the scheduler can check + * its 20-event threshold without coupling AnalyticsClient to the + * scheduler's concrete type. Called best-effort — throws are swallowed + * by the surrounding try/catch in `trackAsync`. + */ + onAfterTrack?: () => void queue: IAnalyticsQueue sender: IAnalyticsSender superPropsResolver: ISuperPropertiesResolver @@ -214,6 +223,13 @@ export class AnalyticsClient implements IAnalyticsClient { // the in-memory mirror queue without a durable on-disk row. await this.deps.jsonlStore.append(record) this.deps.queue.push(record) + + // M4.3: notify the flush scheduler that a record landed so it can + // check its 20-event threshold. Fires only on the durable success + // path (jsonlStore.append resolved + queue.push completed) so a + // failed persist does NOT trigger a flush of a queue that did not + // grow. Errors are swallowed by the outer try/catch. + this.deps.onAfterTrack?.() } catch { // Analytics MUST NOT crash the consumer. Errors silently dropped. } diff --git a/src/server/infra/analytics/analytics-flush-scheduler.ts b/src/server/infra/analytics/analytics-flush-scheduler.ts new file mode 100644 index 000000000..cc6f5e392 --- /dev/null +++ b/src/server/infra/analytics/analytics-flush-scheduler.ts @@ -0,0 +1,234 @@ +const DEFAULT_INTERVAL_MS = 30_000 +const DEFAULT_THRESHOLD_COUNT = 20 + +export interface AnalyticsFlushSchedulerDeps { + /** + * Async flush operation invoked when a trigger fires. MUST NOT throw — + * the scheduler wraps every call in `.catch` so a flush failure cannot + * crash the interval loop or shutdown sequence. + */ + flush: () => Promise + /** Polling interval for the time-based trigger. Defaults to 30s. */ + intervalMs?: number + /** + * Lazy analytics-enabled gate. Re-checked on every trigger so a runtime + * `brv analytics disable` (M1.4) immediately suspends scheduled flushes + * without restarting the daemon. + */ + isEnabled: () => boolean + /** + * Count of records pending shipment (JSONL `status='pending'` rows). + * Used by the interval timer and `flushFinal()` to skip flushes when + * there is nothing left to ship. + * + * MUST track JSONL state, NOT the in-memory queue mirror: the queue + * never decrements after a successful flush (queue.drain only runs on + * auth transitions), so using it here would make the scheduler fire + * every 30s indefinitely and waste a no-op HTTP call each time. + * `HttpAnalyticsSender` flips rows from `pending` to `sent` on 2xx, so + * this counter shrinks as work completes. + * + * Async because reading the JSONL file is I/O; the cost is one read + * per trigger (≤ once per `intervalMs` plus any threshold firings). + */ + pendingCount: () => Promise + /** + * Synchronous in-memory queue depth, read by the threshold trigger + * inside `notifyPushed()`. Sync + cheap so `track()` stays on the + * fast-path; correctness here only requires that the counter grows + * monotonically across recent pushes, which the bounded queue + * satisfies. + */ + queueSize: () => number + /** Queue depth that trips the threshold-based trigger. Defaults to 20. */ + thresholdCount?: number +} + +export type FlushFinalOptions = { + /** Hard cap on how long the shutdown flush is allowed to take. */ + timeoutMs: number +} + +/** + * Drives automatic flushes for the daemon-scoped analytics client. + * + * Two triggers (whichever fires first wins): + * - **Interval timer** (`intervalMs`, default 30s): every tick, if the + * queue is non-empty AND analytics is enabled, request a flush. + * - **Threshold notification** (`thresholdCount`, default 20): callers + * invoke `notifyPushed()` after enqueuing a record; if the queue is + * at or above the threshold, a flush is scheduled via `setImmediate` + * so `track()` stays synchronous from the consumer's view. + * + * Single-flight: while a flush is in flight, any new trigger is dropped + * (NOT queued). The in-flight promise is exposed via `flushFinal()` so + * shutdown can join it rather than starting a second send. + * + * `flushFinal({timeoutMs})` is the shutdown hook: races the in-flight or + * fresh flush against a timeout and resolves either way, so the daemon + * exit sequence cannot hang on a slow telemetry backend. + * + * Lifecycle owned by the composition root: `start()` after construction, + * `stop()` during shutdown (before `flushFinal()` so no new ticks fire + * mid-shutdown). + * + * Errors from `flush()` are swallowed at this layer. M4.5's backoff + * policy will react to the structured failure reason later; for M4.3 + * the scheduler just needs to keep ticking. + */ +export class AnalyticsFlushScheduler { + private readonly deps: Required + private intervalHandle: ReturnType | undefined + // Single-flight slot. Any trigger that arrives while this is set is + // dropped; `flushFinal()` awaits it so shutdown joins rather than races. + private pendingFlush: Promise | undefined + + public constructor(deps: AnalyticsFlushSchedulerDeps) { + this.deps = { + flush: deps.flush, + intervalMs: deps.intervalMs ?? DEFAULT_INTERVAL_MS, + isEnabled: deps.isEnabled, + pendingCount: deps.pendingCount, + queueSize: deps.queueSize, + thresholdCount: deps.thresholdCount ?? DEFAULT_THRESHOLD_COUNT, + } + } + + /** + * Best-effort final flush for the daemon shutdown sequence. Races the + * underlying flush against `timeoutMs` and resolves either way so the + * caller cannot hang on a slow backend. + * + * Joins an in-flight flush (returns its promise) rather than starting + * a second send. Skips the flush entirely when there is nothing in + * JSONL pending (avoids a wasted no-op HTTP call during shutdown). + */ + public async flushFinal(options: FlushFinalOptions): Promise { + if (!this.deps.isEnabled()) return + + // Snapshot the existing in-flight before checking pendingCount so a + // concurrent flush we should join is honored even if pendingCount + // reports zero at this exact moment (race-safe: an in-flight flush + // implies records WERE pending when it started). + if (this.pendingFlush !== undefined) { + await this.race(this.pendingFlush, options.timeoutMs) + return + } + + if ((await this.deps.pendingCount()) === 0) return + + // Double-check the slot AFTER the pendingCount I/O. During that + // await, a competing trigger (a queued setImmediate from + // `notifyPushed`, or an interval tick still mid-flight when `stop()` + // ran) may have called `startFlush` and claimed `pendingFlush`. + // Without this re-check the next line would call `startFlush` again, + // overwrite the slot with a second promise, and the backend would + // ingest the same records twice. Join the in-flight flush instead. + if (this.pendingFlush !== undefined) { + await this.race(this.pendingFlush, options.timeoutMs) + return + } + + await this.race(this.startFlush(), options.timeoutMs) + } + + /** + * Called by `AnalyticsClient.track()` after enqueuing a record. Checks + * the threshold (fast, in-memory queue size) and, if crossed, defers + * the flush to `setImmediate` so the synchronous `track()` contract + * holds. Threshold uses queueSize (not pendingCount) because: (a) it + * runs on every track and must stay sync + cheap, and (b) the gate's + * intent is "20 records pushed since startup" — the queue mirror is + * exactly that. + */ + public notifyPushed(): void { + if (!this.deps.isEnabled()) return + if (this.deps.queueSize() < this.deps.thresholdCount) return + setImmediate(() => { + // eslint-disable-next-line no-void + void this.tryFlush() + }) + } + + /** + * Start the recurring interval timer. Idempotent: a second call while + * already running is a no-op (does NOT install a second timer). + */ + public start(): void { + if (this.intervalHandle !== undefined) return + this.intervalHandle = setInterval(() => { + // Interval ticks are fire-and-forget; tryFlush handles its own + // errors and the void prefix opts out of unhandled-rejection noise. + // eslint-disable-next-line no-void + void this.tryFlush() + }, this.deps.intervalMs) + } + + /** + * Stop the recurring timer. Idempotent. Does NOT cancel an in-flight + * flush — call `flushFinal()` for that. + */ + public stop(): void { + if (this.intervalHandle === undefined) return + clearInterval(this.intervalHandle) + this.intervalHandle = undefined + } + + /** + * Race the given flush promise against a timeout. Used by `flushFinal` + * to enforce the shutdown budget without blocking on a slow backend. + */ + private async race(flushPromise: Promise, timeoutMs: number): Promise { + await Promise.race([ + flushPromise, + new Promise((resolve) => { + setTimeout(resolve, timeoutMs) + }), + ]) + } + + /** + * Invoke the flush and own the single-flight slot for its lifetime. + * Errors are swallowed at this layer — M4.5 owns retry/backoff. + */ + private startFlush(): Promise { + const promise: Promise = this.deps + .flush() + .then( + () => { + // Discard the flush return value; the scheduler only cares + // about settlement, not the AnalyticsBatch payload. + }, + () => { + // Analytics MUST NOT crash the daemon. M4.5 will surface + // failure reasons via a different channel. + }, + ) + .finally(() => { + if (this.pendingFlush === promise) { + this.pendingFlush = undefined + } + }) + this.pendingFlush = promise + return promise + } + + /** + * Common gate for interval and threshold triggers. Honors the + * isEnabled gate, the empty-pending skip (JSONL-backed, not queue), + * and single-flight; delegates to `startFlush` for the actual call. + * + * Async so the pendingCount I/O is awaited inside the gate rather + * than fanned out as a fire-and-forget side effect. Errors are + * swallowed by `startFlush`; this method itself never throws. + */ + private async tryFlush(): Promise { + if (!this.deps.isEnabled()) return + if (this.pendingFlush !== undefined) return + if ((await this.deps.pendingCount()) === 0) return + // pendingFlush may have been set by a competing trigger during the + // pendingCount I/O — re-check before claiming the slot. + if (this.pendingFlush !== undefined) return + await this.startFlush() + } +} diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index fed12c327..0336a5043 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -516,9 +516,19 @@ async function main(): Promise { }) // 9. Create shutdown handler (agent pool shut down before transport) + // + // M4.3: the analytics flush scheduler is constructed inside + // setupFeatureHandlers (later), so the final-flush closure resolves + // through a mutable holder. The shutdown sequence calls this hook + // after the agent pool stops; if setupFeatureHandlers never ran + // (e.g. startup crashed early) the holder stays undefined and the + // hook is skipped. + // eslint-disable-next-line prefer-const + let analyticsFinalFlush: (() => Promise) | undefined shutdownHandler = new ShutdownHandler({ agentIdleTimeoutPolicy, agentPool, + analyticsFinalFlush: () => analyticsFinalFlush?.() ?? Promise.resolve(), daemonResilience, heartbeatWriter, idleTimeoutPolicy, @@ -668,7 +678,7 @@ async function main(): Promise { // Feature handlers (auth, init, status, push, pull, etc.) require async OIDC discovery. // Placed after daemon:getState so the debug endpoint is available immediately, // without waiting for OIDC discovery (~400ms). - const {analyticsClient, isAnalyticsEnabled} = await setupFeatureHandlers({ + const {analyticsClient, analyticsFlushScheduler, isAnalyticsEnabled} = await setupFeatureHandlers({ authStateStore, broadcastToProject(projectPath, event, data) { broadcastToProjectRoom(projectRegistry, projectRouter, projectPath, event, data) @@ -703,12 +713,20 @@ async function main(): Promise { // stamp every daemon_start anonymously even for logged-in users. analyticsClient.track(AnalyticsEventNames.DAEMON_START) + // M4.3: start the flush scheduler AFTER the first track lands so the + // initial 30s window aligns with real traffic, and wire the shutdown + // hook now that the scheduler exists. Hook stops the scheduler first + // (no new ticks mid-shutdown) before awaiting the best-effort final + // flush against a 3s budget. + analyticsFlushScheduler.start() + analyticsFinalFlush = async () => { + analyticsFlushScheduler.stop() + await analyticsFlushScheduler.flushFinal({timeoutMs: 3000}) + } + // 11. Start idle timer + register signal handlers idleTimeoutPolicy.start() - // TODO(M4): await analyticsClient.flush() and ship the batch before exit. - // Today, queued events are dropped on SIGTERM/SIGINT — acceptable per the - // M2 ticket scope ("in-memory only"); revisit when the network sender lands. process.once('SIGTERM', () => { log('SIGTERM received') shutdownHandler.shutdown().catch((error: unknown) => { diff --git a/src/server/infra/daemon/shutdown-handler.ts b/src/server/infra/daemon/shutdown-handler.ts index 65867a3a6..f239206d1 100644 --- a/src/server/infra/daemon/shutdown-handler.ts +++ b/src/server/infra/daemon/shutdown-handler.ts @@ -19,6 +19,14 @@ interface IWebUiServer { export interface ShutdownHandlerDeps { readonly agentIdleTimeoutPolicy?: IAgentIdleTimeoutPolicy readonly agentPool?: IAgentPool + /** + * M4.3: best-effort final analytics flush. Invoked after the agent pool + * stops (no more new events) and before the transport server stops so + * the daemon ships any queued events before the network goes down. + * The hook owns its own timeout — the shutdown sequence does not block + * for more than the hook itself permits. + */ + readonly analyticsFinalFlush?: () => Promise readonly daemonResilience: IDaemonResilience readonly heartbeatWriter: IHeartbeatWriter readonly idleTimeoutPolicy: IIdleTimeoutPolicy @@ -112,6 +120,20 @@ export class ShutdownHandler implements IShutdownHandler { } } + // Step 5.5. Final analytics flush (M4.3). Best-effort: the hook owns + // its own timeout so the shutdown sequence cannot stall on a slow + // telemetry backend. Runs AFTER agent pool stops (no new events + // arrive) and BEFORE transport stops (analytics uses axios, not + // transport, so the ordering is for invariant clarity rather than + // correctness — keeps "no daemon services are stopped" intuition). + if (this.deps.analyticsFinalFlush) { + try { + await this.deps.analyticsFinalFlush() + } catch (error) { + log(`Error during final analytics flush: ${error instanceof Error ? error.message : String(error)}`) + } + } + // Step 6. Stop transport server (disconnect all sockets, close HTTP) // Wrapped in Promise.race with timeout to prevent hanging — if Socket.IO // blocks (e.g., waiting for in-flight responses), we proceed with remaining diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 18b8183e2..92750777f 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -16,6 +16,7 @@ import type {IProviderOAuthTokenStore} from '../../core/interfaces/i-provider-oa import type {IProjectRegistry} from '../../core/interfaces/project/i-project-registry.js' import type {IAuthStateStore} from '../../core/interfaces/state/i-auth-state-store.js' import type {ITransportServer} from '../../core/interfaces/transport/i-transport-server.js' +import type {AnalyticsFlushScheduler} from '../analytics/analytics-flush-scheduler.js' import type {ProjectBroadcaster, ProjectPathResolver} from '../transport/handlers/handler-types.js' import {ReviewEvents} from '../../../shared/transport/events/review-events.js' @@ -83,6 +84,7 @@ import { import {HttpUserService} from '../user/http-user-service.js' import {FileVcGitConfigStore} from '../vc/file-vc-git-config-store.js' import {wireAnalyticsAuthTransition} from './wire-analytics-auth-transition.js' +import {wireAnalyticsFlushScheduler} from './wire-analytics-flush-scheduler.js' import {wireAnalyticsHttpSender} from './wire-analytics-http-sender.js' export interface FeatureHandlersOptions { @@ -108,6 +110,14 @@ export interface FeatureHandlersOptions { */ export interface SetupFeatureHandlersResult { readonly analyticsClient: IAnalyticsClient + /** + * M4.3: scheduler that owns the 30s interval + 20-event threshold + * triggers. The composition root (`brv-server.ts`) starts it after + * auth state has loaded so the first tick has a real identity, and + * stops it during shutdown before invoking `flushFinal()` so no new + * ticks fire mid-shutdown. + */ + readonly analyticsFlushScheduler: AnalyticsFlushScheduler /** * Returns the daemon's cached analytics-enabled flag. M12.3 consumers * (e.g. AnalyticsHook) use this to short-circuit disk I/O when analytics @@ -183,15 +193,38 @@ export async function setupFeatureHandlers({ globalConfigStore, version: readCliVersion(), }) + // M4.3: scheduler is built AFTER the client but needs to be referenced + // by it (`onAfterTrack: () => scheduler.notifyPushed()`). Resolve the + // cycle with a mutable holder: the client closure reads the latest + // assigned value at call-time, so the scheduler is in place by the + // time the first track lands. Queue is hoisted to a shared instance so + // both client (push) and scheduler (queueSize) observe the same state. + const analyticsQueue = new BoundedQueue() + // Holder for the scheduler reference shared with `onAfterTrack`. Using + // a plain object instead of `let` so the lint rule sees a const binding + // (the closure reads `.value` on every call). The scheduler instance is + // assigned immediately after AnalyticsClient construction below. + const schedulerHolder: {value: AnalyticsFlushScheduler | undefined} = {value: undefined} const analyticsClient: IAnalyticsClient = new AnalyticsClient({ identityResolver: new IdentityResolver(authStateStore, globalConfigStore), isEnabled: () => globalConfigHandler.getCachedAnalytics(), jsonlStore: jsonlAnalyticsStore, - queue: new BoundedQueue(), + onAfterTrack() { + schedulerHolder.value?.notifyPushed() + }, + queue: analyticsQueue, sender: analyticsSender, superPropsResolver: new SuperPropertiesResolver(globalConfigStore), }) + const analyticsFlushScheduler = wireAnalyticsFlushScheduler({ + analyticsClient, + isEnabled: () => globalConfigHandler.getCachedAnalytics(), + jsonlStore: jsonlAnalyticsStore, + queue: analyticsQueue, + }) + schedulerHolder.value = analyticsFlushScheduler + // M4.1: subscribe the analytics client to identity-changing auth // transitions. See `wireAnalyticsAuthTransition` for the // login/logout/refresh decision logic. @@ -413,5 +446,9 @@ export async function setupFeatureHandlers({ // M12.3: expose the cached-analytics check so daemon-side consumers // (e.g. AnalyticsHook) can short-circuit disk I/O when analytics is off. // Same callback shape used internally by AnalyticsClient at line 171. - return {analyticsClient, isAnalyticsEnabled: (): boolean => globalConfigHandler.getCachedAnalytics()} + return { + analyticsClient, + analyticsFlushScheduler, + isAnalyticsEnabled: (): boolean => globalConfigHandler.getCachedAnalytics(), + } } diff --git a/src/server/infra/process/wire-analytics-flush-scheduler.ts b/src/server/infra/process/wire-analytics-flush-scheduler.ts new file mode 100644 index 000000000..e848da188 --- /dev/null +++ b/src/server/infra/process/wire-analytics-flush-scheduler.ts @@ -0,0 +1,54 @@ +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' +import type {IJsonlAnalyticsStore} from '../../core/interfaces/analytics/i-jsonl-analytics-store.js' + +import {AnalyticsFlushScheduler} from '../analytics/analytics-flush-scheduler.js' + +export type AnalyticsFlushSchedulerWiring = { + analyticsClient: IAnalyticsClient + /** Override the 30s interval (default) for tests / dev experiments. */ + intervalMs?: number + isEnabled: () => boolean + /** + * JSONL store used to count pending rows for the empty-skip gate. The + * scheduler uses `loadPending().length` (NOT `queue.size()`) because + * the in-memory queue mirror never decrements after a successful flush, + * which would make the interval timer fire 30s indefinitely with + * nothing left to ship. + */ + jsonlStore: IJsonlAnalyticsStore + queue: IAnalyticsQueue + /** Override the 20-event threshold (default) for tests / dev experiments. */ + thresholdCount?: number +} + +/** + * Compose the M4.3 flush scheduler. + * + * The scheduler is the orchestrator that decides WHEN to flush; it + * delegates the actual flush work to `IAnalyticsClient.flush()`. Two + * triggers (whichever first): + * - 30s interval timer + * - 20-event queue depth + * + * Returned `AnalyticsFlushScheduler` is owned by the composition root: + * - call `start()` after the AnalyticsClient is wired (so the first + * tick has a working sender). + * - call `stop()` in the shutdown sequence before `flushFinal()` so + * no new ticks fire mid-shutdown. + * + * Extracted from `feature-handlers.ts` so the wiring is testable in + * isolation — mirrors the M4.1 / M4.2 wiring helper pattern. + */ +export function wireAnalyticsFlushScheduler( + wiring: AnalyticsFlushSchedulerWiring, +): AnalyticsFlushScheduler { + return new AnalyticsFlushScheduler({ + flush: () => wiring.analyticsClient.flush(), + ...(wiring.intervalMs === undefined ? {} : {intervalMs: wiring.intervalMs}), + isEnabled: wiring.isEnabled, + pendingCount: async () => (await wiring.jsonlStore.loadPending()).length, + queueSize: () => wiring.queue.size(), + ...(wiring.thresholdCount === undefined ? {} : {thresholdCount: wiring.thresholdCount}), + }) +} diff --git a/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts b/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts new file mode 100644 index 000000000..c7310314f --- /dev/null +++ b/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts @@ -0,0 +1,290 @@ + +import {expect} from 'chai' +import sinon from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {IAnalyticsQueue} from '../../../../../src/server/core/interfaces/analytics/i-analytics-queue.js' +import type {IJsonlAnalyticsStore} from '../../../../../src/server/core/interfaces/analytics/i-jsonl-analytics-store.js' +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {wireAnalyticsFlushScheduler} from '../../../../../src/server/infra/process/wire-analytics-flush-scheduler.js' + +/** + * Integration test for the M4.3 composition-root binding that wires + * AnalyticsClient.flush() ⇄ AnalyticsFlushScheduler. Mirrors the M4.1 / + * M4.2 wiring-helper precedent: every composition-root binding gets a + * focused integration test so a future misconfigured wiring (wrong + * isEnabled gate, missing queue ref, swapped intervals) is caught at + * unit-test speed without booting the whole daemon. + */ + +type FakeClient = IAnalyticsClient & {readonly flushCalls: number; resetFlushCalls(): void} + +function makeFakeClient(): FakeClient { + let calls = 0 + const stub: FakeClient = { + async flush() { + calls += 1 + return AnalyticsBatch.create([]) + }, + get flushCalls() { + return calls + }, + async onAuthTransition() {}, + resetFlushCalls() { + calls = 0 + }, + // Hand-rolled noop preserves the generic `track` signature. + track() { + /* no-op */ + }, + } + return stub +} + +const noop = (): void => { + /* no-op */ +} + +const asyncNoop = async (): Promise => {} + +function makeQueueStub(size: number): IAnalyticsQueue { + return { + drain: () => [], + droppedCount: () => 0, + push: noop, + size: () => size, + } +} + +/** + * Build a stub `IJsonlAnalyticsStore` whose `loadPending()` returns a + * synthetic list of `pendingCount` records. The scheduler only inspects + * `length`, so the record shapes are irrelevant — we keep them minimal + * while still matching the `StoredAnalyticsRecord` schema (camelCase + * `deviceId` only — wire-shape snake_case lives in the identity sub-DTO + * but our domain entity reflects the in-memory representation). + */ +function makeJsonlStoreStub(pendingCount: number): IJsonlAnalyticsStore { + /* eslint-disable camelcase */ + const records: StoredAnalyticsRecord[] = Array.from({length: pendingCount}, (_, i) => ({ + attempts: 0, + id: `r${String(i)}`, + identity: {device_id: '550e8400-e29b-41d4-a716-446655440000'}, + name: 'daemon_start', + properties: {}, + status: 'pending', + timestamp: 0, + })) + /* eslint-enable camelcase */ + return { + append: asyncNoop, + clear: asyncNoop, + droppedFullCount: () => 0, + droppedSentCount: () => 0, + list: async () => ({rows: records, total: records.length}), + loadPending: async () => records, + updateStatus: asyncNoop, + } +} + +describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + afterEach(() => { + clock.restore() + }) + + it('returns a scheduler that flushes via the wired client on the configured interval', async () => { + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + intervalMs: 100, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(5), + queue: makeQueueStub(5), + }) + + scheduler.start() + await clock.tickAsync(100) + + expect(client.flushCalls).to.equal(1) + scheduler.stop() + }) + + it('honors the isEnabled gate (disabled analytics → no flush on tick)', async () => { + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + intervalMs: 100, + isEnabled: () => false, + jsonlStore: makeJsonlStoreStub(5), + queue: makeQueueStub(5), + }) + + scheduler.start() + await clock.tickAsync(500) + + expect(client.flushCalls).to.equal(0) + scheduler.stop() + }) + + it('skips interval flush when JSONL pending=0 even though queue mirror is non-zero (regression for queue-never-decrements)', async () => { + // Regression: BoundedQueue.push grows the mirror but flush only + // shrinks JSONL pending (queue.drain runs on auth transitions, not + // flushes). If the scheduler gated on queue.size() it would fire + // every 30s indefinitely after the first track ever; gating on + // pendingCount keeps the scheduler quiet once everything has shipped. + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + intervalMs: 100, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(0), // nothing left to ship + queue: makeQueueStub(50), // mirror still reflects past pushes + }) + + scheduler.start() + await clock.tickAsync(500) + + expect(client.flushCalls, 'mirror-non-zero must NOT trigger flushes when pending=0').to.equal(0) + scheduler.stop() + }) + + it('honors the queue size for the empty-skip path', async () => { + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + intervalMs: 100, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(0), + queue: makeQueueStub(0), + }) + + scheduler.start() + await clock.tickAsync(500) + + expect(client.flushCalls, 'empty queue must NOT trigger a flush').to.equal(0) + scheduler.stop() + }) + + it('threshold trigger uses the wired threshold via notifyPushed()', async () => { + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(20), + queue: makeQueueStub(20), + thresholdCount: 20, + }) + + scheduler.notifyPushed() + // notifyPushed defers via setImmediate; tick once to drain it. + await clock.tickAsync(1) + + expect(client.flushCalls).to.equal(1) + }) + + it('flushFinal joins an in-flight flush rather than starting a second send', async () => { + let releaseFlush!: () => void + const slowClient: IAnalyticsClient = { + flush: () => + new Promise((resolve) => { + releaseFlush = () => resolve(AnalyticsBatch.create([])) + }), + async onAuthTransition() {}, + track() { + /* no-op */ + }, + } + let flushCount = 0 + const flushSpy: IAnalyticsClient = { + ...slowClient, + async flush() { + flushCount += 1 + return slowClient.flush() + }, + } + + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: flushSpy, + intervalMs: 100, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(5), + queue: makeQueueStub(5), + }) + scheduler.start() + + await clock.tickAsync(100) + expect(flushCount).to.equal(1) + + const finalPromise = scheduler.flushFinal({timeoutMs: 3000}) + releaseFlush() + await finalPromise + + expect(flushCount, 'flushFinal must join in-flight').to.equal(1) + scheduler.stop() + }) + + it('flushFinal resolves under the timeout when flush never settles', async () => { + const slowClient: IAnalyticsClient = { + flush: () => + new Promise(() => { + /* never resolves */ + }), + async onAuthTransition() {}, + track() { + /* no-op */ + }, + } + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: slowClient, + intervalMs: 100, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(5), + queue: makeQueueStub(5), + }) + + const finalPromise = scheduler.flushFinal({timeoutMs: 3000}) + await clock.tickAsync(3000) + await finalPromise + // Reaching here proves the timeout resolved the race. + expect(true).to.equal(true) + }) + + it('uses the default 30s interval when intervalMs is omitted', async () => { + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(5), + queue: makeQueueStub(5), + }) + + scheduler.start() + await clock.tickAsync(29_999) + expect(client.flushCalls).to.equal(0) + await clock.tickAsync(1) + expect(client.flushCalls).to.equal(1) + scheduler.stop() + }) + + it('uses the default 20-event threshold when thresholdCount is omitted', async () => { + const client = makeFakeClient() + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(19), + queue: makeQueueStub(19), + }) + + scheduler.notifyPushed() + await clock.tickAsync(1) + expect(client.flushCalls, 'below default threshold of 20 → no flush').to.equal(0) + }) +}) diff --git a/test/unit/infra/daemon/shutdown-handler.test.ts b/test/unit/infra/daemon/shutdown-handler.test.ts index bf748820d..07d4104f0 100644 --- a/test/unit/infra/daemon/shutdown-handler.test.ts +++ b/test/unit/infra/daemon/shutdown-handler.test.ts @@ -275,4 +275,71 @@ describe('shutdown-handler', () => { expect(messages.some((m: string) => m.includes('Shutdown initiated'))).to.be.true expect(messages.some((m: string) => m.includes('Shutdown complete'))).to.be.true }) + + describe('M4.3 analyticsFinalFlush hook', () => { + it('invokes analyticsFinalFlush after agent pool stops and before transport stops', async () => { + const analyticsFlushStub = sandbox.stub().callsFake(async () => { + callOrder.push('analyticsFinalFlush') + }) + + const handler = new ShutdownHandler({ + analyticsFinalFlush: analyticsFlushStub, + daemonResilience: mockDaemonResilience, + heartbeatWriter: mockHeartbeatWriter, + idleTimeoutPolicy: mockIdleTimeoutPolicy, + instanceManager: mockInstanceManager, + log: logStub, + transportServer: mockTransportServer, + }) + + await handler.shutdown() + + expect(analyticsFlushStub.calledOnce).to.equal(true) + // Final flush sits between heartbeat.stop and transport.stop in this + // config (no agent pool wired, so step 5 is a no-op). + const heartbeatIdx = callOrder.indexOf('heartbeatWriter.stop') + const flushIdx = callOrder.indexOf('analyticsFinalFlush') + const transportIdx = callOrder.indexOf('transportServer.stop') + expect(flushIdx).to.be.greaterThan(heartbeatIdx) + expect(flushIdx).to.be.lessThan(transportIdx) + }) + + it('continues shutdown when analyticsFinalFlush rejects', async () => { + const analyticsFlushStub = sandbox.stub().rejects(new Error('telemetry down')) + + const handler = new ShutdownHandler({ + analyticsFinalFlush: analyticsFlushStub, + daemonResilience: mockDaemonResilience, + heartbeatWriter: mockHeartbeatWriter, + idleTimeoutPolicy: mockIdleTimeoutPolicy, + instanceManager: mockInstanceManager, + log: logStub, + transportServer: mockTransportServer, + }) + + await handler.shutdown() + + // Subsequent steps still run despite the analytics rejection. + expect(transportStopStub.calledOnce).to.equal(true) + expect(instanceReleaseStub.calledOnce).to.equal(true) + const messages = logStub.getCalls().map((c) => c.args[0]) + expect(messages.some((m: string) => m.includes('Error during final analytics flush'))).to.equal(true) + }) + + it('skips the hook when analyticsFinalFlush is not wired', async () => { + const handler = new ShutdownHandler({ + daemonResilience: mockDaemonResilience, + heartbeatWriter: mockHeartbeatWriter, + idleTimeoutPolicy: mockIdleTimeoutPolicy, + instanceManager: mockInstanceManager, + log: logStub, + transportServer: mockTransportServer, + }) + + await handler.shutdown() + + // No flush traces in the call order, just the pre-existing steps. + expect(callOrder).to.not.include('analyticsFinalFlush') + }) + }) }) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index 60c3f1861..9777dd56e 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -567,6 +567,81 @@ describe('AnalyticsClient', () => { }) }) + describe('M4.3 onAfterTrack hook (threshold notification)', () => { + it('fires onAfterTrack after a successful JSONL+queue persist', async () => { + const jsonlStore = makeFakeJsonlStore() + const onAfterTrack = spy() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + onAfterTrack: () => onAfterTrack(), + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + expect(onAfterTrack.calledOnce).to.equal(true) + }) + + it('does NOT fire onAfterTrack when JSONL append fails (no record landed)', async () => { + const jsonlStore = makeFakeJsonlStore({appendError: new Error('disk full')}) + const onAfterTrack = spy() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + onAfterTrack: () => onAfterTrack(), + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + expect(onAfterTrack.called, 'failed persist must not signal the scheduler').to.equal(false) + }) + + it('fires onAfterTrack once per successful track', async () => { + const onAfterTrack = spy() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + onAfterTrack: () => onAfterTrack(), + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + for (let i = 0; i < 5; i++) client.track(AnalyticsEventNames.DAEMON_START) + await flushMicrotasks() + + expect(onAfterTrack.callCount).to.equal(5) + }) + + it('does NOT crash when onAfterTrack throws (analytics no-crash guarantee)', async () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + onAfterTrack() { + throw new Error('scheduler boom') + }, + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + expect(() => client.track(AnalyticsEventNames.DAEMON_START)).to.not.throw() + await flushMicrotasks() + }) + }) + describe('M10.2 mirror flush: invokes sender, mirrors result back to JSONL via updateStatus', () => { it('should pass loadPending records to sender.send exactly once per flush', async () => { const jsonlStore = makeFakeJsonlStore() diff --git a/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts b/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts new file mode 100644 index 000000000..22e61c04c --- /dev/null +++ b/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts @@ -0,0 +1,426 @@ + +import {expect} from 'chai' +import sinon from 'sinon' + +import {AnalyticsFlushScheduler} from '../../../../../src/server/infra/analytics/analytics-flush-scheduler.js' + +type Deps = { + flush: sinon.SinonStub + isEnabled: sinon.SinonStub + pendingCount: sinon.SinonStub + queueSize: sinon.SinonStub +} + +function buildDeps( + overrides: Partial<{ + enabled: boolean + flushImpl: () => Promise + /** + * Shared depth for both `queueSize` (sync, threshold trigger) and + * `pendingCount` (async, empty-skip gate). Tests that want to + * distinguish the two paths override one stub explicitly after this + * call; the default keeps them in sync to mirror the steady-state + * production invariant (a record pushed is a record pending). + */ + size: number + }> = {}, +): Deps { + const size = overrides.size ?? 0 + return { + flush: sinon.stub().callsFake(overrides.flushImpl ?? (async () => {})), + isEnabled: sinon.stub().returns(overrides.enabled ?? true), + pendingCount: sinon.stub().resolves(size), + queueSize: sinon.stub().returns(size), + } +} + +// Shared fixture: a `flush` impl that never settles. Used by the +// timeout-budget tests to prove `flushFinal` resolves on the timer side +// of the race regardless of how slow the underlying flush is. +const neverResolvingFlush = (): Promise => + new Promise(() => { + /* intentional never-settle */ + }) + +async function flushMicrotasks(): Promise { + // Drain microtasks AND setImmediate so notifyPushed's scheduled flush runs. + await new Promise((resolve) => { + setImmediate(resolve) + }) + await new Promise((resolve) => { + setImmediate(resolve) + }) +} + +describe('AnalyticsFlushScheduler', () => { + describe('interval timer', () => { + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + afterEach(() => { + clock.restore() + }) + + it('does NOT flush before the interval elapses', async () => { + const deps = buildDeps({size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + scheduler.start() + + await clock.tickAsync(29_000) + + expect(deps.flush.called).to.equal(false) + scheduler.stop() + }) + + it('flushes once when the interval elapses with a non-empty queue', async () => { + const deps = buildDeps({size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + scheduler.start() + + await clock.tickAsync(30_000) + + expect(deps.flush.calledOnce).to.equal(true) + scheduler.stop() + }) + + it('does NOT flush at the interval when the queue is empty', async () => { + const deps = buildDeps({size: 0}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + scheduler.start() + + await clock.tickAsync(60_000) + + expect(deps.flush.called).to.equal(false) + scheduler.stop() + }) + + it('gates the empty-skip on pendingCount, NOT queueSize (mirror-non-zero with pending=0 is silent)', async () => { + // Regression for the queue-mirror-never-decrements behavior: the + // in-memory queue grows on push but is only drained on auth + // transitions, so after a successful flush queueSize() > 0 yet + // pendingCount() === 0. The scheduler must consult the JSONL- + // backed pendingCount; using queueSize would re-fire flushes + // every 30s forever for an empty backlog. + const deps = buildDeps({size: 0}) // pendingCount + queueSize default sync + deps.queueSize.returns(50) // mirror still reflects past pushes + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + scheduler.start() + + await clock.tickAsync(90_000) // three intervals + + expect(deps.flush.called, 'mirror-non-zero with pending=0 must NOT trigger').to.equal(false) + scheduler.stop() + }) + + it('skips the tick when analytics is disabled', async () => { + const deps = buildDeps({enabled: false, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + scheduler.start() + + await clock.tickAsync(60_000) + + expect(deps.flush.called).to.equal(false) + scheduler.stop() + }) + + it('fires every interval, not just once (recurring timer)', async () => { + const deps = buildDeps({size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + scheduler.start() + + await clock.tickAsync(30_000) + await clock.tickAsync(30_000) + await clock.tickAsync(30_000) + + expect(deps.flush.callCount).to.equal(3) + scheduler.stop() + }) + + it('stop() halts further ticks', async () => { + const deps = buildDeps({size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + scheduler.start() + await clock.tickAsync(30_000) + scheduler.stop() + + await clock.tickAsync(60_000) + + expect(deps.flush.callCount).to.equal(1) + }) + + it('start() is idempotent (double-start does NOT install two timers)', async () => { + const deps = buildDeps({size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + scheduler.start() + scheduler.start() + + await clock.tickAsync(30_000) + + expect(deps.flush.callCount).to.equal(1) + scheduler.stop() + }) + }) + + describe('threshold trigger via notifyPushed()', () => { + it('flushes via setImmediate when queue.size() crosses the threshold', async () => { + const deps = buildDeps({size: 20}) + const scheduler = new AnalyticsFlushScheduler({...deps, thresholdCount: 20}) + + scheduler.notifyPushed() + // `notifyPushed` returns synchronously; flush runs on the next setImmediate tick. + expect(deps.flush.called, 'flush must be deferred, not synchronous').to.equal(false) + + await flushMicrotasks() + + expect(deps.flush.calledOnce).to.equal(true) + }) + + it('does NOT flush when queue.size() is below the threshold', async () => { + const deps = buildDeps({size: 19}) + const scheduler = new AnalyticsFlushScheduler({...deps, thresholdCount: 20}) + + scheduler.notifyPushed() + await flushMicrotasks() + + expect(deps.flush.called).to.equal(false) + }) + + it('does NOT flush when analytics is disabled', async () => { + const deps = buildDeps({enabled: false, size: 100}) + const scheduler = new AnalyticsFlushScheduler({...deps, thresholdCount: 20}) + + scheduler.notifyPushed() + await flushMicrotasks() + + expect(deps.flush.called).to.equal(false) + }) + }) + + describe('idempotency (single-flight)', () => { + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + afterEach(() => { + clock.restore() + }) + + it('does NOT issue a second flush while one is already in flight (timer + threshold race)', async () => { + let releaseFlush!: () => void + const slowFlush = (): Promise => + new Promise((resolve) => { + releaseFlush = resolve + }) + const deps = buildDeps({flushImpl: slowFlush, size: 25}) + const scheduler = new AnalyticsFlushScheduler({ + ...deps, + intervalMs: 30_000, + thresholdCount: 20, + }) + scheduler.start() + + // Timer fires → flush (1) starts and stays pending. + await clock.tickAsync(30_000) + expect(deps.flush.callCount).to.equal(1) + + // Threshold trip while flush-1 is in flight: setImmediate is faked + // so we tick once to drain it; the trigger must still be skipped. + scheduler.notifyPushed() + await clock.tickAsync(1) + expect(deps.flush.callCount, 'in-flight flush must skip new triggers').to.equal(1) + + // Another timer tick before flush-1 settles: also skipped. + await clock.tickAsync(30_000) + expect(deps.flush.callCount).to.equal(1) + + // Settle flush-1. After settle, next trigger should run fresh. + releaseFlush() + await clock.tickAsync(0) + + scheduler.notifyPushed() + await clock.tickAsync(1) + expect(deps.flush.callCount, 'new trigger after settle must run').to.equal(2) + scheduler.stop() + }) + + it('continues to flush on the next interval after the in-flight settles', async () => { + let releaseFlush!: () => void + const slowFlush = (): Promise => + new Promise((resolve) => { + releaseFlush = resolve + }) + const deps = buildDeps({flushImpl: slowFlush, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + scheduler.start() + + await clock.tickAsync(30_000) + expect(deps.flush.callCount).to.equal(1) + + releaseFlush() + await clock.tickAsync(0) + + await clock.tickAsync(30_000) + expect(deps.flush.callCount).to.equal(2) + scheduler.stop() + }) + }) + + describe('flushFinal() for shutdown', () => { + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + afterEach(() => { + clock.restore() + }) + + it('returns the flush result when flush completes within the timeout', async () => { + const deps = buildDeps({async flushImpl() {}, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + + const promise = scheduler.flushFinal({timeoutMs: 3000}) + await clock.tickAsync(1) + await promise + + expect(deps.flush.calledOnce).to.equal(true) + }) + + it('resolves after the timeout when flush takes too long (best-effort guarantee)', async () => { + const deps = buildDeps({flushImpl: neverResolvingFlush, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + + const promise = scheduler.flushFinal({timeoutMs: 3000}) + await clock.tickAsync(3000) + await promise + + expect(deps.flush.calledOnce).to.equal(true) + }) + + it('skips flush entirely when the queue is empty', async () => { + const deps = buildDeps({size: 0}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + + await scheduler.flushFinal({timeoutMs: 3000}) + + expect(deps.flush.called, 'no flush on empty queue').to.equal(false) + }) + + it('skips flush when analytics is disabled', async () => { + const deps = buildDeps({enabled: false, size: 100}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + + await scheduler.flushFinal({timeoutMs: 3000}) + + expect(deps.flush.called).to.equal(false) + }) + + it('joins an in-flight flush rather than starting a second', async () => { + let releaseFlush!: () => void + const slowFlush = (): Promise => + new Promise((resolve) => { + releaseFlush = resolve + }) + const deps = buildDeps({flushImpl: slowFlush, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + scheduler.start() + + await clock.tickAsync(30_000) + expect(deps.flush.callCount).to.equal(1) + + const finalPromise = scheduler.flushFinal({timeoutMs: 3000}) + releaseFlush() + await finalPromise + + expect(deps.flush.callCount, 'final must join in-flight flush, not start a second').to.equal(1) + scheduler.stop() + }) + + it('joins a concurrent flush that claimed the slot mid-pendingCount (race regression)', async () => { + // Regression for the flushFinal double-send race: + // 1. flushFinal enters, sees pendingFlush=undefined. + // 2. flushFinal awaits pendingCount() (I/O). + // 3. During that await, a competing trigger (setImmediate from + // notifyPushed, or a last interval tick) calls startFlush and + // sets pendingFlush. + // 4. flushFinal resumes — without the double-check it would call + // startFlush again, overwrite the slot, and ship the same + // records twice. + // + // Reproducing the race deterministically requires forcing the + // tryFlush trigger to claim the slot BETWEEN flushFinal's + // pendingCount call and its post-await line. We do this by hooking + // a manually-released gate into `deps.pendingCount` and calling + // `tryFlush` (via the public threshold path) while flushFinal is + // parked on that gate. + let releaseFlush!: () => void + const slowFlush = (): Promise => + new Promise((resolve) => { + releaseFlush = resolve + }) + const deps = buildDeps({flushImpl: slowFlush, size: 20}) + + // Make pendingCount wait on a manual gate so the test can interleave + // a competing trigger before flushFinal resumes. + let releasePendingCount!: () => void + const pendingGate = new Promise((resolve) => { + releasePendingCount = resolve + }) + // First call (from flushFinal) waits on the gate; subsequent calls + // (from tryFlush triggered by notifyPushed) resolve immediately so + // the competing path can complete and claim pendingFlush. + let pendingCallCount = 0 + deps.pendingCount = sinon.stub().callsFake(async () => { + pendingCallCount += 1 + if (pendingCallCount === 1) await pendingGate + return 5 + }) + const scheduler = new AnalyticsFlushScheduler({ + ...deps, + intervalMs: 30_000, + thresholdCount: 20, + }) + + // Step A: flushFinal enters and parks on pendingCount. + const finalPromise = scheduler.flushFinal({timeoutMs: 3000}) + + // Step B: trigger a competing tryFlush via the threshold path while + // flushFinal is still parked. notifyPushed schedules setImmediate; + // tickAsync(1) drains it and lets tryFlush call startFlush, which + // synchronously claims pendingFlush. + scheduler.notifyPushed() + await clock.tickAsync(1) + + // Step C: now release flushFinal's pendingCount gate. flushFinal + // resumes with pendingFlush ALREADY set by the competing tryFlush. + // The double-check must catch this and join instead of overwriting. + releasePendingCount() + releaseFlush() + await finalPromise + + expect(deps.flush.callCount, 'race regression: flushFinal must NOT start a second send').to.equal(1) + }) + + it('does NOT throw when the underlying flush rejects (analytics MUST NOT crash shutdown)', async () => { + const deps = buildDeps({async flushImpl() { throw new Error('network boom'); }, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + + let threw = false + try { + const promise = scheduler.flushFinal({timeoutMs: 3000}) + await clock.tickAsync(1) + await promise + } catch { + threw = true + } + + expect(threw, 'flushFinal must swallow flush rejections').to.equal(false) + }) + }) +}) From 13fc2509e921f2c7affc7383531367b4ece76553 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Thu, 21 May 2026 21:40:00 +0700 Subject: [PATCH 45/87] fix: [ENG-2645] notifyPushed gates on queue-size delta, not absolute size The queue mirror is monotonic across a session (drained only on auth transitions, not on a successful flush), so the previous absolute `queueSize >= thresholdCount` gate stayed true forever after the first crossing. Every subsequent track would schedule a fresh setImmediate -> tryFlush -> HTTP POST and the batching contract would collapse for slow-emit workloads (one event per CLI command after the first 20). Now compares against a moving baseline (`lastTriggerQueueSize`) so the threshold fires at queue depths 20 / 40 / 60 / ... The baseline resets to 0 when the queue shrinks below it, so an auth-transition drain correctly re-arms the next 20-push fire. Adds two regression tests; updates one pre-existing single-flight test to grow the queue between the two notifyPushed calls so it exercises the new delta semantic instead of the old absolute-size behavior. --- .../analytics/analytics-flush-scheduler.ts | 34 +++++++++---- .../analytics-flush-scheduler.test.ts | 48 +++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/server/infra/analytics/analytics-flush-scheduler.ts b/src/server/infra/analytics/analytics-flush-scheduler.ts index cc6f5e392..aa6bd2b5d 100644 --- a/src/server/infra/analytics/analytics-flush-scheduler.ts +++ b/src/server/infra/analytics/analytics-flush-scheduler.ts @@ -79,6 +79,13 @@ export type FlushFinalOptions = { export class AnalyticsFlushScheduler { private readonly deps: Required private intervalHandle: ReturnType | undefined + // Snapshot of `queueSize` at the last threshold fire. Together with + // `thresholdCount` this gates `notifyPushed` on the DELTA since last + // fire (queue depths 20/40/60/...) instead of the absolute size — the + // queue mirror is monotonic across a session (drained only on auth + // transitions), so without a moving baseline every push past the + // first threshold crossing would re-fire. + private lastTriggerQueueSize: number = 0 // Single-flight slot. Any trigger that arrives while this is set is // dropped; `flushFinal()` awaits it so shutdown joins rather than races. private pendingFlush: Promise | undefined @@ -133,17 +140,28 @@ export class AnalyticsFlushScheduler { } /** - * Called by `AnalyticsClient.track()` after enqueuing a record. Checks - * the threshold (fast, in-memory queue size) and, if crossed, defers - * the flush to `setImmediate` so the synchronous `track()` contract - * holds. Threshold uses queueSize (not pendingCount) because: (a) it - * runs on every track and must stay sync + cheap, and (b) the gate's - * intent is "20 records pushed since startup" — the queue mirror is - * exactly that. + * Called by `AnalyticsClient.track()` after enqueuing a record. Fires a + * flush via `setImmediate` once the queue has grown by `thresholdCount` + * since the last trigger, so `track()` stays synchronous from the + * consumer's view. + * + * Threshold uses `queueSize` (not `pendingCount`) because: (a) it runs + * on every track and must stay sync + cheap, and (b) the gate's intent + * is "fire every N pushes". The mirror is monotonic across a session + * (drained only on auth transitions), so we compare against a moving + * baseline `lastTriggerQueueSize` rather than the absolute size — + * otherwise every push past the first threshold crossing would re-fire + * and the batching contract would collapse for slow-emit workloads. + * + * When the queue size drops below the previous baseline (auth-transition + * drain), the baseline resets to 0 so the next N pushes fire again. */ public notifyPushed(): void { if (!this.deps.isEnabled()) return - if (this.deps.queueSize() < this.deps.thresholdCount) return + const size = this.deps.queueSize() + if (size < this.lastTriggerQueueSize) this.lastTriggerQueueSize = 0 + if (size - this.lastTriggerQueueSize < this.deps.thresholdCount) return + this.lastTriggerQueueSize = size setImmediate(() => { // eslint-disable-next-line no-void void this.tryFlush() diff --git a/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts b/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts index 22e61c04c..791ff2ef3 100644 --- a/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts +++ b/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts @@ -197,6 +197,49 @@ describe('AnalyticsFlushScheduler', () => { expect(deps.flush.called).to.equal(false) }) + + it('does NOT re-trigger between threshold multiples (regression: queue mirror is monotonic past 20 → every push would fire)', async () => { + // The queue mirror only decrements on auth-transition drain, NOT on a + // successful flush. Without a moving baseline, queueSize >= 20 stays + // true forever after the first crossing, so every subsequent track + // would schedule a fresh setImmediate→tryFlush→HTTP POST and the + // 20-event batching contract would collapse for slow-emit workloads. + const deps = buildDeps({size: 5}) // pendingCount > 0 so tryFlush proceeds past the empty-skip gate + deps.queueSize.onCall(0).returns(20) + deps.queueSize.onCall(1).returns(21) + deps.queueSize.onCall(2).returns(22) + deps.queueSize.onCall(3).returns(39) + deps.queueSize.onCall(4).returns(40) + const scheduler = new AnalyticsFlushScheduler({...deps, thresholdCount: 20}) + + scheduler.notifyPushed() // size=20: cross 1st threshold → fire + scheduler.notifyPushed() // size=21: must NOT fire + scheduler.notifyPushed() // size=22: must NOT fire + scheduler.notifyPushed() // size=39: must NOT fire + scheduler.notifyPushed() // size=40: cross 2nd threshold → fire + await flushMicrotasks() + + expect(deps.flush.callCount, 'threshold must fire only at 20 and 40, not on every push past 20').to.equal(2) + }) + + it('resets baseline when queue is drained below previous trigger size (auth transition)', async () => { + // M4.1 onAuthTransition drains the queue mirror. After a drain, the + // next 20-event crossing must fire again — without baseline reset, + // the comparison `size - lastTrigger` would go negative and stay + // sub-threshold forever after a login/logout cycle. + const deps = buildDeps({size: 5}) + deps.queueSize.onCall(0).returns(20) // 1st trigger + deps.queueSize.onCall(1).returns(0) // drain + deps.queueSize.onCall(2).returns(20) // re-built post-drain → must fire again + const scheduler = new AnalyticsFlushScheduler({...deps, thresholdCount: 20}) + + scheduler.notifyPushed() + scheduler.notifyPushed() + scheduler.notifyPushed() + await flushMicrotasks() + + expect(deps.flush.callCount, 'drain must reset baseline so next 20 push fires again').to.equal(2) + }) }) describe('idempotency (single-flight)', () => { @@ -242,6 +285,11 @@ describe('AnalyticsFlushScheduler', () => { releaseFlush() await clock.tickAsync(0) + // Grow the queue past the next threshold (25 → 45, delta 20). The + // post-fix notifyPushed gates on the DELTA since the last trigger, + // not the absolute size, so a follow-up call with the same size + // would correctly be a no-op. + deps.queueSize.returns(45) scheduler.notifyPushed() await clock.tickAsync(1) expect(deps.flush.callCount, 'new trigger after settle must run').to.equal(2) From db2a2b51d11e59b2f593ae9ecb74b32ec4c7f781 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Thu, 21 May 2026 23:08:04 +0700 Subject: [PATCH 46/87] feat: [ENG-2646] M4.4 auth-transition force-flush + disable aborts in-flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds onBeforeAuthChange pre-transition hook on AuthStateStore that fires BEFORE cachedToken mutates. Pre-listeners observe the OLD token via getToken(), so the analytics client can flush surviving events under the OLD session header before the new identity replaces the cache. Pairs with the existing M4.1 post-hook (drops what the flush could not deliver). Identity-change only — token refresh (same userId) skips the flush, matching the M4.1 precedent. Each pre-listener is raced against a 6s hang-guard so a wedged subsystem cannot deadlock auth transitions; the timer is cleared on the fast-path so no phantom Node timer leaks per transition. Adds AnalyticsClient.abort() so brv analytics disable can cancel any in-flight HTTP send and the daemon does not half-ship a batch across an enable/disable boundary. AbortSignal is plumbed from the client through IAnalyticsSender to IAnalyticsHttpClient. When the controller fired (disable-driven abort), the failed-update is skipped — records stay at status=pending so M9.2's retry-cap counter is not bumped on cancel, preserving the invariant that they ship cleanly on the next enabled flush. Disable / enable semantic clarification (intentional divergence from the original ticket text, agreed during planning): track() is now unconditional with respect to the enable flag — local persistence (JSONL + queue) always runs, only flush() is gated. Disable does not drop the queue or clear JSONL. A disabled session accumulates events locally; re-enable resumes shipping the backlog on the next scheduler tick. Trade-off chosen for the "no local data loss on toggle" guarantee. Composition root wiring: GlobalConfigHandler accepts an optional analyticsClient; the enable to disable transition triggers abort() inside the existing serialized write chain. Late-binding setter (setAnalyticsClient) closes the handler-client init cycle without construction-order changes. wireAnalyticsAuthPreTransition mirrors the M4.1 wiring helper pattern. Tests: 8238 passing across the suite, with RED/GREEN cycles for pre-hook ordering, hang-guard timer cleanup, disable-on-transition abort, and the no-attempts-bump invariant for aborted flushes. End-to-end verified by file-rename of the local credentials store (logout detected within ~1s, pre-hook flush ran, JSONL emptied via M4.1 post-hook, re-login restored state cleanly). --- .../analytics/i-analytics-client.ts | 8 + .../analytics/i-analytics-http-client.ts | 16 +- .../analytics/i-analytics-sender.ts | 11 +- .../interfaces/state/i-auth-state-store.ts | 32 +++ .../infra/analytics/analytics-client.ts | 54 ++++- .../analytics/axios-analytics-http-client.ts | 8 + .../infra/analytics/http-analytics-sender.ts | 29 ++- .../infra/analytics/no-op-analytics-client.ts | 4 + .../infra/analytics/no-op-analytics-sender.ts | 5 + src/server/infra/process/feature-handlers.ts | 14 ++ .../wire-analytics-auth-pre-transition.ts | 39 ++++ src/server/infra/state/auth-state-store.ts | 74 ++++++- .../handlers/global-config-handler.ts | 38 ++++ test/helpers/mock-factories.ts | 1 + .../infra/git/isomorphic-git-service.test.ts | 1 + .../analytics-hook-async-stress.test.ts | 1 + ...wire-analytics-auth-pre-transition.test.ts | 172 +++++++++++++++ .../wire-analytics-auth-transition.test.ts | 9 + .../wire-analytics-flush-scheduler.test.ts | 9 + .../unit/infra/state/auth-state-store.test.ts | 180 ++++++++++++++++ .../handlers/global-config-handler.test.ts | 143 +++++++++++++ .../infra/analytics/analytics-client.test.ts | 201 ++++++++++++++++-- .../axios-analytics-http-client.test.ts | 62 ++++++ .../infra/process/analytics-hook.test.ts | 9 +- .../handlers/analytics-handler.test.ts | 3 + 25 files changed, 1094 insertions(+), 29 deletions(-) create mode 100644 src/server/infra/process/wire-analytics-auth-pre-transition.ts create mode 100644 test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts diff --git a/src/server/core/interfaces/analytics/i-analytics-client.ts b/src/server/core/interfaces/analytics/i-analytics-client.ts index c086b2a0b..b16716a20 100644 --- a/src/server/core/interfaces/analytics/i-analytics-client.ts +++ b/src/server/core/interfaces/analytics/i-analytics-client.ts @@ -16,6 +16,14 @@ import type {AnalyticsBatch} from '../../domain/analytics/batch.js' * statically checked. */ export interface IAnalyticsClient { + /** + * Cancel any in-flight `flush()`'s HTTP request. M4.4: invoked by + * `GlobalConfigHandler` when `brv analytics disable` flips the flag + * so the daemon doesn't half-ship a batch across an enable/disable + * boundary. No-op when no flush is in flight. + */ + abort: () => void + /** * Drains the queue and returns the events as a serializable batch. * Used by the network sender (M4) and by tests. diff --git a/src/server/core/interfaces/analytics/i-analytics-http-client.ts b/src/server/core/interfaces/analytics/i-analytics-http-client.ts index 4739280f8..e0002ccd4 100644 --- a/src/server/core/interfaces/analytics/i-analytics-http-client.ts +++ b/src/server/core/interfaces/analytics/i-analytics-http-client.ts @@ -51,6 +51,20 @@ export type AnalyticsHttpSendResult = * - `AxiosAnalyticsHttpClient` — production transport over axios. * - In-process fakes in unit tests for offline assertion. */ +/** + * Optional per-call controls. `signal` is the M4.4 cancellation hook + * used by `brv analytics disable` (and by the daemon shutdown path) to + * abort an in-flight send so the daemon doesn't half-ship a batch + * across an enable/disable boundary. + */ +export type AnalyticsHttpSendOptions = Readonly<{ + signal?: AbortSignal +}> + export interface IAnalyticsHttpClient { - send: (batch: AnalyticsBatch, headers: AnalyticsHttpHeaders) => Promise + send: ( + batch: AnalyticsBatch, + headers: AnalyticsHttpHeaders, + options?: AnalyticsHttpSendOptions, + ) => Promise } diff --git a/src/server/core/interfaces/analytics/i-analytics-sender.ts b/src/server/core/interfaces/analytics/i-analytics-sender.ts index ccf2faa5f..c006e7be8 100644 --- a/src/server/core/interfaces/analytics/i-analytics-sender.ts +++ b/src/server/core/interfaces/analytics/i-analytics-sender.ts @@ -26,6 +26,15 @@ export type SendResult = Readonly<{ * Test seam — used to assert the M10.2 "leave-JSONL-untouched" invariant * without going through the real transport. */ +/** + * Per-send options. `signal` is the M4.4 cancellation hook so the + * AnalyticsClient can abort an in-flight send when `brv analytics + * disable` fires. + */ +export type AnalyticsSenderOptions = Readonly<{ + signal?: AbortSignal +}> + export interface IAnalyticsSender { /** * Attempts to ship `records`. Returns the per-record outcome as id arrays. @@ -33,5 +42,5 @@ export interface IAnalyticsSender { * that hit a transient error (network failure, 5xx) should classify * those records as `failed` and let M9.2's retry-cap policy handle them. */ - send: (records: readonly StoredAnalyticsRecord[]) => Promise + send: (records: readonly StoredAnalyticsRecord[], options?: AnalyticsSenderOptions) => Promise } diff --git a/src/server/core/interfaces/state/i-auth-state-store.ts b/src/server/core/interfaces/state/i-auth-state-store.ts index 4f88dd74e..019eea51a 100644 --- a/src/server/core/interfaces/state/i-auth-state-store.ts +++ b/src/server/core/interfaces/state/i-auth-state-store.ts @@ -7,6 +7,22 @@ import type {AuthToken} from '../../domain/entities/auth-token.js' */ export type AuthChangedCallback = (token: AuthToken | undefined) => void +/** + * Callback fired BEFORE the cached auth token is mutated. Listeners + * can read `getToken()` and observe the OLD token, which is the + * critical guarantee for M4.4's auth-transition force-flush: the + * analytics client needs to flush events under the OLD session header + * before the new token replaces the cache. + * + * Listeners are awaited in registration order. A listener that hangs + * is bounded by `beforeAuthChangeTimeoutMs` (default 6s) so a wedged + * subsystem cannot deadlock the auth transition. + */ +export type BeforeAuthChangedCallback = ( + oldToken: AuthToken | undefined, + newToken: AuthToken | undefined, +) => Promise | void + /** * Callback fired when auth token has expired. * Separate from AuthChanged because an expired token is still "present" — @@ -65,6 +81,22 @@ export interface IAuthStateStore { */ onAuthExpired(callback: AuthExpiredCallback): void + /** + * Register a pre-transition callback that fires BEFORE `cachedToken` + * mutates. The store awaits the callback (bounded by + * `beforeAuthChangeTimeoutMs`) before committing the new token and + * firing `onAuthChanged`. This is the M4.4 hook used by the analytics + * client to flush events under the OLD session header. + * + * Multiple callbacks are awaited in registration order, in series. A + * rejecting callback is logged best-effort and does NOT block + * subsequent callbacks or the transition itself. + * + * @param callback - Function called with `(oldToken, newToken)`; may + * return a Promise (will be awaited) or void. + */ + onBeforeAuthChange(callback: BeforeAuthChangedCallback): void + /** * Start polling the token store for changes. * Must be called after construction to begin monitoring. diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index 17e4dfb8e..646b32071 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -62,6 +62,10 @@ export interface AnalyticsClientDeps { * allocations beyond the function call frame. */ export class AnalyticsClient implements IAnalyticsClient { + // M4.4 cancellation slot. Held only while a flush is in flight; the + // signal is piped through `sender.send` to the underlying HTTP client. + // `abort()` is a no-op when this is undefined (no in-flight to cancel). + private currentFlushController?: AbortController private readonly deps: AnalyticsClientDeps // Single-flight slot for an in-flight `flush()`. Concurrent callers join the // existing promise instead of starting a second read-then-decide cycle — @@ -87,6 +91,21 @@ export class AnalyticsClient implements IAnalyticsClient { this.deps = deps } + /** + * M4.4 cancellation hook. Aborts the AbortController tied to the + * in-flight `flush()`'s HTTP request (if any). The signal propagates + * through `sender.send` to the underlying `IAnalyticsHttpClient`, + * which classifies aborted requests as `network` failures — JSONL + * records stay `pending` (so they ship on the next enabled flush). + * + * Called from `GlobalConfigHandler` when `brv analytics disable` + * flips the flag, so the daemon doesn't half-ship a batch across an + * enable/disable boundary. No-op when no flush is in flight. + */ + public abort(): void { + this.currentFlushController?.abort() + } + /** * Reads pending rows from JSONL (NOT from the in-memory queue), invokes * the registered sender, and mirrors the per-record outcome back to JSONL @@ -107,6 +126,12 @@ export class AnalyticsClient implements IAnalyticsClient { * `flush()` is a thin caller — it does not inspect attempts. */ public async flush(): Promise { + // M4.4: `brv analytics disable` semantically means "stop shipping to + // remote" — local tracking (JSONL + queue) continues unconditionally. + // Gate here, NOT in `track()`. Records stay at `status='pending'` in + // JSONL; the next flush after re-enable picks them up automatically. + if (!this.deps.isEnabled()) return AnalyticsBatch.create([]) + // Single-flight: if a flush is already running, hand its promise to the // joining caller so both observe the same loadPending snapshot, the same // sender invocation, and the same mirror writes. @@ -156,7 +181,10 @@ export class AnalyticsClient implements IAnalyticsClient { } public track(event: E, ...rest: PropsArg): void { - if (!this.deps.isEnabled()) return + // M4.4 semantic: local tracking is unconditional. `isEnabled` only + // gates `flush()` (remote send). A disabled session still writes + // every track to JSONL + the in-memory queue; re-enabling picks the + // backlog up on the next flush. // Capture the timestamp synchronously at call-site so it reflects WHEN the // user action happened, not when the async resolver chain settled. Under // burst load (many tracks queued before the first resolver completes) this @@ -176,15 +204,35 @@ export class AnalyticsClient implements IAnalyticsClient { private async runFlush(): Promise { const records = await this.deps.jsonlStore.loadPending() + // M4.4: per-flush AbortController, exposed via `abort()` so the + // disable-handler can cancel the in-flight HTTP. Cleared in finally + // so a stale controller can't be aborted after settlement. + const controller = new AbortController() + this.currentFlushController = controller + let result: SendResult try { - result = await this.deps.sender.send(records) + result = await this.deps.sender.send(records, {signal: controller.signal}) } catch { result = {failed: records.map((r) => r.id), succeeded: []} + } finally { + if (this.currentFlushController === controller) { + this.currentFlushController = undefined + } } await this.deps.jsonlStore.updateStatus(result.succeeded, 'sent') - await this.deps.jsonlStore.updateStatus(result.failed, 'failed') + // M4.4 N3 fix: when we cancelled the send ourselves (`abort()` fired + // because `brv analytics disable` flipped the flag), DO NOT mark the + // failed records as 'failed' — that bumps the M9.2 retry-cap + // `attempts` counter on every cancel, and a few disable/enable + // toggles during shipping could terminate records as `'failed'` + // before they ever land. Leaving them at `status='pending'` + // preserves the invariant the `abort()` JSDoc claims: aborted + // records ship cleanly on the next enabled flush. + if (!controller.signal.aborted) { + await this.deps.jsonlStore.updateStatus(result.failed, 'failed') + } return AnalyticsBatch.create(records.map((r) => toWireEvent(r))) } diff --git a/src/server/infra/analytics/axios-analytics-http-client.ts b/src/server/infra/analytics/axios-analytics-http-client.ts index 4995f1dec..85045ed7a 100644 --- a/src/server/infra/analytics/axios-analytics-http-client.ts +++ b/src/server/infra/analytics/axios-analytics-http-client.ts @@ -5,6 +5,7 @@ import axios, {AxiosError} from 'axios' import type {AnalyticsBatch} from '../../core/domain/analytics/batch.js' import type { AnalyticsHttpHeaders, + AnalyticsHttpSendOptions, AnalyticsHttpSendResult, IAnalyticsHttpClient, } from '../../core/interfaces/analytics/i-analytics-http-client.js' @@ -52,10 +53,17 @@ export class AxiosAnalyticsHttpClient implements IAnalyticsHttpClient { public async send( batch: AnalyticsBatch, headers: AnalyticsHttpHeaders, + options?: AnalyticsHttpSendOptions, ): Promise { try { const response = await this.axios.post(EVENTS_PATH, batch.toJson(), { headers: this.composeHeaders(headers), + // M4.4: surface the caller's AbortSignal so `brv analytics + // disable` / daemon shutdown can cancel an in-flight POST. + // Pre-aborted signals are honored by axios (it short-circuits + // before dispatch). Aborted requests classify as `network` + // (client-side termination, not a server-side condition). + ...(options?.signal === undefined ? {} : {signal: options.signal}), }) return classifyResponse(response) } catch (error: unknown) { diff --git a/src/server/infra/analytics/http-analytics-sender.ts b/src/server/infra/analytics/http-analytics-sender.ts index 0a14e5b46..3045560a0 100644 --- a/src/server/infra/analytics/http-analytics-sender.ts +++ b/src/server/infra/analytics/http-analytics-sender.ts @@ -1,6 +1,10 @@ import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' import type {IAnalyticsHttpClient} from '../../core/interfaces/analytics/i-analytics-http-client.js' -import type {IAnalyticsSender, SendResult} from '../../core/interfaces/analytics/i-analytics-sender.js' +import type { + AnalyticsSenderOptions, + IAnalyticsSender, + SendResult, +} from '../../core/interfaces/analytics/i-analytics-sender.js' import type {IAuthStateReader} from '../../core/interfaces/analytics/i-identity-resolver.js' import type {IGlobalConfigStore} from '../../core/interfaces/storage/i-global-config-store.js' @@ -46,7 +50,10 @@ export class HttpAnalyticsSender implements IAnalyticsSender { this.deps = deps } - public async send(records: readonly StoredAnalyticsRecord[]): Promise { + public async send( + records: readonly StoredAnalyticsRecord[], + options?: AnalyticsSenderOptions, + ): Promise { if (records.length === 0) return {failed: [], succeeded: []} const ids = records.map((r) => r.id) @@ -63,11 +70,19 @@ export class HttpAnalyticsSender implements IAnalyticsSender { const sessionKey = this.deps.authStateReader.getToken()?.sessionKey const batch = AnalyticsBatch.create(records.map((r) => toWireEvent(r))) - const httpResult = await this.deps.httpClient.send(batch, { - deviceId, - ...(sessionKey !== undefined && sessionKey !== '' ? {sessionId: sessionKey} : {}), - userAgent: this.deps.userAgent, - }) + const httpResult = await this.deps.httpClient.send( + batch, + { + deviceId, + ...(sessionKey !== undefined && sessionKey !== '' ? {sessionId: sessionKey} : {}), + userAgent: this.deps.userAgent, + }, + // M4.4: forward the cancellation signal so `brv analytics disable` + // (or shutdown) can abort an in-flight POST. The http client + // classifies aborted requests as `network`, which maps here to + // an all-failed result — same as any other transport failure. + options?.signal === undefined ? undefined : {signal: options.signal}, + ) if (httpResult.ok) return {failed: [], succeeded: [...ids]} return {failed: [...ids], succeeded: []} diff --git a/src/server/infra/analytics/no-op-analytics-client.ts b/src/server/infra/analytics/no-op-analytics-client.ts index 75cd98177..26c54fda6 100644 --- a/src/server/infra/analytics/no-op-analytics-client.ts +++ b/src/server/infra/analytics/no-op-analytics-client.ts @@ -11,6 +11,10 @@ import {AnalyticsBatch} from '../../core/domain/analytics/batch.js' * an empty batch. */ export class NoOpAnalyticsClient implements IAnalyticsClient { + public abort(): void { + // intentional no-op — no in-flight flush to cancel. + } + public async flush(): Promise { return AnalyticsBatch.create([]) } diff --git a/src/server/infra/analytics/no-op-analytics-sender.ts b/src/server/infra/analytics/no-op-analytics-sender.ts index 5eeb9a7ef..85e7ef52c 100644 --- a/src/server/infra/analytics/no-op-analytics-sender.ts +++ b/src/server/infra/analytics/no-op-analytics-sender.ts @@ -21,6 +21,11 @@ import type {IAnalyticsSender, SendResult} from '../../core/interfaces/analytics */ export class NoOpAnalyticsSender implements IAnalyticsSender { public async send(_records: readonly StoredAnalyticsRecord[]): Promise { + // M4.4 `AnalyticsSenderOptions` (signal) intentionally accepted by + // the interface but ignored here — the no-op sender never reaches a + // transport that could be cancelled. Omitting the parameter keeps + // the structural-type assignment to `IAnalyticsSender` valid (optional + // parameter). return {failed: [], succeeded: []} } } diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 92750777f..f2bd5df1b 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -83,6 +83,7 @@ import { } from '../transport/handlers/index.js' import {HttpUserService} from '../user/http-user-service.js' import {FileVcGitConfigStore} from '../vc/file-vc-git-config-store.js' +import {wireAnalyticsAuthPreTransition} from './wire-analytics-auth-pre-transition.js' import {wireAnalyticsAuthTransition} from './wire-analytics-auth-transition.js' import {wireAnalyticsFlushScheduler} from './wire-analytics-flush-scheduler.js' import {wireAnalyticsHttpSender} from './wire-analytics-http-sender.js' @@ -230,6 +231,19 @@ export async function setupFeatureHandlers({ // login/logout/refresh decision logic. wireAnalyticsAuthTransition(authStateStore, analyticsClient) + // M4.4: subscribe the pre-transition hook so the client flushes + // surviving events under the OLD session header BEFORE the new + // token replaces the cache. Paired with the M4.1 post-hook above + // (drops anything the flush couldn't deliver in time). + wireAnalyticsAuthPreTransition(authStateStore, analyticsClient) + + // M4.4: close the global-config-handler ↔ analyticsClient cycle. + // The handler was constructed earlier (so its sync cache was + // populated before the client read it); now that the client + // exists, register it so `brv analytics disable` can call + // `abort()` to cancel any in-flight HTTP. + globalConfigHandler.setAnalyticsClient(analyticsClient) + // M2.6: route incoming analytics:track events from non-forked clients // (TUI, oclif, MCP, webui) to the same singleton. new AnalyticsHandler({analyticsClient, transport}).setup() diff --git a/src/server/infra/process/wire-analytics-auth-pre-transition.ts b/src/server/infra/process/wire-analytics-auth-pre-transition.ts new file mode 100644 index 000000000..31efd7901 --- /dev/null +++ b/src/server/infra/process/wire-analytics-auth-pre-transition.ts @@ -0,0 +1,39 @@ +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import type {IAuthStateStore} from '../../core/interfaces/state/i-auth-state-store.js' + +/** + * Subscribe the analytics client to the pre-transition hook so it can + * flush events under the OLD session header before the auth state + * commits. + * + * The hook fires for any accessToken change, but we only want to flush + * when the IDENTITY actually changed: login (anon → auth), logout + * (auth → anon), or account switch (userA → userB). A pure access-token + * refresh keeps the same userId and would just waste an HTTP call. + * + * Errors from flush() are swallowed: analytics MUST NOT block the auth + * transition. The store's hang-guard provides the upper bound; this + * helper provides the error-tolerance. + * + * Pairs with `wireAnalyticsAuthTransition` (M4.1): pre-hook ships + * surviving events, then the post-hook drops anything left behind + * (e.g. records the flush couldn't deliver before the backend timed + * out). + */ +export function wireAnalyticsAuthPreTransition( + authStateStore: IAuthStateStore, + analyticsClient: IAnalyticsClient, +): void { + authStateStore.onBeforeAuthChange(async (oldToken, newToken) => { + // Identity-change guard: same userId across the transition (typical + // access-token refresh) is NOT an identity change. Skip the flush. + if (oldToken?.userId === newToken?.userId) return + + try { + await analyticsClient.flush() + } catch { + // Swallowed: analytics failures MUST NOT block auth transitions. + // M4.5 will surface failure reasons via a different channel. + } + }) +} diff --git a/src/server/infra/state/auth-state-store.ts b/src/server/infra/state/auth-state-store.ts index b30598cb4..057bfba6a 100644 --- a/src/server/infra/state/auth-state-store.ts +++ b/src/server/infra/state/auth-state-store.ts @@ -3,12 +3,22 @@ import type {ITokenStore} from '../../core/interfaces/auth/i-token-store.js' import type { AuthChangedCallback, AuthExpiredCallback, + BeforeAuthChangedCallback, IAuthStateStore, } from '../../core/interfaces/state/i-auth-state-store.js' import {AUTH_STATE_POLL_INTERVAL_MS} from '../../constants.js' +const DEFAULT_BEFORE_AUTH_CHANGE_TIMEOUT_MS = 6000 + type AuthStateStoreOptions = { + /** + * Hang-guard for `onBeforeAuthChange` listeners. Each pre-listener is + * raced against this timeout so a wedged subsystem (e.g. analytics + * flush stuck on a slow backend) cannot deadlock auth transitions. + * Default 6000ms = HTTP-client 5s timeout + 1s slack. + */ + beforeAuthChangeTimeoutMs?: number /** Logging function (optional, defaults to no-op) */ log?: (message: string) => void /** Polling interval in milliseconds (optional, defaults to AUTH_STATE_POLL_INTERVAL_MS) */ @@ -35,6 +45,8 @@ type AuthStateStoreOptions = { export class AuthStateStore implements IAuthStateStore { private readonly authChangedCallbacks: AuthChangedCallback[] = [] private readonly authExpiredCallbacks: AuthExpiredCallback[] = [] + private readonly beforeAuthChangeCallbacks: BeforeAuthChangedCallback[] = [] + private readonly beforeAuthChangeTimeoutMs: number private cachedToken: AuthToken | undefined private isPolling = false private readonly log: (message: string) => void @@ -47,6 +59,7 @@ export class AuthStateStore implements IAuthStateStore { constructor(options: AuthStateStoreOptions) { this.tokenStore = options.tokenStore this.pollIntervalMs = options.pollIntervalMs ?? AUTH_STATE_POLL_INTERVAL_MS + this.beforeAuthChangeTimeoutMs = options.beforeAuthChangeTimeoutMs ?? DEFAULT_BEFORE_AUTH_CHANGE_TIMEOUT_MS this.log = options.log ?? (() => {}) } @@ -57,7 +70,7 @@ export class AuthStateStore implements IAuthStateStore { async loadToken(): Promise { try { const token = await this.tokenStore.load() - this.updateCachedToken(token) + await this.updateCachedToken(token) return this.cachedToken } catch (error) { this.log(`Failed to load token: ${error instanceof Error ? error.message : String(error)}`) @@ -73,6 +86,10 @@ export class AuthStateStore implements IAuthStateStore { this.authExpiredCallbacks.push(callback) } + onBeforeAuthChange(callback: BeforeAuthChangedCallback): void { + this.beforeAuthChangeCallbacks.push(callback) + } + startPolling(): void { if (this.pollInterval) return @@ -118,6 +135,45 @@ export class AuthStateStore implements IAuthStateStore { } } + /** + * Fire pre-transition listeners in registration order, each bounded by + * `beforeAuthChangeTimeoutMs`. A listener that rejects or hangs is + * logged best-effort and does NOT block subsequent listeners or the + * transition itself — this is the contract the analytics force-flush + * relies on (must not deadlock auth on a wedged backend). + * + * The cached token is NOT mutated yet — `getToken()` still returns the + * old token throughout this call. That guarantee is what lets the + * analytics flush carry the OLD session header. + */ + private async fireBeforeAuthChange( + oldToken: AuthToken | undefined, + newToken: AuthToken | undefined, + ): Promise { + for (const callback of this.beforeAuthChangeCallbacks) { + let timer: ReturnType | undefined + try { + // eslint-disable-next-line no-await-in-loop + await Promise.race([ + Promise.resolve(callback(oldToken, newToken)), + new Promise((resolve) => { + timer = setTimeout(resolve, this.beforeAuthChangeTimeoutMs) + }), + ]) + } catch (error) { + this.log(`onBeforeAuthChange callback rejected: ${error instanceof Error ? error.message : String(error)}`) + } finally { + // Always clear the hang-guard timer when the callback wins the + // race (the common fast path). Without this clear, every + // transition leaks a pending Node timer that keeps the event + // loop alive for `beforeAuthChangeTimeoutMs` after the callback + // settled — a shutdown triggered shortly after a transition + // would block up to that budget waiting for the phantom timer. + if (timer !== undefined) clearTimeout(timer) + } + } + } + /** * Single poll cycle. Loads token from store and compares with cached. * Skips if a poll is already in-flight. @@ -128,7 +184,7 @@ export class AuthStateStore implements IAuthStateStore { this.isPolling = true try { const token = await this.tokenStore.load() - this.updateCachedToken(token) + await this.updateCachedToken(token) } catch (error) { this.log(`Auth poll error: ${error instanceof Error ? error.message : String(error)}`) } finally { @@ -137,14 +193,24 @@ export class AuthStateStore implements IAuthStateStore { } /** - * Compare loaded token with cached and fire appropriate callbacks. + * Compare loaded token with cached, fire pre-transition listeners + * (M4.4), then mutate the cache and fire post-transition listeners. + * + * Ordering is load-bearing: the pre-listeners observe the OLD token + * via `getToken()` because `this.cachedToken` only mutates AFTER they + * resolve. Without that ordering, M4.4's flush-then-drop hybrid would + * ship events with the NEW session header but OLD per-event identity, + * tripping the backend's identity-mismatch path and downgrading those + * events to anonymous. */ - private updateCachedToken(token: AuthToken | undefined): void { + private async updateCachedToken(token: AuthToken | undefined): Promise { const previousAccessToken = this.cachedToken?.accessToken const newAccessToken = token?.accessToken // Detect change: different accessToken (including undefined <-> defined) if (previousAccessToken !== newAccessToken) { + const oldToken = this.cachedToken + await this.fireBeforeAuthChange(oldToken, token) this.cachedToken = token this.wasExpired = false this.log(`Auth state changed: ${token ? 'token present' : 'token removed'}`) diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts index 70a3a6c44..6422f7a13 100644 --- a/src/server/infra/transport/handlers/global-config-handler.ts +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -1,5 +1,6 @@ import {randomUUID} from 'node:crypto' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {IGlobalConfigStore} from '../../../core/interfaces/storage/i-global-config-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' @@ -13,6 +14,14 @@ import {GLOBAL_CONFIG_VERSION} from '../../../constants.js' import {GlobalConfig} from '../../../core/domain/entities/global-config.js' export interface GlobalConfigHandlerDeps { + /** + * M4.4: optional analytics client used to cancel any in-flight HTTP + * send when `brv analytics disable` flips the flag from true → false. + * Disable does NOT drop the queue or clear JSONL — those stay so a + * future re-enable ships the backlog. Optional for back-compat with + * test harnesses that don't construct a real analytics client. + */ + analyticsClient?: IAnalyticsClient globalConfigStore: IGlobalConfigStore transport: ITransportServer } @@ -40,6 +49,7 @@ export interface GlobalConfigHandlerDeps { * disk — the cache is purely an in-process bridge for sync consumers. */ export class GlobalConfigHandler { + private analyticsClient: IAnalyticsClient | undefined private cachedAnalytics: boolean | undefined private readonly globalConfigStore: IGlobalConfigStore private readonly transport: ITransportServer @@ -51,6 +61,7 @@ export class GlobalConfigHandler { private writeChain: Promise = Promise.resolve() constructor(deps: GlobalConfigHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.globalConfigStore = deps.globalConfigStore this.transport = deps.transport } @@ -98,6 +109,19 @@ export class GlobalConfigHandler { } } + /** + * M4.4: late-bound analytics client setter. The composition root + * constructs `GlobalConfigHandler` BEFORE `AnalyticsClient` exists + * (the cached-analytics flag must be populated before the client + * reads it). This setter closes that loop: once the client is built, + * the daemon wires it in so disable-time `abort()` works. + * + * Calling this more than once silently replaces the reference. Idempotent. + */ + setAnalyticsClient(client: IAnalyticsClient): void { + this.analyticsClient = client + } + setup(): void { this.transport.onRequest(GlobalConfigEvents.GET, async () => this.read()) this.transport.onRequest( @@ -126,6 +150,20 @@ export class GlobalConfigHandler { // edits) are NOT observable until the next daemon restart. The // single-daemon model makes this safe today. this.cachedAnalytics = updated.analytics + + // M4.4: on enable → disable, abort any in-flight analytics HTTP so + // the daemon doesn't half-ship a batch across the boundary. Disable + // does NOT drop the queue or clear JSONL — the backlog persists and + // ships on re-enable. abort() errors are swallowed: a failed cancel + // MUST NOT block the config write the user explicitly requested. + if (previous && !analytics) { + try { + this.analyticsClient?.abort() + } catch { + /* swallow — analytics MUST NOT block config writes */ + } + } + return {current: updated.analytics, previous} } diff --git a/test/helpers/mock-factories.ts b/test/helpers/mock-factories.ts index e7df234a0..625c4c93f 100644 --- a/test/helpers/mock-factories.ts +++ b/test/helpers/mock-factories.ts @@ -545,6 +545,7 @@ export function createMockAuthStateStore( loadToken: sandbox.stub().resolves(token), onAuthChanged: sandbox.stub(), onAuthExpired: sandbox.stub(), + onBeforeAuthChange: sandbox.stub(), startPolling: sandbox.stub(), stopPolling: sandbox.stub(), } diff --git a/test/integration/infra/git/isomorphic-git-service.test.ts b/test/integration/infra/git/isomorphic-git-service.test.ts index f8da888ef..671275509 100644 --- a/test/integration/infra/git/isomorphic-git-service.test.ts +++ b/test/integration/infra/git/isomorphic-git-service.test.ts @@ -33,6 +33,7 @@ function makeAuth(options?: {noAuth: true}): IAuthStateStore { loadToken: stub<[], Promise>().resolves(), onAuthChanged: stub(), onAuthExpired: stub(), + onBeforeAuthChange: stub(), startPolling: stub(), stopPolling: stub(), } diff --git a/test/integration/infra/process/analytics-hook-async-stress.test.ts b/test/integration/infra/process/analytics-hook-async-stress.test.ts index bbe30f899..88960727c 100644 --- a/test/integration/infra/process/analytics-hook-async-stress.test.ts +++ b/test/integration/infra/process/analytics-hook-async-stress.test.ts @@ -98,6 +98,7 @@ function makeStubProjectRouter(sandbox: SinonSandbox): IProjectRouter { function makeAnalyticsClient(sandbox: SinonSandbox): {client: IAnalyticsClient; trackStub: SinonStub} { const trackStub = sandbox.stub() const client: IAnalyticsClient = { + abort: sandbox.stub(), flush: sandbox.stub().resolves(), onAuthTransition: sandbox.stub().resolves(), track: trackStub, diff --git a/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts b/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts new file mode 100644 index 000000000..c7a0c92c1 --- /dev/null +++ b/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts @@ -0,0 +1,172 @@ +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type { + AuthChangedCallback, + AuthExpiredCallback, + BeforeAuthChangedCallback, + IAuthStateStore, +} from '../../../../../src/server/core/interfaces/state/i-auth-state-store.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' +import {wireAnalyticsAuthPreTransition} from '../../../../../src/server/infra/process/wire-analytics-auth-pre-transition.js' + +/** + * Integration test for the M4.4 auth pre-transition wiring. + * + * The pre-hook fires BEFORE `AuthStateStore.cachedToken` mutates, so a + * `flush()` invoked here ships pending events under the OLD session + * header. Without this ordering the events would carry old per-event + * identity but new request-level session, tripping the backend's + * identity-mismatch path. + * + * Same identity-change distinguisher as the M4.1 post-transition wiring + * (`wire-analytics-auth-transition.ts`): + * - login (anon → auth) → flush + * - logout (auth → anon) → flush + * - account switch (A → B) → flush + * - access-token refresh → SKIP (same userId) + */ + +function makeToken(overrides: Partial<{accessToken: string; userId: string}> = {}): AuthToken { + const accessToken = overrides.accessToken ?? 'access-1' + const userId = overrides.userId ?? 'user-A' + return new AuthToken({ + accessToken, + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'refresh-1', + sessionKey: 'session-1', + userEmail: 'alice@example.com', + userId, + userName: 'Alice', + }) +} + +function makeFakeAuthStateStore(initial?: AuthToken): IAuthStateStore & { + fire(oldToken: AuthToken | undefined, newToken: AuthToken | undefined): Promise + readonly preCallbacks: BeforeAuthChangedCallback[] +} { + const preCallbacks: BeforeAuthChangedCallback[] = [] + const cached: AuthToken | undefined = initial + return { + async fire(oldToken: AuthToken | undefined, newToken: AuthToken | undefined): Promise { + // Serial execution mirrors AuthStateStore.fireBeforeAuthChange. + for (const cb of preCallbacks) { + // eslint-disable-next-line no-await-in-loop + await cb(oldToken, newToken) + } + }, + getToken: () => cached, + loadToken: async () => cached, + onAuthChanged(_cb: AuthChangedCallback): void { + // not exercised here + }, + onAuthExpired(_cb: AuthExpiredCallback): void { + // not exercised here + }, + onBeforeAuthChange(cb: BeforeAuthChangedCallback): void { + preCallbacks.push(cb) + }, + preCallbacks, + startPolling(): void { + // not exercised here + }, + stopPolling(): void { + // not exercised here + }, + } +} + +function makeFakeAnalyticsClient(): IAnalyticsClient & { + flushSpy: ReturnType +} { + const flushSpy = stub().resolves(AnalyticsBatch.create([])) + return { + abort() { + /* M4.4: not exercised in this test */ + }, + flush: flushSpy, + flushSpy, + onAuthTransition: stub().resolves(), + track(): void { + // intentional no-op + }, + } +} + +describe('M4.4 wireAnalyticsAuthPreTransition (integration)', () => { + describe('identity change → flush', () => { + it('fires flush on login (anon → authenticated)', async () => { + const store = makeFakeAuthStateStore() // initial: undefined + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthPreTransition(store, client) + + await store.fire(undefined, makeToken({userId: 'user-A'})) + + expect(client.flushSpy.calledOnce, 'flush must fire on login').to.equal(true) + }) + + it('fires flush on logout (authenticated → anon)', async () => { + const store = makeFakeAuthStateStore(makeToken({userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthPreTransition(store, client) + + await store.fire(makeToken({userId: 'user-A'})) + + expect(client.flushSpy.calledOnce, 'flush must fire on logout').to.equal(true) + }) + + it('fires flush on account switch (userA → userB)', async () => { + const store = makeFakeAuthStateStore(makeToken({userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthPreTransition(store, client) + + await store.fire(makeToken({userId: 'user-A'}), makeToken({accessToken: 'access-B', userId: 'user-B'})) + + expect(client.flushSpy.calledOnce, 'flush must fire on account switch').to.equal(true) + }) + }) + + describe('token refresh → skip', () => { + it('does NOT fire flush when accessToken changes but userId is unchanged', async () => { + const store = makeFakeAuthStateStore(makeToken({accessToken: 'a1', userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthPreTransition(store, client) + + await store.fire( + makeToken({accessToken: 'a1', userId: 'user-A'}), + makeToken({accessToken: 'a2', userId: 'user-A'}), + ) + + expect(client.flushSpy.called, 'token refresh must NOT trigger pre-flush').to.equal(false) + }) + + it('skips a series of refreshes for the same user', async () => { + const store = makeFakeAuthStateStore(makeToken({userId: 'user-A'})) + const client = makeFakeAnalyticsClient() + wireAnalyticsAuthPreTransition(store, client) + + await store.fire(makeToken({accessToken: 'a1', userId: 'user-A'}), makeToken({accessToken: 'a2', userId: 'user-A'})) + await store.fire(makeToken({accessToken: 'a2', userId: 'user-A'}), makeToken({accessToken: 'a3', userId: 'user-A'})) + await store.fire(makeToken({accessToken: 'a3', userId: 'user-A'}), makeToken({accessToken: 'a4', userId: 'user-A'})) + + expect(client.flushSpy.callCount).to.equal(0) + }) + }) + + describe('failure resilience', () => { + it('does NOT propagate flush() rejection (auth transition must not be blocked)', async () => { + const store = makeFakeAuthStateStore() + const client = makeFakeAnalyticsClient() + client.flushSpy.rejects(new Error('flush boom')) + wireAnalyticsAuthPreTransition(store, client) + + // If the listener propagated the error, this `await store.fire(...)` would reject. + await store.fire(undefined, makeToken({userId: 'user-A'})) + + expect(client.flushSpy.calledOnce, 'flush still attempted').to.equal(true) + }) + }) +}) diff --git a/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts b/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts index 15c8bb0bd..2f7964c70 100644 --- a/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts +++ b/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts @@ -84,6 +84,9 @@ function makeFakeAuthStateStore(initial?: AuthToken): IAuthStateStore & { onAuthExpired(_cb: AuthExpiredCallback): void { // not exercised here }, + onBeforeAuthChange(): void { + // M4.4: pre-hook not exercised in this M4.1 test + }, startPolling(): void { // not exercised here }, @@ -98,6 +101,9 @@ function makeFakeAnalyticsClient(): IAnalyticsClient & { } { const onAuthTransition = stub().resolves() return { + abort() { + /* M4.4: not exercised in this test */ + }, flush: stub().resolves(AnalyticsBatch.create([])), onAuthTransition, onAuthTransitionSpy: onAuthTransition, @@ -259,6 +265,9 @@ describe('M4.1 wireAnalyticsAuthTransition (integration)', () => { callbacks.push(cb) }, onAuthExpired(_cb: AuthExpiredCallback): void {}, + onBeforeAuthChange(): void { + // M4.4: pre-hook not exercised in this M4.1 test + }, startPolling(): void {}, stopPolling(): void {}, } diff --git a/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts b/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts index c7310314f..c42ef4321 100644 --- a/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts +++ b/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts @@ -24,6 +24,9 @@ type FakeClient = IAnalyticsClient & {readonly flushCalls: number; resetFlushCal function makeFakeClient(): FakeClient { let calls = 0 const stub: FakeClient = { + abort() { + /* M4.4: not exercised in this test */ + }, async flush() { calls += 1 return AnalyticsBatch.create([]) @@ -193,6 +196,9 @@ describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { it('flushFinal joins an in-flight flush rather than starting a second send', async () => { let releaseFlush!: () => void const slowClient: IAnalyticsClient = { + abort() { + /* M4.4: not exercised in this test */ + }, flush: () => new Promise((resolve) => { releaseFlush = () => resolve(AnalyticsBatch.create([])) @@ -233,6 +239,9 @@ describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { it('flushFinal resolves under the timeout when flush never settles', async () => { const slowClient: IAnalyticsClient = { + abort() { + /* M4.4: not exercised in this test */ + }, flush: () => new Promise(() => { /* never resolves */ diff --git a/test/unit/infra/state/auth-state-store.test.ts b/test/unit/infra/state/auth-state-store.test.ts index fede5c50b..b597910a2 100644 --- a/test/unit/infra/state/auth-state-store.test.ts +++ b/test/unit/infra/state/auth-state-store.test.ts @@ -479,4 +479,184 @@ describe('AuthStateStore', () => { expect(cb2.calledOnce).to.be.true }) }) + + describe('onBeforeAuthChange (M4.4 pre-transition hook)', () => { + // The pre-hook fires BEFORE `cachedToken` is mutated, so listeners + // (analytics force-flush) can read `getToken()` and observe the + // OLD token. Without this ordering guarantee, M4.4's flush-then-drop + // hybrid would ship events with the NEW session header but OLD + // per-event identity — backend would treat them as anonymous. + const HANG_GUARD_MS = 50 // shrunk for tests; prod default is 6000 + + it('fires the pre-listener BEFORE cachedToken mutates (getToken returns OLD)', async () => { + const token1 = createValidToken({accessToken: 'old'}) + loadStub.resolves(token1) + await store.loadToken() + + let observedDuringPre: string | undefined + store.onBeforeAuthChange(async (_oldToken, _newToken) => { + // Reading getToken() here MUST return the OLD token — the whole + // point of the pre-hook is the OLD token is still in place. + observedDuringPre = store.getToken()?.accessToken + }) + + const token2 = createValidToken({accessToken: 'new'}) + loadStub.resolves(token2) + await store.loadToken() + + expect(observedDuringPre, 'pre-listener must see OLD token via getToken()').to.equal('old') + expect(store.getToken()?.accessToken, 'post-transition cached token is NEW').to.equal('new') + }) + + it('awaits the async pre-listener before firing onAuthChanged (post-hook)', async () => { + const order: string[] = [] + let releasePre!: () => void + store.onBeforeAuthChange( + () => + new Promise((resolve) => { + order.push('pre-start') + releasePre = () => { + order.push('pre-end') + resolve() + } + }), + ) + store.onAuthChanged(() => { + order.push('post') + }) + + loadStub.resolves(createValidToken({accessToken: 'a'})) + + const loadPromise = store.loadToken() + // Pre-listener registered but not resolved yet → post must NOT fire + await clock.tickAsync(0) + expect(order).to.deep.equal(['pre-start']) + + releasePre() + await loadPromise + + expect(order).to.deep.equal(['pre-start', 'pre-end', 'post']) + }) + + it('skips pre-listeners when accessToken is unchanged (token-refresh shortcut path is unrelated)', async () => { + // Same accessToken across loads = no change detected, NO pre-listener fire. + const preCb = sandbox.stub().resolves() + store.onBeforeAuthChange(preCb) + + const token = createValidToken({accessToken: 'stable'}) + loadStub.resolves(token) + await store.loadToken() // first load: undefined → token, pre fires + await store.loadToken() // second load: same accessToken, NO pre + + expect(preCb.calledOnce, 'pre fires only on the actual transition').to.be.true + }) + + it('hang-guard: pre-listener that never resolves does NOT block the transition past beforeAuthChangeTimeoutMs', async () => { + // Construct a store with a small hang-guard so the test can finish + // in reasonable time. Prod default is 6s. + const fastStore = new AuthStateStore({ + beforeAuthChangeTimeoutMs: HANG_GUARD_MS, + pollIntervalMs: POLL_INTERVAL, + tokenStore, + }) + fastStore.onBeforeAuthChange( + () => + new Promise(() => { + /* never resolves */ + }), + ) + const postCb = sandbox.stub() + fastStore.onAuthChanged(postCb) + + loadStub.resolves(createValidToken({accessToken: 'a'})) + const loadPromise = fastStore.loadToken() + + await clock.tickAsync(HANG_GUARD_MS + 1) + await loadPromise + + expect(postCb.calledOnce, 'post-hook must still fire after hang-guard expires').to.be.true + expect(fastStore.getToken()?.accessToken, 'cachedToken must commit even though pre hung').to.equal('a') + }) + + it('clears the hang-guard timer when the pre-listener wins the race (no leaked Node timer)', async () => { + // Regression for N2 review finding: without clearTimeout, every + // transition leaks a 6s timer that keeps the event loop alive. + // We verify by counting pending timers via the fake clock: after + // a fast callback resolves and the loadToken settles, no setTimeout + // queued by fireBeforeAuthChange should remain. + const fastStore = new AuthStateStore({ + beforeAuthChangeTimeoutMs: HANG_GUARD_MS, + pollIntervalMs: POLL_INTERVAL, + tokenStore, + }) + fastStore.onBeforeAuthChange(async () => { + // resolves on the next microtask — wins the race trivially. + }) + + loadStub.resolves(createValidToken({accessToken: 'a'})) + // Snapshot the pending-timer count before and after the transition. + const before = clock.countTimers() + await fastStore.loadToken() + const after = clock.countTimers() + + expect(after - before, 'no pending timer leaked by the hang-guard').to.equal(0) + }) + + it('runs multiple pre-listeners in registration order, awaiting each in series', async () => { + const order: string[] = [] + store.onBeforeAuthChange(async () => { + await Promise.resolve() + order.push('pre1') + }) + store.onBeforeAuthChange(async () => { + await Promise.resolve() + order.push('pre2') + }) + store.onBeforeAuthChange(async () => { + await Promise.resolve() + order.push('pre3') + }) + + loadStub.resolves(createValidToken({accessToken: 'a'})) + await store.loadToken() + + expect(order).to.deep.equal(['pre1', 'pre2', 'pre3']) + }) + + it('continues to subsequent pre-listeners when an earlier one rejects', async () => { + const cb1 = sandbox.stub().rejects(new Error('pre1 boom')) + const cb2 = sandbox.stub().resolves() + const cb3 = sandbox.stub().resolves() + store.onBeforeAuthChange(cb1) + store.onBeforeAuthChange(cb2) + store.onBeforeAuthChange(cb3) + const postCb = sandbox.stub() + store.onAuthChanged(postCb) + + loadStub.resolves(createValidToken({accessToken: 'a'})) + await store.loadToken() + + expect(cb1.calledOnce).to.be.true + expect(cb2.calledOnce, 'cb2 must run after cb1 rejected').to.be.true + expect(cb3.calledOnce, 'cb3 must run after cb1 rejected').to.be.true + expect(postCb.calledOnce, 'post-hook still fires').to.be.true + }) + + it('passes (oldToken, newToken) to the pre-listener', async () => { + const token1 = createValidToken({accessToken: 'a'}) + loadStub.resolves(token1) + await store.loadToken() + + const cb = sandbox.stub().resolves() + store.onBeforeAuthChange(cb) + + const token2 = createValidToken({accessToken: 'b'}) + loadStub.resolves(token2) + await store.loadToken() + + expect(cb.calledOnce).to.be.true + expect(cb.firstCall.args[0]?.accessToken, 'arg0 is OLD token').to.equal('a') + expect(cb.firstCall.args[1]?.accessToken, 'arg1 is NEW token').to.equal('b') + }) + }) }) diff --git a/test/unit/infra/transport/handlers/global-config-handler.test.ts b/test/unit/infra/transport/handlers/global-config-handler.test.ts index 07b57170f..dbafe539e 100644 --- a/test/unit/infra/transport/handlers/global-config-handler.test.ts +++ b/test/unit/infra/transport/handlers/global-config-handler.test.ts @@ -18,6 +18,13 @@ function createMockGlobalConfigStore(): SinonStubbedInstance } } +// M4.4: minimal analytics client double whose only relevant member for +// the disable-side-effect tests is `abort`. Hoisted to module scope to +// satisfy `unicorn/consistent-function-scoping`. +function makeAnalyticsClientStub(): {abort: ReturnType} { + return {abort: stub()} +} + describe('GlobalConfigHandler', () => { let store: SinonStubbedInstance let transport: MockTransportServer @@ -202,4 +209,140 @@ describe('GlobalConfigHandler', () => { expect(second.current).to.be.true }) }) + + describe('M4.4 abort-on-disable side effect', () => { + // Disable does NOT drop the queue or clear JSONL — those stay so a + // future re-enable ships the backlog. The only side effect is + // cancelling an in-flight HTTP send so the daemon doesn't + // half-ship a batch across an enable/disable boundary. + + it('calls analyticsClient.abort() exactly once when analytics flips true → false', async () => { + const analyticsClient = makeAnalyticsClientStub() + const handlerWithClient = new GlobalConfigHandler({ + analyticsClient: { + abort: analyticsClient.abort, + flush: stub().resolves(), + onAuthTransition: stub().resolves(), + // Hand-rolled noop preserves the generic `track` signature. + track(): void { + /* no-op */ + }, + }, + globalConfigStore: store, + transport, + }) + handlerWithClient.setup() + + // Seed disk as currently enabled. + const enabled = GlobalConfig.create('device-x').withAnalytics(true) + store.read.resolves(enabled) + + // Now disable. + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: false}, 'client-1') + + expect(analyticsClient.abort.calledOnce, 'abort must fire on enable→disable transition').to.be.true + }) + + it('does NOT call abort() when the disable is an idempotent no-op (already disabled)', async () => { + const analyticsClient = makeAnalyticsClientStub() + const handlerWithClient = new GlobalConfigHandler({ + analyticsClient: { + abort: analyticsClient.abort, + flush: stub().resolves(), + onAuthTransition: stub().resolves(), + // Hand-rolled noop preserves the generic `track` signature. + track(): void { + /* no-op */ + }, + }, + globalConfigStore: store, + transport, + }) + handlerWithClient.setup() + + // Already disabled (or never enabled). previous === false, requested === false. + store.read.resolves() + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: false}, 'client-1') + + expect(analyticsClient.abort.called, 'no transition = no abort').to.be.false + }) + + it('does NOT call abort() when the user enables (false → true)', async () => { + const analyticsClient = makeAnalyticsClientStub() + const handlerWithClient = new GlobalConfigHandler({ + analyticsClient: { + abort: analyticsClient.abort, + flush: stub().resolves(), + onAuthTransition: stub().resolves(), + // Hand-rolled noop preserves the generic `track` signature. + track(): void { + /* no-op */ + }, + }, + globalConfigStore: store, + transport, + }) + handlerWithClient.setup() + + const disabled = GlobalConfig.create('device-x').withAnalytics(false) + store.read.resolves(disabled) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: true}, 'client-1') + + expect(analyticsClient.abort.called, 'enable is not a transition that requires abort').to.be.false + }) + + it('still completes the config write when abort() throws', async () => { + const handlerWithClient = new GlobalConfigHandler({ + analyticsClient: { + abort() { + throw new Error('abort boom') + }, + flush: stub().resolves(), + onAuthTransition: stub().resolves(), + // Hand-rolled noop preserves the generic `track` signature. + track(): void { + /* no-op */ + }, + }, + globalConfigStore: store, + transport, + }) + handlerWithClient.setup() + + const enabled = GlobalConfig.create('device-x').withAnalytics(true) + store.read.resolves(enabled) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + const response = await fn({analytics: false}, 'client-1') + + expect(response.current, 'config write must complete even if abort threw').to.be.false + expect(response.previous).to.be.true + expect(store.write.calledOnce, 'config flush still happens').to.be.true + }) + + it('does not require analyticsClient (backwards-compat: dep is optional)', async () => { + // Pre-M4.4 callers (or test harnesses) don't wire analyticsClient. + // The handler must still work — the abort side-effect is skipped. + const handlerNoClient = new GlobalConfigHandler({globalConfigStore: store, transport}) + handlerNoClient.setup() + + const enabled = GlobalConfig.create('device-x').withAnalytics(true) + store.read.resolves(enabled) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + const response = await fn({analytics: false}, 'client-1') + + expect(response.current, 'works without analyticsClient').to.be.false + }) + }) }) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index 9777dd56e..60333a32c 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -163,30 +163,41 @@ async function seedPending(client: AnalyticsClient, count: number): Promise { - describe('disabled state (ticket scenario 1)', () => { - it('should be a true no-op when isEnabled returns false', async () => { + describe('disabled state (M4.4 semantic: track local-only)', () => { + // Pre-M4.4 this test asserted "no-op when disabled" (no JSONL append, + // no queue push, no resolver calls). Post-M4.4 the semantic is + // "local tracking always; remote send only when enabled" — disable + // gates the FLUSH layer, not the TRACK layer. `brv analytics disable` + // means "stop shipping to remote", not "stop collecting locally". + it('still tracks (JSONL + queue + resolvers) when isEnabled returns false; flush is the gate', async () => { const queue = new BoundedQueue() + const jsonlStore = makeFakeJsonlStore() const identityResolver = makeStubIdentityResolver(makeAnonIdentity()) const superPropsResolver = makeStubSuperPropsResolver(makeSuperProps()) + const sender = makeFakeSender() const client = new AnalyticsClient({ identityResolver, isEnabled: () => false, - jsonlStore: makeFakeJsonlStore(), + jsonlStore, queue, - sender: makeFakeSender(), + sender, superPropsResolver, }) - for (let i = 0; i < 1000; i++) { + for (let i = 0; i < 5; i++) { client.track(AnalyticsEventNames.DAEMON_START) } await flushMicrotasks() - expect(queue.size()).to.equal(0) - expect((identityResolver.resolve as ReturnType).called, 'identityResolver.resolve must NOT be called').to.be.false - expect((superPropsResolver.resolve as ReturnType).called, 'superPropsResolver.resolve must NOT be called').to.be.false + expect(queue.size(), 'queue STILL grows when disabled (local tracking unconditional)').to.equal(5) + expect(jsonlStore.records.length, 'JSONL STILL appended when disabled').to.equal(5) + expect((identityResolver.resolve as ReturnType).called, 'resolvers still run').to.be.true + + // Flush is the gate now: it must NOT call sender when disabled. + await client.flush() + expect(sender.calls.length, 'flush must NOT call sender when disabled').to.equal(0) }) }) @@ -546,7 +557,7 @@ describe('AnalyticsClient', () => { expect(jsonlStore.records).to.have.lengthOf(N) }) - it('should NOT call jsonlStore.append when analytics disabled', async () => { + it('STILL calls jsonlStore.append when analytics disabled (M4.4: local tracking unconditional)', async () => { const queue = new BoundedQueue() const jsonlStore = makeFakeJsonlStore() const client = new AnalyticsClient({ @@ -561,9 +572,9 @@ describe('AnalyticsClient', () => { client.track(AnalyticsEventNames.DAEMON_START) await flushMicrotasks() - expect(jsonlStore.appendSpy.called).to.equal(false) - expect(jsonlStore.records).to.have.lengthOf(0) - expect(queue.size()).to.equal(0) + expect(jsonlStore.appendSpy.calledOnce, 'append fires regardless of enable state').to.be.true + expect(jsonlStore.records).to.have.lengthOf(1) + expect(queue.size()).to.equal(1) }) }) @@ -1126,4 +1137,170 @@ describe('AnalyticsClient', () => { expect(second.events, 'second flush sees no pending rows after first settled').to.deep.equal([]) }) }) + + describe('M4.4 flush gate: disabled state skips remote send, leaves JSONL intact', () => { + it('flush() returns empty batch and does NOT call sender when isEnabled returns false', async () => { + const jsonlStore = makeFakeJsonlStore() + const sender = makeFakeSender() + // Pre-seed JSONL with a pending record (simulating an event tracked + // BEFORE the user disabled analytics — backlog scenario). + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 3) + expect(jsonlStore.records, 'precondition: 3 pending').to.have.lengthOf(3) + + // Now disable and flush. Records MUST stay `pending` in JSONL; + // re-enable later ships them on the next scheduler tick. + const disabledClient = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => false, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + const batch = await disabledClient.flush() + + expect(batch.events, 'disabled flush returns empty batch').to.deep.equal([]) + expect(sender.calls, 'disabled flush must NOT call sender').to.have.lengthOf(0) + expect( + jsonlStore.records.every((r) => r.status === 'pending'), + 'disabled flush must leave JSONL records as pending (backlog preserved)', + ).to.be.true + }) + + it('flush() ships the backlog after re-enable (disabled → enabled transition resumes shipping)', async () => { + const jsonlStore = makeFakeJsonlStore() + const sender = makeFakeSender() + let enabled = false + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => enabled, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Track 5 events while disabled — JSONL still grows. + await seedPending(client, 5) + expect(jsonlStore.records).to.have.lengthOf(5) + + // Flush while disabled — no-op on sender, backlog stays pending. + await client.flush() + expect(sender.calls).to.have.lengthOf(0) + + // Re-enable + flush — backlog ships. + enabled = true + await client.flush() + expect(sender.calls, 'enabled flush ships the backlog').to.have.lengthOf(1) + expect(sender.calls[0]).to.have.lengthOf(5) + }) + }) + + describe('M4.4 abort(): cancels in-flight flush via signal piped to sender', () => { + it('abort() during in-flight flush causes sender to receive an aborted signal', async () => { + let observedSignal: AbortSignal | undefined + let releaseSend!: () => void + const sender: IAnalyticsSender = { + async send(records, options) { + observedSignal = options?.signal + await new Promise((resolve) => { + releaseSend = resolve + }) + // Mimic HttpAnalyticsSender on abort: all-failed. + return {failed: records.map((r) => r.id), succeeded: []} + }, + } + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 2) + const flushPromise = client.flush() + await flushMicrotasks() + // Sender is now in-flight; abort the client. + client.abort() + expect(observedSignal?.aborted, 'sender must observe signal.aborted=true after abort()').to.equal(true) + + // Release the sender to let flush settle. JSONL records get marked + // failed (not stuck pending) per the existing failure-classification + // path; that's M9.2's retry-cap concern, not M4.4's. + releaseSend() + await flushPromise + }) + + it('abort() is a no-op when no flush is in flight', () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Must not throw. + client.abort() + }) + + it('does NOT bump M9.2 attempts on aborted flush (records stay pending for next enabled flush)', async () => { + // Regression for N3 review finding: without this skip, every + // disable-during-flush would call updateStatus(failed, 'failed') + // which bumps `attempts` via the M9.2 retry-cap. A few + // disable/enable toggles during shipping could drive records to + // attempts >= MAX_ATTEMPTS and terminate them — silent data loss. + let releaseSend!: () => void + const sender: IAnalyticsSender = { + async send(records, _options) { + await new Promise((resolve) => { + releaseSend = resolve + }) + // Mimic the abort-classification path: all-failed. + return {failed: records.map((r) => r.id), succeeded: []} + }, + } + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 3) + expect(jsonlStore.records.every((r) => r.status === 'pending')).to.be.true + + const flushPromise = client.flush() + await flushMicrotasks() + client.abort() + releaseSend() + await flushPromise + + const failedUpdates = jsonlStore.updateStatusCalls.filter((c) => c.status === 'failed' && c.ids.length > 0) + expect( + failedUpdates, + 'aborted flush must NOT call updateStatus(_, failed) with any ids — preserves M9.2 attempts', + ).to.have.lengthOf(0) + expect( + jsonlStore.records.every((r) => r.status === 'pending'), + 'records remain pending so the next enabled flush ships them cleanly', + ).to.be.true + }) + }) }) diff --git a/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts b/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts index 88f1e94d7..83a456b4f 100644 --- a/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts +++ b/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts @@ -210,4 +210,66 @@ describe('AxiosAnalyticsHttpClient', () => { expect(restored?.events).to.have.lengthOf(3) }) }) + + describe('abort support (M4.4)', () => { + it('returns ok=false reason=network when the signal is aborted mid-flight', async () => { + // Server takes 500ms to reply; we abort after the request is in + // flight. Without abort plumbing the client would wait the full + // 5s timeout and the test would slow the suite. + nock(baseUrl).post('/v1/events').delay(500).reply(200, {}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + const controller = new AbortController() + + const sendPromise = client.send( + makeBatch(1), + {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}, + {signal: controller.signal}, + ) + // Give axios a tick to dispatch the request, then abort. + await new Promise((resolve) => { + setTimeout(resolve, 20) + }) + controller.abort() + const result = await sendPromise + + expect(result.ok).to.equal(false) + if (result.ok) throw new Error('unreachable') + // Aborted requests classify as `network` (not `timeout`) — they + // were terminated client-side, the server never replied. + expect(result.reason).to.equal('network') + }) + + it('returns ok=false reason=network when the signal is already aborted before send', async () => { + // No nock interceptor — if axios honored the pre-aborted signal it + // never hits the network. If it didn't, this would 503 with + // "Nock: No match" and fail the assertion below. + const client = new AxiosAnalyticsHttpClient({baseUrl}) + const controller = new AbortController() + controller.abort() + + const result = await client.send( + makeBatch(1), + {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}, + {signal: controller.signal}, + ) + + expect(result.ok).to.equal(false) + if (result.ok) throw new Error('unreachable') + expect(result.reason).to.equal('network') + }) + + it('completes normally when an unaborted signal is passed', async () => { + nock(baseUrl).post('/v1/events').reply(200, {accepted: 1}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + const controller = new AbortController() // never abort() + + const result = await client.send( + makeBatch(1), + {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}, + {signal: controller.signal}, + ) + + expect(result.ok, 'unaborted signal must not block a healthy send').to.equal(true) + }) + }) }) diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts index 4fbe40fab..bc53b7319 100644 --- a/test/unit/server/infra/process/analytics-hook.test.ts +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -31,7 +31,14 @@ type StubBundle = { const buildAnalyticsClient = (): StubBundle => { const trackStub = sinon.stub() const flushStub = sinon.stub().resolves(AnalyticsBatch.create([])) - const client: IAnalyticsClient = {flush: flushStub, onAuthTransition: sinon.stub().resolves(), track: trackStub} + const client: IAnalyticsClient = { + abort() { + /* M4.4: not exercised in this test */ + }, + flush: flushStub, + onAuthTransition: sinon.stub().resolves(), + track: trackStub, + } return {client, flushStub, trackStub} } diff --git a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts index 21ca3cff9..b30555b0d 100644 --- a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts @@ -28,6 +28,9 @@ type MockAnalyticsClient = IAnalyticsClient & { function makeMockAnalyticsClient(): MockAnalyticsClient { const trackCalls: TrackCall[] = [] const mock: MockAnalyticsClient = { + abort() { + /* M4.4: not exercised in this test */ + }, flush: () => Promise.resolve(AnalyticsBatch.create([])), onAuthTransition: () => Promise.resolve(), track(event: E, ...rest: PropsArg): void { From 80c024390d912699dfad86451b7948bae4c51bec Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Fri, 22 May 2026 13:19:39 +0700 Subject: [PATCH 47/87] feat: [ENG-2647] M4.5 exponential backoff + endpoint reachability counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AnalyticsBackoffPolicy: an in-memory failure counter with a fixed 30s → 60s → 2m → 5m schedule, capped at 5m, that resets to 30s on the first success. Exposes `consecutiveFailures()` as the raw signal so M4.6 can map healthy / degraded / unreachable labels for `brv analytics status` without baking the labels into the policy itself. SendResult now carries an optional `reason` (timeout / network / http_5xx / http_4xx) so AnalyticsClient.runFlush can feed the policy by failure mode. HttpAnalyticsSender propagates the underlying httpResult.reason; the collaborator-throw catch path tags as `network` (transient). The existing missing-deviceId path stays unreasoned so the caller skips the policy entirely. Backoff decision table inside AnalyticsClient.feedBackoffPolicy: - aborted (M4.4 disable cancel) → skip - reason = http_4xx → skip + log "ignored" - reason undefined, succeeded.length=0 → skip (empty no-op or uncategorized failure, neither is a clean ship) - reason undefined, succeeded.length>0 → onSuccess() - timeout / network / http_5xx → onFailure() Each transition emits a structured log line so ops can trace why flush cadence changed without grepping for implicit timing shifts. AnalyticsFlushScheduler refactored from setInterval to a setTimeout chain that re-arms via `nextIntervalMs()` after every tick settles. The delay is read live at arm-time, so the backoff policy's latest state takes effect on the very next tick. The wire helper accepts the policy directly; tests can pass a literal `nextIntervalMs: () => N` to exercise dynamic intervals without standing up a real policy. Composition root shares the same policy instance between the client and the scheduler. 4xx handling matches the ticket's "log but don't back off" intent. The "drop the batch" part is bounded by M9.2's existing 3-attempt retry-cap (updateStatus(failed, 'failed') increments attempts, terminates at MAX_ATTEMPTS=3), so a 4xx record is dropped after at most 2 wasted retries. Implementing immediate-drop would expand M4.5 into M9.2's terminal-classification API; deferred. Tests: 8266 passing across the suite. 15 backoff-policy cases cover the schedule + reset semantics; AnalyticsClient gains 7 policy-feedback cases including the I1 regression (failed-without-reason does NOT call onSuccess). The integration test exercises the canonical 30→60→120→300 gap pattern end-to-end through the wire helper. --- .../analytics/i-analytics-backoff-policy.ts | 49 +++++ .../analytics/i-analytics-sender.ts | 36 +++- .../analytics/analytics-backoff-policy.ts | 38 ++++ .../infra/analytics/analytics-client.ts | 68 ++++++ .../analytics/analytics-flush-scheduler.ts | 92 ++++++-- .../infra/analytics/http-analytics-sender.ts | 14 +- src/server/infra/process/feature-handlers.ts | 8 + .../process/wire-analytics-flush-scheduler.ts | 33 ++- ...wire-analytics-auth-pre-transition.test.ts | 7 +- .../wire-analytics-flush-scheduler.test.ts | 73 ++++++- .../wire-analytics-http-sender.test.ts | 3 +- .../analytics-backoff-policy.test.ts | 130 ++++++++++++ .../infra/analytics/analytics-client.test.ts | 199 ++++++++++++++++++ .../analytics-flush-scheduler.test.ts | 102 +++++++-- .../analytics/http-analytics-sender.test.ts | 45 +++- 15 files changed, 826 insertions(+), 71 deletions(-) create mode 100644 src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts create mode 100644 src/server/infra/analytics/analytics-backoff-policy.ts create mode 100644 test/unit/server/infra/analytics/analytics-backoff-policy.test.ts diff --git a/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts b/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts new file mode 100644 index 000000000..75736b6ab --- /dev/null +++ b/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts @@ -0,0 +1,49 @@ +/** + * M4.5 failure-resilience policy for the analytics flush scheduler. + * + * Pure in-memory state — no persistence. A daemon restart starts from + * the base interval; passive failure tracking is the goal, not exact + * accounting across restarts. + * + * Backoff schedule: `30s → 60s → 2m → 5m`, cap at `5m`. First success + * resets to `30s`. The schedule lives inside the implementation; this + * interface only exposes the next effective delay and the state-mutation + * callbacks. + * + * The reachability state (healthy / degraded / unreachable) used by + * `brv analytics status` (M4.6) is DERIVED from `consecutiveFailures()` + * by the caller, not exposed here. Mapping (M4.6 owns the labels): + * - 0 failures → healthy + * - 1-2 failures → degraded + * - 3+ failures → unreachable + */ +export interface IAnalyticsBackoffPolicy { + /** + * Number of failures since the last `onSuccess()`. Unbounded — used + * by M4.6 to classify reachability beyond the backoff cap (a daemon + * that has been offline for hours should display "unreachable", not + * just "delay capped at 5m"). + */ + consecutiveFailures(): number + + /** + * Effective next-tick delay in milliseconds. Reading this method is + * pure: it does NOT advance the schedule. Callers should treat the + * value as live (read at arm-time) so a concurrent success-or-failure + * between two reads picks up correctly. + */ + nextDelayMs(): number + + /** + * Record a transient failure (HTTP 5xx, timeout, network). Advances + * the schedule one step, up to the cap. `http_4xx` is a payload-shape + * problem, not a transient signal — callers MUST NOT call this for 4xx. + */ + onFailure(): void + + /** + * Record a successful flush. Resets the schedule and the consecutive + * counter to zero immediately, regardless of prior peak. + */ + onSuccess(): void +} diff --git a/src/server/core/interfaces/analytics/i-analytics-sender.ts b/src/server/core/interfaces/analytics/i-analytics-sender.ts index c006e7be8..fbf6ffae8 100644 --- a/src/server/core/interfaces/analytics/i-analytics-sender.ts +++ b/src/server/core/interfaces/analytics/i-analytics-sender.ts @@ -1,5 +1,17 @@ import type {StoredAnalyticsRecord} from '../../../../shared/analytics/stored-record.js' +/** + * Classification of a failure mode, surfaced by `HttpAnalyticsSender` so + * `AnalyticsClient` can feed it into the M4.5 backoff policy. + * + * - `timeout` - request exceeded the 5s budget. Transient → back off. + * - `network` - connection refused / DNS / TLS / aborted. Transient → back off. + * - `http_5xx` - server error. Transient → back off. + * - `http_4xx` - backend rejected the payload shape. NOT transient — the + * caller MUST NOT advance backoff on 4xx; retrying won't help. + */ +export type SendFailureReason = 'http_4xx' | 'http_5xx' | 'network' | 'timeout' + /** * Per-send outcome. Each input record's `id` is mirrored back in exactly * one of `succeeded` / `failed`; M10.2's flush wiring will then translate @@ -10,9 +22,24 @@ import type {StoredAnalyticsRecord} from '../../../../shared/analytics/stored-re */ export type SendResult = Readonly<{ failed: string[] + /** + * Present only when `failed.length > 0`. Absent on success and on + * empty-batch no-op calls. Callers that don't care about backoff + * (no-op senders, tests) may continue to ignore this field. + */ + reason?: SendFailureReason succeeded: string[] }> +/** + * Per-send options. `signal` is the M4.4 cancellation hook so the + * AnalyticsClient can abort an in-flight send when `brv analytics + * disable` fires. + */ +export type AnalyticsSenderOptions = Readonly<{ + signal?: AbortSignal +}> + /** * Daemon-side sender contract. M10.2's `AnalyticsClient.flush` invokes * `send()` with a snapshot of pending JSONL rows; the sender's only @@ -26,15 +53,6 @@ export type SendResult = Readonly<{ * Test seam — used to assert the M10.2 "leave-JSONL-untouched" invariant * without going through the real transport. */ -/** - * Per-send options. `signal` is the M4.4 cancellation hook so the - * AnalyticsClient can abort an in-flight send when `brv analytics - * disable` fires. - */ -export type AnalyticsSenderOptions = Readonly<{ - signal?: AbortSignal -}> - export interface IAnalyticsSender { /** * Attempts to ship `records`. Returns the per-record outcome as id arrays. diff --git a/src/server/infra/analytics/analytics-backoff-policy.ts b/src/server/infra/analytics/analytics-backoff-policy.ts new file mode 100644 index 000000000..d14221eaa --- /dev/null +++ b/src/server/infra/analytics/analytics-backoff-policy.ts @@ -0,0 +1,38 @@ +import type {IAnalyticsBackoffPolicy} from '../../core/interfaces/analytics/i-analytics-backoff-policy.js' + +// Schedule fixed by the M4.5 ticket: 30s, 60s, 2m, 5m, capped at 5m. +// Index = consecutiveFailures clamped to [0, length - 1]. +const BACKOFF_STEPS_MS: readonly number[] = [30_000, 60_000, 120_000, 300_000] + +/** + * In-memory exponential-backoff policy. See `IAnalyticsBackoffPolicy` + * for the contract. + * + * Single private counter `failures` is the load-bearing state. The + * schedule lookup is a clamped index into `BACKOFF_STEPS_MS`, so the + * cap behavior falls out of the data shape rather than a separate + * conditional. + * + * Not thread-safe. The daemon runs in a single Node event loop; the + * scheduler's serialized tick chain is the only writer. + */ +export class AnalyticsBackoffPolicy implements IAnalyticsBackoffPolicy { + private failures = 0 + + public consecutiveFailures(): number { + return this.failures + } + + public nextDelayMs(): number { + const index = Math.min(this.failures, BACKOFF_STEPS_MS.length - 1) + return BACKOFF_STEPS_MS[index] + } + + public onFailure(): void { + this.failures += 1 + } + + public onSuccess(): void { + this.failures = 0 + } +} diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index 646b32071..291587b36 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -3,6 +3,7 @@ import {randomUUID} from 'node:crypto' import type {AnalyticsEventName} from '../../../shared/analytics/event-names.js' import type {PropsArg, PropsForEvent} from '../../../shared/analytics/events/index.js' import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' +import type {IAnalyticsBackoffPolicy} from '../../core/interfaces/analytics/i-analytics-backoff-policy.js' import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' import type {IAnalyticsSender, SendResult} from '../../core/interfaces/analytics/i-analytics-sender.js' @@ -14,6 +15,22 @@ import {toWireEvent} from '../../../shared/analytics/stored-record.js' import {AnalyticsBatch} from '../../core/domain/analytics/batch.js' export interface AnalyticsClientDeps { + /** + * M4.5: optional failure-resilience policy. When wired, `runFlush` + * feeds the `SendResult.reason` into the policy after every flush: + * - undefined reason (all-succeeded) → `onSuccess()` resets the backoff. + * - `timeout` / `network` / `http_5xx` → `onFailure()` advances the + * backoff one step (capped at 5m by the policy impl). + * - `http_4xx` → neither call. 4xx is a payload-shape error, not a + * backend health signal — retrying or backing off won't help. + * - Aborted (controller.signal.aborted) → neither call. User-driven + * cancellation (M4.4 disable) must not poison the M4.6 + * reachability counter. + * + * Optional so M2/M4.3 test fakes that don't care about backoff keep + * working with their pre-M4.5 construction shape. + */ + backoffPolicy?: IAnalyticsBackoffPolicy identityResolver: IIdentityResolver isEnabled: () => boolean jsonlStore: IJsonlAnalyticsStore @@ -201,6 +218,55 @@ export class AnalyticsClient implements IAnalyticsClient { }) } + /** + * Feed the `SendResult` into the optional M4.5 backoff policy. + * + * Decision table (skip = call neither onSuccess nor onFailure): + * - policy not wired → skip + * - aborted (M4.4 disable cancel) → skip (user action, not a backend signal) + * - reason = `http_4xx` → skip (payload-shape, not a health signal) + * - reason undefined AND succeeded.length === 0 → skip (empty no-op + * race, or HttpAnalyticsSender's `missing-deviceId` path that + * returns failed-without-reason; neither is a clean ship) + * - reason undefined AND succeeded.length > 0 → onSuccess() + * - reason = `timeout` / `network` / `http_5xx` → onFailure() + * + * Emits a structured log line on every real transition so ops can + * trace "why did flushes suddenly slow down" without grepping for + * implicit cadence changes. + */ + private feedBackoffPolicy(result: SendResult, aborted: boolean): void { + const policy = this.deps.backoffPolicy + if (policy === undefined) return + if (aborted) return + if (result.reason === 'http_4xx') { + // Tag 4xx in the log so ops sees the divergence (we do NOT advance + // backoff for permanent payload errors, only for transient ones). + this.deps.log?.( + `analytics.backoff: http_4xx ignored (consecutive_failures=${policy.consecutiveFailures()}, next=${policy.nextDelayMs()}ms)`, + ) + return + } + + if (result.reason === undefined) { + if (result.succeeded.length === 0) return // empty no-op or uncategorized failure: no signal + const beforeFailures = policy.consecutiveFailures() + policy.onSuccess() + if (beforeFailures > 0) { + this.deps.log?.( + `analytics.backoff: reset on success (was consecutive_failures=${beforeFailures}, next=${policy.nextDelayMs()}ms)`, + ) + } + + return + } + + policy.onFailure() + this.deps.log?.( + `analytics.backoff: advanced on ${result.reason} (consecutive_failures=${policy.consecutiveFailures()}, next=${policy.nextDelayMs()}ms)`, + ) + } + private async runFlush(): Promise { const records = await this.deps.jsonlStore.loadPending() @@ -234,6 +300,8 @@ export class AnalyticsClient implements IAnalyticsClient { await this.deps.jsonlStore.updateStatus(result.failed, 'failed') } + this.feedBackoffPolicy(result, controller.signal.aborted) + return AnalyticsBatch.create(records.map((r) => toWireEvent(r))) } diff --git a/src/server/infra/analytics/analytics-flush-scheduler.ts b/src/server/infra/analytics/analytics-flush-scheduler.ts index aa6bd2b5d..657545e36 100644 --- a/src/server/infra/analytics/analytics-flush-scheduler.ts +++ b/src/server/infra/analytics/analytics-flush-scheduler.ts @@ -8,14 +8,24 @@ export interface AnalyticsFlushSchedulerDeps { * crash the interval loop or shutdown sequence. */ flush: () => Promise - /** Polling interval for the time-based trigger. Defaults to 30s. */ - intervalMs?: number /** * Lazy analytics-enabled gate. Re-checked on every trigger so a runtime * `brv analytics disable` (M1.4) immediately suspends scheduled flushes * without restarting the daemon. */ isEnabled: () => boolean + /** + * M4.5: live next-tick delay in milliseconds. Read AFTER each tick + * settles, when the scheduler arms its `setTimeout` for the next + * tick — so the latest backoff state (advanced by the just-finished + * flush via `AnalyticsClient.runFlush`) takes effect immediately. + * + * Production wires this to `analyticsBackoffPolicy.nextDelayMs()`. + * Tests pass a literal (`() => 30_000`) or a closure over a mutable + * value to exercise dynamic intervals. Defaults to 30s so existing + * test fakes that omit the dep keep working. + */ + nextIntervalMs?: () => number /** * Count of records pending shipment (JSONL `status='pending'` rows). * Used by the interval timer and `flushFinal()` to skip flushes when @@ -53,12 +63,19 @@ export type FlushFinalOptions = { * Drives automatic flushes for the daemon-scoped analytics client. * * Two triggers (whichever fires first wins): - * - **Interval timer** (`intervalMs`, default 30s): every tick, if the - * queue is non-empty AND analytics is enabled, request a flush. + * - **Periodic tick** (`nextIntervalMs()`, default 30s): each tick + * re-arms via `setTimeout` AFTER the previous flush settles, reading + * the delay live at arm-time. In production this is wired to + * `AnalyticsBackoffPolicy.nextDelayMs()`, so a failing backend + * stretches the gap to 60s → 2m → 5m (M4.5); on first success the + * policy resets and the next tick is 30s again. * - **Threshold notification** (`thresholdCount`, default 20): callers - * invoke `notifyPushed()` after enqueuing a record; if the queue is - * at or above the threshold, a flush is scheduled via `setImmediate` - * so `track()` stays synchronous from the consumer's view. + * invoke `notifyPushed()` after enqueuing a record; if the queue + * has grown by `thresholdCount` since the last threshold fire, a + * flush is scheduled via `setImmediate` so `track()` stays + * synchronous from the consumer's view. The threshold path is + * intentionally NOT throttled by backoff — single-flight rate-limits + * it, and gating the 20-event burst would defeat its purpose. * * Single-flight: while a flush is in flight, any new trigger is dropped * (NOT queued). The in-flight promise is exposed via `flushFinal()` so @@ -72,13 +89,19 @@ export type FlushFinalOptions = { * `stop()` during shutdown (before `flushFinal()` so no new ticks fire * mid-shutdown). * - * Errors from `flush()` are swallowed at this layer. M4.5's backoff - * policy will react to the structured failure reason later; for M4.3 - * the scheduler just needs to keep ticking. + * Errors from `flush()` are swallowed at this layer. The M4.5 backoff + * policy reacts to the structured failure reason via + * `AnalyticsClient.runFlush`; the scheduler itself only needs the live + * `nextDelayMs()` value at each re-arm and otherwise keeps ticking. */ export class AnalyticsFlushScheduler { private readonly deps: Required - private intervalHandle: ReturnType | undefined + // M4.5: handle of the most-recently armed `setTimeout` for the + // periodic tick. Each tick re-arms itself from `nextIntervalMs()` + // after the flush settles, so the backoff policy's latest state + // takes effect on the very next tick. `start()` is idempotent via + // this slot (a second start while running is a no-op). + private intervalHandle: ReturnType | undefined // Snapshot of `queueSize` at the last threshold fire. Together with // `thresholdCount` this gates `notifyPushed` on the DELTA since last // fire (queue depths 20/40/60/...) instead of the absolute size — the @@ -89,12 +112,17 @@ export class AnalyticsFlushScheduler { // Single-flight slot. Any trigger that arrives while this is set is // dropped; `flushFinal()` awaits it so shutdown joins rather than races. private pendingFlush: Promise | undefined + // M4.5: set true on `stop()` so a settling flush's `.finally` does + // NOT re-arm the next tick. Without this, calling `stop()` while a + // tick was in flight would still queue one more tick after the + // current one settled. + private stopped = false public constructor(deps: AnalyticsFlushSchedulerDeps) { this.deps = { flush: deps.flush, - intervalMs: deps.intervalMs ?? DEFAULT_INTERVAL_MS, isEnabled: deps.isEnabled, + nextIntervalMs: deps.nextIntervalMs ?? (() => DEFAULT_INTERVAL_MS), pendingCount: deps.pendingCount, queueSize: deps.queueSize, thresholdCount: deps.thresholdCount ?? DEFAULT_THRESHOLD_COUNT, @@ -169,29 +197,47 @@ export class AnalyticsFlushScheduler { } /** - * Start the recurring interval timer. Idempotent: a second call while - * already running is a no-op (does NOT install a second timer). + * Start the recurring tick. Idempotent: a second call while already + * running is a no-op (the slot is occupied). M4.5: implemented as a + * `setTimeout` chain so each tick reads `nextIntervalMs()` at arm-time; + * the backoff policy's latest state takes effect on the very next tick. */ public start(): void { if (this.intervalHandle !== undefined) return - this.intervalHandle = setInterval(() => { - // Interval ticks are fire-and-forget; tryFlush handles its own - // errors and the void prefix opts out of unhandled-rejection noise. - // eslint-disable-next-line no-void - void this.tryFlush() - }, this.deps.intervalMs) + this.stopped = false + this.armNextTick() } /** - * Stop the recurring timer. Idempotent. Does NOT cancel an in-flight - * flush — call `flushFinal()` for that. + * Stop the recurring tick. Idempotent. Does NOT cancel an in-flight + * flush — call `flushFinal()` for that. The `stopped` flag prevents + * a settling flush's `.finally` from arming one extra tick after stop. */ public stop(): void { + this.stopped = true if (this.intervalHandle === undefined) return - clearInterval(this.intervalHandle) + clearTimeout(this.intervalHandle) this.intervalHandle = undefined } + /** + * Arm the next periodic tick at `nextIntervalMs()` from now. Called + * by `start()` initially and by each tick's `.finally` after the + * flush settles. `stopped` guard short-circuits when the daemon is + * winding down so we don't keep firing post-stop(). + */ + private armNextTick(): void { + if (this.stopped) { + this.intervalHandle = undefined + return + } + + this.intervalHandle = setTimeout(() => { + // eslint-disable-next-line no-void + void this.tryFlush().finally(() => this.armNextTick()) + }, this.deps.nextIntervalMs()) + } + /** * Race the given flush promise against a timeout. Used by `flushFinal` * to enforce the shutdown budget without blocking on a slow backend. diff --git a/src/server/infra/analytics/http-analytics-sender.ts b/src/server/infra/analytics/http-analytics-sender.ts index 3045560a0..50509636d 100644 --- a/src/server/infra/analytics/http-analytics-sender.ts +++ b/src/server/infra/analytics/http-analytics-sender.ts @@ -85,12 +85,20 @@ export class HttpAnalyticsSender implements IAnalyticsSender { ) if (httpResult.ok) return {failed: [], succeeded: [...ids]} - return {failed: [...ids], succeeded: []} + // M4.5: surface the http-level failure reason so AnalyticsClient + // can feed it into the backoff policy. `http_4xx` is intentionally + // forwarded as-is so the caller can suppress backoff advancement + // (4xx is a payload-shape problem, not a transient signal). + return {failed: [...ids], reason: httpResult.reason, succeeded: []} } catch { // Defensive: any collaborator surprise (config read throws, // toWireEvent edge case, etc.) maps to a batch-level failure. - // The retry-cap policy owns terminal classification. - return {failed: [...ids], succeeded: []} + // The retry-cap policy owns terminal classification. Tagged as + // `network` for M4.5 — internal collaborator failures are treated + // as transient (try again later), not as permanent payload-shape + // errors. M4.5's `AnalyticsClient` advances the backoff policy + // when it sees this reason. + return {failed: [...ids], reason: 'network', succeeded: []} } } } diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index f2bd5df1b..615c25239 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -26,6 +26,7 @@ import {API_V1_PATH, BRV_DIR} from '../../constants.js' import {getGlobalDataDir} from '../../utils/global-data-path.js' import {getProjectDataDir} from '../../utils/path-utils.js' import {readCliVersion} from '../../utils/read-cli-version.js' +import {AnalyticsBackoffPolicy} from '../analytics/analytics-backoff-policy.js' import {AnalyticsClient} from '../analytics/analytics-client.js' import {BoundedQueue} from '../analytics/bounded-queue.js' import {IdentityResolver} from '../analytics/identity-resolver.js' @@ -201,12 +202,18 @@ export async function setupFeatureHandlers({ // time the first track lands. Queue is hoisted to a shared instance so // both client (push) and scheduler (queueSize) observe the same state. const analyticsQueue = new BoundedQueue() + // M4.5: backoff policy shared between the client (mutates via + // onSuccess/onFailure inside runFlush) and the scheduler (reads + // nextDelayMs at each arm). Pure in-memory state, no persistence — + // a daemon restart starts from the base 30s interval. + const analyticsBackoffPolicy = new AnalyticsBackoffPolicy() // Holder for the scheduler reference shared with `onAfterTrack`. Using // a plain object instead of `let` so the lint rule sees a const binding // (the closure reads `.value` on every call). The scheduler instance is // assigned immediately after AnalyticsClient construction below. const schedulerHolder: {value: AnalyticsFlushScheduler | undefined} = {value: undefined} const analyticsClient: IAnalyticsClient = new AnalyticsClient({ + backoffPolicy: analyticsBackoffPolicy, identityResolver: new IdentityResolver(authStateStore, globalConfigStore), isEnabled: () => globalConfigHandler.getCachedAnalytics(), jsonlStore: jsonlAnalyticsStore, @@ -220,6 +227,7 @@ export async function setupFeatureHandlers({ const analyticsFlushScheduler = wireAnalyticsFlushScheduler({ analyticsClient, + backoffPolicy: analyticsBackoffPolicy, isEnabled: () => globalConfigHandler.getCachedAnalytics(), jsonlStore: jsonlAnalyticsStore, queue: analyticsQueue, diff --git a/src/server/infra/process/wire-analytics-flush-scheduler.ts b/src/server/infra/process/wire-analytics-flush-scheduler.ts index e848da188..faad29425 100644 --- a/src/server/infra/process/wire-analytics-flush-scheduler.ts +++ b/src/server/infra/process/wire-analytics-flush-scheduler.ts @@ -1,3 +1,4 @@ +import type {IAnalyticsBackoffPolicy} from '../../core/interfaces/analytics/i-analytics-backoff-policy.js' import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' import type {IAnalyticsQueue} from '../../core/interfaces/analytics/i-analytics-queue.js' import type {IJsonlAnalyticsStore} from '../../core/interfaces/analytics/i-jsonl-analytics-store.js' @@ -6,8 +7,17 @@ import {AnalyticsFlushScheduler} from '../analytics/analytics-flush-scheduler.js export type AnalyticsFlushSchedulerWiring = { analyticsClient: IAnalyticsClient - /** Override the 30s interval (default) for tests / dev experiments. */ - intervalMs?: number + /** + * M4.5: optional backoff policy. When wired, the scheduler arms its + * next tick from `policy.nextDelayMs()` so a failing backend stretches + * the inter-tick gap to 60s → 2m → 5m (capped). The `AnalyticsClient` + * already feeds this same policy from inside `runFlush`, so the + * scheduler just reads the live value. + * + * Omitted in tests / dev experiments → scheduler keeps its fixed + * 30s default. + */ + backoffPolicy?: IAnalyticsBackoffPolicy isEnabled: () => boolean /** * JSONL store used to count pending rows for the empty-skip gate. The @@ -17,6 +27,15 @@ export type AnalyticsFlushSchedulerWiring = { * nothing left to ship. */ jsonlStore: IJsonlAnalyticsStore + /** + * Direct override for the per-tick delay. Tests pass a closure to + * exercise dynamic intervals without standing up a real policy. + * Production code should pass `backoffPolicy` instead. + * + * If both `backoffPolicy` and `nextIntervalMs` are wired, + * `backoffPolicy` wins. + */ + nextIntervalMs?: () => number queue: IAnalyticsQueue /** Override the 20-event threshold (default) for tests / dev experiments. */ thresholdCount?: number @@ -43,10 +62,18 @@ export type AnalyticsFlushSchedulerWiring = { export function wireAnalyticsFlushScheduler( wiring: AnalyticsFlushSchedulerWiring, ): AnalyticsFlushScheduler { + // M4.5 precedence: a real backoffPolicy wins over a literal + // nextIntervalMs override. This keeps test ergonomics simple + // (pass `nextIntervalMs: () => 50` for fast tests) while production + // wiring (`backoffPolicy` only) reads the policy at arm-time. + // Capture `policy` in a const so the arrow closure keeps the narrowed + // type (avoiding the `!` non-null assertion that CLAUDE.md discourages). + const policy = wiring.backoffPolicy + const nextIntervalMs = policy === undefined ? wiring.nextIntervalMs : (): number => policy.nextDelayMs() return new AnalyticsFlushScheduler({ flush: () => wiring.analyticsClient.flush(), - ...(wiring.intervalMs === undefined ? {} : {intervalMs: wiring.intervalMs}), isEnabled: wiring.isEnabled, + ...(nextIntervalMs === undefined ? {} : {nextIntervalMs}), pendingCount: async () => (await wiring.jsonlStore.loadPending()).length, queueSize: () => wiring.queue.size(), ...(wiring.thresholdCount === undefined ? {} : {thresholdCount: wiring.thresholdCount}), diff --git a/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts b/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts index c7a0c92c1..9e49c60bc 100644 --- a/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts +++ b/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts @@ -45,13 +45,16 @@ function makeToken(overrides: Partial<{accessToken: string; userId: string}> = { } function makeFakeAuthStateStore(initial?: AuthToken): IAuthStateStore & { - fire(oldToken: AuthToken | undefined, newToken: AuthToken | undefined): Promise + fire(oldToken?: AuthToken, newToken?: AuthToken): Promise readonly preCallbacks: BeforeAuthChangedCallback[] } { const preCallbacks: BeforeAuthChangedCallback[] = [] const cached: AuthToken | undefined = initial return { - async fire(oldToken: AuthToken | undefined, newToken: AuthToken | undefined): Promise { + // Optional params let callers omit either slot for anon-side + // transitions without triggering `unicorn/no-useless-undefined` + // autofix to strip the literal `undefined` we'd otherwise pass. + async fire(oldToken?: AuthToken, newToken?: AuthToken): Promise { // Serial execution mirrors AuthStateStore.fireBeforeAuthChange. for (const cb of preCallbacks) { // eslint-disable-next-line no-await-in-loop diff --git a/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts b/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts index c42ef4321..260a109e3 100644 --- a/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts +++ b/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts @@ -8,6 +8,7 @@ import type {IJsonlAnalyticsStore} from '../../../../../src/server/core/interfac import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsBackoffPolicy} from '../../../../../src/server/infra/analytics/analytics-backoff-policy.js' import {wireAnalyticsFlushScheduler} from '../../../../../src/server/infra/process/wire-analytics-flush-scheduler.js' /** @@ -107,9 +108,9 @@ describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { const client = makeFakeClient() const scheduler = wireAnalyticsFlushScheduler({ analyticsClient: client, - intervalMs: 100, isEnabled: () => true, jsonlStore: makeJsonlStoreStub(5), + nextIntervalMs: () => 100, queue: makeQueueStub(5), }) @@ -124,9 +125,9 @@ describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { const client = makeFakeClient() const scheduler = wireAnalyticsFlushScheduler({ analyticsClient: client, - intervalMs: 100, isEnabled: () => false, jsonlStore: makeJsonlStoreStub(5), + nextIntervalMs: () => 100, queue: makeQueueStub(5), }) @@ -146,9 +147,9 @@ describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { const client = makeFakeClient() const scheduler = wireAnalyticsFlushScheduler({ analyticsClient: client, - intervalMs: 100, isEnabled: () => true, jsonlStore: makeJsonlStoreStub(0), // nothing left to ship + nextIntervalMs: () => 100, queue: makeQueueStub(50), // mirror still reflects past pushes }) @@ -163,9 +164,9 @@ describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { const client = makeFakeClient() const scheduler = wireAnalyticsFlushScheduler({ analyticsClient: client, - intervalMs: 100, isEnabled: () => true, jsonlStore: makeJsonlStoreStub(0), + nextIntervalMs: () => 100, queue: makeQueueStub(0), }) @@ -219,9 +220,9 @@ describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { const scheduler = wireAnalyticsFlushScheduler({ analyticsClient: flushSpy, - intervalMs: 100, isEnabled: () => true, jsonlStore: makeJsonlStoreStub(5), + nextIntervalMs: () => 100, queue: makeQueueStub(5), }) scheduler.start() @@ -253,9 +254,9 @@ describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { } const scheduler = wireAnalyticsFlushScheduler({ analyticsClient: slowClient, - intervalMs: 100, isEnabled: () => true, jsonlStore: makeJsonlStoreStub(5), + nextIntervalMs: () => 100, queue: makeQueueStub(5), }) @@ -296,4 +297,64 @@ describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { await clock.tickAsync(1) expect(client.flushCalls, 'below default threshold of 20 → no flush').to.equal(0) }) + + describe('M4.5 backoff policy integration', () => { + it('reads tick delay from the wired backoffPolicy at each arm (30 → 60 → 120 → 300)', async () => { + // End-to-end timing: a real AnalyticsBackoffPolicy plus a flush + // that always reports failure (via the M4.5 reason injection) + // should produce the canonical 30s → 60s → 2m → 5m gap pattern. + // We can't easily wire a real `AnalyticsClient.runFlush` from + // this seam (the scheduler test owns the client), so we model + // production by hand-advancing the policy inside the flush body + // — same call sequence runFlush makes on a transient failure. + const policy = new AnalyticsBackoffPolicy() + const client: IAnalyticsClient = { + abort() { + /* no-op */ + }, + async flush() { + policy.onFailure() + return AnalyticsBatch.create([]) + }, + async onAuthTransition() {}, + track() { + /* no-op */ + }, + } + const scheduler = wireAnalyticsFlushScheduler({ + analyticsClient: client, + backoffPolicy: policy, + isEnabled: () => true, + jsonlStore: makeJsonlStoreStub(5), + queue: makeQueueStub(5), + }) + + // T0: tick 1 must fire at +30s (policy starts at 30s base interval). + scheduler.start() + await clock.tickAsync(30_000) + expect(policy.consecutiveFailures(), 'after tick 1 → 1 failure').to.equal(1) + + // Tick 2 must fire at +60s from tick 1 (policy.nextDelayMs == 60_000 now). + await clock.tickAsync(59_999) + expect(policy.consecutiveFailures(), 'still 1 — 60s arm not elapsed').to.equal(1) + await clock.tickAsync(1) + expect(policy.consecutiveFailures(), 'after tick 2 → 2 failures').to.equal(2) + + // Tick 3 at +120s from tick 2. + await clock.tickAsync(120_000) + expect(policy.consecutiveFailures(), 'after tick 3 → 3 failures').to.equal(3) + + // Tick 4 at +300s from tick 3 (cap reached). + await clock.tickAsync(299_999) + expect(policy.consecutiveFailures(), 'still 3 — 5m cap not elapsed').to.equal(3) + await clock.tickAsync(1) + expect(policy.consecutiveFailures(), 'after tick 4 → 4 failures, schedule still at cap').to.equal(4) + + // One more tick at +300s confirms the cap holds. + await clock.tickAsync(300_000) + expect(policy.consecutiveFailures(), 'tick 5 at the cap').to.equal(5) + + scheduler.stop() + }) + }) }) diff --git a/test/integration/server/infra/process/wire-analytics-http-sender.test.ts b/test/integration/server/infra/process/wire-analytics-http-sender.test.ts index 888b02076..f0a69910b 100644 --- a/test/integration/server/infra/process/wire-analytics-http-sender.test.ts +++ b/test/integration/server/infra/process/wire-analytics-http-sender.test.ts @@ -136,7 +136,8 @@ describe('M4.2 wireAnalyticsHttpSender (integration)', () => { const result = await sender.send([makeRecord({id: 'a'}), makeRecord({id: 'b'})]) - expect(result).to.deep.equal({failed: ['a', 'b'], succeeded: []}) + // M4.5: 5xx now propagates `reason` so the M4.5 backoff can advance. + expect(result).to.deep.equal({failed: ['a', 'b'], reason: 'http_5xx', succeeded: []}) }) it('returns empty result without HTTP traffic for an empty batch', async () => { diff --git a/test/unit/server/infra/analytics/analytics-backoff-policy.test.ts b/test/unit/server/infra/analytics/analytics-backoff-policy.test.ts new file mode 100644 index 000000000..bf124cd91 --- /dev/null +++ b/test/unit/server/infra/analytics/analytics-backoff-policy.test.ts @@ -0,0 +1,130 @@ +import {expect} from 'chai' + +import {AnalyticsBackoffPolicy} from '../../../../../src/server/infra/analytics/analytics-backoff-policy.js' + +/** + * M4.5 backoff policy: 30s → 60s → 2m → 5m, cap at 5m. First success + * resets to 30s. Reachability state (healthy / degraded / unreachable) + * is derived from `consecutiveFailures()` by M4.6, not exposed here. + */ +describe('AnalyticsBackoffPolicy (M4.5)', () => { + describe('initial state', () => { + it('starts at 30s with zero consecutive failures', () => { + const policy = new AnalyticsBackoffPolicy() + expect(policy.nextDelayMs(), 'base interval is 30s').to.equal(30_000) + expect(policy.consecutiveFailures()).to.equal(0) + }) + + it('repeated nextDelayMs() calls do NOT advance the policy (read-only)', () => { + const policy = new AnalyticsBackoffPolicy() + expect(policy.nextDelayMs()).to.equal(30_000) + expect(policy.nextDelayMs()).to.equal(30_000) + expect(policy.consecutiveFailures(), 'reading state must not mutate').to.equal(0) + }) + }) + + describe('exponential backoff schedule', () => { + it('after 1 failure: 60s', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() + expect(policy.nextDelayMs()).to.equal(60_000) + expect(policy.consecutiveFailures()).to.equal(1) + }) + + it('after 2 failures: 2 minutes (120s)', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() + policy.onFailure() + expect(policy.nextDelayMs()).to.equal(120_000) + expect(policy.consecutiveFailures()).to.equal(2) + }) + + it('after 3 failures: 5 minutes (300s)', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() + policy.onFailure() + policy.onFailure() + expect(policy.nextDelayMs()).to.equal(300_000) + expect(policy.consecutiveFailures()).to.equal(3) + }) + + it('after 4 failures: still 5 minutes (capped)', () => { + const policy = new AnalyticsBackoffPolicy() + for (let i = 0; i < 4; i++) policy.onFailure() + expect(policy.nextDelayMs(), 'cap holds at 5m').to.equal(300_000) + expect(policy.consecutiveFailures()).to.equal(4) + }) + + it('after many failures: still capped at 5 minutes, counter keeps growing', () => { + const policy = new AnalyticsBackoffPolicy() + for (let i = 0; i < 50; i++) policy.onFailure() + expect(policy.nextDelayMs()).to.equal(300_000) + expect(policy.consecutiveFailures(), 'counter is unbounded for reachability classification').to.equal(50) + }) + }) + + describe('reset on success', () => { + it('onSuccess() from clean state stays at 30s with zero failures', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onSuccess() + expect(policy.nextDelayMs()).to.equal(30_000) + expect(policy.consecutiveFailures()).to.equal(0) + }) + + it('onSuccess() after 1 failure resets to 30s with zero failures', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() + policy.onSuccess() + expect(policy.nextDelayMs()).to.equal(30_000) + expect(policy.consecutiveFailures()).to.equal(0) + }) + + it('onSuccess() after the cap resets to 30s with zero failures', () => { + const policy = new AnalyticsBackoffPolicy() + for (let i = 0; i < 10; i++) policy.onFailure() + expect(policy.nextDelayMs()).to.equal(300_000) + policy.onSuccess() + expect(policy.nextDelayMs(), 'cap-then-success must drop straight to 30s').to.equal(30_000) + expect(policy.consecutiveFailures()).to.equal(0) + }) + + it('failure-success-failure pattern advances from the base, not the prior peak', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() + policy.onFailure() + expect(policy.nextDelayMs()).to.equal(120_000) + policy.onSuccess() + policy.onFailure() + expect(policy.nextDelayMs(), 'after success we start the schedule fresh').to.equal(60_000) + }) + }) + + describe('reachability counter (M4.6 will derive labels from this)', () => { + it('counter starts at 0 → healthy zone', () => { + const policy = new AnalyticsBackoffPolicy() + expect(policy.consecutiveFailures()).to.equal(0) + }) + + it('counter at 1-2 → degraded zone (M4.6 mapping)', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() + expect(policy.consecutiveFailures(), '1 failure').to.equal(1) + policy.onFailure() + expect(policy.consecutiveFailures(), '2 failures').to.equal(2) + }) + + it('counter at 3+ → unreachable zone (M4.6 mapping)', () => { + const policy = new AnalyticsBackoffPolicy() + for (let i = 0; i < 3; i++) policy.onFailure() + expect(policy.consecutiveFailures()).to.equal(3) + }) + + it('onSuccess() returns counter to 0 (unreachable → healthy)', () => { + const policy = new AnalyticsBackoffPolicy() + for (let i = 0; i < 5; i++) policy.onFailure() + expect(policy.consecutiveFailures()).to.equal(5) + policy.onSuccess() + expect(policy.consecutiveFailures(), 'first success collapses any unreachable count').to.equal(0) + }) + }) +}) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index 60333a32c..11dbf22cc 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -162,6 +162,22 @@ async function seedPending(client: AnalyticsClient, count: number): Promise r.id)} + } + + return {failed: records.map((r) => r.id), reason, succeeded: []} + }, + } +} + describe('AnalyticsClient', () => { describe('disabled state (M4.4 semantic: track local-only)', () => { // Pre-M4.4 this test asserted "no-op when disabled" (no JSONL append, @@ -1303,4 +1319,187 @@ describe('AnalyticsClient', () => { ).to.be.true }) }) + + describe('M4.5 backoff policy feedback', () => { + type StubPolicy = { + consecutiveFailures: () => number + nextDelayMs: () => number + onFailure: ReturnType + onSuccess: ReturnType + } + + function makePolicyStub(): StubPolicy { + return { + consecutiveFailures: () => 0, + nextDelayMs: () => 30_000, + onFailure: stub(), + onSuccess: stub(), + } + } + + it('calls policy.onSuccess() when the batch fully succeeds', async () => { + const policy = makePolicyStub() + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 2) + await client.flush() + + expect(policy.onSuccess.calledOnce, 'success advances onSuccess once').to.be.true + expect(policy.onFailure.called).to.be.false + }) + + it('calls policy.onFailure() on http_5xx (transient → back off)', async () => { + const policy = makePolicyStub() + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason('http_5xx'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + expect(policy.onFailure.calledOnce).to.be.true + expect(policy.onSuccess.called).to.be.false + }) + + it('calls policy.onFailure() on timeout', async () => { + const policy = makePolicyStub() + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason('timeout'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + expect(policy.onFailure.calledOnce).to.be.true + }) + + it('calls policy.onFailure() on network failure', async () => { + const policy = makePolicyStub() + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason('network'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + expect(policy.onFailure.calledOnce).to.be.true + }) + + it('does NOT advance the policy on http_4xx (payload shape is wrong, not a backend health signal)', async () => { + const policy = makePolicyStub() + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason('http_4xx'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + expect(policy.onFailure.called, '4xx must NOT advance backoff').to.be.false + expect(policy.onSuccess.called, '4xx is not a success either').to.be.false + }) + + it('does NOT touch the policy when abort() fired during the flush (user-driven cancel, not a backend signal)', async () => { + const policy = makePolicyStub() + let releaseSend!: () => void + const sender: IAnalyticsSender = { + async send(records, _options) { + await new Promise((resolve) => { + releaseSend = resolve + }) + // Mimic the abort-classification path: all-failed with network. + return {failed: records.map((r) => r.id), reason: 'network', succeeded: []} + }, + } + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + const flushPromise = client.flush() + await flushMicrotasks() + client.abort() + releaseSend() + await flushPromise + + expect(policy.onFailure.called, 'abort-driven failure must NOT poison the M4.6 reachability counter').to.be.false + expect(policy.onSuccess.called).to.be.false + }) + + it('works without a backoff policy wired (back-compat: dep is optional)', async () => { + // No backoffPolicy in deps — sender returns http_5xx — must not crash. + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason('http_5xx'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + // No assertion needed beyond "did not throw". + }) + + it('does NOT call onSuccess() on failed-without-reason (missing-deviceId / uncategorized failure)', async () => { + // Regression for review finding I1: prior code treated `reason === undefined` + // as success and called `onSuccess()`. The missing-deviceId path in + // `HttpAnalyticsSender` returns `{failed: ids, succeeded: [], reason: undefined}` + // — a "we never tried" outcome, NOT a clean ship. Resetting backoff + // here would wrongly clear the unreachable counter on a first-boot + // config bug. Should skip entirely. + const policy = makePolicyStub() + const sender: IAnalyticsSender = { + async send(records) { + // Mimic the missing-deviceId path: failed-with-no-reason. + return {failed: records.map((r) => r.id), succeeded: []} + }, + } + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender, + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + expect(policy.onSuccess.called, 'failed-without-reason must NOT call onSuccess').to.be.false + expect(policy.onFailure.called, 'failed-without-reason must NOT call onFailure either').to.be.false + }) + }) }) diff --git a/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts b/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts index 791ff2ef3..01ea01b3f 100644 --- a/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts +++ b/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts @@ -66,7 +66,7 @@ describe('AnalyticsFlushScheduler', () => { it('does NOT flush before the interval elapses', async () => { const deps = buildDeps({size: 5}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) scheduler.start() await clock.tickAsync(29_000) @@ -77,7 +77,7 @@ describe('AnalyticsFlushScheduler', () => { it('flushes once when the interval elapses with a non-empty queue', async () => { const deps = buildDeps({size: 5}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) scheduler.start() await clock.tickAsync(30_000) @@ -88,7 +88,7 @@ describe('AnalyticsFlushScheduler', () => { it('does NOT flush at the interval when the queue is empty', async () => { const deps = buildDeps({size: 0}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) scheduler.start() await clock.tickAsync(60_000) @@ -106,7 +106,7 @@ describe('AnalyticsFlushScheduler', () => { // every 30s forever for an empty backlog. const deps = buildDeps({size: 0}) // pendingCount + queueSize default sync deps.queueSize.returns(50) // mirror still reflects past pushes - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) scheduler.start() await clock.tickAsync(90_000) // three intervals @@ -117,7 +117,7 @@ describe('AnalyticsFlushScheduler', () => { it('skips the tick when analytics is disabled', async () => { const deps = buildDeps({enabled: false, size: 5}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) scheduler.start() await clock.tickAsync(60_000) @@ -128,7 +128,7 @@ describe('AnalyticsFlushScheduler', () => { it('fires every interval, not just once (recurring timer)', async () => { const deps = buildDeps({size: 5}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) scheduler.start() await clock.tickAsync(30_000) @@ -141,7 +141,7 @@ describe('AnalyticsFlushScheduler', () => { it('stop() halts further ticks', async () => { const deps = buildDeps({size: 5}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) scheduler.start() await clock.tickAsync(30_000) scheduler.stop() @@ -153,7 +153,7 @@ describe('AnalyticsFlushScheduler', () => { it('start() is idempotent (double-start does NOT install two timers)', async () => { const deps = buildDeps({size: 5}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) scheduler.start() scheduler.start() @@ -162,6 +162,74 @@ describe('AnalyticsFlushScheduler', () => { expect(deps.flush.callCount).to.equal(1) scheduler.stop() }) + + it('M4.5: re-reads nextIntervalMs() on every re-arm (dynamic backoff takes effect on next tick)', async () => { + // The whole point of converting setInterval to a setTimeout chain + // in M4.5 is that the next-tick delay can change AFTER each tick + // settles. A backoff policy that advances 30 → 60 → 120 between + // ticks must produce exactly that gap pattern at the scheduler. + // + // The mutation MUST happen inside the flush (before `.finally` + // re-arms), matching production: `AnalyticsClient.runFlush` + // updates the policy after `sender.send` returns, then resolves — + // and only then does the scheduler's `.finally` read the new value. + let currentInterval = 30_000 + const policyAdvanceQueue: Array<() => void> = [ + () => { + currentInterval = 60_000 + }, + () => { + currentInterval = 120_000 + }, + ] + const flushImpl = async (): Promise => { + const next = policyAdvanceQueue.shift() + if (next) next() + } + + const deps = buildDeps({flushImpl, size: 5}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => currentInterval}) + scheduler.start() + + // Tick 1 fires at +30s; flush body sets currentInterval=60_000 + // BEFORE the .finally re-arms. The next setTimeout is therefore + // armed at +60s from tick 1. + await clock.tickAsync(30_000) + expect(deps.flush.callCount, 'tick 1 at 30s').to.equal(1) + + // 30s after tick 1 is NOT enough — the next arm is 60s. + await clock.tickAsync(30_000) + expect(deps.flush.callCount, 'still 1 at +60s (next arm is 60s)').to.equal(1) + + // Reach the 60s mark from tick 1's settle: tick 2 fires; flush + // body sets currentInterval=120_000 before the re-arm. + await clock.tickAsync(30_000) + expect(deps.flush.callCount, 'tick 2 fires once 60s elapsed since tick 1').to.equal(2) + + // 60s after tick 2 is NOT enough for the 120s arm. + await clock.tickAsync(60_000) + expect(deps.flush.callCount, 'still 2 — 120s arm has not elapsed').to.equal(2) + + // Reach the 120s mark from tick 2. + await clock.tickAsync(60_000) + expect(deps.flush.callCount, 'tick 3 fires once 120s elapsed since tick 2').to.equal(3) + + scheduler.stop() + }) + + it('M4.5: defaults to 30s when nextIntervalMs is not provided (back-compat)', async () => { + // Existing test fakes that omit the dep continue to work — and + // the default is the same 30s constant M4.3 shipped with. + const deps = buildDeps({size: 5}) + const scheduler = new AnalyticsFlushScheduler(deps) + scheduler.start() + + await clock.tickAsync(29_999) + expect(deps.flush.called, 'must not fire before 30s').to.equal(false) + await clock.tickAsync(1) + expect(deps.flush.calledOnce, 'fires at exactly 30s').to.equal(true) + scheduler.stop() + }) }) describe('threshold trigger via notifyPushed()', () => { @@ -262,7 +330,7 @@ describe('AnalyticsFlushScheduler', () => { const deps = buildDeps({flushImpl: slowFlush, size: 25}) const scheduler = new AnalyticsFlushScheduler({ ...deps, - intervalMs: 30_000, + nextIntervalMs: () => 30_000, thresholdCount: 20, }) scheduler.start() @@ -303,7 +371,7 @@ describe('AnalyticsFlushScheduler', () => { releaseFlush = resolve }) const deps = buildDeps({flushImpl: slowFlush, size: 5}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) scheduler.start() await clock.tickAsync(30_000) @@ -331,7 +399,7 @@ describe('AnalyticsFlushScheduler', () => { it('returns the flush result when flush completes within the timeout', async () => { const deps = buildDeps({async flushImpl() {}, size: 5}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) const promise = scheduler.flushFinal({timeoutMs: 3000}) await clock.tickAsync(1) @@ -342,7 +410,7 @@ describe('AnalyticsFlushScheduler', () => { it('resolves after the timeout when flush takes too long (best-effort guarantee)', async () => { const deps = buildDeps({flushImpl: neverResolvingFlush, size: 5}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) const promise = scheduler.flushFinal({timeoutMs: 3000}) await clock.tickAsync(3000) @@ -353,7 +421,7 @@ describe('AnalyticsFlushScheduler', () => { it('skips flush entirely when the queue is empty', async () => { const deps = buildDeps({size: 0}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) await scheduler.flushFinal({timeoutMs: 3000}) @@ -362,7 +430,7 @@ describe('AnalyticsFlushScheduler', () => { it('skips flush when analytics is disabled', async () => { const deps = buildDeps({enabled: false, size: 100}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) await scheduler.flushFinal({timeoutMs: 3000}) @@ -376,7 +444,7 @@ describe('AnalyticsFlushScheduler', () => { releaseFlush = resolve }) const deps = buildDeps({flushImpl: slowFlush, size: 5}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) scheduler.start() await clock.tickAsync(30_000) @@ -431,7 +499,7 @@ describe('AnalyticsFlushScheduler', () => { }) const scheduler = new AnalyticsFlushScheduler({ ...deps, - intervalMs: 30_000, + nextIntervalMs: () => 30_000, thresholdCount: 20, }) @@ -457,7 +525,7 @@ describe('AnalyticsFlushScheduler', () => { it('does NOT throw when the underlying flush rejects (analytics MUST NOT crash shutdown)', async () => { const deps = buildDeps({async flushImpl() { throw new Error('network boom'); }, size: 5}) - const scheduler = new AnalyticsFlushScheduler({...deps, intervalMs: 30_000}) + const scheduler = new AnalyticsFlushScheduler({...deps, nextIntervalMs: () => 30_000}) let threw = false try { diff --git a/test/unit/server/infra/analytics/http-analytics-sender.test.ts b/test/unit/server/infra/analytics/http-analytics-sender.test.ts index 228d0a842..717ad63d2 100644 --- a/test/unit/server/infra/analytics/http-analytics-sender.test.ts +++ b/test/unit/server/infra/analytics/http-analytics-sender.test.ts @@ -146,7 +146,7 @@ describe('HttpAnalyticsSender', () => { }) describe('failure mapping', () => { - it('returns all ids as failed when http client reports http_5xx', async () => { + it('returns all ids as failed when http client reports http_5xx (carries reason for M4.5 backoff)', async () => { const httpClient = makeCapturingHttpClient({ok: false, reason: 'http_5xx', status: 503}) const sender = new HttpAnalyticsSender({ authStateReader: makeAuthReader(), @@ -159,10 +159,10 @@ describe('HttpAnalyticsSender', () => { const r2 = makeRecord({id: 'r2'}) const result = await sender.send([r1, r2]) - expect(result).to.deep.equal({failed: ['r1', 'r2'], succeeded: []}) + expect(result).to.deep.equal({failed: ['r1', 'r2'], reason: 'http_5xx', succeeded: []}) }) - it('returns all ids as failed when http client reports timeout', async () => { + it('returns all ids as failed when http client reports timeout (carries reason)', async () => { const httpClient = makeCapturingHttpClient({ok: false, reason: 'timeout'}) const sender = new HttpAnalyticsSender({ authStateReader: makeAuthReader(), @@ -173,10 +173,10 @@ describe('HttpAnalyticsSender', () => { const result = await sender.send([makeRecord({id: 'only'})]) - expect(result).to.deep.equal({failed: ['only'], succeeded: []}) + expect(result).to.deep.equal({failed: ['only'], reason: 'timeout', succeeded: []}) }) - it('returns all ids as failed when http client reports network failure', async () => { + it('returns all ids as failed when http client reports network failure (carries reason)', async () => { const httpClient = makeCapturingHttpClient({ok: false, reason: 'network'}) const sender = new HttpAnalyticsSender({ authStateReader: makeAuthReader(), @@ -187,7 +187,36 @@ describe('HttpAnalyticsSender', () => { const result = await sender.send([makeRecord({id: 'only'})]) - expect(result).to.deep.equal({failed: ['only'], succeeded: []}) + expect(result).to.deep.equal({failed: ['only'], reason: 'network', succeeded: []}) + }) + + it('returns http_4xx reason verbatim (caller uses this to decide NOT to advance backoff)', async () => { + const httpClient = makeCapturingHttpClient({ok: false, reason: 'http_4xx', status: 400}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'only'})]) + + expect(result).to.deep.equal({failed: ['only'], reason: 'http_4xx', succeeded: []}) + }) + + it('does NOT set reason on a successful send (M4.5 caller treats absence as success)', async () => { + const httpClient = makeCapturingHttpClient({ok: true}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'r1'})]) + + expect(result).to.deep.equal({failed: [], succeeded: ['r1']}) + expect(Object.hasOwn(result, 'reason'), 'reason key must be absent on success').to.equal(false) }) }) @@ -214,7 +243,9 @@ describe('HttpAnalyticsSender', () => { } expect(threw, 'sender must NOT throw on collaborator failure').to.equal(false) - expect(result).to.deep.equal({failed: ['r1'], succeeded: []}) + // M4.5: collaborator throws are tagged `network` so the M4.5 + // backoff treats them as transient (caller will advance backoff). + expect(result).to.deep.equal({failed: ['r1'], reason: 'network', succeeded: []}) expect(httpClient.calls).to.have.lengthOf(0) }) From b5b30af31f8b0c404639d36f12b404cb861dd099 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Fri, 22 May 2026 15:29:20 +0700 Subject: [PATCH 48/87] feat: [ENG-2648] M4.6 brv analytics status surfaces operational metrics brv analytics status now displays runtime state instead of just the enabled flag: - Last successful flush (ISO + relative Xm ago, or "never") - Queue depth (true JSONL pending count, not in-memory mirror) - Dropped events (cumulative drop-oldest count) - Backoff state derived from M4.5 consecutiveFailures: healthy (0) / degraded (1-2) / unreachable (3+), with live consecutive_failures + next_delay_ms counters - Endpoint URL, or "(not configured)" when BRV_ANALYTICS_BASE_URL is unset (also forces backoff.state to "unreachable") New transport event analytics:status (Zod schema) carries the wire response; new daemon-side AnalyticsStatusHandler composes runtime state (AnalyticsClient.getRuntimeState - also new) + backoff state + enabled flag + endpoint. AnalyticsClient tracks lastSuccessfulFlushAt on the same gate as M4.5 onSuccess. Injected now() dep makes the timestamp testable. Reachability label mapper lives in the handler so the M4.5 policy stays free of presentation concerns. Defensive on invalid inputs. Text renderer hides runtime detail when disabled (per ticket); JSON renderer always emits the full snake_case schema for stable programmatic consumption. Tests: 8292 passing (+26 vs the M4.5 baseline). 6 mapper + 6 handler composition (incl. not-configured override + disabled-shape) + 7 runtime-state cases + 14 command cases covering all 7 ticket plan items end-to-end. Pre-existing IAnalyticsClient test fakes updated to satisfy the new getRuntimeState member. --- src/oclif/commands/analytics/status.ts | 115 +++++++-- .../analytics/i-analytics-client.ts | 17 ++ .../infra/analytics/analytics-client.ts | 74 ++++-- .../infra/analytics/no-op-analytics-client.ts | 8 + src/server/infra/process/feature-handlers.ts | 14 ++ .../handlers/analytics-status-handler.ts | 100 ++++++++ src/server/infra/transport/handlers/index.ts | 2 + .../transport/events/analytics-events.ts | 30 +++ test/commands/analytics/status.test.ts | 230 +++++++++++++----- .../analytics-hook-async-stress.test.ts | 1 + ...wire-analytics-auth-pre-transition.test.ts | 1 + .../wire-analytics-auth-transition.test.ts | 1 + .../wire-analytics-flush-scheduler.test.ts | 12 + .../handlers/global-config-handler.test.ts | 4 + .../infra/analytics/analytics-client.test.ts | 142 ++++++++++- .../infra/process/analytics-hook.test.ts | 1 + .../handlers/analytics-handler.test.ts | 1 + .../handlers/analytics-status-handler.test.ts | 206 ++++++++++++++++ 18 files changed, 860 insertions(+), 99 deletions(-) create mode 100644 src/server/infra/transport/handlers/analytics-status-handler.ts create mode 100644 test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts diff --git a/src/oclif/commands/analytics/status.ts b/src/oclif/commands/analytics/status.ts index 540d0c607..a0603c202 100644 --- a/src/oclif/commands/analytics/status.ts +++ b/src/oclif/commands/analytics/status.ts @@ -1,17 +1,36 @@ +/* eslint-disable camelcase -- JSON wire shape is snake_case per the M4.6 ticket schema. */ import {Command, Flags} from '@oclif/core' import {PRIVACY_POLICY_URL} from '../../../shared/constants/privacy.js' -import { - GlobalConfigEvents, - type GlobalConfigGetResponse, -} from '../../../shared/transport/events/global-config-events.js' +import {AnalyticsEvents, type AnalyticsStatusResponse} from '../../../shared/transport/events/analytics-events.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' import {writeJsonResponse} from '../../lib/json-response.js' const COMMAND_ID = 'analytics:status' +const MS_PER_MIN = 60_000 +const MS_PER_HOUR = 60 * MS_PER_MIN +const MS_PER_DAY = 24 * MS_PER_HOUR + +/** + * Humanise a millisecond delta to a short relative-time label, matching + * the M4.6 ticket example: `(5m ago)`. Cut points: + * - < 1 minute → "just now" + * - < 1 hour → "{n}m ago" + * - < 1 day → "{n}h ago" + * - >= 1 day → "{n}d ago" + * + * Exposed for tests (also exercised indirectly via the text-output cases). + */ +export function formatRelativeAgo(deltaMs: number): string { + if (!Number.isFinite(deltaMs) || deltaMs < MS_PER_MIN) return 'just now' + if (deltaMs < MS_PER_HOUR) return `${Math.floor(deltaMs / MS_PER_MIN)}m ago` + if (deltaMs < MS_PER_DAY) return `${Math.floor(deltaMs / MS_PER_HOUR)}h ago` + return `${Math.floor(deltaMs / MS_PER_DAY)}d ago` +} + export default class Status extends Command { - public static description = `Show whether ByteRover CLI analytics is enabled or disabled. + public static description = `Show analytics state: enabled flag, last successful flush, queue depth, dropped event count, backoff state, endpoint. Analytics is opt-in (default: off). When enabled, ByteRover collects anonymous usage telemetry (event names, CLI version, OS, Node version, environment) to @@ -29,32 +48,92 @@ Toggle: brv analytics enable | brv analytics disable` }), } - protected async fetchAnalyticsEnabled(options?: DaemonClientOptions): Promise { - return withDaemonRetry(async (client) => { - const response = await client.requestWithAck(GlobalConfigEvents.GET) - return response.analytics - }, options) + protected async fetchAnalyticsStatus(options?: DaemonClientOptions): Promise { + return withDaemonRetry( + async (client) => client.requestWithAck(AnalyticsEvents.STATUS), + options, + ) + } + + /** + * Test seam — overridden in unit tests to pin the relative-time + * calculation against a fixed wall-clock. Production uses Date.now(). + */ + protected now(): number { + return Date.now() } public async run(): Promise { const {flags} = await this.parse(Status) const isJson = flags.format === 'json' + let response: AnalyticsStatusResponse try { - const enabled = await this.fetchAnalyticsEnabled({projectPath: process.cwd()}) - const label = enabled ? 'enabled' : 'disabled' - - if (isJson) { - writeJsonResponse({command: COMMAND_ID, data: {analytics: label}, success: true}) - } else { - this.log(`Analytics: ${label}`) - } + response = await this.fetchAnalyticsStatus({projectPath: process.cwd()}) } catch (error) { if (isJson) { writeJsonResponse({command: COMMAND_ID, data: {error: formatConnectionError(error)}, success: false}) } else { this.log(formatConnectionError(error)) } + + return + } + + if (isJson) { + writeJsonResponse({command: COMMAND_ID, data: this.toJsonShape(response), success: true}) + return + } + + this.renderText(response) + } + + private formatLastFlush(lastFlushAt: number | undefined): string { + if (lastFlushAt === undefined) return 'never' + const iso = new Date(lastFlushAt).toISOString() + const ago = formatRelativeAgo(this.now() - lastFlushAt) + return `${iso} (${ago})` + } + + private renderText(response: AnalyticsStatusResponse): void { + if (!response.enabled) { + this.log('Analytics: disabled') + return + } + + this.log('Analytics: enabled') + this.log(`Last successful flush: ${this.formatLastFlush(response.lastFlushAt)}`) + this.log(`Queue depth: ${response.queueDepth} events`) + this.log(`Dropped events (this session): ${response.droppedCount}`) + this.log( + `Backoff state: ${response.backoff.state} (consecutive_failures=${response.backoff.consecutiveFailures}, next_delay_ms=${response.backoff.nextDelayMs})`, + ) + this.log(`Endpoint: ${response.endpoint}`) + } + + /** + * JSON wire shape per the ticket. `last_flush` is null when undefined; + * snake_case keys for compatibility with downstream JSON consumers. + */ + private toJsonShape(response: AnalyticsStatusResponse): { + backoff: {consecutive_failures: number; next_delay_ms: number; state: string} + dropped_events: number + enabled: boolean + endpoint: string + last_flush: null | string + queue_depth: number + } { + return { + backoff: { + consecutive_failures: response.backoff.consecutiveFailures, + next_delay_ms: response.backoff.nextDelayMs, + state: response.backoff.state, + }, + dropped_events: response.droppedCount, + enabled: response.enabled, + endpoint: response.endpoint, + last_flush: response.lastFlushAt === undefined ? null : new Date(response.lastFlushAt).toISOString(), + queue_depth: response.queueDepth, } } } diff --git a/src/server/core/interfaces/analytics/i-analytics-client.ts b/src/server/core/interfaces/analytics/i-analytics-client.ts index b16716a20..04367abb0 100644 --- a/src/server/core/interfaces/analytics/i-analytics-client.ts +++ b/src/server/core/interfaces/analytics/i-analytics-client.ts @@ -30,6 +30,23 @@ export interface IAnalyticsClient { */ flush: () => Promise + /** + * M4.6: client-owned runtime state for the `brv analytics status` + * command. Returns the timestamp of the last successful flush (or + * `undefined` if none has run this daemon-lifetime), the count of + * records currently pending in JSONL, and the cumulative count of + * events dropped by the in-memory queue's drop-oldest cap. + * + * Async because `queueDepth` reads JSONL — that's the authoritative + * "waiting to ship" metric. The in-memory queue mirror caps at 1000 + * via drop-oldest and would mislead operators. + */ + getRuntimeState: () => Promise<{ + droppedCount: number + lastSuccessfulFlushAt: number | undefined + queueDepth: number + }> + /** * Notify the client that the daemon-wide auth state transitioned * (login, logout, account switch, token revoked). diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index 291587b36..564d16867 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -41,6 +41,13 @@ export interface AnalyticsClientDeps { * a no-op when omitted so existing callers don't have to wire it. */ log?: (message: string) => void + /** + * M4.6: monotonic clock used to stamp `lastSuccessfulFlushAt`. Injected + * so tests can assert against a known value; production defaults to + * `Date.now`. Daemon restart resets the in-memory timestamp; the + * status command surfaces "never" when undefined. + */ + now?: () => number /** * M4.3: optional notification fired after a record has been durably * appended (JSONL + queue mirror). The composition root wires this to @@ -84,6 +91,11 @@ export class AnalyticsClient implements IAnalyticsClient { // `abort()` is a no-op when this is undefined (no in-flight to cancel). private currentFlushController?: AbortController private readonly deps: AnalyticsClientDeps + // M4.6: timestamp of the last flush that actually shipped at least one + // record cleanly (same gate as the M4.5 backoff `onSuccess()` path). + // Surfaced through `getRuntimeState()` for `brv analytics status`. + // Daemon restart resets to undefined; status renders "never". + private lastSuccessfulFlushAt: number | undefined // Single-flight slot for an in-flight `flush()`. Concurrent callers join the // existing promise instead of starting a second read-then-decide cycle — // without this, two parallel flushes would both `loadPending()` the same set, @@ -162,6 +174,24 @@ export class AnalyticsClient implements IAnalyticsClient { } } + /** + * Snapshot of client-owned runtime state for `brv analytics status` + * (M4.6). Backoff state, endpoint, and the enabled flag are NOT here + * — those are composed by the daemon-side status handler from other + * sources (the policy + envConfig + GlobalConfigHandler). Async + * because `queueDepth` reads JSONL pending rows (the authoritative + * "waiting to ship" metric, NOT the in-memory queue mirror which + * caps at 1000 via drop-oldest). + */ + public async getRuntimeState(): Promise<{droppedCount: number; lastSuccessfulFlushAt: number | undefined; queueDepth: number}> { + const pending = await this.deps.jsonlStore.loadPending() + return { + droppedCount: this.deps.queue.droppedCount(), + lastSuccessfulFlushAt: this.lastSuccessfulFlushAt, + queueDepth: pending.length, + } + } + public async onAuthTransition(): Promise { // Snapshot in-flight tracks then wait for them to settle. Any // `trackAsync` that started before this point may still be between @@ -228,7 +258,7 @@ export class AnalyticsClient implements IAnalyticsClient { * - reason undefined AND succeeded.length === 0 → skip (empty no-op * race, or HttpAnalyticsSender's `missing-deviceId` path that * returns failed-without-reason; neither is a clean ship) - * - reason undefined AND succeeded.length > 0 → onSuccess() + * - reason undefined AND succeeded.length > 0 → onSuccess() + M4.6 timestamp stamp * - reason = `timeout` / `network` / `http_5xx` → onFailure() * * Emits a structured log line on every real transition so ops can @@ -237,34 +267,44 @@ export class AnalyticsClient implements IAnalyticsClient { */ private feedBackoffPolicy(result: SendResult, aborted: boolean): void { const policy = this.deps.backoffPolicy - if (policy === undefined) return if (aborted) return if (result.reason === 'http_4xx') { - // Tag 4xx in the log so ops sees the divergence (we do NOT advance - // backoff for permanent payload errors, only for transient ones). - this.deps.log?.( - `analytics.backoff: http_4xx ignored (consecutive_failures=${policy.consecutiveFailures()}, next=${policy.nextDelayMs()}ms)`, - ) + if (policy !== undefined) { + // Tag 4xx in the log so ops sees the divergence (we do NOT advance + // backoff for permanent payload errors, only for transient ones). + this.deps.log?.( + `analytics.backoff: http_4xx ignored (consecutive_failures=${policy.consecutiveFailures()}, next=${policy.nextDelayMs()}ms)`, + ) + } + return } if (result.reason === undefined) { if (result.succeeded.length === 0) return // empty no-op or uncategorized failure: no signal - const beforeFailures = policy.consecutiveFailures() - policy.onSuccess() - if (beforeFailures > 0) { - this.deps.log?.( - `analytics.backoff: reset on success (was consecutive_failures=${beforeFailures}, next=${policy.nextDelayMs()}ms)`, - ) + // M4.6: stamp the timestamp on the same gate as the backoff + // `onSuccess()` so "Last successful flush" reflects real ships. + const now = (this.deps.now ?? Date.now)() + this.lastSuccessfulFlushAt = now + if (policy !== undefined) { + const beforeFailures = policy.consecutiveFailures() + policy.onSuccess() + if (beforeFailures > 0) { + this.deps.log?.( + `analytics.backoff: reset on success (was consecutive_failures=${beforeFailures}, next=${policy.nextDelayMs()}ms)`, + ) + } } return } - policy.onFailure() - this.deps.log?.( - `analytics.backoff: advanced on ${result.reason} (consecutive_failures=${policy.consecutiveFailures()}, next=${policy.nextDelayMs()}ms)`, - ) + if (policy !== undefined) { + policy.onFailure() + this.deps.log?.( + `analytics.backoff: advanced on ${result.reason} (consecutive_failures=${policy.consecutiveFailures()}, next=${policy.nextDelayMs()}ms)`, + ) + } } private async runFlush(): Promise { diff --git a/src/server/infra/analytics/no-op-analytics-client.ts b/src/server/infra/analytics/no-op-analytics-client.ts index 26c54fda6..6781ee104 100644 --- a/src/server/infra/analytics/no-op-analytics-client.ts +++ b/src/server/infra/analytics/no-op-analytics-client.ts @@ -19,6 +19,14 @@ export class NoOpAnalyticsClient implements IAnalyticsClient { return AnalyticsBatch.create([]) } + public async getRuntimeState(): Promise<{ + droppedCount: number + lastSuccessfulFlushAt: number | undefined + queueDepth: number + }> { + return {droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0} + } + public async onAuthTransition(): Promise { // intentional no-op } diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 615c25239..1b2f38d9b 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -62,6 +62,7 @@ import {FsTemplateLoader} from '../template/fs-template-loader.js' import { AnalyticsHandler, AnalyticsListHandler, + AnalyticsStatusHandler, AuthHandler, ConfigHandler, ConnectorsHandler, @@ -260,6 +261,19 @@ export async function setupFeatureHandlers({ // as the AnalyticsClient so reads see exactly what trackAsync persisted. new AnalyticsListHandler({jsonlStore: jsonlAnalyticsStore, transport}).setup() + // M4.6: `brv analytics status` read API. Composes runtime state + // (client) + backoff state (policy) + enabled flag (config) + endpoint + // (env) into one wire response. Endpoint is `envConfig.analyticsBaseUrl` + // or empty string; the handler substitutes "(not configured)" for the + // empty case and forces backoff.state to 'unreachable'. + new AnalyticsStatusHandler({ + analyticsClient, + backoffPolicy: analyticsBackoffPolicy, + endpoint: envConfig.analyticsBaseUrl ?? '', + isAnalyticsEnabled: () => globalConfigHandler.getCachedAnalytics(), + transport, + }).setup() + new AuthHandler({ authService: new OAuthService(authConfig), authStateStore, diff --git a/src/server/infra/transport/handlers/analytics-status-handler.ts b/src/server/infra/transport/handlers/analytics-status-handler.ts new file mode 100644 index 000000000..3771cce4e --- /dev/null +++ b/src/server/infra/transport/handlers/analytics-status-handler.ts @@ -0,0 +1,100 @@ +import type {IAnalyticsBackoffPolicy} from '../../../core/interfaces/analytics/i-analytics-backoff-policy.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import { + AnalyticsEvents, + type AnalyticsStatusResponse, +} from '../../../../shared/transport/events/analytics-events.js' + +/** + * User-facing reachability label derived from the M4.5 backoff policy's + * `consecutiveFailures()` counter. Boundaries fixed by the M4.6 ticket: + * - 0 failures → healthy + * - 1 or 2 failures → degraded + * - 3+ failures → unreachable + * + * The mapper is pure and lives here (presentation layer) rather than in + * the policy itself, so the policy stays free of UX concerns and so + * non-status consumers of `consecutiveFailures()` can apply different + * labels if needed. + * + * Defensive on invalid input: negative or NaN inputs return 'healthy' + * (the most optimistic label) rather than throw, so a malformed counter + * never breaks the status command's hot path. + */ +export type ReachabilityState = 'degraded' | 'healthy' | 'unreachable' + +export function consecutiveFailuresToReachabilityState(consecutiveFailures: number): ReachabilityState { + if (!Number.isFinite(consecutiveFailures) || consecutiveFailures < 1) return 'healthy' + if (consecutiveFailures < 3) return 'degraded' + return 'unreachable' +} + +const NOT_CONFIGURED_ENDPOINT = '(not configured)' + +export interface AnalyticsStatusHandlerDeps { + readonly analyticsClient: IAnalyticsClient + readonly backoffPolicy: IAnalyticsBackoffPolicy + /** + * Resolved `BRV_ANALYTICS_BASE_URL`. Empty string when the env var + * isn't set; the handler substitutes the `(not configured)` placeholder + * AND forces `backoff.state = 'unreachable'` to reflect that no real + * health signal is possible. + */ + readonly endpoint: string + readonly isAnalyticsEnabled: () => boolean + readonly transport: ITransportServer +} + +/** + * Composes the `analytics:status` wire response for `brv analytics + * status` (M4.6). Pulls together: + * - enabled flag (GlobalConfigHandler's cached value) + * - client runtime state (last-flush ts, queue depth, dropped count) + * - backoff state (M4.5 policy + derived reachability label) + * - endpoint URL (or placeholder when unset) + * + * Kept as a focused handler rather than extending the existing + * `AnalyticsHandler` so the track/list domain stays clean and the + * status surface can evolve (M4.6 may grow more fields) without + * accreting unrelated dependencies on the track handler's class. + */ +export class AnalyticsStatusHandler { + private readonly deps: AnalyticsStatusHandlerDeps + + public constructor(deps: AnalyticsStatusHandlerDeps) { + this.deps = deps + } + + public setup(): void { + this.deps.transport.onRequest(AnalyticsEvents.STATUS, async () => this.compose()) + } + + /** + * Compose the wire payload. Visible for tests; production caller is + * the transport handler registered in `setup()`. + */ + private async compose(): Promise { + const runtime = await this.deps.analyticsClient.getRuntimeState() + const consecutiveFailures = this.deps.backoffPolicy.consecutiveFailures() + const nextDelayMs = this.deps.backoffPolicy.nextDelayMs() + const endpointConfigured = this.deps.endpoint !== '' + const endpoint = endpointConfigured ? this.deps.endpoint : NOT_CONFIGURED_ENDPOINT + // M4.6 override: when no endpoint is configured the daemon has + // nothing to be "healthy" against — surface unreachable so the user + // doesn't see a misleading "healthy" label paired with "(not configured)". + const state: ReachabilityState = endpointConfigured + ? consecutiveFailuresToReachabilityState(consecutiveFailures) + : 'unreachable' + + return { + backoff: {consecutiveFailures, nextDelayMs, state}, + droppedCount: runtime.droppedCount, + enabled: this.deps.isAnalyticsEnabled(), + endpoint, + ...(runtime.lastSuccessfulFlushAt === undefined ? {} : {lastFlushAt: runtime.lastSuccessfulFlushAt}), + queueDepth: runtime.queueDepth, + } + } +} diff --git a/src/server/infra/transport/handlers/index.ts b/src/server/infra/transport/handlers/index.ts index 1af15469a..860ab100b 100644 --- a/src/server/infra/transport/handlers/index.ts +++ b/src/server/infra/transport/handlers/index.ts @@ -2,6 +2,8 @@ export {AnalyticsHandler} from './analytics-handler.js' export type {AnalyticsHandlerDeps} from './analytics-handler.js' export {AnalyticsListHandler} from './analytics-list-handler.js' export type {AnalyticsListHandlerDeps} from './analytics-list-handler.js' +export {AnalyticsStatusHandler} from './analytics-status-handler.js' +export type {AnalyticsStatusHandlerDeps} from './analytics-status-handler.js' export {AuthHandler} from './auth-handler.js' export type {AuthHandlerDeps} from './auth-handler.js' export {ConfigHandler} from './config-handler.js' diff --git a/src/shared/transport/events/analytics-events.ts b/src/shared/transport/events/analytics-events.ts index acf090b2b..d8cce9723 100644 --- a/src/shared/transport/events/analytics-events.ts +++ b/src/shared/transport/events/analytics-events.ts @@ -5,9 +5,39 @@ import {StoredAnalyticsRecordSchema} from '../../analytics/stored-record.js' export const AnalyticsEvents = { LIST: 'analytics:list', + STATUS: 'analytics:status', TRACK: 'analytics:track', } as const +/** + * M4.6 `analytics:status` response. Surfaces operational metrics for + * `brv analytics status`: enabled flag (from GlobalConfig), client + * runtime state (last-flush timestamp, JSONL pending depth, dropped + * count), backoff state (M4.5 policy + derived reachability label), + * and the analytics endpoint URL. + * + * `lastFlushAt` is epoch milliseconds (`undefined` when the daemon has + * not shipped anything yet this session → status renders "never"). + * + * `endpoint` is the resolved `BRV_ANALYTICS_BASE_URL` or the literal + * `"(not configured)"` placeholder; when not configured, `backoff.state` + * is forced to `"unreachable"` regardless of `consecutiveFailures`. + */ +export const AnalyticsStatusResponseSchema = z.object({ + backoff: z.object({ + consecutiveFailures: z.number().int().min(0), + nextDelayMs: z.number().int().min(0), + state: z.enum(['healthy', 'degraded', 'unreachable']), + }), + droppedCount: z.number().int().min(0), + enabled: z.boolean(), + endpoint: z.string().min(1), + lastFlushAt: z.number().int().min(0).optional(), + queueDepth: z.number().int().min(0), +}) + +export type AnalyticsStatusResponse = z.infer + /** * Wire-level validation for `analytics:track` payloads. Identity and super * properties are stamped daemon-side on receipt; per-event property schemas diff --git a/test/commands/analytics/status.test.ts b/test/commands/analytics/status.test.ts index c99fb43a6..27e0b517f 100644 --- a/test/commands/analytics/status.test.ts +++ b/test/commands/analytics/status.test.ts @@ -7,10 +7,17 @@ import {expect} from 'chai' import sinon, {restore, stub} from 'sinon' import type {DaemonClientOptions} from '../../../src/oclif/lib/daemon-client.js' +import type {AnalyticsStatusResponse} from '../../../src/shared/transport/events/analytics-events.js' +/* eslint-disable camelcase -- JSON wire shape is snake_case per the M4.6 ticket schema. */ import Status from '../../../src/oclif/commands/analytics/status.js' -import {GlobalConfigEvents} from '../../../src/shared/transport/events/global-config-events.js' +import {AnalyticsEvents} from '../../../src/shared/transport/events/analytics-events.js' +/** + * M4.6 test surface: the command issues a single `analytics:status` + * request to the daemon and renders the response as text or JSON. The + * mock here returns shapes that exercise each branch of the renderer. + */ class TestableStatusCommand extends Status { private readonly mockConnector: () => Promise @@ -19,17 +26,30 @@ class TestableStatusCommand extends Status { this.mockConnector = mockConnector } - protected override async fetchAnalyticsEnabled(options?: DaemonClientOptions): Promise { - return super.fetchAnalyticsEnabled({ + protected override async fetchAnalyticsStatus(options?: DaemonClientOptions): Promise { + return super.fetchAnalyticsStatus({ maxRetries: 1, retryDelayMs: 0, transportConnector: this.mockConnector, ...options, }) } + + // M4.6: pin the clock so "Xm ago" assertions are deterministic. + protected override now(): number { + return 1_700_000_000_000 + } } -describe('analytics status command', () => { +const HEALTHY_RESPONSE: AnalyticsStatusResponse = { + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy'}, + droppedCount: 0, + enabled: true, + endpoint: 'https://telemetry-dev.byterover.dev', + queueDepth: 4, +} + +describe('analytics status command (M4.6)', () => { let config: Config let loggedMessages: string[] let mockClient: sinon.SinonStubbedInstance @@ -41,7 +61,6 @@ describe('analytics status command', () => { beforeEach(() => { loggedMessages = [] - mockClient = { connect: stub().resolves(), disconnect: stub().resolves(), @@ -55,7 +74,7 @@ describe('analytics status command', () => { once: stub(), onStateChange: stub().returns(() => {}), request: stub() as unknown as ITransportClient['request'], - requestWithAck: stub().resolves({analytics: false, deviceId: 'test-device', version: '1.0.0'}), + requestWithAck: stub().resolves(HEALTHY_RESPONSE), } as unknown as sinon.SinonStubbedInstance mockConnector = stub<[], Promise>().resolves({ @@ -71,112 +90,197 @@ describe('analytics status command', () => { function createCommand(argv: string[] = []): TestableStatusCommand { const command = new TestableStatusCommand(mockConnector, config, argv) stub(command, 'log').callsFake((msg?: string) => { - if (msg) loggedMessages.push(msg) + if (msg !== undefined) loggedMessages.push(msg) }) return command } - function mockAnalyticsResponse(analytics: boolean): void { - ;(mockClient.requestWithAck as sinon.SinonStub).resolves({ - analytics, - deviceId: 'test-device', - version: '1.0.0', + function mockStatusResponse(response: AnalyticsStatusResponse): void { + ;(mockClient.requestWithAck as sinon.SinonStub).resolves(response) + } + + function captureJson(argv: string[] = ['--format', 'json']): Promise<{captured: string}> { + return new Promise((resolve) => { + let captured = '' + const writeStub = stub(process.stdout, 'write').callsFake((chunk) => { + captured += chunk + return true + }) + + new TestableStatusCommand(mockConnector, config, argv).run().finally(() => { + writeStub.restore() + resolve({captured}) + }).catch(() => { + // The .finally above already resolves the outer promise; the + // .catch here keeps lint happy about an unhandled rejection on + // the underlying chain (the renderer never throws — error paths + // write a JSON error envelope and return). + }) }) } describe('text output', () => { - it('should print "Analytics: disabled" for a fresh (analytics:false) config', async () => { - mockAnalyticsResponse(false) + it('disabled state: only shows "disabled" (other fields suppressed)', async () => { + mockStatusResponse({...HEALTHY_RESPONSE, enabled: false}) await createCommand().run() expect(loggedMessages.some((m) => m.includes('Analytics: disabled'))).to.be.true + expect(loggedMessages.some((m) => m.includes('Queue depth'))).to.be.false + expect(loggedMessages.some((m) => m.includes('Backoff state'))).to.be.false + expect(loggedMessages.some((m) => m.includes('Endpoint'))).to.be.false }) - it('should print "Analytics: enabled" when underlying config has analytics: true', async () => { - mockAnalyticsResponse(true) + it('enabled, never flushed: "Last successful flush: never"', async () => { + mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: undefined}) await createCommand().run() expect(loggedMessages.some((m) => m.includes('Analytics: enabled'))).to.be.true + expect(loggedMessages.some((m) => m.includes('Last successful flush: never'))).to.be.true }) - }) - describe('JSON output', () => { - it('should emit {"analytics": "disabled"} shape when disabled', async () => { - mockAnalyticsResponse(false) + it('enabled, flushed 5 minutes ago: ISO timestamp with relative time', async () => { + // Pinned `now()` = 1_700_000_000_000 → ISO 2023-11-14T22:13:20.000Z. + // lastFlushAt 5 minutes earlier. + const fiveMinutesAgo = 1_700_000_000_000 - 5 * 60_000 + mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: fiveMinutesAgo}) - let captured = '' - const writeStub = stub(process.stdout, 'write').callsFake((chunk) => { - captured += chunk - return true - }) + await createCommand().run() - try { - await new TestableStatusCommand(mockConnector, config, ['--format', 'json']).run() - } finally { - writeStub.restore() - } + const flushLine = loggedMessages.find((m) => m.includes('Last successful flush')) + expect(flushLine, 'flush line present').to.not.equal(undefined) + expect(flushLine, 'shows ISO timestamp').to.include('2023-11-14T22:08:20') + expect(flushLine, 'shows relative time').to.include('(5m ago)') + }) - const parsed = JSON.parse(captured) as {data: {analytics: string}; success: boolean} - expect(parsed.success).to.be.true - expect(parsed.data.analytics).to.equal('disabled') + it('"just now" for sub-minute deltas', async () => { + mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: 1_700_000_000_000 - 30_000}) + + await createCommand().run() + + const flushLine = loggedMessages.find((m) => m.includes('Last successful flush')) + expect(flushLine).to.include('(just now)') }) - it('should emit {"analytics": "enabled"} shape when enabled', async () => { - mockAnalyticsResponse(true) + it('hours-then-days relative formatting', async () => { + mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: 1_700_000_000_000 - 3 * 60 * 60_000}) + await createCommand().run() + expect(loggedMessages.find((m) => m.includes('Last successful flush'))).to.include('(3h ago)') - let captured = '' - const writeStub = stub(process.stdout, 'write').callsFake((chunk) => { - captured += chunk - return true + loggedMessages.length = 0 + mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: 1_700_000_000_000 - 2 * 24 * 60 * 60_000}) + await createCommand().run() + expect(loggedMessages.find((m) => m.includes('Last successful flush'))).to.include('(2d ago)') + }) + + it('backoff state "degraded": shows label + consecutive failures + next delay', async () => { + mockStatusResponse({ + ...HEALTHY_RESPONSE, + backoff: {consecutiveFailures: 2, nextDelayMs: 120_000, state: 'degraded'}, }) - try { - await new TestableStatusCommand(mockConnector, config, ['--format', 'json']).run() - } finally { - writeStub.restore() - } + await createCommand().run() - const parsed = JSON.parse(captured) as {data: {analytics: string}; success: boolean} - expect(parsed.success).to.be.true - expect(parsed.data.analytics).to.equal('enabled') + const backoffLine = loggedMessages.find((m) => m.toLowerCase().includes('backoff')) + expect(backoffLine).to.include('degraded') + expect(backoffLine).to.include('2') + expect(backoffLine).to.include('120000') }) - it('should output success: false on connection error', async () => { - mockConnector.rejects(new NoInstanceRunningError()) - - let captured = '' - const writeStub = stub(process.stdout, 'write').callsFake((chunk) => { - captured += chunk - return true + it('endpoint not configured: shows literal placeholder', async () => { + mockStatusResponse({ + ...HEALTHY_RESPONSE, + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'unreachable'}, + endpoint: '(not configured)', }) - try { - await new TestableStatusCommand(mockConnector, config, ['--format', 'json']).run() - } finally { - writeStub.restore() + await createCommand().run() + + const endpointLine = loggedMessages.find((m) => m.includes('Endpoint')) + expect(endpointLine).to.include('(not configured)') + const backoffLine = loggedMessages.find((m) => m.toLowerCase().includes('backoff')) + expect(backoffLine, 'overridden to unreachable').to.include('unreachable') + }) + + it('shows queue depth and dropped events on enabled state', async () => { + mockStatusResponse({...HEALTHY_RESPONSE, droppedCount: 7, queueDepth: 12}) + + await createCommand().run() + + expect(loggedMessages.some((m) => m.includes('Queue depth: 12 events'))).to.be.true + expect(loggedMessages.some((m) => m.includes('Dropped events') && m.includes('7'))).to.be.true + }) + }) + + describe('JSON output', () => { + it('emits the documented snake_case schema on enabled state', async () => { + const flushAt = 1_700_000_000_000 - 5 * 60_000 + mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: flushAt}) + + const {captured} = await captureJson() + const parsed = JSON.parse(captured) as { + data: { + backoff: {consecutive_failures: number; next_delay_ms: number; state: string} + dropped_events: number + enabled: boolean + endpoint: string + last_flush: null | string + queue_depth: number + } + success: boolean } + expect(parsed.success).to.equal(true) + expect(parsed.data.enabled).to.equal(true) + expect(parsed.data.last_flush, 'ISO 8601 string').to.equal(new Date(flushAt).toISOString()) + expect(parsed.data.queue_depth).to.equal(4) + expect(parsed.data.dropped_events).to.equal(0) + expect(parsed.data.backoff).to.deep.equal({consecutive_failures: 0, next_delay_ms: 30_000, state: 'healthy'}) + expect(parsed.data.endpoint).to.equal('https://telemetry-dev.byterover.dev') + }) + + it('emits last_flush: null when never flushed', async () => { + mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: undefined}) + + const {captured} = await captureJson() + const parsed = JSON.parse(captured) as {data: {last_flush: null | string}} + expect(parsed.data.last_flush).to.equal(null) + }) + + it('keeps stable shape on disabled state (full fields present)', async () => { + mockStatusResponse({...HEALTHY_RESPONSE, enabled: false}) + + const {captured} = await captureJson() + const parsed = JSON.parse(captured) as {data: Record} + expect(parsed.data.enabled).to.equal(false) + // Ticket schema: shape doesn't depend on enabled flag. + expect(parsed.data).to.have.all.keys('backoff', 'dropped_events', 'enabled', 'endpoint', 'last_flush', 'queue_depth') + }) + + it('returns success=false on connection error', async () => { + mockConnector.rejects(new NoInstanceRunningError()) + + const {captured} = await captureJson() const parsed = JSON.parse(captured) as {success: boolean} - expect(parsed.success).to.be.false + expect(parsed.success).to.equal(false) }) }) describe('transport contract', () => { - it('should issue exactly one read against GlobalConfigEvents.GET', async () => { - mockAnalyticsResponse(false) + it('issues exactly one request against AnalyticsEvents.STATUS', async () => { + mockStatusResponse(HEALTHY_RESPONSE) await createCommand().run() const requestStub = mockClient.requestWithAck as sinon.SinonStub expect(requestStub.callCount).to.equal(1) - expect(requestStub.firstCall.args[0]).to.equal(GlobalConfigEvents.GET) + expect(requestStub.firstCall.args[0]).to.equal(AnalyticsEvents.STATUS) }) }) describe('help text', () => { - it('should declare a description string and not throw on construction', () => { + it('declares a description string and does not throw on construction', () => { expect(Status.description).to.be.a('string').and.not.be.empty }) }) diff --git a/test/integration/infra/process/analytics-hook-async-stress.test.ts b/test/integration/infra/process/analytics-hook-async-stress.test.ts index 88960727c..669e9d336 100644 --- a/test/integration/infra/process/analytics-hook-async-stress.test.ts +++ b/test/integration/infra/process/analytics-hook-async-stress.test.ts @@ -100,6 +100,7 @@ function makeAnalyticsClient(sandbox: SinonSandbox): {client: IAnalyticsClient; const client: IAnalyticsClient = { abort: sandbox.stub(), flush: sandbox.stub().resolves(), + getRuntimeState: sandbox.stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), onAuthTransition: sandbox.stub().resolves(), track: trackStub, } diff --git a/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts b/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts index 9e49c60bc..32ffc5742 100644 --- a/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts +++ b/test/integration/server/infra/process/wire-analytics-auth-pre-transition.test.ts @@ -92,6 +92,7 @@ function makeFakeAnalyticsClient(): IAnalyticsClient & { }, flush: flushSpy, flushSpy, + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), onAuthTransition: stub().resolves(), track(): void { // intentional no-op diff --git a/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts b/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts index 2f7964c70..278d22d32 100644 --- a/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts +++ b/test/integration/server/infra/process/wire-analytics-auth-transition.test.ts @@ -105,6 +105,7 @@ function makeFakeAnalyticsClient(): IAnalyticsClient & { /* M4.4: not exercised in this test */ }, flush: stub().resolves(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), onAuthTransition, onAuthTransitionSpy: onAuthTransition, // Hand-rolled noop to preserve the generic `track(event, ...rest)` diff --git a/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts b/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts index 260a109e3..e2642a98d 100644 --- a/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts +++ b/test/integration/server/infra/process/wire-analytics-flush-scheduler.test.ts @@ -35,6 +35,9 @@ function makeFakeClient(): FakeClient { get flushCalls() { return calls }, + async getRuntimeState() { + return {droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0} + }, async onAuthTransition() {}, resetFlushCalls() { calls = 0 @@ -204,6 +207,9 @@ describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { new Promise((resolve) => { releaseFlush = () => resolve(AnalyticsBatch.create([])) }), + async getRuntimeState() { + return {droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0} + }, async onAuthTransition() {}, track() { /* no-op */ @@ -247,6 +253,9 @@ describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { new Promise(() => { /* never resolves */ }), + async getRuntimeState() { + return {droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0} + }, async onAuthTransition() {}, track() { /* no-op */ @@ -316,6 +325,9 @@ describe('M4.3 wireAnalyticsFlushScheduler (integration)', () => { policy.onFailure() return AnalyticsBatch.create([]) }, + async getRuntimeState() { + return {droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0} + }, async onAuthTransition() {}, track() { /* no-op */ diff --git a/test/unit/infra/transport/handlers/global-config-handler.test.ts b/test/unit/infra/transport/handlers/global-config-handler.test.ts index dbafe539e..e03ab1441 100644 --- a/test/unit/infra/transport/handlers/global-config-handler.test.ts +++ b/test/unit/infra/transport/handlers/global-config-handler.test.ts @@ -222,6 +222,7 @@ describe('GlobalConfigHandler', () => { analyticsClient: { abort: analyticsClient.abort, flush: stub().resolves(), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), onAuthTransition: stub().resolves(), // Hand-rolled noop preserves the generic `track` signature. track(): void { @@ -251,6 +252,7 @@ describe('GlobalConfigHandler', () => { analyticsClient: { abort: analyticsClient.abort, flush: stub().resolves(), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), onAuthTransition: stub().resolves(), // Hand-rolled noop preserves the generic `track` signature. track(): void { @@ -278,6 +280,7 @@ describe('GlobalConfigHandler', () => { analyticsClient: { abort: analyticsClient.abort, flush: stub().resolves(), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), onAuthTransition: stub().resolves(), // Hand-rolled noop preserves the generic `track` signature. track(): void { @@ -306,6 +309,7 @@ describe('GlobalConfigHandler', () => { throw new Error('abort boom') }, flush: stub().resolves(), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), onAuthTransition: stub().resolves(), // Hand-rolled noop preserves the generic `track` signature. track(): void { diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index 11dbf22cc..023bdeaf6 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable camelcase */ +/* eslint-disable camelcase, max-lines -- this file accumulates AnalyticsClient cases across M2-M4.6; splitting would scatter the contract surface. */ import {expect} from 'chai' import {spy, stub} from 'sinon' @@ -1502,4 +1502,144 @@ describe('AnalyticsClient', () => { expect(policy.onFailure.called, 'failed-without-reason must NOT call onFailure either').to.be.false }) }) + + describe('M4.6 runtime state tracking', () => { + /** + * `lastSuccessfulFlushAt` is the timestamp shown by `brv analytics status` + * as "Last successful flush". Updated ONLY on a real clean ship — + * same gate as M4.5's backoff `onSuccess()`. Aborted, 4xx, failed, + * and empty-batch outcomes leave it untouched. The `now: () => number` + * dep is injected for deterministic assertions. + */ + it('lastSuccessfulFlushAt is undefined on a fresh client', async () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + now: () => 1_700_000_000_000, + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + const state = await client.getRuntimeState() + expect(state.lastSuccessfulFlushAt, 'no flush has run yet').to.equal(undefined) + }) + + it('lastSuccessfulFlushAt is set to now() after a clean successful flush', async () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + now: () => 1_700_000_000_000, + queue: new BoundedQueue(), + sender: makeFakeSender({kind: 'all-succeeded'}), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 2) + await client.flush() + + const state = await client.getRuntimeState() + expect(state.lastSuccessfulFlushAt).to.equal(1_700_000_000_000) + }) + + it('lastSuccessfulFlushAt is NOT updated when the flush fails (sender returns reason)', async () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + now: () => 1_700_000_000_000, + queue: new BoundedQueue(), + sender: makeSenderWithReason('http_5xx'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 1) + await client.flush() + + const state = await client.getRuntimeState() + expect(state.lastSuccessfulFlushAt, 'failed flush must not advance the timestamp').to.equal(undefined) + }) + + it('lastSuccessfulFlushAt is NOT updated on http_4xx (payload-shape error)', async () => { + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + now: () => 1_700_000_000_000, + queue: new BoundedQueue(), + sender: makeSenderWithReason('http_4xx'), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 1) + await client.flush() + + const state = await client.getRuntimeState() + expect(state.lastSuccessfulFlushAt).to.equal(undefined) + }) + + it('lastSuccessfulFlushAt is NOT updated on an empty-batch no-op flush', async () => { + // No records seeded; flush still resolves but ships nothing. + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + now: () => 1_700_000_000_000, + queue: new BoundedQueue(), + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await client.flush() + + const state = await client.getRuntimeState() + expect(state.lastSuccessfulFlushAt, 'empty-batch flush is not a real ship').to.equal(undefined) + }) + + it('getRuntimeState surfaces JSONL pending count (NOT in-memory mirror) and droppedCount', async () => { + // Two pending records, one already-sent record. queueDepth should + // see only the pending row; dropped count surfaces from the queue. + const queue = new BoundedQueue(5) + const jsonlStore = makeFakeJsonlStore() + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore, + now: () => 1_700_000_000_000, + queue, + sender: makeFakeSender({kind: 'all-succeeded'}), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + // Seed 3 events, flush 1 batch (all-succeeded), then add 2 more. + await seedPending(client, 3) + await client.flush() // 3 records → 'sent' + await seedPending(client, 2) // 2 new 'pending' records + + const state = await client.getRuntimeState() + expect(state.queueDepth, 'JSONL pending count, NOT queue.size()').to.equal(2) + // No drops in this scenario. + expect(state.droppedCount).to.equal(0) + }) + + it('getRuntimeState reflects droppedCount when the bounded queue evicts oldest', async () => { + // Cap of 2; pushing 4 records evicts the first 2. + const queue = new BoundedQueue(2) + const client = new AnalyticsClient({ + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + now: () => 1_700_000_000_000, + queue, + sender: makeFakeSender(), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + + await seedPending(client, 4) + const state = await client.getRuntimeState() + expect(state.droppedCount, 'queue dropped 2 of 4 oldest events').to.equal(2) + }) + }) }) diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts index bc53b7319..87bae7141 100644 --- a/test/unit/server/infra/process/analytics-hook.test.ts +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -36,6 +36,7 @@ const buildAnalyticsClient = (): StubBundle => { /* M4.4: not exercised in this test */ }, flush: flushStub, + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), onAuthTransition: sinon.stub().resolves(), track: trackStub, } diff --git a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts index b30555b0d..71f29e692 100644 --- a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts @@ -32,6 +32,7 @@ function makeMockAnalyticsClient(): MockAnalyticsClient { /* M4.4: not exercised in this test */ }, flush: () => Promise.resolve(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), onAuthTransition: () => Promise.resolve(), track(event: E, ...rest: PropsArg): void { if (mock.trackThrows) throw mock.trackThrows diff --git a/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts new file mode 100644 index 000000000..d4989a588 --- /dev/null +++ b/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts @@ -0,0 +1,206 @@ +import {expect} from 'chai' + +import type {IAnalyticsBackoffPolicy} from '../../../../../../src/server/core/interfaces/analytics/i-analytics-backoff-policy.js' +import type {IAnalyticsClient} from '../../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {AnalyticsBatch} from '../../../../../../src/server/core/domain/analytics/batch.js' +import { + AnalyticsStatusHandler, + consecutiveFailuresToReachabilityState, +} from '../../../../../../src/server/infra/transport/handlers/analytics-status-handler.js' +import {AnalyticsEvents} from '../../../../../../src/shared/transport/events/analytics-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +/** + * M4.6 reachability label mapper. The ticket fixes the boundaries: + * - 0 failures → healthy + * - 1-2 failures → degraded + * - 3+ failures → unreachable + * + * The policy exposes the raw counter via `consecutiveFailures()`; this + * mapper owns the user-facing label so the policy itself stays free of + * presentation concerns. + */ +/** + * Lightweight test doubles for the handler's three collaborators: + * - the analytics client (only `getRuntimeState` is read) + * - the backoff policy (only `consecutiveFailures` + `nextDelayMs`) + * - the cached-analytics getter from GlobalConfigHandler + * + * Hoisted above the top-level `describe` to satisfy + * `unicorn/consistent-function-scoping`. + */ +type RuntimeStateSnapshot = { + droppedCount: number + lastSuccessfulFlushAt: number | undefined + queueDepth: number +} + +function makeClientStub(state: RuntimeStateSnapshot): IAnalyticsClient { + return { + abort() {}, + flush: async () => AnalyticsBatch.create([]), + getRuntimeState: async () => state, + async onAuthTransition() {}, + track() {}, + } +} + +function makePolicyStub(consecutiveFailures: number, nextDelayMs: number): IAnalyticsBackoffPolicy { + return { + consecutiveFailures: () => consecutiveFailures, + nextDelayMs: () => nextDelayMs, + onFailure() {}, + onSuccess() {}, + } +} + +describe('M4.6 analytics status handler', () => { +describe('consecutiveFailuresToReachabilityState', () => { + it('returns "healthy" for zero failures', () => { + expect(consecutiveFailuresToReachabilityState(0)).to.equal('healthy') + }) + + it('returns "degraded" for one failure', () => { + expect(consecutiveFailuresToReachabilityState(1)).to.equal('degraded') + }) + + it('returns "degraded" for two failures', () => { + expect(consecutiveFailuresToReachabilityState(2)).to.equal('degraded') + }) + + it('returns "unreachable" at the 3-failure threshold', () => { + expect(consecutiveFailuresToReachabilityState(3)).to.equal('unreachable') + }) + + it('returns "unreachable" for many failures (counter is unbounded)', () => { + expect(consecutiveFailuresToReachabilityState(50)).to.equal('unreachable') + }) + + it('treats negative or NaN input defensively as "healthy" (no caller should pass these, but the mapper must not crash)', () => { + // Defense-in-depth: the policy never produces these values, but if a + // future change accidentally pipes a malformed counter through, the + // user should see the most-optimistic label rather than a runtime + // error in the status command's hot path. + expect(consecutiveFailuresToReachabilityState(-1)).to.equal('healthy') + expect(consecutiveFailuresToReachabilityState(Number.NaN)).to.equal('healthy') + }) +}) + +describe('AnalyticsStatusHandler', () => { + it('returns the composed wire response for an enabled, healthy daemon', async () => { + const transport = createMockTransportServer() + const handler = new AnalyticsStatusHandler({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: 1_700_000_000_000, queueDepth: 4}), + backoffPolicy: makePolicyStub(0, 30_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + transport, + }) + handler.setup() + + const fn = transport._handlers.get(AnalyticsEvents.STATUS) + if (!fn) throw new Error('STATUS handler not registered') + const response = await fn(undefined, 'client-1') + + expect(response).to.deep.equal({ + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy'}, + droppedCount: 0, + enabled: true, + endpoint: 'https://telemetry-dev.byterover.dev', + lastFlushAt: 1_700_000_000_000, + queueDepth: 4, + }) + }) + + it('renders "degraded" reachability when 1-2 consecutive failures', async () => { + const transport = createMockTransportServer() + const handler = new AnalyticsStatusHandler({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + backoffPolicy: makePolicyStub(2, 120_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + transport, + }) + handler.setup() + const fn = transport._handlers.get(AnalyticsEvents.STATUS)! + const response = await fn(undefined, 'client-1') + + expect(response.backoff).to.deep.equal({consecutiveFailures: 2, nextDelayMs: 120_000, state: 'degraded'}) + }) + + it('renders "unreachable" reachability when 3+ consecutive failures', async () => { + const transport = createMockTransportServer() + const handler = new AnalyticsStatusHandler({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 5}), + backoffPolicy: makePolicyStub(5, 300_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + transport, + }) + handler.setup() + const fn = transport._handlers.get(AnalyticsEvents.STATUS)! + const response = await fn(undefined, 'client-1') + + expect(response.backoff.state).to.equal('unreachable') + expect(response.backoff.consecutiveFailures).to.equal(5) + }) + + it('forces backoff.state to "unreachable" when endpoint is missing (empty string)', async () => { + // BRV_ANALYTICS_BASE_URL not configured: ticket says endpoint shows + // "(not configured)" AND state is forced to "unreachable" regardless + // of consecutive failures. + const transport = createMockTransportServer() + const handler = new AnalyticsStatusHandler({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + backoffPolicy: makePolicyStub(0, 30_000), + endpoint: '', + isAnalyticsEnabled: () => true, + transport, + }) + handler.setup() + const fn = transport._handlers.get(AnalyticsEvents.STATUS)! + const response = await fn(undefined, 'client-1') + + expect(response.endpoint).to.equal('(not configured)') + expect(response.backoff.state, 'override forces unreachable when endpoint missing').to.equal('unreachable') + }) + + it('keeps the JSON shape stable when analytics is disabled (CLI hides text fields; programmatic shape unchanged)', async () => { + const transport = createMockTransportServer() + const handler = new AnalyticsStatusHandler({ + analyticsClient: makeClientStub({droppedCount: 7, lastSuccessfulFlushAt: 1_700_000_000_000, queueDepth: 3}), + backoffPolicy: makePolicyStub(1, 60_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => false, + transport, + }) + handler.setup() + const fn = transport._handlers.get(AnalyticsEvents.STATUS)! + const response = await fn(undefined, 'client-1') + + expect(response.enabled).to.equal(false) + // All other fields still populated — CLI decides whether to render them. + expect(response.queueDepth).to.equal(3) + expect(response.droppedCount).to.equal(7) + expect(response.lastFlushAt).to.equal(1_700_000_000_000) + expect(response.backoff).to.deep.equal({consecutiveFailures: 1, nextDelayMs: 60_000, state: 'degraded'}) + }) + + it('omits lastFlushAt from the wire when the daemon has never shipped', async () => { + const transport = createMockTransportServer() + const handler = new AnalyticsStatusHandler({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 2}), + backoffPolicy: makePolicyStub(0, 30_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + transport, + }) + handler.setup() + const fn = transport._handlers.get(AnalyticsEvents.STATUS)! + const response = await fn(undefined, 'client-1') + + expect(response.lastFlushAt, 'undefined → key absent (CLI renders "never")').to.equal(undefined) + }) +}) +}) From 40d1941e4d05bd6a215424d505595dd7dbb72a0e Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Fri, 22 May 2026 15:44:14 +0700 Subject: [PATCH 49/87] fix: [ENG-2648] humanize backoff line in `brv analytics status` text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (I1 + I2): the backoff text rendering was leaking the JSON wire shape ("consecutive_failures=2, next_delay_ms=120000") into a human terminal. An operator reading the status had to mentally convert the snake_case identifiers and the raw ms. New text format: Backoff state: degraded (2 consecutive failures, next attempt in 2m) Adds a `formatDelayMs(ms)` humanizer alongside `formatRelativeAgo` (ms → ms / s / m / h depending on magnitude, matching the M4.5 schedule values 30s, 60s, 2m, 5m). The backoff summary uses singular/plural ("1 consecutive failure" vs "N consecutive failures") for friendlier copy. JSON output is unchanged — `consecutive_failures` + `next_delay_ms` snake_case keys preserved for programmatic consumers. Also (review I4): removed an orphaned doc-comment header from analytics-status-handler.test.ts — the second JSDoc block was shadowing the first, so the first one attached to nothing. Merged into one file-level header. Tests: 8293 passing (+1 for the new singular-failure case). --- src/oclif/commands/analytics/status.ts | 33 +++++++++++++++++-- test/commands/analytics/status.test.ts | 21 +++++++++--- .../handlers/analytics-status-handler.test.ts | 23 ++++--------- 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/src/oclif/commands/analytics/status.ts b/src/oclif/commands/analytics/status.ts index a0603c202..99f21f1e0 100644 --- a/src/oclif/commands/analytics/status.ts +++ b/src/oclif/commands/analytics/status.ts @@ -29,6 +29,20 @@ export function formatRelativeAgo(deltaMs: number): string { return `${Math.floor(deltaMs / MS_PER_DAY)}d ago` } +/** + * Humanise a forward-looking delay in milliseconds. Used by the + * backoff line so an operator sees "next attempt in 2m" instead of + * the wire-format "next_delay_ms=120000". Cut points mirror the M4.5 + * backoff schedule (30s, 60s, 2m, 5m). + */ +export function formatDelayMs(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return '0ms' + if (ms < 1000) return `${ms}ms` + if (ms < MS_PER_MIN) return `${Math.floor(ms / 1000)}s` + if (ms < MS_PER_HOUR) return `${Math.floor(ms / MS_PER_MIN)}m` + return `${Math.floor(ms / MS_PER_HOUR)}h` +} + export default class Status extends Command { public static description = `Show analytics state: enabled flag, last successful flush, queue depth, dropped event count, backoff state, endpoint. @@ -88,6 +102,21 @@ Toggle: brv analytics enable | brv analytics disable` this.renderText(response) } + /** + * Human-friendly summary of the backoff counters: a singular/plural + * "N consecutive failure(s)" plus the next-attempt delay as a + * humanized duration. Wire output (JSON) keeps the raw snake_case + * field names and ms units for programmatic consumers. + */ + private formatBackoffSummary(backoff: AnalyticsStatusResponse['backoff']): string { + const failurePart = + backoff.consecutiveFailures === 1 + ? '1 consecutive failure' + : `${backoff.consecutiveFailures} consecutive failures` + const delayPart = `next attempt in ${formatDelayMs(backoff.nextDelayMs)}` + return `${failurePart}, ${delayPart}` + } + private formatLastFlush(lastFlushAt: number | undefined): string { if (lastFlushAt === undefined) return 'never' const iso = new Date(lastFlushAt).toISOString() @@ -105,9 +134,7 @@ Toggle: brv analytics enable | brv analytics disable` this.log(`Last successful flush: ${this.formatLastFlush(response.lastFlushAt)}`) this.log(`Queue depth: ${response.queueDepth} events`) this.log(`Dropped events (this session): ${response.droppedCount}`) - this.log( - `Backoff state: ${response.backoff.state} (consecutive_failures=${response.backoff.consecutiveFailures}, next_delay_ms=${response.backoff.nextDelayMs})`, - ) + this.log(`Backoff state: ${response.backoff.state} (${this.formatBackoffSummary(response.backoff)})`) this.log(`Endpoint: ${response.endpoint}`) } diff --git a/test/commands/analytics/status.test.ts b/test/commands/analytics/status.test.ts index 27e0b517f..64f54d999 100644 --- a/test/commands/analytics/status.test.ts +++ b/test/commands/analytics/status.test.ts @@ -174,7 +174,7 @@ describe('analytics status command (M4.6)', () => { expect(loggedMessages.find((m) => m.includes('Last successful flush'))).to.include('(2d ago)') }) - it('backoff state "degraded": shows label + consecutive failures + next delay', async () => { + it('backoff state "degraded": shows label + consecutive failures + next delay (humanized)', async () => { mockStatusResponse({ ...HEALTHY_RESPONSE, backoff: {consecutiveFailures: 2, nextDelayMs: 120_000, state: 'degraded'}, @@ -183,9 +183,22 @@ describe('analytics status command (M4.6)', () => { await createCommand().run() const backoffLine = loggedMessages.find((m) => m.toLowerCase().includes('backoff')) - expect(backoffLine).to.include('degraded') - expect(backoffLine).to.include('2') - expect(backoffLine).to.include('120000') + expect(backoffLine, 'shows label').to.include('degraded') + expect(backoffLine, 'shows plural failure count').to.include('2 consecutive failures') + expect(backoffLine, 'humanizes next-delay ms (120000 → 2m)').to.include('next attempt in 2m') + }) + + it('singularises "1 consecutive failure" (no trailing s) on a single-failure backoff', async () => { + mockStatusResponse({ + ...HEALTHY_RESPONSE, + backoff: {consecutiveFailures: 1, nextDelayMs: 60_000, state: 'degraded'}, + }) + + await createCommand().run() + + const backoffLine = loggedMessages.find((m) => m.toLowerCase().includes('backoff')) + expect(backoffLine, 'singular').to.include('1 consecutive failure') + expect(backoffLine, 'humanized delay').to.include('next attempt in 1m') }) it('endpoint not configured: shows literal placeholder', async () => { diff --git a/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts index d4989a588..c7901fce5 100644 --- a/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts @@ -12,23 +12,14 @@ import {AnalyticsEvents} from '../../../../../../src/shared/transport/events/ana import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' /** - * M4.6 reachability label mapper. The ticket fixes the boundaries: - * - 0 failures → healthy - * - 1-2 failures → degraded - * - 3+ failures → unreachable + * M4.6 tests for the analytics-status surface: + * - the pure `consecutiveFailuresToReachabilityState` mapper + * - the `AnalyticsStatusHandler` composition (runtime state + + * backoff state + endpoint + enabled flag) * - * The policy exposes the raw counter via `consecutiveFailures()`; this - * mapper owns the user-facing label so the policy itself stays free of - * presentation concerns. - */ -/** - * Lightweight test doubles for the handler's three collaborators: - * - the analytics client (only `getRuntimeState` is read) - * - the backoff policy (only `consecutiveFailures` + `nextDelayMs`) - * - the cached-analytics getter from GlobalConfigHandler - * - * Hoisted above the top-level `describe` to satisfy - * `unicorn/consistent-function-scoping`. + * Test doubles below are hoisted above the top-level `describe` to + * satisfy `unicorn/consistent-function-scoping`. They cover only the + * surfaces the handler reads from each collaborator. */ type RuntimeStateSnapshot = { droppedCount: number From a264894b3ceca077c15887f1d2357d8c91949985 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 25 May 2026 01:21:09 +0700 Subject: [PATCH 50/87] feat: [ENG-2649] M4.7 rewrite e2e analytics harness as single mocha file --- CLAUDE.md | 55 ++++ package.json | 1 + test/e2e/analytics/dev-beta.e2e.ts | 501 +++++++++++++++++++++++++++++ 3 files changed, 557 insertions(+) create mode 100644 test/e2e/analytics/dev-beta.e2e.ts diff --git a/CLAUDE.md b/CLAUDE.md index be4d56e75..919f267f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,6 +143,61 @@ npm run dev:ui:package # Vite dev server resolving shared UI from - **HTTP (nock)**: Must verify `.matchHeader('authorization', ...)` + `.matchHeader('x-byterover-session-id', ...)` - **ES Modules**: Cannot stub ES exports with sinon; test utils with real filesystem (`tmpdir()`) +### M4.7 — analytics e2e against dev-beta (`test/e2e/analytics/dev-beta.e2e.ts`) + +End-to-end smoke for the analytics pipeline (M4.1 -> M4.6). One mocha file that spins up an isolated daemon per scenario under a temp `BRV_DATA_DIR` plus a temp `HOME`, exercises one slice via the real `bin/run.js`, and asserts on JSONL `status` transitions plus scenario-specific runtime state. NOT picked up by `npm test` (glob is `test/**/*.test.ts`; this file is `.e2e.ts`). + +**Run modes**: + +```bash +npm run test:e2e:analytics # all auto scenarios (transition skipped by default) +npm run test:e2e:analytics -- --grep "1 happy" # single scenario +BRV_E2E_TRANSITION=1 npm run test:e2e:analytics -- --grep transition # interactive login scenario +BRV_ANALYTICS_BASE_URL=http://127.0.0.1:3001 npm run test:e2e:analytics # override backend (e.g. local telemetry) +``` + +The npm script chains `npm run build && mocha ...` so `dist/` is always fresh when the test starts. + +Scenarios covered (`describe` names): + +1. `happy` — opt in, emit 1 event, ship within 35s +2. `burst` — 25 events via `analytics:track`, 20-event threshold flush +3. `idle` — 1 event, wait 45s for the interval flush +4. `transition` — anon -> `brv login` -> authed; interactive, gated on `BRV_E2E_TRANSITION=1` +5. `down` — backend down via inline drop-proxy on a random port; failed flush + backoff counters move +6. `disable` — ship 1 baseline, disable, queue more, no further ships + +**Prereqs**: + +- `npm install` (so `bin/run.js` and `node_modules/.bin` are present). `npm link` is NOT required: the test spawns `node /bin/run.js` directly so it always exercises THIS checkout. +- `npm run build` is chained in front of mocha inside the npm script; you only need to run it manually if you invoke `mocha` directly without going through `npm run test:e2e:analytics`. +- Network access to `dev-beta-iam.byterover.dev` and `telemetry-dev.byterover.dev` (the test defaults `BRV_ANALYTICS_BASE_URL` + `BRV_IAM_BASE_URL` to those; override via env to point at a different backend). +- **Backend on the M4.x wire format**: this CLI sends `timestamp` as epoch milliseconds (per M4.1). If the targeted backend is still on the older ISO-8601 schema, every scenario would FAIL with retry-cap exhaustion. The top-level `before()` runs one known-good POST and `this.skip()`s the entire suite with a clear reason if the backend rejects it - dev-beta in particular has historically been on the ISO schema, so coordinate with the telemetry team before running. +- For scenario 4: a browser to complete the OAuth login flow. + +**Test isolation**: each scenario builds a per-scenario `env` object with a temp `BRV_DATA_DIR` and a temp `HOME` and passes it to every `spawnSync(node, [bin/run.js, ...])`. That isolates the analytics JSONL queue, daemon log, auth token store, and the platform-derived global config path (`~/Library/Application Support/brv/config.json` on macOS) away from the developer's real profile. Teardown uses `brv restart` (with the scenario env) instead of `bin/kill-daemon.js` — `restart` properly cleans the SCENARIO's daemon + state files; calling `kill-daemon.js` without scoped env would read the user's real global `daemon.json` and leak the scenario daemon to the process table. The emit helper temporarily mutates `process.env.BRV_DATA_DIR` / `HOME` because `connectToDaemon` reads them for instance discovery, and restores in `finally` - safe because mocha runs scenarios sequentially. Do NOT pass `--parallel`. + +**What "PASS" actually proves**: + +The positive signal is `status: pending -> sent` in the JSONL for explicit post-enable test events. That flip happens only when the M4.2 `HttpAnalyticsSender` sees a 2xx response from the backend. So "PASS" means "the backend accepted the batch with 2xx" - sufficient for the M4.7 "events land within 30s" goal. + +Scenario 5 is narrower: it proves the daemon attempted to flush against an unavailable backend and that the backoff counters moved. It does not prove automatic recovery against dev-beta after the proxy is removed. + +The deeper "events actually exist in dev-beta's Postgres" check needs ops-only read credentials and is intentionally NOT in the auto path. If you have those credentials, the manual verification is: + +```sql +-- replace device_id with the value from your test's $BRV_DATA_DIR/config.json +SELECT id, event_name, identity_user_id, identity_device_id, received_at +FROM raw_events +WHERE identity_device_id = '' +ORDER BY received_at DESC +LIMIT 25; +``` + +**Scenario 4 operator step**: the test prints the exact `HOME=... BRV_DATA_DIR=... BRV_IAM_BASE_URL=... node /bin/run.js login` command to run in another terminal. Use that exact command so the login writes into the scenario's isolated token/config paths instead of your normal profile. + +**Estimated runtime**: ~3 minutes for the auto suite (driven by the 30-45s interval windows in scenarios 3 + 5 + 6). + ## Conventions - ES modules with `.js` import extensions required diff --git a/package.json b/package.json index 8e8751792..80484e464 100644 --- a/package.json +++ b/package.json @@ -210,6 +210,7 @@ "prepack": "npm run build && BRV_ENV=production oclif manifest", "prepare": "husky", "test": "mocha --forbid-only \"test/**/*.test.ts\"", + "test:e2e:analytics": "npm run build && mocha --forbid-only --timeout 600000 \"test/e2e/analytics/dev-beta.e2e.ts\"", "typecheck": "tsc --noEmit && tsc --noEmit -p src/webui/tsconfig.json", "version": "git add README.md" }, diff --git a/test/e2e/analytics/dev-beta.e2e.ts b/test/e2e/analytics/dev-beta.e2e.ts new file mode 100644 index 000000000..6a8271ecb --- /dev/null +++ b/test/e2e/analytics/dev-beta.e2e.ts @@ -0,0 +1,501 @@ +/* eslint-disable camelcase, no-await-in-loop */ +/** + * M4.7 (ENG-2649) end-to-end smoke for the analytics pipeline against a + * real backend. Replaces the prior shell + .mjs harness with a single + * mocha file that still drives the real `brv` binary, real daemon, and + * real HTTP path - so PASS here still means "the backend accepted the + * batch with 2xx". + * + * Not picked up by `npm test` (glob is "test/**\/*.test.ts"); run via + * `npm run test:e2e:analytics`. The `pretest:e2e:analytics` npm hook + * runs `npm run build` automatically so `dist/` is fresh; no `npm link` + * is required because the test spawns `bin/run.js` directly from the + * repo (so the harness always exercises THIS checkout, never a globally + * linked one). + * + * Per-scenario isolation uses a temp `BRV_DATA_DIR` + temp `HOME`, and + * `brv restart` (with the scenario's env) is used for teardown so it + * properly cleans the SCENARIO's daemon (`bin/kill-daemon.js` alone + * would read the user's real global daemon.json and leak scenario + * daemons to the process table). + * + * Sequential by design. Do NOT run with `--parallel`: scenarios mutate + * `process.env.BRV_DATA_DIR` / `HOME` inside `emitEvents` and restore + * in `finally`. Parallel runs would corrupt each other. + */ + +import {expect} from 'chai' +import {spawnSync} from 'node:child_process' +import {randomUUID} from 'node:crypto' +import {existsSync, mkdtempSync, readFileSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {createServer, type Server as NetServer} from 'node:net' +import {tmpdir} from 'node:os' +import {dirname, join, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' + +import {resolveLocalServerMainPath} from '../../../src/server/utils/server-main-resolver.js' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(HERE, '..', '..', '..') +const BRV_BIN = join(REPO_ROOT, 'bin', 'run.js') +const DEFAULT_BACKEND = process.env.BRV_ANALYTICS_BASE_URL ?? 'https://telemetry-dev.byterover.dev' +const DEFAULT_IAM = process.env.BRV_IAM_BASE_URL ?? 'https://dev-beta-iam.byterover.dev' +const DIST_DAEMON = join(REPO_ROOT, 'dist', 'server', 'infra', 'daemon', 'brv-server.js') + +type ScenarioEnv = { + dataDir: string + env: NodeJS.ProcessEnv + home: string +} + +type JsonlRow = { + attempts?: number + id?: string + identity?: {device_id?: string; user_id?: string} + name?: string + status?: 'failed' | 'pending' | 'sent' +} + +function sleep(ms: number): Promise { + return new Promise((res) => { + setTimeout(res, ms) + }) +} + +function makeScenarioEnv(overrideBackend?: string): ScenarioEnv { + const dataDir = mkdtempSync(join(tmpdir(), 'brv-e2e-')) + const home = mkdtempSync(join(tmpdir(), 'brv-home-')) + return { + dataDir, + env: { + ...process.env, + BRV_ANALYTICS_BASE_URL: overrideBackend ?? DEFAULT_BACKEND, + BRV_DATA_DIR: dataDir, + BRV_ENV: 'development', + BRV_IAM_BASE_URL: DEFAULT_IAM, + HOME: home, + }, + home, + } +} + +function runBrv(args: string[], env: NodeJS.ProcessEnv, timeoutMs = 30_000): {ok: boolean; reason?: string} { + // process.execPath (the current node) + bin/run.js avoids depending on + // `npm link` and always exercises THIS checkout. The scenario env carries + // BRV_DATA_DIR / HOME / BRV_ANALYTICS_BASE_URL — all the toggles brv reads. + const result = spawnSync(process.execPath, [BRV_BIN, ...args], {env, stdio: 'ignore', timeout: timeoutMs}) + if (result.error) return {ok: false, reason: `brv ${args.join(' ')} failed to spawn: ${result.error.message}`} + if (result.status !== 0) return {ok: false, reason: `brv ${args.join(' ')} exit ${result.status}`} + return {ok: true} +} + +/** + * Tears down the daemon for `env.BRV_DATA_DIR` via `brv restart`, which: + * 1. kills brv client procs (TUI/MCP/headless), + * 2. SIGTERM/SIGKILL the daemon registered in `${BRV_DATA_DIR}/daemon.json`, + * 3. pattern-kills orphan brv-server.js / agent-process.js procs, + * 4. removes daemon.json / heartbeat / spawn-lock from the scoped data dir. + * Returning `void` because failures are non-fatal: the temp dir is about to + * be rm -rf'd anyway, and the next scenario boots into its own fresh dir. + */ +function restartBrv(env: NodeJS.ProcessEnv): void { + spawnSync(process.execPath, [BRV_BIN, 'restart'], {env, stdio: 'ignore', timeout: 30_000}) +} + +function bootDaemon(env: NodeJS.ProcessEnv): {ok: boolean; reason?: string} { + return runBrv(['status'], env) +} + +function enableAnalytics(env: NodeJS.ProcessEnv): {ok: boolean; reason?: string} { + return runBrv(['analytics', 'enable', '--yes'], env) +} + +function disableAnalytics(env: NodeJS.ProcessEnv): {ok: boolean; reason?: string} { + return runBrv(['analytics', 'disable'], env) +} + +function jsonlPath(dataDir: string): string { + return join(dataDir, 'analytics-queue.jsonl') +} + +function readRows(path: string): JsonlRow[] { + if (!existsSync(path)) return [] + const content = readFileSync(path, 'utf8') + const rows: JsonlRow[] = [] + for (const line of content.split('\n')) { + if (line.length === 0) continue + rows.push(JSON.parse(line) as JsonlRow) + } + + return rows +} + +function countStatus(path: string, status: 'failed' | 'pending' | 'sent'): number { + return readRows(path).filter((r) => r.status === status).length +} + +async function waitFor(predicate: () => boolean, timeoutMs: number, intervalMs = 1000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) return true + await sleep(intervalMs) + } + + return predicate() +} + +async function waitForStatus( + path: string, + target: 'failed' | 'pending' | 'sent', + timeoutMs: number, +): Promise { + return waitFor(() => { + const rows = readRows(path) + const last = rows.at(-1) + return last !== undefined && last.status === target + }, timeoutMs) +} + +/** + * Emits `count` cli_invocation events via `analytics:track` against the + * daemon under `env.BRV_DATA_DIR`. Temporarily mutates `process.env` + * because `connectToDaemon` reads it for instance discovery, then + * restores in `finally`. Safe because mocha runs `describe`/`it` + * sequentially - do NOT add `--parallel`. + */ +async function emitEvents(count: number, env: NodeJS.ProcessEnv): Promise<{failed: number; succeeded: number}> { + const prev = { + BRV_ANALYTICS_BASE_URL: process.env.BRV_ANALYTICS_BASE_URL, + BRV_DATA_DIR: process.env.BRV_DATA_DIR, + BRV_ENV: process.env.BRV_ENV, + BRV_IAM_BASE_URL: process.env.BRV_IAM_BASE_URL, + HOME: process.env.HOME, + } + process.env.BRV_ANALYTICS_BASE_URL = env.BRV_ANALYTICS_BASE_URL + process.env.BRV_DATA_DIR = env.BRV_DATA_DIR + process.env.BRV_ENV = env.BRV_ENV + process.env.BRV_IAM_BASE_URL = env.BRV_IAM_BASE_URL + process.env.HOME = env.HOME + try { + const {connectToDaemon} = await import('@campfirein/brv-transport-client') + const {client} = await connectToDaemon({ + clientType: 'cli', + fromDir: REPO_ROOT, + projectPath: REPO_ROOT, + serverPath: resolveLocalServerMainPath(), + }) + const now = Date.now() + let succeeded = 0 + let failed = 0 + for (let i = 0; i < count; i++) { + try { + await client.requestWithAck('analytics:track', { + event: 'cli_invocation', + properties: { + client_sent_at: now + i, + command_id: `e2e-${randomUUID().slice(0, 8)}-${i}`, + flag_names: [], + is_ci: false, + is_tty: false, + package_manager: 'npm', + runtime: 'node', + }, + }) + succeeded += 1 + } catch { + failed += 1 + } + } + + await client.disconnect() + return {failed, succeeded} + } finally { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } +} + +async function startDropProxy(): Promise<{close: () => Promise; port: number}> { + return new Promise((res, rej) => { + const server: NetServer = createServer((socket) => socket.destroy()) + server.on('error', rej) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() + if (addr === null || typeof addr === 'string') { + rej(new Error('drop-proxy: unexpected address shape')) + return + } + + res({ + close: () => + new Promise((closeRes) => { + server.close(() => closeRes()) + }), + port: addr.port, + }) + }) + }) +} + +function analyticsStatusJson(env: NodeJS.ProcessEnv): Record | undefined { + const result = spawnSync('brv', ['analytics', 'status', '--format', 'json'], {env, timeout: 15_000}) + if (result.status !== 0) return undefined + try { + return JSON.parse(result.stdout.toString()) as Record + } catch { + return undefined + } +} + +async function preflightBackend(url: string): Promise<{ok: boolean; reason?: string}> { + // Mirrors scripts/e2e-analytics.sh:170-195 — send one known-good M4.x + // wire-format batch and check that the backend accepts it. If the + // deployment is still on the old ISO timestamp schema, all scenarios + // would FAIL with retry-cap exhaustion; better to skip the suite + // up-front with a clear reason. + const body = JSON.stringify({ + events: [ + { + identity: {device_id: 'e2e-preflight'}, + name: 'daemon_start', + properties: { + cli_version: '3.12.0', + environment: 'development', + node_version: process.version, + os: process.platform, + }, + timestamp: Date.now(), + }, + ], + schema_version: 1, + }) + try { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), 8000) + // eslint-disable-next-line n/no-unsupported-features/node-builtins + const res = await fetch(`${url}/v1/events`, { + body, + headers: {'content-type': 'application/json', 'x-byterover-device-id': 'e2e-preflight'}, + method: 'POST', + signal: ctrl.signal, + }) + clearTimeout(timer) + if (res.status >= 200 && res.status < 300) return {ok: true} + if (res.status === 400) { + return { + ok: false, + reason: `backend at ${url} returned 400 to the M4.x wire format - likely still on the older ISO-8601 timestamp schema`, + } + } + + return {ok: false, reason: `unexpected preflight status ${res.status} from ${url}`} + } catch (error) { + return {ok: false, reason: `preflight unreachable: ${(error as Error).message}`} + } +} + +describe('M4.7 analytics e2e (real CLI, real daemon, real backend)', function () { + this.timeout(600_000) + + const cleanupDirs: string[] = [] + let currentScenario: ScenarioEnv | undefined + + before(async function () { + if (!existsSync(BRV_BIN)) { + console.log(`[M4.7 e2e] ${BRV_BIN} missing - run \`npm install\` first. Skipping suite.`) + this.skip() + } + + if (!existsSync(DIST_DAEMON)) { + console.log( + `[M4.7 e2e] ${DIST_DAEMON} missing - run \`npm run build\` first ` + + `(the npm script does this automatically via pretest:e2e:analytics). Skipping suite.`, + ) + this.skip() + } + + const pre = await preflightBackend(DEFAULT_BACKEND) + if (!pre.ok) { + console.log(`[M4.7 e2e] preflight failed: ${pre.reason}. Skipping suite.`) + this.skip() + } + }) + + afterEach(async () => { + // Tear down the SCENARIO's daemon (scoped via env). Skipping this would + // leak a node process per scenario - bin/kill-daemon.js without scoped env + // would read the user's real ~/Library/.../daemon.json instead. + if (currentScenario !== undefined) { + restartBrv(currentScenario.env) + currentScenario = undefined + } + + await sleep(500) + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop() + if (dir !== undefined && existsSync(dir)) { + await rm(dir, {force: true, recursive: true}) + } + } + }) + + describe('1 happy', () => { + it('opt-in + 1 event ships within 35s', async () => { + const scenario = makeScenarioEnv() + currentScenario = scenario + cleanupDirs.push(scenario.dataDir, scenario.home) + expect(enableAnalytics(scenario.env)).to.deep.include({ok: true}) + expect(bootDaemon(scenario.env)).to.deep.include({ok: true}) + const emit = await emitEvents(1, scenario.env) + expect(emit.failed, 'emit failures').to.equal(0) + const ok = await waitForStatus(jsonlPath(scenario.dataDir), 'sent', 35_000) + expect(ok, `timeout waiting for status=sent in ${jsonlPath(scenario.dataDir)}`).to.equal(true) + }) + }) + + describe('2 burst', () => { + it('25 events trigger the 20-event threshold flush', async function () { + this.timeout(120_000) + const scenario = makeScenarioEnv() + currentScenario = scenario + cleanupDirs.push(scenario.dataDir, scenario.home) + expect(enableAnalytics(scenario.env)).to.deep.include({ok: true}) + expect(bootDaemon(scenario.env)).to.deep.include({ok: true}) + const emit = await emitEvents(25, scenario.env) + expect(emit.failed, 'emit failures').to.equal(0) + // 20-event threshold ships first batch immediately; periodic 30s + // tick catches stragglers. Wait up to 60s for >= 25 sent. + const ok = await waitFor(() => countStatus(jsonlPath(scenario.dataDir), 'sent') >= 25, 60_000, 2000) + const sent = countStatus(jsonlPath(scenario.dataDir), 'sent') + const pending = countStatus(jsonlPath(scenario.dataDir), 'pending') + expect(ok, `only ${sent} sent after 60s (pending=${pending})`).to.equal(true) + }) + }) + + describe('3 idle', () => { + it('1 event ships via the 30s interval timer', async function () { + this.timeout(90_000) + const scenario = makeScenarioEnv() + currentScenario = scenario + cleanupDirs.push(scenario.dataDir, scenario.home) + expect(enableAnalytics(scenario.env)).to.deep.include({ok: true}) + expect(bootDaemon(scenario.env)).to.deep.include({ok: true}) + const emit = await emitEvents(1, scenario.env) + expect(emit.failed, 'emit failures').to.equal(0) + const ok = await waitForStatus(jsonlPath(scenario.dataDir), 'sent', 45_000) + expect(ok, 'timeout waiting for interval-driven flush').to.equal(true) + }) + }) + + describe('4 transition (manual brv login)', () => { + const enabled = process.env.BRV_E2E_TRANSITION === '1' + const itMaybe = enabled ? it : it.skip + itMaybe('anon -> brv login -> authed, both ship with correct identity', async function () { + this.timeout(360_000) + const scenario = makeScenarioEnv() + currentScenario = scenario + cleanupDirs.push(scenario.dataDir, scenario.home) + expect(enableAnalytics(scenario.env)).to.deep.include({ok: true}) + expect(bootDaemon(scenario.env)).to.deep.include({ok: true}) + expect((await emitEvents(1, scenario.env)).failed).to.equal(0) + expect(await waitForStatus(jsonlPath(scenario.dataDir), 'sent', 35_000), 'anon event did not ship').to.equal(true) + + console.log('\n[M4.7 e2e] transition: anon event shipped. NOW run this exact command in another terminal:\n') + console.log( + ` HOME='${scenario.home}' BRV_DATA_DIR='${scenario.dataDir}' ` + + `BRV_IAM_BASE_URL='${DEFAULT_IAM}' BRV_ENV=development ` + + `node '${BRV_BIN}' login\n`, + ) + console.log('[M4.7 e2e] waiting up to 5 minutes for credentials to appear...\n') + + const credentialsPath = join(scenario.dataDir, 'credentials') + const loggedIn = await waitFor(() => existsSync(credentialsPath), 300_000, 2000) + expect(loggedIn, 'no login detected within 5 minutes').to.equal(true) + + // Pre-hook flushes anon batch; post-hook clears the on-disk queue. + // Wait for that to settle before emitting the authed event. + await sleep(10_000) + expect((await emitEvents(1, scenario.env)).failed).to.equal(0) + expect(await waitForStatus(jsonlPath(scenario.dataDir), 'sent', 45_000), 'post-login event did not ship').to.equal( + true, + ) + const rows = readRows(jsonlPath(scenario.dataDir)) + const last = rows.at(-1) + const userId = last?.identity?.user_id ?? '' + expect(userId, 'post-login event must carry a user_id').to.be.a('string').and.have.length.greaterThan(0) + }) + }) + + describe('5 down (drop-proxy)', () => { + let proxy: undefined | {close: () => Promise; port: number} + + afterEach(async () => { + if (proxy) { + await proxy.close() + proxy = undefined + } + }) + + it('failed flush advances backoff counters', async function () { + this.timeout(120_000) + proxy = await startDropProxy() + const backend = `http://127.0.0.1:${proxy.port}` + const scenario = makeScenarioEnv(backend) + currentScenario = scenario + cleanupDirs.push(scenario.dataDir, scenario.home) + expect(enableAnalytics(scenario.env)).to.deep.include({ok: true}) + expect(bootDaemon(scenario.env)).to.deep.include({ok: true}) + expect((await emitEvents(1, scenario.env)).failed).to.equal(0) + + // One 30s tick + slack for retry classification. + await sleep(35_000) + + const rows = readRows(jsonlPath(scenario.dataDir)) + const tracked = rows.filter((r) => r.name === 'cli_invocation') + let maxAttempts = 0 + for (const row of tracked) { + if ((row.attempts ?? 0) > maxAttempts) maxAttempts = row.attempts ?? 0 + } + + expect(maxAttempts, 'daemon never attempted to flush against drop-proxy').to.be.greaterThan(0) + + const status = analyticsStatusJson(scenario.env) + const backoff = (status?.data as undefined | {backoff?: Record})?.backoff + const failures = (backoff?.consecutive_failures as number | undefined) ?? 0 + const state = (backoff?.state as string | undefined) ?? 'unknown' + expect(failures, `expected backoff.consecutive_failures > 0 (state=${state})`).to.be.greaterThan(0) + }) + }) + + describe('6 disable mid-flight', () => { + it('post-disable events stay pending, no further ships', async function () { + this.timeout(120_000) + const scenario = makeScenarioEnv() + currentScenario = scenario + cleanupDirs.push(scenario.dataDir, scenario.home) + expect(enableAnalytics(scenario.env)).to.deep.include({ok: true}) + expect(bootDaemon(scenario.env)).to.deep.include({ok: true}) + + expect((await emitEvents(1, scenario.env)).failed).to.equal(0) + expect(await waitForStatus(jsonlPath(scenario.dataDir), 'sent', 35_000), 'baseline event did not ship').to.equal( + true, + ) + const sentBefore = countStatus(jsonlPath(scenario.dataDir), 'sent') + + expect(disableAnalytics(scenario.env)).to.deep.include({ok: true}) + + // After disable, `analytics:track` still writes to JSONL but the + // flush scheduler is gated, so status should stay pending. + expect((await emitEvents(5, scenario.env)).failed).to.equal(0) + await sleep(35_000) + + const sentAfter = countStatus(jsonlPath(scenario.dataDir), 'sent') + const pendingAfter = countStatus(jsonlPath(scenario.dataDir), 'pending') + expect(sentAfter, `expected no new sends after disable (was ${sentBefore})`).to.equal(sentBefore) + expect(pendingAfter, 'expected >= 5 pending rows after disable').to.be.at.least(5) + }) + }) +}) From 62c71a2650bf5223eb641838003c7925700c43aa Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 25 May 2026 08:33:01 +0700 Subject: [PATCH 51/87] feat: [ENG-2649] M4.7 scenario 5 backend recovery half via accept-proxy --- CLAUDE.md | 6 +- test/e2e/analytics/dev-beta.e2e.ts | 97 +++++++++++++++++++++++++----- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 919f267f1..5862f6730 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,7 +172,7 @@ Scenarios covered (`describe` names): - `npm install` (so `bin/run.js` and `node_modules/.bin` are present). `npm link` is NOT required: the test spawns `node /bin/run.js` directly so it always exercises THIS checkout. - `npm run build` is chained in front of mocha inside the npm script; you only need to run it manually if you invoke `mocha` directly without going through `npm run test:e2e:analytics`. - Network access to `dev-beta-iam.byterover.dev` and `telemetry-dev.byterover.dev` (the test defaults `BRV_ANALYTICS_BASE_URL` + `BRV_IAM_BASE_URL` to those; override via env to point at a different backend). -- **Backend on the M4.x wire format**: this CLI sends `timestamp` as epoch milliseconds (per M4.1). If the targeted backend is still on the older ISO-8601 schema, every scenario would FAIL with retry-cap exhaustion. The top-level `before()` runs one known-good POST and `this.skip()`s the entire suite with a clear reason if the backend rejects it - dev-beta in particular has historically been on the ISO schema, so coordinate with the telemetry team before running. +- **Backend on the M4.x wire format**: this CLI sends `timestamp` as epoch milliseconds (per M4.1). The top-level `before()` runs one known-good POST and `this.skip()`s the entire suite with a clear reason if the backend rejects it. Last verified green against `https://telemetry-dev.byterover.dev` on 2026-05-25. If a future deployment regresses to the older ISO-8601 schema, every scenario would FAIL with retry-cap exhaustion - coordinate with the telemetry team before running. - For scenario 4: a browser to complete the OAuth login flow. **Test isolation**: each scenario builds a per-scenario `env` object with a temp `BRV_DATA_DIR` and a temp `HOME` and passes it to every `spawnSync(node, [bin/run.js, ...])`. That isolates the analytics JSONL queue, daemon log, auth token store, and the platform-derived global config path (`~/Library/Application Support/brv/config.json` on macOS) away from the developer's real profile. Teardown uses `brv restart` (with the scenario env) instead of `bin/kill-daemon.js` — `restart` properly cleans the SCENARIO's daemon + state files; calling `kill-daemon.js` without scoped env would read the user's real global `daemon.json` and leak the scenario daemon to the process table. The emit helper temporarily mutates `process.env.BRV_DATA_DIR` / `HOME` because `connectToDaemon` reads them for instance discovery, and restores in `finally` - safe because mocha runs scenarios sequentially. Do NOT pass `--parallel`. @@ -181,9 +181,9 @@ Scenarios covered (`describe` names): The positive signal is `status: pending -> sent` in the JSONL for explicit post-enable test events. That flip happens only when the M4.2 `HttpAnalyticsSender` sees a 2xx response from the backend. So "PASS" means "the backend accepted the batch with 2xx" - sufficient for the M4.7 "events land within 30s" goal. -Scenario 5 is narrower: it proves the daemon attempted to flush against an unavailable backend and that the backoff counters moved. It does not prove automatic recovery against dev-beta after the proxy is removed. +Scenario 5 covers both halves of the M4.7 "backend down -> recovery" test: Phase A boots a TCP drop-proxy on a random localhost port, points the CLI at it, and asserts `backoff.consecutive_failures > 0` after the first flush tick; Phase B closes the drop-proxy and brings up a HTTP accept-proxy on the SAME port, then polls (up to ~90s, since M4.5 exponential backoff delays the next retry) for `consecutive_failures` to drop back to 0 + at least one row to flip to `status=sent`. -The deeper "events actually exist in dev-beta's Postgres" check needs ops-only read credentials and is intentionally NOT in the auto path. If you have those credentials, the manual verification is: +**Postgres-side verification (DoD #7-8) is intentionally NOT in the auto path.** It needs ops-only read credentials that aren't available to the harness. The CLI-side proof (JSONL `status=sent` + backend 2xx) is sufficient for "the pipeline ships and the backend accepts" - asserting the row exists in dev-beta's `raw_events` is a separate ops/manual step. If you have those credentials, the manual SQL is: ```sql -- replace device_id with the value from your test's $BRV_DATA_DIR/config.json diff --git a/test/e2e/analytics/dev-beta.e2e.ts b/test/e2e/analytics/dev-beta.e2e.ts index 6a8271ecb..04ab899d8 100644 --- a/test/e2e/analytics/dev-beta.e2e.ts +++ b/test/e2e/analytics/dev-beta.e2e.ts @@ -29,6 +29,7 @@ import {spawnSync} from 'node:child_process' import {randomUUID} from 'node:crypto' import {existsSync, mkdtempSync, readFileSync} from 'node:fs' import {rm} from 'node:fs/promises' +import {createServer as createHttpServer, type Server as HttpServer} from 'node:http' import {createServer, type Server as NetServer} from 'node:net' import {tmpdir} from 'node:os' import {dirname, join, resolve} from 'node:path' @@ -240,8 +241,36 @@ async function startDropProxy(): Promise<{close: () => Promise; port: numb }) } +/** + * Minimal HTTP backend that responds 200 to anything. Used as Phase B of + * scenario 5 to simulate "backend came back up" on the same port the + * drop-proxy was using - so the daemon (still pointed at that URL) sees + * a successful flush and the M4.5 backoff policy resets + * `consecutive_failures` to 0. + */ +async function startAcceptProxy(port: number): Promise<{close: () => Promise}> { + return new Promise((res, rej) => { + const server: HttpServer = createHttpServer((_req, response) => { + response.writeHead(200, {'content-type': 'application/json'}) + response.end('{"ok":true}') + }) + server.on('error', rej) + server.listen(port, '127.0.0.1', () => { + res({ + close: () => + new Promise((closeRes) => { + server.close(() => closeRes()) + }), + }) + }) + }) +} + function analyticsStatusJson(env: NodeJS.ProcessEnv): Record | undefined { - const result = spawnSync('brv', ['analytics', 'status', '--format', 'json'], {env, timeout: 15_000}) + const result = spawnSync(process.execPath, [BRV_BIN, 'analytics', 'status', '--format', 'json'], { + env, + timeout: 15_000, + }) if (result.status !== 0) return undefined try { return JSON.parse(result.stdout.toString()) as Record @@ -250,6 +279,15 @@ function analyticsStatusJson(env: NodeJS.ProcessEnv): Record | } } +function readBackoffFailures(env: NodeJS.ProcessEnv): {failures: number; state: string} { + const status = analyticsStatusJson(env) + const backoff = (status?.data as undefined | {backoff?: Record})?.backoff + return { + failures: (backoff?.consecutive_failures as number | undefined) ?? -1, + state: (backoff?.state as string | undefined) ?? 'unknown', + } +} + async function preflightBackend(url: string): Promise<{ok: boolean; reason?: string}> { // Mirrors scripts/e2e-analytics.sh:170-195 — send one known-good M4.x // wire-format batch and check that the backend accepts it. If the @@ -430,19 +468,29 @@ describe('M4.7 analytics e2e (real CLI, real daemon, real backend)', function () }) describe('5 down (drop-proxy)', () => { - let proxy: undefined | {close: () => Promise; port: number} + let dropProxy: undefined | {close: () => Promise; port: number} + let acceptProxy: undefined | {close: () => Promise} afterEach(async () => { - if (proxy) { - await proxy.close() - proxy = undefined + if (acceptProxy) { + await acceptProxy.close() + acceptProxy = undefined + } + + if (dropProxy) { + await dropProxy.close() + dropProxy = undefined } }) - it('failed flush advances backoff counters', async function () { - this.timeout(120_000) - proxy = await startDropProxy() - const backend = `http://127.0.0.1:${proxy.port}` + it('failed flush advances backoff counters AND backend-up resets them', async function () { + // Phase A (~35s drop wait) + Phase B (~35s accept wait) + boot/emit + slack. + this.timeout(180_000) + + // -------- Phase A: backend down -------- + dropProxy = await startDropProxy() + const {port} = dropProxy + const backend = `http://127.0.0.1:${port}` const scenario = makeScenarioEnv(backend) currentScenario = scenario cleanupDirs.push(scenario.dataDir, scenario.home) @@ -461,12 +509,33 @@ describe('M4.7 analytics e2e (real CLI, real daemon, real backend)', function () } expect(maxAttempts, 'daemon never attempted to flush against drop-proxy').to.be.greaterThan(0) + const downState = readBackoffFailures(scenario.env) + expect( + downState.failures, + `expected backoff.consecutive_failures > 0 (state=${downState.state})`, + ).to.be.greaterThan(0) + + // -------- Phase B: backend back up, observe recovery -------- + // Free the port so the accept-proxy can bind it. Daemon URL doesn't + // change - the next flush tick will hit the new server on same port. + await dropProxy.close() + dropProxy = undefined + acceptProxy = await startAcceptProxy(port) + + // Emit one fresh event so the queue is non-empty after Phase A wipes. + // M4.5 backoff is exponential, so a single 30s tick may not trigger + // the next retry attempt - poll up to 90s for `consecutive_failures` + // to drop back to 0 (covers up to ~3 backoff windows). + expect((await emitEvents(1, scenario.env)).failed).to.equal(0) + const recovered = await waitFor(() => readBackoffFailures(scenario.env).failures === 0, 90_000, 2000) - const status = analyticsStatusJson(scenario.env) - const backoff = (status?.data as undefined | {backoff?: Record})?.backoff - const failures = (backoff?.consecutive_failures as number | undefined) ?? 0 - const state = (backoff?.state as string | undefined) ?? 'unknown' - expect(failures, `expected backoff.consecutive_failures > 0 (state=${state})`).to.be.greaterThan(0) + const upState = readBackoffFailures(scenario.env) + expect( + recovered, + `expected backoff.consecutive_failures to reset to 0 within 90s (was ${downState.failures}, now ${upState.failures}, state=${upState.state})`, + ).to.equal(true) + const sentAfter = countStatus(jsonlPath(scenario.dataDir), 'sent') + expect(sentAfter, 'expected >=1 sent row after backend recovery').to.be.at.least(1) }) }) From 785293ffaa1a242a3eea02d2afa065e8dcd8300d Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Mon, 25 May 2026 09:00:13 +0700 Subject: [PATCH 52/87] chore: fix: conflict resolve proj/analytics-system --- src/oclif/commands/curate/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/oclif/commands/curate/index.ts b/src/oclif/commands/curate/index.ts index c77b3385f..1d716c99d 100644 --- a/src/oclif/commands/curate/index.ts +++ b/src/oclif/commands/curate/index.ts @@ -176,7 +176,16 @@ Bad examples: await ensureBillingFunds({billing, client}) } - await this.submitTask({client, content: resolvedContent, flags, format, projectRoot, taskType, worktreeRoot}) + await this.submitTask({ + client, + cliMetadata, + content: resolvedContent, + flags, + format, + projectRoot, + taskType, + worktreeRoot, + }) }, { ...this.getDaemonClientOptions(), From b09388ba43a80ec3e1d5a6b534d5be3860712f4d Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 27 May 2026 13:48:43 +0700 Subject: [PATCH 53/87] feat: [ENG-2961] M15.1 foundation + lifecycle outcome taxonomy Foundation pieces: 36 per-event Zod schemas (33 carrying the lifecycle outcome taxonomy with required `outcome: 'success' | 'failure'` and optional coarse `failure_kind` tag; 3 exempt observation events), `client_kind` AsyncLocalStorage context wired at the Socket.IO transport boundary so every emit automatically inherits the originating ClientType via the super-property layer, and pilot emits in auth-handler (OAuth + API-key login + logout, both terminals) and global-config-handler (analytics_disabled before cache flip). --- .../analytics/i-super-properties-resolver.ts | 7 + .../analytics/super-properties-resolver.ts | 3 + src/server/infra/daemon/brv-server.ts | 9 + src/server/infra/process/feature-handlers.ts | 4 + .../infra/transport/client-kind-context.ts | 32 ++ .../transport/handlers/analytics-handler.ts | 293 +++++++++++++++++- .../infra/transport/handlers/auth-handler.ts | 65 ++++ .../handlers/global-config-handler.ts | 18 ++ .../transport/socket-io-transport-server.ts | 33 +- src/shared/analytics/event-names.ts | 36 +++ .../analytics/events/analytics-disabled.ts | 13 + src/shared/analytics/events/auth-login.ts | 30 ++ src/shared/analytics/events/auth-logout.ts | 25 ++ src/shared/analytics/events/brv-init.ts | 22 ++ .../analytics/events/connector-installed.ts | 20 ++ .../events/context-tree-file-edited.ts | 21 ++ .../analytics/events/daemon-reset-executed.ts | 17 + .../analytics/events/hub-package-installed.ts | 21 ++ .../analytics/events/hub-registry-added.ts | 20 ++ .../analytics/events/hub-registry-removed.ts | 15 + src/shared/analytics/events/index.ts | 108 +++++++ .../events/onboarding-auto-setup-started.ts | 20 ++ .../analytics/events/onboarding-completed.ts | 18 ++ .../analytics/events/review-approved.ts | 19 ++ .../analytics/events/review-rejected.ts | 19 ++ src/shared/analytics/events/review-toggled.ts | 19 ++ .../analytics/events/setting-changed.ts | 22 ++ src/shared/analytics/events/setting-reset.ts | 18 ++ src/shared/analytics/events/source-added.ts | 20 ++ src/shared/analytics/events/source-removed.ts | 15 + src/shared/analytics/events/space-switched.ts | 19 ++ src/shared/analytics/events/vc-branched.ts | 18 ++ src/shared/analytics/events/vc-checked-out.ts | 18 ++ src/shared/analytics/events/vc-cloned.ts | 19 ++ src/shared/analytics/events/vc-commit.ts | 21 ++ src/shared/analytics/events/vc-discarded.ts | 16 + src/shared/analytics/events/vc-fetched.ts | 16 + src/shared/analytics/events/vc-init.ts | 18 ++ src/shared/analytics/events/vc-merged.ts | 18 ++ src/shared/analytics/events/vc-pulled.ts | 21 ++ src/shared/analytics/events/vc-pushed.ts | 19 ++ .../analytics/events/vc-remote-changed.ts | 19 ++ .../analytics/events/vc-reset-executed.ts | 16 + .../analytics/events/webui-session-ended.ts | 22 ++ .../analytics/events/webui-session-started.ts | 20 ++ src/shared/analytics/events/worktree-added.ts | 19 ++ .../analytics/events/worktree-removed.ts | 15 + .../transport/handlers/auth-handler.test.ts | 129 ++++++++ .../handlers/global-config-handler.test.ts | 115 +++++++ .../socket-io-transport-server.test.ts | 75 +++++ .../super-properties-resolver.test.ts | 36 +++ .../transport/client-kind-context.test.ts | 67 ++++ .../unit/shared/analytics/event-names.test.ts | 38 ++- .../shared/analytics/privacy-fixture.test.ts | 36 +++ 54 files changed, 1787 insertions(+), 5 deletions(-) create mode 100644 src/server/infra/transport/client-kind-context.ts create mode 100644 src/shared/analytics/events/analytics-disabled.ts create mode 100644 src/shared/analytics/events/auth-login.ts create mode 100644 src/shared/analytics/events/auth-logout.ts create mode 100644 src/shared/analytics/events/brv-init.ts create mode 100644 src/shared/analytics/events/connector-installed.ts create mode 100644 src/shared/analytics/events/context-tree-file-edited.ts create mode 100644 src/shared/analytics/events/daemon-reset-executed.ts create mode 100644 src/shared/analytics/events/hub-package-installed.ts create mode 100644 src/shared/analytics/events/hub-registry-added.ts create mode 100644 src/shared/analytics/events/hub-registry-removed.ts create mode 100644 src/shared/analytics/events/onboarding-auto-setup-started.ts create mode 100644 src/shared/analytics/events/onboarding-completed.ts create mode 100644 src/shared/analytics/events/review-approved.ts create mode 100644 src/shared/analytics/events/review-rejected.ts create mode 100644 src/shared/analytics/events/review-toggled.ts create mode 100644 src/shared/analytics/events/setting-changed.ts create mode 100644 src/shared/analytics/events/setting-reset.ts create mode 100644 src/shared/analytics/events/source-added.ts create mode 100644 src/shared/analytics/events/source-removed.ts create mode 100644 src/shared/analytics/events/space-switched.ts create mode 100644 src/shared/analytics/events/vc-branched.ts create mode 100644 src/shared/analytics/events/vc-checked-out.ts create mode 100644 src/shared/analytics/events/vc-cloned.ts create mode 100644 src/shared/analytics/events/vc-commit.ts create mode 100644 src/shared/analytics/events/vc-discarded.ts create mode 100644 src/shared/analytics/events/vc-fetched.ts create mode 100644 src/shared/analytics/events/vc-init.ts create mode 100644 src/shared/analytics/events/vc-merged.ts create mode 100644 src/shared/analytics/events/vc-pulled.ts create mode 100644 src/shared/analytics/events/vc-pushed.ts create mode 100644 src/shared/analytics/events/vc-remote-changed.ts create mode 100644 src/shared/analytics/events/vc-reset-executed.ts create mode 100644 src/shared/analytics/events/webui-session-ended.ts create mode 100644 src/shared/analytics/events/webui-session-started.ts create mode 100644 src/shared/analytics/events/worktree-added.ts create mode 100644 src/shared/analytics/events/worktree-removed.ts create mode 100644 test/unit/server/infra/transport/client-kind-context.test.ts diff --git a/src/server/core/interfaces/analytics/i-super-properties-resolver.ts b/src/server/core/interfaces/analytics/i-super-properties-resolver.ts index 9f786aefa..cc1ac3665 100644 --- a/src/server/core/interfaces/analytics/i-super-properties-resolver.ts +++ b/src/server/core/interfaces/analytics/i-super-properties-resolver.ts @@ -1,12 +1,19 @@ +import type {ClientType} from '../../domain/client/client-info.js' /** * Super properties stamped onto every analytics event. Wire-format * snake_case throughout. `device_id` is sourced from `GlobalConfig`; * the remaining four are static across the daemon's lifetime. + * + * `client_kind` (M15.1) is stamped when the analytics emit originates + * from a Socket.IO transport call wrapped in `clientKindContext.run()`. + * Absent when the emit happens outside any context wrap (daemon-internal + * track or agent-fork connection). */ export type SuperProperties = Readonly<{ cli_version: string + client_kind?: ClientType device_id: string environment: 'development' | 'production' node_version: string diff --git a/src/server/infra/analytics/super-properties-resolver.ts b/src/server/infra/analytics/super-properties-resolver.ts index 2a24945b4..469466d48 100644 --- a/src/server/infra/analytics/super-properties-resolver.ts +++ b/src/server/infra/analytics/super-properties-resolver.ts @@ -3,6 +3,7 @@ import type {ISuperPropertiesResolver, SuperProperties} from '../../core/interfa import type {IGlobalConfigStore} from '../../core/interfaces/storage/i-global-config-store.js' import {readCliVersion} from '../../utils/read-cli-version.js' +import {getClientKindFromContext} from '../transport/client-kind-context.js' type StaticFields = Readonly<{ cli_version: string @@ -41,8 +42,10 @@ export class SuperPropertiesResolver implements ISuperPropertiesResolver { } const config = await this.globalConfigStore.read() + const clientKind = getClientKindFromContext() return { cli_version: this.staticFields.cli_version, + ...(clientKind ? {client_kind: clientKind} : {}), device_id: config?.deviceId ?? '', environment: this.staticFields.environment, node_version: this.staticFields.node_version, diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index ba8fc746e..5206d2c52 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -263,6 +263,15 @@ async function main(): Promise { const projectRouter = new ProjectRouter({transport: transportServer}) const clientManager = new ClientManager() + // M15.1: stamp `client_kind` on analytics super-properties for every + // request originating from a registered Socket.IO client. Agent-fork + // connections bypass the wrap (return undefined) so daemon-internal + // task lifecycle emits stay envelope-clean. + transportServer.setGetClientKind((clientId) => { + const type = clientManager.getClient(clientId)?.type + return type && type !== 'agent' ? type : undefined + }) + authStateStore = new AuthStateStore({log, tokenStore: createTokenStore()}) const projectStateLoader = new ProjectStateLoader({ configStore: new ProjectConfigStore(), diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 54bac8f4f..e66810bcf 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -290,6 +290,10 @@ export async function setupFeatureHandlers({ }).setup() new AuthHandler({ + // M15.1: thread the analytics client so the auth handler can emit + // auth_login / auth_logout on identity transitions. M6's Mixpanel + // alias() path keys off the auth_login event. + analyticsClient, authService: new OAuthService(authConfig), authStateStore, browserLauncher: new SystemBrowserLauncher(), diff --git a/src/server/infra/transport/client-kind-context.ts b/src/server/infra/transport/client-kind-context.ts new file mode 100644 index 000000000..11847ae79 --- /dev/null +++ b/src/server/infra/transport/client-kind-context.ts @@ -0,0 +1,32 @@ +/* eslint-disable camelcase */ +import {AsyncLocalStorage} from 'node:async_hooks' + +import type {ClientType} from '../../core/domain/client/client-info.js' + +/** + * Async-context scope for the daemon-stamped Socket.IO `client_kind`. + * + * The Socket.IO transport layer wraps every incoming transport-handler + * invocation in `clientKindContext.run({client_kind}, ...)` keyed off + * the originating socket's registered ClientType. SuperPropertiesResolver + * reads the value during super-property resolution so every analytics + * event automatically carries the originating client kind on its outer + * envelope — no per-handler signature change required. + * + * Same propagation model as `reviewDisabledStorage` in + * src/agent/infra/tools/implementations/curate-tool-task-context.ts. + * + * Outside any scope, `getClientKindFromContext()` returns `undefined` + * and the resolver omits `client_kind` from the stamped envelope — + * exercised by the agent-fork bypass and any direct daemon-internal + * track() call that does not originate from a Socket.IO event. + */ +export const clientKindContext = new AsyncLocalStorage<{client_kind: ClientType}>() + +export function runWithClientKind(client_kind: ClientType, fn: () => Promise): Promise { + return clientKindContext.run({client_kind}, fn) +} + +export function getClientKindFromContext(): ClientType | undefined { + return clientKindContext.getStore()?.client_kind +} diff --git a/src/server/infra/transport/handlers/analytics-handler.ts b/src/server/infra/transport/handlers/analytics-handler.ts index bb2a3b30e..2cea36e9e 100644 --- a/src/server/infra/transport/handlers/analytics-handler.ts +++ b/src/server/infra/transport/handlers/analytics-handler.ts @@ -3,17 +3,53 @@ import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analyt import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' +import {AnalyticsDisabledSchema} from '../../../../shared/analytics/events/analytics-disabled.js' +import {AuthLoginSchema} from '../../../../shared/analytics/events/auth-login.js' +import {AuthLogoutSchema} from '../../../../shared/analytics/events/auth-logout.js' +import {BrvInitSchema} from '../../../../shared/analytics/events/brv-init.js' import {CliInvocationSchema} from '../../../../shared/analytics/events/cli-invocation.js' +import {ConnectorInstalledSchema} from '../../../../shared/analytics/events/connector-installed.js' +import {ContextTreeFileEditedSchema} from '../../../../shared/analytics/events/context-tree-file-edited.js' import {CurateOperationAppliedSchema} from '../../../../shared/analytics/events/curate-operation-applied.js' import {CurateRunCompletedSchema} from '../../../../shared/analytics/events/curate-run-completed.js' +import {DaemonResetExecutedSchema} from '../../../../shared/analytics/events/daemon-reset-executed.js' import {DaemonStartSchema} from '../../../../shared/analytics/events/daemon-start.js' +import {HubPackageInstalledSchema} from '../../../../shared/analytics/events/hub-package-installed.js' +import {HubRegistryAddedSchema} from '../../../../shared/analytics/events/hub-registry-added.js' +import {HubRegistryRemovedSchema} from '../../../../shared/analytics/events/hub-registry-removed.js' import {isAnalyticsEventName} from '../../../../shared/analytics/events/index.js' import {McpSessionStartSchema} from '../../../../shared/analytics/events/mcp-session-start.js' import {McpToolCalledSchema} from '../../../../shared/analytics/events/mcp-tool-called.js' +import {OnboardingAutoSetupStartedSchema} from '../../../../shared/analytics/events/onboarding-auto-setup-started.js' +import {OnboardingCompletedSchema} from '../../../../shared/analytics/events/onboarding-completed.js' import {QueryCompletedSchema} from '../../../../shared/analytics/events/query-completed.js' +import {ReviewApprovedSchema} from '../../../../shared/analytics/events/review-approved.js' +import {ReviewRejectedSchema} from '../../../../shared/analytics/events/review-rejected.js' +import {ReviewToggledSchema} from '../../../../shared/analytics/events/review-toggled.js' +import {SettingChangedSchema} from '../../../../shared/analytics/events/setting-changed.js' +import {SettingResetSchema} from '../../../../shared/analytics/events/setting-reset.js' +import {SourceAddedSchema} from '../../../../shared/analytics/events/source-added.js' +import {SourceRemovedSchema} from '../../../../shared/analytics/events/source-removed.js' +import {SpaceSwitchedSchema} from '../../../../shared/analytics/events/space-switched.js' import {TaskCompletedSchema} from '../../../../shared/analytics/events/task-completed.js' import {TaskCreatedSchema} from '../../../../shared/analytics/events/task-created.js' import {TaskFailedSchema} from '../../../../shared/analytics/events/task-failed.js' +import {VcBranchedSchema} from '../../../../shared/analytics/events/vc-branched.js' +import {VcCheckedOutSchema} from '../../../../shared/analytics/events/vc-checked-out.js' +import {VcClonedSchema} from '../../../../shared/analytics/events/vc-cloned.js' +import {VcCommitSchema} from '../../../../shared/analytics/events/vc-commit.js' +import {VcDiscardedSchema} from '../../../../shared/analytics/events/vc-discarded.js' +import {VcFetchedSchema} from '../../../../shared/analytics/events/vc-fetched.js' +import {VcInitSchema} from '../../../../shared/analytics/events/vc-init.js' +import {VcMergedSchema} from '../../../../shared/analytics/events/vc-merged.js' +import {VcPulledSchema} from '../../../../shared/analytics/events/vc-pulled.js' +import {VcPushedSchema} from '../../../../shared/analytics/events/vc-pushed.js' +import {VcRemoteChangedSchema} from '../../../../shared/analytics/events/vc-remote-changed.js' +import {VcResetExecutedSchema} from '../../../../shared/analytics/events/vc-reset-executed.js' +import {WebuiSessionEndedSchema} from '../../../../shared/analytics/events/webui-session-ended.js' +import {WebuiSessionStartedSchema} from '../../../../shared/analytics/events/webui-session-started.js' +import {WorktreeAddedSchema} from '../../../../shared/analytics/events/worktree-added.js' +import {WorktreeRemovedSchema} from '../../../../shared/analytics/events/worktree-removed.js' import { AnalyticsEvents, type AnalyticsTrackPayload, @@ -38,9 +74,8 @@ export interface AnalyticsHandlerDeps { * so the daemon's typed `track()` always receives a valid pair. * * The dispatch switch covers every entry in `AnalyticsEventNames`, including - * deferred scaffolding events (cli_invocation, mcp_*, task_*) that have a - * schema but no daemon-side producer yet. Wire-side validation is in place - * for the moment the producer ticket lands. + * deferred scaffolding events that have a schema but no daemon-side producer + * yet. Wire-side validation is in place for the moment the producer ticket lands. * * Malformed payloads and any throw from track() are silently dropped: * analytics MUST NOT crash the emitting client. @@ -79,6 +114,34 @@ export class AnalyticsHandler { // eslint-disable-next-line complexity private dispatch(event: AnalyticsEventName, rawProperties: unknown): void { switch (event) { + case AnalyticsEventNames.ANALYTICS_DISABLED: { + const props = AnalyticsDisabledSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.ANALYTICS_DISABLED) + break + } + + case AnalyticsEventNames.AUTH_LOGIN: { + const props = AuthLoginSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.AUTH_LOGIN, props.data) + break + } + + case AnalyticsEventNames.AUTH_LOGOUT: { + const props = AuthLogoutSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.AUTH_LOGOUT, props.data) + break + } + + case AnalyticsEventNames.BRV_INIT: { + const props = BrvInitSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.BRV_INIT, props.data) + break + } + case AnalyticsEventNames.CLI_INVOCATION: { const props = CliInvocationSchema.safeParse(rawProperties ?? {}) if (!props.success) return @@ -86,6 +149,20 @@ export class AnalyticsHandler { break } + case AnalyticsEventNames.CONNECTOR_INSTALLED: { + const props = ConnectorInstalledSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.CONNECTOR_INSTALLED, props.data) + break + } + + case AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED: { + const props = ContextTreeFileEditedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED, props.data) + break + } + case AnalyticsEventNames.CURATE_OPERATION_APPLIED: { const props = CurateOperationAppliedSchema.safeParse(rawProperties ?? {}) if (!props.success) return @@ -100,6 +177,13 @@ export class AnalyticsHandler { break } + case AnalyticsEventNames.DAEMON_RESET_EXECUTED: { + const props = DaemonResetExecutedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.DAEMON_RESET_EXECUTED, props.data) + break + } + case AnalyticsEventNames.DAEMON_START: { const props = DaemonStartSchema.safeParse(rawProperties ?? {}) if (!props.success) return @@ -107,6 +191,27 @@ export class AnalyticsHandler { break } + case AnalyticsEventNames.HUB_PACKAGE_INSTALLED: { + const props = HubPackageInstalledSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.HUB_PACKAGE_INSTALLED, props.data) + break + } + + case AnalyticsEventNames.HUB_REGISTRY_ADDED: { + const props = HubRegistryAddedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.HUB_REGISTRY_ADDED, props.data) + break + } + + case AnalyticsEventNames.HUB_REGISTRY_REMOVED: { + const props = HubRegistryRemovedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.HUB_REGISTRY_REMOVED, props.data) + break + } + case AnalyticsEventNames.MCP_SESSION_START: { const props = McpSessionStartSchema.safeParse(rawProperties ?? {}) if (!props.success) return @@ -121,6 +226,20 @@ export class AnalyticsHandler { break } + case AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED: { + const props = OnboardingAutoSetupStartedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED, props.data) + break + } + + case AnalyticsEventNames.ONBOARDING_COMPLETED: { + const props = OnboardingCompletedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.ONBOARDING_COMPLETED, props.data) + break + } + case AnalyticsEventNames.QUERY_COMPLETED: { const props = QueryCompletedSchema.safeParse(rawProperties ?? {}) if (!props.success) return @@ -128,6 +247,62 @@ export class AnalyticsHandler { break } + case AnalyticsEventNames.REVIEW_APPROVED: { + const props = ReviewApprovedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.REVIEW_APPROVED, props.data) + break + } + + case AnalyticsEventNames.REVIEW_REJECTED: { + const props = ReviewRejectedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.REVIEW_REJECTED, props.data) + break + } + + case AnalyticsEventNames.REVIEW_TOGGLED: { + const props = ReviewToggledSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.REVIEW_TOGGLED, props.data) + break + } + + case AnalyticsEventNames.SETTING_CHANGED: { + const props = SettingChangedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.SETTING_CHANGED, props.data) + break + } + + case AnalyticsEventNames.SETTING_RESET: { + const props = SettingResetSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.SETTING_RESET, props.data) + break + } + + case AnalyticsEventNames.SOURCE_ADDED: { + const props = SourceAddedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.SOURCE_ADDED, props.data) + break + } + + case AnalyticsEventNames.SOURCE_REMOVED: { + const props = SourceRemovedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.SOURCE_REMOVED, props.data) + break + } + + case AnalyticsEventNames.SPACE_SWITCHED: { + const props = SpaceSwitchedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.SPACE_SWITCHED, props.data) + break + } + case AnalyticsEventNames.TASK_COMPLETED: { const props = TaskCompletedSchema.safeParse(rawProperties ?? {}) if (!props.success) return @@ -148,6 +323,118 @@ export class AnalyticsHandler { this.analyticsClient.track(AnalyticsEventNames.TASK_FAILED, props.data) break } + + case AnalyticsEventNames.VC_BRANCHED: { + const props = VcBranchedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_BRANCHED, props.data) + break + } + + case AnalyticsEventNames.VC_CHECKED_OUT: { + const props = VcCheckedOutSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_CHECKED_OUT, props.data) + break + } + + case AnalyticsEventNames.VC_CLONED: { + const props = VcClonedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_CLONED, props.data) + break + } + + case AnalyticsEventNames.VC_COMMIT: { + const props = VcCommitSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_COMMIT, props.data) + break + } + + case AnalyticsEventNames.VC_DISCARDED: { + const props = VcDiscardedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_DISCARDED, props.data) + break + } + + case AnalyticsEventNames.VC_FETCHED: { + const props = VcFetchedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_FETCHED, props.data) + break + } + + case AnalyticsEventNames.VC_INIT: { + const props = VcInitSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_INIT, props.data) + break + } + + case AnalyticsEventNames.VC_MERGED: { + const props = VcMergedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_MERGED, props.data) + break + } + + case AnalyticsEventNames.VC_PULLED: { + const props = VcPulledSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_PULLED, props.data) + break + } + + case AnalyticsEventNames.VC_PUSHED: { + const props = VcPushedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_PUSHED, props.data) + break + } + + case AnalyticsEventNames.VC_REMOTE_CHANGED: { + const props = VcRemoteChangedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_REMOTE_CHANGED, props.data) + break + } + + case AnalyticsEventNames.VC_RESET_EXECUTED: { + const props = VcResetExecutedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.VC_RESET_EXECUTED, props.data) + break + } + + case AnalyticsEventNames.WEBUI_SESSION_ENDED: { + const props = WebuiSessionEndedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.WEBUI_SESSION_ENDED, props.data) + break + } + + case AnalyticsEventNames.WEBUI_SESSION_STARTED: { + const props = WebuiSessionStartedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.WEBUI_SESSION_STARTED, props.data) + break + } + + case AnalyticsEventNames.WORKTREE_ADDED: { + const props = WorktreeAddedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.WORKTREE_ADDED, props.data) + break + } + + case AnalyticsEventNames.WORKTREE_REMOVED: { + const props = WorktreeRemovedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.WORKTREE_REMOVED, props.data) + break + } // No default — `event` is narrowed to AnalyticsEventName by isAnalyticsEventName(). } } diff --git a/src/server/infra/transport/handlers/auth-handler.ts b/src/server/infra/transport/handlers/auth-handler.ts index 0aae87864..74ff89d0e 100644 --- a/src/server/infra/transport/handlers/auth-handler.ts +++ b/src/server/infra/transport/handlers/auth-handler.ts @@ -1,5 +1,8 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' import type {UserDTO} from '../../../../shared/transport/types/dto.js' import type {User} from '../../../core/domain/entities/user.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {IAuthService} from '../../../core/interfaces/auth/i-auth-service.js' import type {ICallbackHandler} from '../../../core/interfaces/auth/i-callback-handler.js' import type {ITokenStore} from '../../../core/interfaces/auth/i-token-store.js' @@ -11,6 +14,7 @@ import type {IProjectConfigStore} from '../../../core/interfaces/storage/i-proje import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' import type {ProjectPathResolver} from './handler-types.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { AuthEvents, type AuthGetStateRequest, @@ -45,6 +49,14 @@ function toUserDTO(user: User): UserDTO { } export interface AuthHandlerDeps { + /** + * M15.1: optional. When provided, the handler emits `auth_login` / + * `auth_logout` analytics events on identity transitions. Optional so + * that legacy construction (and unit tests that don't care about + * analytics) doesn't need to thread the dep through. Wired in + * `feature-handlers.ts`. + */ + analyticsClient?: IAnalyticsClient authService: IAuthService authStateStore: IAuthStateStore browserLauncher: IBrowserLauncher @@ -62,6 +74,7 @@ export interface AuthHandlerDeps { * Business logic for authentication — no terminal/UI calls. */ export class AuthHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly authService: IAuthService private readonly authStateStore: IAuthStateStore private readonly browserLauncher: IBrowserLauncher @@ -74,6 +87,7 @@ export class AuthHandler { private readonly userService: IUserService constructor(deps: AuthHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.authService = deps.authService this.authStateStore = deps.authStateStore this.browserLauncher = deps.browserLauncher @@ -136,6 +150,20 @@ export class AuthHandler { } } + /** + * M15.1: analytics emit helper. Mirrors the try/processLog pattern from + * `analytics-hook.ts` so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Auth] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + private async processLoginCallback( authContext: {authUrl: string; state: string}, redirectUri: string, @@ -162,6 +190,11 @@ export class AuthHandler { // new token without waiting for the next 5-second poll cycle. await this.authStateStore.loadToken() + // M15.1: emit AFTER loadToken so the per-event identity resolver + // stamps the row with the new authenticated user_id (M6's alias + // path keys off `{name: auth_login, outcome: success}`). + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGIN, {outcome: 'success'}) + this.transport.broadcast(AuthEvents.LOGIN_COMPLETED, { success: true, user: toUserDTO(user), @@ -172,6 +205,12 @@ export class AuthHandler { user: toUserDTO(user), }) } catch (error) { + // M15.1: emit the failure terminal so the funnel sees both halves. + // Identity is still anonymous (token never committed). `failure_kind` + // is a coarse tag — never leak `error.message` here (would risk PII). + // eslint-disable-next-line camelcase + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGIN, {failure_kind: 'oauth_flow', outcome: 'failure'}) + this.transport.broadcast(AuthEvents.LOGIN_COMPLETED, { error: getErrorMessage(error), success: false, @@ -272,6 +311,9 @@ export class AuthHandler { await this.tokenStore.save(authToken) await this.authStateStore.loadToken() + // M15.1: emit AFTER loadToken (same rationale as the OAuth path). + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGIN, {outcome: 'success'}) + this.transport.broadcast(AuthEvents.STATE_CHANGED, { isAuthorized: true, user: toUserDTO(user), @@ -279,6 +321,12 @@ export class AuthHandler { return {success: true, userEmail: user.email} } catch (error) { + // M15.1: failure-path emit covers api-key auth failures (invalid key, + // network error, user fetch failure). Stays anonymous — no token was + // committed. + // eslint-disable-next-line camelcase + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGIN, {failure_kind: 'api_key', outcome: 'failure'}) + return {error: getErrorMessage(error), success: false} } }, @@ -291,9 +339,26 @@ export class AuthHandler { await this.tokenStore.clear() await this.disconnectByteRoverProvider() await this.authStateStore.loadToken() + + // M15.1: emit AFTER cleanup but on the success terminal. The + // pre-transition flush hook fires when loadToken() above changes + // identity, draining any pending events under the logged-in + // identity. After loadToken() identity is anonymous, so this + // success row stamps anonymously — which correctly reflects "logout + // succeeded for the now-anonymous session." Downstream consumers + // join on `device_id` to attribute the logout back to the user. + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGOUT, {outcome: 'success'}) + this.transport.broadcast(AuthEvents.STATE_CHANGED, {isAuthorized: false}) return {success: true} } catch { + // M15.1: failure-path emit covers token-clear / provider-disconnect / + // state-reload errors. Identity may be either logged-in (if clear + // failed first) or anonymous (if clear succeeded but a later step + // failed) — both are correct for diagnostic purposes. + // eslint-disable-next-line camelcase + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGOUT, {failure_kind: 'logout_flow', outcome: 'failure'}) + return {success: false} } }) diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts index 6422f7a13..1f38f7e88 100644 --- a/src/server/infra/transport/handlers/global-config-handler.ts +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -4,6 +4,7 @@ import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analyt import type {IGlobalConfigStore} from '../../../core/interfaces/storage/i-global-config-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { GlobalConfigEvents, type GlobalConfigGetResponse, @@ -12,6 +13,7 @@ import { } from '../../../../shared/transport/events/global-config-events.js' import {GLOBAL_CONFIG_VERSION} from '../../../constants.js' import {GlobalConfig} from '../../../core/domain/entities/global-config.js' +import {processLog} from '../../../utils/process-logger.js' export interface GlobalConfigHandlerDeps { /** @@ -145,6 +147,22 @@ export class GlobalConfigHandler { const current = existing ?? GlobalConfig.create(randomUUID()) const updated = current.withAnalytics(analytics) await this.globalConfigStore.write(updated) + + // M15.1: emit BEFORE flipping `cachedAnalytics` so AnalyticsClient.isEnabled + // (which reads the cache) still resolves true at track time and the row + // enters the queue. After this line the cache flips to false and any + // subsequent track() is no-op'd. analytics_enabled is intentionally NOT + // tracked (the user has not consented at receive time when enabling). + if (previous && !analytics) { + try { + this.analyticsClient?.track(AnalyticsEventNames.ANALYTICS_DISABLED) + } catch (error) { + processLog( + `[GlobalConfig] analytics_disabled track failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + // Cache is in-process-authoritative — we trust the value just written. // Cross-process changes (another daemon writing the same file, manual // edits) are NOT observable until the next daemon restart. The diff --git a/src/server/infra/transport/socket-io-transport-server.ts b/src/server/infra/transport/socket-io-transport-server.ts index 1adba341f..4b39657bd 100644 --- a/src/server/infra/transport/socket-io-transport-server.ts +++ b/src/server/infra/transport/socket-io-transport-server.ts @@ -2,6 +2,7 @@ import {instrument} from '@socket.io/admin-ui' import {createServer, Server as HttpServer, type RequestListener} from 'node:http' import {Server, Socket} from 'socket.io' +import type {ClientType} from '../../core/domain/client/client-info.js' import type {TransportServerConfig} from '../../core/domain/transport/types.js' import type { ConnectionHandler, @@ -18,6 +19,7 @@ import { TransportServerNotStartedError, } from '../../core/domain/errors/transport-error.js' import {transportLog} from '../../utils/process-logger.js' +import {clientKindContext} from './client-kind-context.js' /** * Internal protocol constants for request/response pattern. @@ -43,6 +45,16 @@ export class SocketIOTransportServer implements ITransportServer { private readonly config: Required private connectionHandlers: ConnectionHandler[] = [] private disconnectionHandlers: ConnectionHandler[] = [] + /** + * M15.1: optional lookup that resolves a Socket.IO clientId to its + * registered `ClientType`. When set and the lookup returns a non-undefined + * value, every incoming request handler invocation is wrapped in + * `clientKindContext.run({client_kind}, ...)` so SuperPropertiesResolver + * can stamp `client_kind` on the analytics envelope. Pre-filter the + * `agent` ClientType at the caller (return undefined) so agent-fork + * connections bypass the wrap entirely. + */ + private getClientKind: ((clientId: string) => ClientType | undefined) | undefined private httpRequestHandler?: RequestListener private httpServer: HttpServer | undefined private io: Server | undefined @@ -139,6 +151,16 @@ export class SocketIOTransportServer implements ITransportServer { } } + /** + * Register a lookup that maps a socket clientId to its ClientType. + * Used by M15.1 to stamp `client_kind` on analytics super-properties. + * Setter (not constructor injection) because ClientManager is constructed + * AFTER the transport server in brv-server.ts boot order. + */ + setGetClientKind(getter: (clientId: string) => ClientType | undefined): void { + this.getClientKind = getter + } + /** * Sets an HTTP request handler (e.g., Express app) to handle non-Socket.IO HTTP requests. * Must be called before start(). @@ -269,7 +291,16 @@ export class SocketIOTransportServer implements ITransportServer { private registerEventHandler(socket: Socket, event: string, handler: StoredRequestHandler): void { socket.on(event, async (data: unknown, callback?: (response: unknown) => void) => { try { - const result = await handler(data, socket.id) + // M15.1: wrap the handler in clientKindContext so SuperPropertiesResolver + // can stamp `client_kind` on any analytics event emitted during this + // handler invocation. Skip the wrap when no lookup is registered or the + // lookup returns undefined (agent-fork bypass / unregistered sockets). + const clientKind = this.getClientKind?.(socket.id) + const invokeHandler = (): Promise | unknown => handler(data, socket.id) + const result = await (clientKind + ? // eslint-disable-next-line camelcase + clientKindContext.run({client_kind: clientKind}, invokeHandler) + : invokeHandler()) // Support both callback style and event-based response if (callback) { diff --git a/src/shared/analytics/event-names.ts b/src/shared/analytics/event-names.ts index 65cbe0221..0a557f069 100644 --- a/src/shared/analytics/event-names.ts +++ b/src/shared/analytics/event-names.ts @@ -14,16 +14,52 @@ * upcoming milestones will wire the producer alongside its consumer. */ export const AnalyticsEventNames = { + ANALYTICS_DISABLED: 'analytics_disabled', + AUTH_LOGIN: 'auth_login', + AUTH_LOGOUT: 'auth_logout', + BRV_INIT: 'brv_init', CLI_INVOCATION: 'cli_invocation', + CONNECTOR_INSTALLED: 'connector_installed', + CONTEXT_TREE_FILE_EDITED: 'context_tree_file_edited', CURATE_OPERATION_APPLIED: 'curate_operation_applied', CURATE_RUN_COMPLETED: 'curate_run_completed', + DAEMON_RESET_EXECUTED: 'daemon_reset_executed', DAEMON_START: 'daemon_start', + HUB_PACKAGE_INSTALLED: 'hub_package_installed', + HUB_REGISTRY_ADDED: 'hub_registry_added', + HUB_REGISTRY_REMOVED: 'hub_registry_removed', MCP_SESSION_START: 'mcp_session_start', MCP_TOOL_CALLED: 'mcp_tool_called', + ONBOARDING_AUTO_SETUP_STARTED: 'onboarding_auto_setup_started', + ONBOARDING_COMPLETED: 'onboarding_completed', QUERY_COMPLETED: 'query_completed', + REVIEW_APPROVED: 'review_approved', + REVIEW_REJECTED: 'review_rejected', + REVIEW_TOGGLED: 'review_toggled', + SETTING_CHANGED: 'setting_changed', + SETTING_RESET: 'setting_reset', + SOURCE_ADDED: 'source_added', + SOURCE_REMOVED: 'source_removed', + SPACE_SWITCHED: 'space_switched', TASK_COMPLETED: 'task_completed', TASK_CREATED: 'task_created', TASK_FAILED: 'task_failed', + VC_BRANCHED: 'vc_branched', + VC_CHECKED_OUT: 'vc_checked_out', + VC_CLONED: 'vc_cloned', + VC_COMMIT: 'vc_commit', + VC_DISCARDED: 'vc_discarded', + VC_FETCHED: 'vc_fetched', + VC_INIT: 'vc_init', + VC_MERGED: 'vc_merged', + VC_PULLED: 'vc_pulled', + VC_PUSHED: 'vc_pushed', + VC_REMOTE_CHANGED: 'vc_remote_changed', + VC_RESET_EXECUTED: 'vc_reset_executed', + WEBUI_SESSION_ENDED: 'webui_session_ended', + WEBUI_SESSION_STARTED: 'webui_session_started', + WORKTREE_ADDED: 'worktree_added', + WORKTREE_REMOVED: 'worktree_removed', } as const export type AnalyticsEventName = (typeof AnalyticsEventNames)[keyof typeof AnalyticsEventNames] diff --git a/src/shared/analytics/events/analytics-disabled.ts b/src/shared/analytics/events/analytics-disabled.ts new file mode 100644 index 000000000..716389454 --- /dev/null +++ b/src/shared/analytics/events/analytics-disabled.ts @@ -0,0 +1,13 @@ +import {z} from 'zod' + +/** + * Per-event schema for `analytics_disabled`. + * + * No properties. The emit captures the moment the user opts out via + * `brv analytics disable`; identity is stamped by the per-event identity + * resolver and `client_kind` by the super-property layer. The disable + * action itself is the entire signal. + */ +export const AnalyticsDisabledSchema = z.object({}).strict() + +export type AnalyticsDisabledProps = z.infer diff --git a/src/shared/analytics/events/auth-login.ts b/src/shared/analytics/events/auth-login.ts new file mode 100644 index 000000000..be1194282 --- /dev/null +++ b/src/shared/analytics/events/auth-login.ts @@ -0,0 +1,30 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `auth_login`. + * + * Carries the lifecycle outcome (`success` | `failure`) so a single event + * name covers both the OAuth success terminal and the failure terminal. + * `failure_kind` is a coarse enum-like tag (free string today but emitters + * should pick from a small discrete vocabulary like `'callback_timeout'`, + * `'token_exchange'`, `'user_fetch'`, `'token_save'`, `'state_reload'`, + * `'unknown'`) so downstream consumers can aggregate failure modes without + * raw error-message PII risk. Never put `error_message`-style free text in + * `failure_kind`. + * + * Identity (the new authenticated `user_id` on success, anonymous on + * failure) is stamped on the per-event identity by the resolver; + * `client_kind` is stamped on the envelope by the super-property layer. + * + * M6's Mixpanel forwarding pipeline keys its server-side + * `alias(deviceId -> User.id)` off `{name: auth_login, outcome: success}`. + */ +export const AuthLoginSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + }) + .strict() + +export type AuthLoginProps = z.infer diff --git a/src/shared/analytics/events/auth-logout.ts b/src/shared/analytics/events/auth-logout.ts new file mode 100644 index 000000000..695a87b8e --- /dev/null +++ b/src/shared/analytics/events/auth-logout.ts @@ -0,0 +1,25 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `auth_logout`. + * + * Carries the lifecycle outcome (`success` | `failure`). On success the + * emit fires BEFORE `tokenStore.clear()` so the per-event identity is the + * logged-in user that just opted out. On failure (e.g. `tokenStore.clear()` + * threw, `disconnectByteRoverProvider()` threw, or `loadToken()` threw) + * the identity is whatever the in-memory store still holds — which is the + * logged-in user because identity rebinding hasn't happened yet. + * + * `failure_kind` should be a coarse enum-like tag (e.g. `'token_clear'`, + * `'provider_disconnect'`, `'state_reload'`, `'unknown'`) — see + * `auth-login.ts` for the rationale. Never put raw error messages here. + */ +export const AuthLogoutSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + }) + .strict() + +export type AuthLogoutProps = z.infer diff --git a/src/shared/analytics/events/brv-init.ts b/src/shared/analytics/events/brv-init.ts new file mode 100644 index 000000000..746ce33fb --- /dev/null +++ b/src/shared/analytics/events/brv-init.ts @@ -0,0 +1,22 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `brv_init`. + * + * Activation-funnel entry: `brv init` ran on a project (success or failure). + * `project_path_hash` = sha256 of the absolute project path (raw paths + * are forbidden); `had_existing_brv_dir` separates "first-touch" from + * "re-init" funnels. `outcome` covers both terminals; `failure_kind` is a + * coarse tag — never a raw error message. + */ +export const BrvInitSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + had_existing_brv_dir: z.boolean(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type BrvInitProps = z.infer diff --git a/src/shared/analytics/events/connector-installed.ts b/src/shared/analytics/events/connector-installed.ts new file mode 100644 index 000000000..68c8480dc --- /dev/null +++ b/src/shared/analytics/events/connector-installed.ts @@ -0,0 +1,20 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `connector_installed`. + * + * `connector_id` identifies which connector (e.g. `claude-code`, `cursor`, + * `amp`); `agent_target` is the external coding-agent surface it installs + * into. `outcome` covers both terminals; `failure_kind` is a coarse tag. + */ +export const ConnectorInstalledSchema = z + .object({ + agent_target: z.string().min(1), + connector_id: z.string().min(1), + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + }) + .strict() + +export type ConnectorInstalledProps = z.infer diff --git a/src/shared/analytics/events/context-tree-file-edited.ts b/src/shared/analytics/events/context-tree-file-edited.ts new file mode 100644 index 000000000..a736472bb --- /dev/null +++ b/src/shared/analytics/events/context-tree-file-edited.ts @@ -0,0 +1,21 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `context_tree_file_edited`. + * + * Direct WebUI edit of a context-tree file. `byte_delta` is only known on + * success — optional. `file_relative_path_hash` is computed at request time + * and stays valid for both outcomes. + */ +export const ContextTreeFileEditedSchema = z + .object({ + byte_delta: z.number().int().optional(), + failure_kind: z.string().min(1).max(64).optional(), + file_relative_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type ContextTreeFileEditedProps = z.infer diff --git a/src/shared/analytics/events/daemon-reset-executed.ts b/src/shared/analytics/events/daemon-reset-executed.ts new file mode 100644 index 000000000..38a96be97 --- /dev/null +++ b/src/shared/analytics/events/daemon-reset-executed.ts @@ -0,0 +1,17 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `daemon_reset_executed`. + * + * `brv reset` escape hatch. + */ +export const DaemonResetExecutedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + reset_scope: z.enum(['project', 'global']), + }) + .strict() + +export type DaemonResetExecutedProps = z.infer diff --git a/src/shared/analytics/events/hub-package-installed.ts b/src/shared/analytics/events/hub-package-installed.ts new file mode 100644 index 000000000..5baa19eca --- /dev/null +++ b/src/shared/analytics/events/hub-package-installed.ts @@ -0,0 +1,21 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `hub_package_installed`. + * + * Context Hub install (npm-style). `package_identifier` is the + * `/` identifier known at request time; `version_pin` is the + * resolved version pin (only known on success — optional). `outcome` + * covers both terminals; `failure_kind` is a coarse tag. + */ +export const HubPackageInstalledSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + package_identifier: z.string().min(1), + version_pin: z.string().min(1).optional(), + }) + .strict() + +export type HubPackageInstalledProps = z.infer diff --git a/src/shared/analytics/events/hub-registry-added.ts b/src/shared/analytics/events/hub-registry-added.ts new file mode 100644 index 000000000..32af6e8f7 --- /dev/null +++ b/src/shared/analytics/events/hub-registry-added.ts @@ -0,0 +1,20 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `hub_registry_added`. + * + * Adds a registry source to the Context Hub config. + * `registry_kind` classifies the registry type; `is_default` flags whether + * it was added as the default. `outcome` covers both terminals. + */ +export const HubRegistryAddedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + is_default: z.boolean(), + outcome: z.enum(['success', 'failure']), + registry_kind: z.string().min(1), + }) + .strict() + +export type HubRegistryAddedProps = z.infer diff --git a/src/shared/analytics/events/hub-registry-removed.ts b/src/shared/analytics/events/hub-registry-removed.ts new file mode 100644 index 000000000..ecde37086 --- /dev/null +++ b/src/shared/analytics/events/hub-registry-removed.ts @@ -0,0 +1,15 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `hub_registry_removed`. + */ +export const HubRegistryRemovedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + registry_kind: z.string().min(1), + }) + .strict() + +export type HubRegistryRemovedProps = z.infer diff --git a/src/shared/analytics/events/index.ts b/src/shared/analytics/events/index.ts index 8170a6c98..dc394a2cf 100644 --- a/src/shared/analytics/events/index.ts +++ b/src/shared/analytics/events/index.ts @@ -1,16 +1,52 @@ import type {AnalyticsEventName} from '../event-names.js' import {AnalyticsEventNames} from '../event-names.js' +import {type AnalyticsDisabledProps, AnalyticsDisabledSchema} from './analytics-disabled.js' +import {type AuthLoginProps, AuthLoginSchema} from './auth-login.js' +import {type AuthLogoutProps, AuthLogoutSchema} from './auth-logout.js' +import {type BrvInitProps, BrvInitSchema} from './brv-init.js' import {type CliInvocationProps, CliInvocationSchema} from './cli-invocation.js' +import {type ConnectorInstalledProps, ConnectorInstalledSchema} from './connector-installed.js' +import {type ContextTreeFileEditedProps, ContextTreeFileEditedSchema} from './context-tree-file-edited.js' import {type CurateOperationAppliedProps, CurateOperationAppliedSchema} from './curate-operation-applied.js' import {type CurateRunCompletedProps, CurateRunCompletedSchema} from './curate-run-completed.js' +import {type DaemonResetExecutedProps, DaemonResetExecutedSchema} from './daemon-reset-executed.js' import {type DaemonStartProps, DaemonStartSchema} from './daemon-start.js' +import {type HubPackageInstalledProps, HubPackageInstalledSchema} from './hub-package-installed.js' +import {type HubRegistryAddedProps, HubRegistryAddedSchema} from './hub-registry-added.js' +import {type HubRegistryRemovedProps, HubRegistryRemovedSchema} from './hub-registry-removed.js' import {type McpSessionStartProps, McpSessionStartSchema} from './mcp-session-start.js' import {type McpToolCalledProps, McpToolCalledSchema} from './mcp-tool-called.js' +import {type OnboardingAutoSetupStartedProps, OnboardingAutoSetupStartedSchema} from './onboarding-auto-setup-started.js' +import {type OnboardingCompletedProps, OnboardingCompletedSchema} from './onboarding-completed.js' import {type QueryCompletedProps, QueryCompletedSchema} from './query-completed.js' +import {type ReviewApprovedProps, ReviewApprovedSchema} from './review-approved.js' +import {type ReviewRejectedProps, ReviewRejectedSchema} from './review-rejected.js' +import {type ReviewToggledProps, ReviewToggledSchema} from './review-toggled.js' +import {type SettingChangedProps, SettingChangedSchema} from './setting-changed.js' +import {type SettingResetProps, SettingResetSchema} from './setting-reset.js' +import {type SourceAddedProps, SourceAddedSchema} from './source-added.js' +import {type SourceRemovedProps, SourceRemovedSchema} from './source-removed.js' +import {type SpaceSwitchedProps, SpaceSwitchedSchema} from './space-switched.js' import {type TaskCompletedProps, TaskCompletedSchema} from './task-completed.js' import {type TaskCreatedProps, TaskCreatedSchema} from './task-created.js' import {type TaskFailedProps, TaskFailedSchema} from './task-failed.js' +import {type VcBranchedProps, VcBranchedSchema} from './vc-branched.js' +import {type VcCheckedOutProps, VcCheckedOutSchema} from './vc-checked-out.js' +import {type VcClonedProps, VcClonedSchema} from './vc-cloned.js' +import {type VcCommitProps, VcCommitSchema} from './vc-commit.js' +import {type VcDiscardedProps, VcDiscardedSchema} from './vc-discarded.js' +import {type VcFetchedProps, VcFetchedSchema} from './vc-fetched.js' +import {type VcInitProps, VcInitSchema} from './vc-init.js' +import {type VcMergedProps, VcMergedSchema} from './vc-merged.js' +import {type VcPulledProps, VcPulledSchema} from './vc-pulled.js' +import {type VcPushedProps, VcPushedSchema} from './vc-pushed.js' +import {type VcRemoteChangedProps, VcRemoteChangedSchema} from './vc-remote-changed.js' +import {type VcResetExecutedProps, VcResetExecutedSchema} from './vc-reset-executed.js' +import {type WebuiSessionEndedProps, WebuiSessionEndedSchema} from './webui-session-ended.js' +import {type WebuiSessionStartedProps, WebuiSessionStartedSchema} from './webui-session-started.js' +import {type WorktreeAddedProps, WorktreeAddedSchema} from './worktree-added.js' +import {type WorktreeRemovedProps, WorktreeRemovedSchema} from './worktree-removed.js' /** * Registry of every shipped event schema, keyed by wire name. Used by: @@ -28,16 +64,52 @@ import {type TaskFailedProps, TaskFailedSchema} from './task-failed.js' * cover them (drop with Zod parse) once an emitter lands. */ export const ALL_EVENT_SCHEMAS = { + [AnalyticsEventNames.ANALYTICS_DISABLED]: AnalyticsDisabledSchema, + [AnalyticsEventNames.AUTH_LOGIN]: AuthLoginSchema, + [AnalyticsEventNames.AUTH_LOGOUT]: AuthLogoutSchema, + [AnalyticsEventNames.BRV_INIT]: BrvInitSchema, [AnalyticsEventNames.CLI_INVOCATION]: CliInvocationSchema, + [AnalyticsEventNames.CONNECTOR_INSTALLED]: ConnectorInstalledSchema, + [AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED]: ContextTreeFileEditedSchema, [AnalyticsEventNames.CURATE_OPERATION_APPLIED]: CurateOperationAppliedSchema, [AnalyticsEventNames.CURATE_RUN_COMPLETED]: CurateRunCompletedSchema, + [AnalyticsEventNames.DAEMON_RESET_EXECUTED]: DaemonResetExecutedSchema, [AnalyticsEventNames.DAEMON_START]: DaemonStartSchema, + [AnalyticsEventNames.HUB_PACKAGE_INSTALLED]: HubPackageInstalledSchema, + [AnalyticsEventNames.HUB_REGISTRY_ADDED]: HubRegistryAddedSchema, + [AnalyticsEventNames.HUB_REGISTRY_REMOVED]: HubRegistryRemovedSchema, [AnalyticsEventNames.MCP_SESSION_START]: McpSessionStartSchema, [AnalyticsEventNames.MCP_TOOL_CALLED]: McpToolCalledSchema, + [AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED]: OnboardingAutoSetupStartedSchema, + [AnalyticsEventNames.ONBOARDING_COMPLETED]: OnboardingCompletedSchema, [AnalyticsEventNames.QUERY_COMPLETED]: QueryCompletedSchema, + [AnalyticsEventNames.REVIEW_APPROVED]: ReviewApprovedSchema, + [AnalyticsEventNames.REVIEW_REJECTED]: ReviewRejectedSchema, + [AnalyticsEventNames.REVIEW_TOGGLED]: ReviewToggledSchema, + [AnalyticsEventNames.SETTING_CHANGED]: SettingChangedSchema, + [AnalyticsEventNames.SETTING_RESET]: SettingResetSchema, + [AnalyticsEventNames.SOURCE_ADDED]: SourceAddedSchema, + [AnalyticsEventNames.SOURCE_REMOVED]: SourceRemovedSchema, + [AnalyticsEventNames.SPACE_SWITCHED]: SpaceSwitchedSchema, [AnalyticsEventNames.TASK_COMPLETED]: TaskCompletedSchema, [AnalyticsEventNames.TASK_CREATED]: TaskCreatedSchema, [AnalyticsEventNames.TASK_FAILED]: TaskFailedSchema, + [AnalyticsEventNames.VC_BRANCHED]: VcBranchedSchema, + [AnalyticsEventNames.VC_CHECKED_OUT]: VcCheckedOutSchema, + [AnalyticsEventNames.VC_CLONED]: VcClonedSchema, + [AnalyticsEventNames.VC_COMMIT]: VcCommitSchema, + [AnalyticsEventNames.VC_DISCARDED]: VcDiscardedSchema, + [AnalyticsEventNames.VC_FETCHED]: VcFetchedSchema, + [AnalyticsEventNames.VC_INIT]: VcInitSchema, + [AnalyticsEventNames.VC_MERGED]: VcMergedSchema, + [AnalyticsEventNames.VC_PULLED]: VcPulledSchema, + [AnalyticsEventNames.VC_PUSHED]: VcPushedSchema, + [AnalyticsEventNames.VC_REMOTE_CHANGED]: VcRemoteChangedSchema, + [AnalyticsEventNames.VC_RESET_EXECUTED]: VcResetExecutedSchema, + [AnalyticsEventNames.WEBUI_SESSION_ENDED]: WebuiSessionEndedSchema, + [AnalyticsEventNames.WEBUI_SESSION_STARTED]: WebuiSessionStartedSchema, + [AnalyticsEventNames.WORKTREE_ADDED]: WorktreeAddedSchema, + [AnalyticsEventNames.WORKTREE_REMOVED]: WorktreeRemovedSchema, } as const /** @@ -46,16 +118,52 @@ export const ALL_EVENT_SCHEMAS = { * against the matching per-event type. */ export type AnyAnalyticsEvent = + | {name: typeof AnalyticsEventNames.ANALYTICS_DISABLED; properties: AnalyticsDisabledProps} + | {name: typeof AnalyticsEventNames.AUTH_LOGIN; properties: AuthLoginProps} + | {name: typeof AnalyticsEventNames.AUTH_LOGOUT; properties: AuthLogoutProps} + | {name: typeof AnalyticsEventNames.BRV_INIT; properties: BrvInitProps} | {name: typeof AnalyticsEventNames.CLI_INVOCATION; properties: CliInvocationProps} + | {name: typeof AnalyticsEventNames.CONNECTOR_INSTALLED; properties: ConnectorInstalledProps} + | {name: typeof AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED; properties: ContextTreeFileEditedProps} | {name: typeof AnalyticsEventNames.CURATE_OPERATION_APPLIED; properties: CurateOperationAppliedProps} | {name: typeof AnalyticsEventNames.CURATE_RUN_COMPLETED; properties: CurateRunCompletedProps} + | {name: typeof AnalyticsEventNames.DAEMON_RESET_EXECUTED; properties: DaemonResetExecutedProps} | {name: typeof AnalyticsEventNames.DAEMON_START; properties: DaemonStartProps} + | {name: typeof AnalyticsEventNames.HUB_PACKAGE_INSTALLED; properties: HubPackageInstalledProps} + | {name: typeof AnalyticsEventNames.HUB_REGISTRY_ADDED; properties: HubRegistryAddedProps} + | {name: typeof AnalyticsEventNames.HUB_REGISTRY_REMOVED; properties: HubRegistryRemovedProps} | {name: typeof AnalyticsEventNames.MCP_SESSION_START; properties: McpSessionStartProps} | {name: typeof AnalyticsEventNames.MCP_TOOL_CALLED; properties: McpToolCalledProps} + | {name: typeof AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED; properties: OnboardingAutoSetupStartedProps} + | {name: typeof AnalyticsEventNames.ONBOARDING_COMPLETED; properties: OnboardingCompletedProps} | {name: typeof AnalyticsEventNames.QUERY_COMPLETED; properties: QueryCompletedProps} + | {name: typeof AnalyticsEventNames.REVIEW_APPROVED; properties: ReviewApprovedProps} + | {name: typeof AnalyticsEventNames.REVIEW_REJECTED; properties: ReviewRejectedProps} + | {name: typeof AnalyticsEventNames.REVIEW_TOGGLED; properties: ReviewToggledProps} + | {name: typeof AnalyticsEventNames.SETTING_CHANGED; properties: SettingChangedProps} + | {name: typeof AnalyticsEventNames.SETTING_RESET; properties: SettingResetProps} + | {name: typeof AnalyticsEventNames.SOURCE_ADDED; properties: SourceAddedProps} + | {name: typeof AnalyticsEventNames.SOURCE_REMOVED; properties: SourceRemovedProps} + | {name: typeof AnalyticsEventNames.SPACE_SWITCHED; properties: SpaceSwitchedProps} | {name: typeof AnalyticsEventNames.TASK_COMPLETED; properties: TaskCompletedProps} | {name: typeof AnalyticsEventNames.TASK_CREATED; properties: TaskCreatedProps} | {name: typeof AnalyticsEventNames.TASK_FAILED; properties: TaskFailedProps} + | {name: typeof AnalyticsEventNames.VC_BRANCHED; properties: VcBranchedProps} + | {name: typeof AnalyticsEventNames.VC_CHECKED_OUT; properties: VcCheckedOutProps} + | {name: typeof AnalyticsEventNames.VC_CLONED; properties: VcClonedProps} + | {name: typeof AnalyticsEventNames.VC_COMMIT; properties: VcCommitProps} + | {name: typeof AnalyticsEventNames.VC_DISCARDED; properties: VcDiscardedProps} + | {name: typeof AnalyticsEventNames.VC_FETCHED; properties: VcFetchedProps} + | {name: typeof AnalyticsEventNames.VC_INIT; properties: VcInitProps} + | {name: typeof AnalyticsEventNames.VC_MERGED; properties: VcMergedProps} + | {name: typeof AnalyticsEventNames.VC_PULLED; properties: VcPulledProps} + | {name: typeof AnalyticsEventNames.VC_PUSHED; properties: VcPushedProps} + | {name: typeof AnalyticsEventNames.VC_REMOTE_CHANGED; properties: VcRemoteChangedProps} + | {name: typeof AnalyticsEventNames.VC_RESET_EXECUTED; properties: VcResetExecutedProps} + | {name: typeof AnalyticsEventNames.WEBUI_SESSION_ENDED; properties: WebuiSessionEndedProps} + | {name: typeof AnalyticsEventNames.WEBUI_SESSION_STARTED; properties: WebuiSessionStartedProps} + | {name: typeof AnalyticsEventNames.WORKTREE_ADDED; properties: WorktreeAddedProps} + | {name: typeof AnalyticsEventNames.WORKTREE_REMOVED; properties: WorktreeRemovedProps} /** * Type-derived properties for a given event name. Magic-string typos diff --git a/src/shared/analytics/events/onboarding-auto-setup-started.ts b/src/shared/analytics/events/onboarding-auto-setup-started.ts new file mode 100644 index 000000000..bb7567ba3 --- /dev/null +++ b/src/shared/analytics/events/onboarding-auto-setup-started.ts @@ -0,0 +1,20 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `onboarding_auto_setup_started`. + * + * The onboarding flow began an auto-setup pass. `mode` discriminates entry + * modes (e.g. `auto`, `manual`). `outcome` covers the start-attempt + * terminal — `success` if the auto-setup actually kicked off, `failure` if + * the start path errored. + */ +export const OnboardingAutoSetupStartedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + mode: z.string().min(1), + outcome: z.enum(['success', 'failure']), + }) + .strict() + +export type OnboardingAutoSetupStartedProps = z.infer diff --git a/src/shared/analytics/events/onboarding-completed.ts b/src/shared/analytics/events/onboarding-completed.ts new file mode 100644 index 000000000..9c5d82706 --- /dev/null +++ b/src/shared/analytics/events/onboarding-completed.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `onboarding_completed`. + * + * Activation funnel terminal. `steps_completed_count` is only meaningful + * on success and so is optional. `outcome` covers both terminals. + */ +export const OnboardingCompletedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + steps_completed_count: z.number().int().nonnegative().optional(), + }) + .strict() + +export type OnboardingCompletedProps = z.infer diff --git a/src/shared/analytics/events/review-approved.ts b/src/shared/analytics/events/review-approved.ts new file mode 100644 index 000000000..090280290 --- /dev/null +++ b/src/shared/analytics/events/review-approved.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `review_approved`. + * + * HITL review: user approved a pending curate operation. + * `operation_kind` is the curate operation discriminator. + */ +export const ReviewApprovedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + operation_kind: z.string().min(1), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type ReviewApprovedProps = z.infer diff --git a/src/shared/analytics/events/review-rejected.ts b/src/shared/analytics/events/review-rejected.ts new file mode 100644 index 000000000..a931c91d3 --- /dev/null +++ b/src/shared/analytics/events/review-rejected.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `review_rejected`. + * + * Mirrors `review_approved` shape so downstream consumers can compute + * per-operation approve/reject ratios. + */ +export const ReviewRejectedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + operation_kind: z.string().min(1), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type ReviewRejectedProps = z.infer diff --git a/src/shared/analytics/events/review-toggled.ts b/src/shared/analytics/events/review-toggled.ts new file mode 100644 index 000000000..440a80697 --- /dev/null +++ b/src/shared/analytics/events/review-toggled.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `review_toggled`. + * + * User toggled HITL review on or off (`brv review enable` / `disable`). + * `new_state` is only meaningful on success — optional. + */ +export const ReviewToggledSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + new_state: z.enum(['enabled', 'disabled']).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type ReviewToggledProps = z.infer diff --git a/src/shared/analytics/events/setting-changed.ts b/src/shared/analytics/events/setting-changed.ts new file mode 100644 index 000000000..e43f03aaf --- /dev/null +++ b/src/shared/analytics/events/setting-changed.ts @@ -0,0 +1,22 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `setting_changed`. + * + * Carries `setting_key` only — NEVER the raw value, because a future + * string-typed setting could carry paths or secrets. `value_kind` + * discriminates the type bucket; `value_changed_from_default` is only + * computable on success (optional). `outcome` covers both terminals. + */ +export const SettingChangedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + setting_key: z.string().min(1), + value_changed_from_default: z.boolean().optional(), + value_kind: z.enum(['integer', 'boolean']), + }) + .strict() + +export type SettingChangedProps = z.infer diff --git a/src/shared/analytics/events/setting-reset.ts b/src/shared/analytics/events/setting-reset.ts new file mode 100644 index 000000000..e45cab887 --- /dev/null +++ b/src/shared/analytics/events/setting-reset.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `setting_reset`. + * + * Symmetric with `setting_changed`; does NOT carry the value. + */ +export const SettingResetSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + setting_key: z.string().min(1), + value_kind: z.enum(['integer', 'boolean']), + }) + .strict() + +export type SettingResetProps = z.infer diff --git a/src/shared/analytics/events/source-added.ts b/src/shared/analytics/events/source-added.ts new file mode 100644 index 000000000..6217e88e6 --- /dev/null +++ b/src/shared/analytics/events/source-added.ts @@ -0,0 +1,20 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `source_added`. + * + * Adds another project's context tree as a read-only knowledge source. + * `source_origin_hash` is only stable on success (raw path forbidden) — + * optional. + */ +export const SourceAddedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + source_origin_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + }) + .strict() + +export type SourceAddedProps = z.infer diff --git a/src/shared/analytics/events/source-removed.ts b/src/shared/analytics/events/source-removed.ts new file mode 100644 index 000000000..01604cb99 --- /dev/null +++ b/src/shared/analytics/events/source-removed.ts @@ -0,0 +1,15 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `source_removed`. + */ +export const SourceRemovedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type SourceRemovedProps = z.infer diff --git a/src/shared/analytics/events/space-switched.ts b/src/shared/analytics/events/space-switched.ts new file mode 100644 index 000000000..c077e1d33 --- /dev/null +++ b/src/shared/analytics/events/space-switched.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `space_switched`. + * + * Active Context Hub space changed. `to_space_id` only set on success + * (the switch landed). `from_space_id` is always known at request time. + */ +export const SpaceSwitchedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + from_space_id: z.string().min(1), + outcome: z.enum(['success', 'failure']), + to_space_id: z.string().min(1).optional(), + }) + .strict() + +export type SpaceSwitchedProps = z.infer diff --git a/src/shared/analytics/events/vc-branched.ts b/src/shared/analytics/events/vc-branched.ts new file mode 100644 index 000000000..025a419e2 --- /dev/null +++ b/src/shared/analytics/events/vc-branched.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_branched`. + * + * `from_default_branch` only meaningful on success. + */ +export const VcBranchedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + from_default_branch: z.boolean().optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type VcBranchedProps = z.infer diff --git a/src/shared/analytics/events/vc-checked-out.ts b/src/shared/analytics/events/vc-checked-out.ts new file mode 100644 index 000000000..6259d8901 --- /dev/null +++ b/src/shared/analytics/events/vc-checked-out.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_checked_out`. + * + * `branch_kind = 'existing' | 'created'`. Only meaningful on success. + */ +export const VcCheckedOutSchema = z + .object({ + branch_kind: z.enum(['existing', 'created']).optional(), + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type VcCheckedOutProps = z.infer diff --git a/src/shared/analytics/events/vc-cloned.ts b/src/shared/analytics/events/vc-cloned.ts new file mode 100644 index 000000000..4c763dc8c --- /dev/null +++ b/src/shared/analytics/events/vc-cloned.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_cloned`. + * + * First-touch event for a new project. `project_path_hash` only stable on + * success (the directory exists). `remote_kind` is known at request time. + */ +export const VcClonedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + remote_kind: z.enum(['byterover', 'external']), + }) + .strict() + +export type VcClonedProps = z.infer diff --git a/src/shared/analytics/events/vc-commit.ts b/src/shared/analytics/events/vc-commit.ts new file mode 100644 index 000000000..498ede445 --- /dev/null +++ b/src/shared/analytics/events/vc-commit.ts @@ -0,0 +1,21 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_commit`. + * + * `files_changed_count` is only known on success (post-commit). `had_message` + * is known at request time. `client_kind` super-property segments CLI-typed + * commits vs WebUI Changes-tab clicks. + */ +export const VcCommitSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + files_changed_count: z.number().int().nonnegative().optional(), + had_message: z.boolean(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type VcCommitProps = z.infer diff --git a/src/shared/analytics/events/vc-discarded.ts b/src/shared/analytics/events/vc-discarded.ts new file mode 100644 index 000000000..914e0c44c --- /dev/null +++ b/src/shared/analytics/events/vc-discarded.ts @@ -0,0 +1,16 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_discarded`. + */ +export const VcDiscardedSchema = z + .object({ + discard_scope: z.enum(['file', 'all']), + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type VcDiscardedProps = z.infer diff --git a/src/shared/analytics/events/vc-fetched.ts b/src/shared/analytics/events/vc-fetched.ts new file mode 100644 index 000000000..646891719 --- /dev/null +++ b/src/shared/analytics/events/vc-fetched.ts @@ -0,0 +1,16 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_fetched`. + */ +export const VcFetchedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + remote_kind: z.enum(['byterover', 'external']), + }) + .strict() + +export type VcFetchedProps = z.infer diff --git a/src/shared/analytics/events/vc-init.ts b/src/shared/analytics/events/vc-init.ts new file mode 100644 index 000000000..735ab522d --- /dev/null +++ b/src/shared/analytics/events/vc-init.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_init`. + * + * `had_existing_git_dir` separates fresh-init from convert-existing. + */ +export const VcInitSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + had_existing_git_dir: z.boolean(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type VcInitProps = z.infer diff --git a/src/shared/analytics/events/vc-merged.ts b/src/shared/analytics/events/vc-merged.ts new file mode 100644 index 000000000..4c74e734a --- /dev/null +++ b/src/shared/analytics/events/vc-merged.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_merged`. + * + * `had_fast_forward` only known on success. + */ +export const VcMergedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + had_fast_forward: z.boolean().optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type VcMergedProps = z.infer diff --git a/src/shared/analytics/events/vc-pulled.ts b/src/shared/analytics/events/vc-pulled.ts new file mode 100644 index 000000000..b53dba46e --- /dev/null +++ b/src/shared/analytics/events/vc-pulled.ts @@ -0,0 +1,21 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_pulled`. + * + * `branch_name_hash` = sha256 of the branch name (raw branch names may + * carry user-identifying tokens at organizations using `/` + * conventions). + */ +export const VcPulledSchema = z + .object({ + branch_name_hash: z.string().regex(/^[0-9a-f]{64}$/), + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + remote_kind: z.enum(['byterover', 'external']), + }) + .strict() + +export type VcPulledProps = z.infer diff --git a/src/shared/analytics/events/vc-pushed.ts b/src/shared/analytics/events/vc-pushed.ts new file mode 100644 index 000000000..0a84ce11b --- /dev/null +++ b/src/shared/analytics/events/vc-pushed.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_pushed`. + * + * See `vc-pulled.ts` for the rationale on `branch_name_hash`. + */ +export const VcPushedSchema = z + .object({ + branch_name_hash: z.string().regex(/^[0-9a-f]{64}$/), + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + remote_kind: z.enum(['byterover', 'external']), + }) + .strict() + +export type VcPushedProps = z.infer diff --git a/src/shared/analytics/events/vc-remote-changed.ts b/src/shared/analytics/events/vc-remote-changed.ts new file mode 100644 index 000000000..ab4b0010d --- /dev/null +++ b/src/shared/analytics/events/vc-remote-changed.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_remote_changed`. + * + * Collapses the 3 `brv vc remote` subcommands via `change_kind`. + */ +export const VcRemoteChangedSchema = z + .object({ + change_kind: z.enum(['added', 'removed', 'url_set']), + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + remote_kind: z.enum(['byterover', 'external']), + }) + .strict() + +export type VcRemoteChangedProps = z.infer diff --git a/src/shared/analytics/events/vc-reset-executed.ts b/src/shared/analytics/events/vc-reset-executed.ts new file mode 100644 index 000000000..6a6f26d31 --- /dev/null +++ b/src/shared/analytics/events/vc-reset-executed.ts @@ -0,0 +1,16 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `vc_reset_executed`. + */ +export const VcResetExecutedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + reset_mode: z.enum(['soft', 'mixed', 'hard']), + }) + .strict() + +export type VcResetExecutedProps = z.infer diff --git a/src/shared/analytics/events/webui-session-ended.ts b/src/shared/analytics/events/webui-session-ended.ts new file mode 100644 index 000000000..1a07136b5 --- /dev/null +++ b/src/shared/analytics/events/webui-session-ended.ts @@ -0,0 +1,22 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `webui_session_ended`. + * + * Matches a `webui_session_started` row via `started_at_unix_ms`. + * `session_duration_ms` is computed daemon-side from `ClientInfo.connectedAt`. + * + * IMPORTANT: NO `session_id` field — that name is on `forbidden-field-names.ts` + * and would be runtime-redacted by `redactRecord`. The `started_at_unix_ms` + * Date.now() value at register time serves as the join key instead. + */ +export const WebuiSessionEndedSchema = z + .object({ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + session_duration_ms: z.number().int().nonnegative(), + started_at_unix_ms: z.number().int().nonnegative(), + }) + .strict() + +export type WebuiSessionEndedProps = z.infer diff --git a/src/shared/analytics/events/webui-session-started.ts b/src/shared/analytics/events/webui-session-started.ts new file mode 100644 index 000000000..5643a110e --- /dev/null +++ b/src/shared/analytics/events/webui-session-started.ts @@ -0,0 +1,20 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `webui_session_started`. + * + * Fires when a browser dashboard connects to the daemon over Socket.IO. + * `started_at_unix_ms` (Date.now()) is the join key with the matching + * `webui_session_ended` row. + * + * IMPORTANT: NO `session_id` field — see `webui-session-ended.ts`. + */ +export const WebuiSessionStartedSchema = z + .object({ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + started_at_unix_ms: z.number().int().nonnegative(), + }) + .strict() + +export type WebuiSessionStartedProps = z.infer diff --git a/src/shared/analytics/events/worktree-added.ts b/src/shared/analytics/events/worktree-added.ts new file mode 100644 index 000000000..685b9c39a --- /dev/null +++ b/src/shared/analytics/events/worktree-added.ts @@ -0,0 +1,19 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `worktree_added`. + * + * `worktree_kind` classifies the worktree model (e.g. `pointer`, `real`). + * Only known on success. + */ +export const WorktreeAddedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + worktree_kind: z.string().min(1).optional(), + }) + .strict() + +export type WorktreeAddedProps = z.infer diff --git a/src/shared/analytics/events/worktree-removed.ts b/src/shared/analytics/events/worktree-removed.ts new file mode 100644 index 000000000..923ca4112 --- /dev/null +++ b/src/shared/analytics/events/worktree-removed.ts @@ -0,0 +1,15 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `worktree_removed`. + */ +export const WorktreeRemovedSchema = z + .object({ + failure_kind: z.string().min(1).max(64).optional(), + outcome: z.enum(['success', 'failure']), + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export type WorktreeRemovedProps = z.infer diff --git a/test/unit/infra/transport/handlers/auth-handler.test.ts b/test/unit/infra/transport/handlers/auth-handler.test.ts index ee9434826..c7a973338 100644 --- a/test/unit/infra/transport/handlers/auth-handler.test.ts +++ b/test/unit/infra/transport/handlers/auth-handler.test.ts @@ -3,6 +3,7 @@ import type {SinonStubbedInstance} from 'sinon' import {expect} from 'chai' import {restore, stub} from 'sinon' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' import type {IAuthService} from '../../../../../src/server/core/interfaces/auth/i-auth-service.js' import type {ICallbackHandler} from '../../../../../src/server/core/interfaces/auth/i-callback-handler.js' import type {ITokenStore} from '../../../../../src/server/core/interfaces/auth/i-token-store.js' @@ -18,6 +19,7 @@ import {BrvConfig} from '../../../../../src/server/core/domain/entities/brv-conf import {User} from '../../../../../src/server/core/domain/entities/user.js' import {TransportDaemonEventNames} from '../../../../../src/server/core/domain/transport/schemas.js' import {AuthHandler, type AuthHandlerDeps} from '../../../../../src/server/infra/transport/handlers/auth-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' import {AuthEvents} from '../../../../../src/shared/transport/events/auth-events.js' // ==================== Test Helpers ==================== @@ -82,6 +84,18 @@ function createTestBrvConfig(): BrvConfig { // ==================== Tests ==================== +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: ReturnType} { + const trackSpy = stub() + return { + abort: stub(), + flush: stub().resolves({events: []}), + getRuntimeState: stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: ReturnType} +} + function createMockProviderConfigStore( options: {isConnected?: boolean} = {}, ): SinonStubbedInstance { @@ -524,4 +538,119 @@ describe('AuthHandler — setupExternalAuthSync', () => { .to.be.lessThan(callOrder.indexOf('LOGIN_COMPLETED')) }) }) + + describe('analytics emits (M15.1)', () => { + it('emits auth_logout with outcome=success on the happy logout path', async () => { + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient}) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + const trackCalls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGOUT) + expect(trackCalls.length, 'auth_logout fires exactly once on success').to.equal(1) + expect(trackCalls[0].args[1]).to.deep.equal({outcome: 'success'}) + }) + + it('emits auth_logout with outcome=failure when the logout flow throws', async () => { + const analyticsClient = makeFakeAnalyticsClient() + const tokenStore = { + clear: stub().rejects(new Error('disk full')), + load: stub().resolves(), + save: stub().resolves(), + } as unknown as ITokenStore + + createHandler({analyticsClient, tokenStore}) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: false}) + const trackCalls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGOUT) + expect(trackCalls.length, 'auth_logout fires exactly once on failure').to.equal(1) + const props = trackCalls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind, 'failure_kind is a coarse tag, never a raw error message').to.be.a('string') + }) + + it('does not throw when analyticsClient.track throws on logout (analytics failures are swallowed)', async () => { + const analyticsClient = makeFakeAnalyticsClient() + analyticsClient.trackSpy.throws(new Error('boom')) + + createHandler({analyticsClient}) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + }) + + it('is a no-op when no analyticsClient is injected (optional dep, backward compat)', async () => { + createHandler() // no analyticsClient override + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + }) + + it('emits auth_login with outcome=success on API-key login after token save + loadToken', async () => { + const callOrder: string[] = [] + const analyticsClient = makeFakeAnalyticsClient() + analyticsClient.trackSpy.callsFake((event: string) => { + callOrder.push(`track:${event}`) + }) + const tokenStore = { + clear: stub().resolves(), + load: stub().resolves(), + save: stub().callsFake(async () => { + callOrder.push('tokenStore.save') + }), + } as unknown as ITokenStore + authStateStore.loadToken = stub().callsFake(async () => { + callOrder.push('authStateStore.loadToken') + }) as unknown as typeof authStateStore.loadToken + + createHandler({analyticsClient, tokenStore}) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + await handler({apiKey: 'test-key'}, 'client-1') + + const trackCalls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGIN) + expect(trackCalls.length, 'auth_login fires exactly once on API-key success').to.equal(1) + expect(trackCalls[0].args[1]).to.deep.equal({outcome: 'success'}) + expect(callOrder.indexOf('tokenStore.save'), 'save should precede track').to.be.lessThan( + callOrder.indexOf(`track:${AnalyticsEventNames.AUTH_LOGIN}`), + ) + expect(callOrder.indexOf('authStateStore.loadToken'), 'loadToken should precede track').to.be.lessThan( + callOrder.indexOf(`track:${AnalyticsEventNames.AUTH_LOGIN}`), + ) + }) + + it('emits auth_login with outcome=failure when API-key login throws', async () => { + const analyticsClient = makeFakeAnalyticsClient() + userService.getCurrentUser = stub().rejects(new Error('invalid key')) as unknown as typeof userService.getCurrentUser + + createHandler({analyticsClient}) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + const result = await handler({apiKey: 'bad-key'}, 'client-1') + + expect(result.success).to.equal(false) + const trackCalls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGIN) + expect(trackCalls.length, 'auth_login fires exactly once on API-key failure').to.equal(1) + const props = trackCalls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.be.a('string') + }) + }) }) diff --git a/test/unit/infra/transport/handlers/global-config-handler.test.ts b/test/unit/infra/transport/handlers/global-config-handler.test.ts index e03ab1441..f47268d6c 100644 --- a/test/unit/infra/transport/handlers/global-config-handler.test.ts +++ b/test/unit/infra/transport/handlers/global-config-handler.test.ts @@ -8,6 +8,7 @@ import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces import {GLOBAL_CONFIG_VERSION} from '../../../../../src/server/constants.js' import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' import {GlobalConfigHandler} from '../../../../../src/server/infra/transport/handlers/global-config-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' import {GlobalConfigEvents} from '../../../../../src/shared/transport/events/global-config-events.js' import {createMockTransportServer, type MockTransportServer} from '../../../../helpers/mock-factories.js' @@ -25,6 +26,24 @@ function makeAnalyticsClientStub(): {abort: ReturnType} { return {abort: stub()} } +// M15.1: full analytics client double for the analytics_disabled emit tests. +// Same module-scope hoist rationale as makeAnalyticsClientStub above. +function makeTrackingClient(): { + abort: ReturnType + flush: ReturnType + getRuntimeState: ReturnType + onAuthTransition: ReturnType + track: ReturnType +} { + return { + abort: stub(), + flush: stub().resolves({events: []}), + getRuntimeState: stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + track: stub(), + } +} + describe('GlobalConfigHandler', () => { let store: SinonStubbedInstance let transport: MockTransportServer @@ -349,4 +368,100 @@ describe('GlobalConfigHandler', () => { expect(response.current, 'works without analyticsClient').to.be.false }) }) + + describe('M15.1 analytics_disabled emit', () => { + it('emits analytics_disabled exactly once on enable→disable transition', async () => { + const analyticsClient = makeTrackingClient() + const handlerWithClient = new GlobalConfigHandler({analyticsClient, globalConfigStore: store, transport}) + handlerWithClient.setup() + + const enabled = GlobalConfig.create('device-x').withAnalytics(true) + store.read.resolves(enabled) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: false}, 'client-1') + + const trackCalls = analyticsClient.track + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.ANALYTICS_DISABLED) + expect(trackCalls.length, 'analytics_disabled fires exactly once on disable transition').to.equal(1) + }) + + it('emits BEFORE cachedAnalytics flips (so isEnabled reads true at track time)', async () => { + const analyticsClient = makeTrackingClient() + const handlerWithClient = new GlobalConfigHandler({analyticsClient, globalConfigStore: store, transport}) + handlerWithClient.setup() + + const enabled = GlobalConfig.create('device-x').withAnalytics(true) + store.read.resolves(enabled) + await handlerWithClient.refreshCache() + expect(handlerWithClient.getCachedAnalytics(), 'cache starts true after refresh').to.be.true + + // Capture the value of cachedAnalytics at the moment track() is called. + let cacheAtTrack: boolean | undefined + analyticsClient.track.callsFake(() => { + cacheAtTrack = handlerWithClient.getCachedAnalytics() + }) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: false}, 'client-1') + + expect(cacheAtTrack, 'cache still reports true at the moment track fires').to.equal(true) + expect(handlerWithClient.getCachedAnalytics(), 'cache flips to false after the call returns').to.equal(false) + }) + + it('does NOT emit on idempotent disable (false → false)', async () => { + const analyticsClient = makeTrackingClient() + const handlerWithClient = new GlobalConfigHandler({analyticsClient, globalConfigStore: store, transport}) + handlerWithClient.setup() + + store.read.resolves() // no config = previous false + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: false}, 'client-1') + + const trackCalls = analyticsClient.track + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.ANALYTICS_DISABLED) + expect(trackCalls.length, 'no transition = no emit').to.equal(0) + }) + + it('does NOT emit on enable (false → true) — analytics_enabled is intentionally not tracked', async () => { + const analyticsClient = makeTrackingClient() + const handlerWithClient = new GlobalConfigHandler({analyticsClient, globalConfigStore: store, transport}) + handlerWithClient.setup() + + const disabled = GlobalConfig.create('device-x').withAnalytics(false) + store.read.resolves(disabled) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + await fn({analytics: true}, 'client-1') + + const trackCalls = analyticsClient.track + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.ANALYTICS_DISABLED) + expect(trackCalls.length, 'enable must never produce analytics_disabled').to.equal(0) + }) + + it('does not crash the SET when track throws', async () => { + const analyticsClient = makeTrackingClient() + analyticsClient.track.throws(new Error('boom')) + const handlerWithClient = new GlobalConfigHandler({analyticsClient, globalConfigStore: store, transport}) + handlerWithClient.setup() + + const enabled = GlobalConfig.create('device-x').withAnalytics(true) + store.read.resolves(enabled) + + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + const response = await fn({analytics: false}, 'client-1') + + expect(response.current, 'disable completes even when track throws').to.be.false + expect(response.previous).to.be.true + }) + }) }) diff --git a/test/unit/infra/transport/socket-io-transport-server.test.ts b/test/unit/infra/transport/socket-io-transport-server.test.ts index 40d7eb21d..f7e169b7a 100644 --- a/test/unit/infra/transport/socket-io-transport-server.test.ts +++ b/test/unit/infra/transport/socket-io-transport-server.test.ts @@ -1,11 +1,14 @@ import {expect} from 'chai' import {Socket as ClientSocket, io} from 'socket.io-client' +import type {ClientType} from '../../../../src/server/core/domain/client/client-info.js' + import { TransportPortInUseError, TransportServerAlreadyRunningError, TransportServerNotStartedError, } from '../../../../src/server/core/domain/errors/transport-error.js' +import {getClientKindFromContext} from '../../../../src/server/infra/transport/client-kind-context.js' import {SocketIOTransportServer} from '../../../../src/server/infra/transport/socket-io-transport-server.js' describe('SocketIOTransportServer', () => { @@ -444,4 +447,76 @@ describe('SocketIOTransportServer', () => { expect(server.isRunning()).to.be.true }) }) + + describe('client_kind context wrap (M15.1)', () => { + it('exposes the registered ClientType inside the request handler via getClientKindFromContext', async () => { + const typeByClientId = new Map() + server.setGetClientKind((clientId) => typeByClientId.get(clientId)) + await server.start(9980) + + let observed: ClientType | undefined + server.onRequest('client-kind:probe', () => { + observed = getClientKindFromContext() + return {ok: true} + }) + + clientSocket = io('http://127.0.0.1:9980') + await new Promise((resolve) => { + clientSocket!.on('connect', resolve) + }) + + typeByClientId.set(clientSocket.id!, 'cli') + + await new Promise((resolve) => { + clientSocket!.emit('client-kind:probe', {}, () => resolve()) + }) + + expect(observed).to.equal('cli') + }) + + it('omits the wrap when the lookup returns undefined (no context set)', async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + const noopLookup: (clientId: string) => ClientType | undefined = () => undefined + server.setGetClientKind(noopLookup) + await server.start(9981) + + let observed: 'sentinel' | ClientType | undefined = 'sentinel' + server.onRequest('client-kind:probe', () => { + observed = getClientKindFromContext() + return {ok: true} + }) + + clientSocket = io('http://127.0.0.1:9981') + await new Promise((resolve) => { + clientSocket!.on('connect', resolve) + }) + + await new Promise((resolve) => { + clientSocket!.emit('client-kind:probe', {}, () => resolve()) + }) + + expect(observed).to.equal(undefined) + }) + + it('skips the wrap entirely when no getClientKind callback is registered (backward compat)', async () => { + await server.start(9982) + + let observed: 'sentinel' | ClientType | undefined = 'sentinel' + server.onRequest('client-kind:probe', () => { + observed = getClientKindFromContext() + return {ok: true} + }) + + clientSocket = io('http://127.0.0.1:9982') + await new Promise((resolve) => { + clientSocket!.on('connect', resolve) + }) + + await new Promise((resolve) => { + clientSocket!.emit('client-kind:probe', {}, () => resolve()) + }) + + expect(observed).to.equal(undefined) + }) + }) }) diff --git a/test/unit/server/infra/analytics/super-properties-resolver.test.ts b/test/unit/server/infra/analytics/super-properties-resolver.test.ts index d1147cf95..56cb4ad42 100644 --- a/test/unit/server/infra/analytics/super-properties-resolver.test.ts +++ b/test/unit/server/infra/analytics/super-properties-resolver.test.ts @@ -6,6 +6,7 @@ import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' import {SuperPropertiesResolver} from '../../../../../src/server/infra/analytics/super-properties-resolver.js' +import {runWithClientKind} from '../../../../../src/server/infra/transport/client-kind-context.js' const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' @@ -52,6 +53,41 @@ describe('SuperPropertiesResolver', () => { }) }) + describe('client_kind (M15.1)', () => { + it('omits client_kind when no clientKindContext scope is active', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await resolver.resolve() + + expect(props).to.not.have.property('client_kind') + }) + + it('stamps client_kind=cli when wrapped in runWithClientKind("cli")', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await runWithClientKind('cli', () => resolver.resolve()) + + expect(props.client_kind).to.equal('cli') + }) + + it('stamps client_kind=webui when wrapped in runWithClientKind("webui")', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await runWithClientKind('webui', () => resolver.resolve()) + + expect(props.client_kind).to.equal('webui') + }) + + it('keeps the other super-properties stable when client_kind is added', async () => { + const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') + + const props = await runWithClientKind('tui', () => resolver.resolve()) + + expect(props.cli_version).to.equal('1.2.3') + expect(props.device_id).to.equal(validDeviceId) + }) + }) + describe('device_id (ticket scenario 2)', () => { it('should match what IGlobalConfigStore returned', async () => { const customId = '11111111-1111-1111-1111-111111111111' diff --git a/test/unit/server/infra/transport/client-kind-context.test.ts b/test/unit/server/infra/transport/client-kind-context.test.ts new file mode 100644 index 000000000..9086a451e --- /dev/null +++ b/test/unit/server/infra/transport/client-kind-context.test.ts @@ -0,0 +1,67 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import { + clientKindContext, + getClientKindFromContext, + runWithClientKind, +} from '../../../../../src/server/infra/transport/client-kind-context.js' + +describe('clientKindContext', () => { + describe('outside any scope', () => { + it('returns undefined from getClientKindFromContext()', () => { + expect(getClientKindFromContext()).to.equal(undefined) + }) + }) + + describe('runWithClientKind', () => { + it('exposes the wrapped value inside the callback', async () => { + const observed = await runWithClientKind('cli', async () => getClientKindFromContext()) + expect(observed).to.equal('cli') + }) + + it('returns the callback result', async () => { + const result = await runWithClientKind('webui', async () => 42) + expect(result).to.equal(42) + }) + + it('propagates value through an await boundary', async () => { + const observed = await runWithClientKind('tui', async () => { + await Promise.resolve() + return getClientKindFromContext() + }) + expect(observed).to.equal('tui') + }) + + it('isolates sibling scopes', async () => { + const [a, b] = await Promise.all([ + runWithClientKind('cli', async () => { + await Promise.resolve() + return getClientKindFromContext() + }), + runWithClientKind('webui', async () => { + await Promise.resolve() + return getClientKindFromContext() + }), + ]) + expect(a).to.equal('cli') + expect(b).to.equal('webui') + }) + + it('does not leak into the outer scope after the callback resolves', async () => { + await runWithClientKind('mcp', async () => {}) + expect(getClientKindFromContext()).to.equal(undefined) + }) + }) + + describe('clientKindContext (raw AsyncLocalStorage export)', () => { + it('is the same store that runWithClientKind wraps', async () => { + const observed = await new Promise((resolve) => { + clientKindContext.run({client_kind: 'extension'}, () => { + resolve(getClientKindFromContext()) + }) + }) + expect(observed).to.equal('extension') + }) + }) +}) diff --git a/test/unit/shared/analytics/event-names.test.ts b/test/unit/shared/analytics/event-names.test.ts index 84fd2a7ff..5bcc6d634 100644 --- a/test/unit/shared/analytics/event-names.test.ts +++ b/test/unit/shared/analytics/event-names.test.ts @@ -3,18 +3,54 @@ import {expect} from 'chai' import {type AnalyticsEventName, AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' describe('AnalyticsEventNames', () => { - it('should expose exactly the ten shipped event names', () => { + it('should expose exactly the forty-six shipped event names (M15.1 adds 36)', () => { expect(Object.keys(AnalyticsEventNames).sort()).to.deep.equal([ + 'ANALYTICS_DISABLED', + 'AUTH_LOGIN', + 'AUTH_LOGOUT', + 'BRV_INIT', 'CLI_INVOCATION', + 'CONNECTOR_INSTALLED', + 'CONTEXT_TREE_FILE_EDITED', 'CURATE_OPERATION_APPLIED', 'CURATE_RUN_COMPLETED', + 'DAEMON_RESET_EXECUTED', 'DAEMON_START', + 'HUB_PACKAGE_INSTALLED', + 'HUB_REGISTRY_ADDED', + 'HUB_REGISTRY_REMOVED', 'MCP_SESSION_START', 'MCP_TOOL_CALLED', + 'ONBOARDING_AUTO_SETUP_STARTED', + 'ONBOARDING_COMPLETED', 'QUERY_COMPLETED', + 'REVIEW_APPROVED', + 'REVIEW_REJECTED', + 'REVIEW_TOGGLED', + 'SETTING_CHANGED', + 'SETTING_RESET', + 'SOURCE_ADDED', + 'SOURCE_REMOVED', + 'SPACE_SWITCHED', 'TASK_COMPLETED', 'TASK_CREATED', 'TASK_FAILED', + 'VC_BRANCHED', + 'VC_CHECKED_OUT', + 'VC_CLONED', + 'VC_COMMIT', + 'VC_DISCARDED', + 'VC_FETCHED', + 'VC_INIT', + 'VC_MERGED', + 'VC_PULLED', + 'VC_PUSHED', + 'VC_REMOTE_CHANGED', + 'VC_RESET_EXECUTED', + 'WEBUI_SESSION_ENDED', + 'WEBUI_SESSION_STARTED', + 'WORKTREE_ADDED', + 'WORKTREE_REMOVED', ]) }) diff --git a/test/unit/shared/analytics/privacy-fixture.test.ts b/test/unit/shared/analytics/privacy-fixture.test.ts index dee883859..57aa7b46e 100644 --- a/test/unit/shared/analytics/privacy-fixture.test.ts +++ b/test/unit/shared/analytics/privacy-fixture.test.ts @@ -116,16 +116,52 @@ describe('analytics privacy fixture (smoke)', () => { it('should expose every shipped event name under ALL_EVENT_SCHEMAS', () => { expect(Object.keys(ALL_EVENT_SCHEMAS).sort()).to.deep.equal([ + 'analytics_disabled', + 'auth_login', + 'auth_logout', + 'brv_init', 'cli_invocation', + 'connector_installed', + 'context_tree_file_edited', 'curate_operation_applied', 'curate_run_completed', + 'daemon_reset_executed', 'daemon_start', + 'hub_package_installed', + 'hub_registry_added', + 'hub_registry_removed', 'mcp_session_start', 'mcp_tool_called', + 'onboarding_auto_setup_started', + 'onboarding_completed', 'query_completed', + 'review_approved', + 'review_rejected', + 'review_toggled', + 'setting_changed', + 'setting_reset', + 'source_added', + 'source_removed', + 'space_switched', 'task_completed', 'task_created', 'task_failed', + 'vc_branched', + 'vc_checked_out', + 'vc_cloned', + 'vc_commit', + 'vc_discarded', + 'vc_fetched', + 'vc_init', + 'vc_merged', + 'vc_pulled', + 'vc_pushed', + 'vc_remote_changed', + 'vc_reset_executed', + 'webui_session_ended', + 'webui_session_started', + 'worktree_added', + 'worktree_removed', ]) }) From 25a3e52538e4ab67fba1aec55365e4b08064d813 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 27 May 2026 14:52:08 +0700 Subject: [PATCH 54/87] fix: [ENG-2961] M15.1 review follow-ups + drop milestone markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test additions: parameterized dispatch coverage in analytics-handler.test asserting all 36 catalog event names reach `track()` (guards against a silent `case`-branch regression in `dispatch()`); shared `assertFailureKindDiscipline()` helper enforcing snake_case + ≤64 chars on every emitted `failure_kind` tag (catches future "pasted error.message" mistakes at review time). Comments-only: drop the `M15.1:` prefix from every code/test comment and describe string. The milestone marker rots; the explanation underneath it should stand on its own. Inherited M2-M13 markers in pre-existing code remain untouched. --- .../analytics/i-super-properties-resolver.ts | 8 +- src/server/infra/daemon/brv-server.ts | 4 +- src/server/infra/process/feature-handlers.ts | 6 +- .../infra/transport/handlers/auth-handler.ts | 49 +++--- .../handlers/global-config-handler.ts | 2 +- .../transport/socket-io-transport-server.ts | 11 +- .../transport/handlers/auth-handler.test.ts | 21 ++- .../handlers/global-config-handler.test.ts | 4 +- .../socket-io-transport-server.test.ts | 2 +- .../super-properties-resolver.test.ts | 2 +- .../handlers/analytics-handler.test.ts | 163 ++++++++++++++++++ .../unit/shared/analytics/event-names.test.ts | 2 +- 12 files changed, 229 insertions(+), 45 deletions(-) diff --git a/src/server/core/interfaces/analytics/i-super-properties-resolver.ts b/src/server/core/interfaces/analytics/i-super-properties-resolver.ts index cc1ac3665..bf0a8c8f2 100644 --- a/src/server/core/interfaces/analytics/i-super-properties-resolver.ts +++ b/src/server/core/interfaces/analytics/i-super-properties-resolver.ts @@ -6,10 +6,10 @@ import type {ClientType} from '../../domain/client/client-info.js' * snake_case throughout. `device_id` is sourced from `GlobalConfig`; * the remaining four are static across the daemon's lifetime. * - * `client_kind` (M15.1) is stamped when the analytics emit originates - * from a Socket.IO transport call wrapped in `clientKindContext.run()`. - * Absent when the emit happens outside any context wrap (daemon-internal - * track or agent-fork connection). + * `client_kind` is stamped when the analytics emit originates from a + * Socket.IO transport call wrapped in `clientKindContext.run()`. Absent + * when the emit happens outside any context wrap (daemon-internal track + * or agent-fork connection). */ export type SuperProperties = Readonly<{ cli_version: string diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index 5206d2c52..daab7fe3c 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -263,8 +263,8 @@ async function main(): Promise { const projectRouter = new ProjectRouter({transport: transportServer}) const clientManager = new ClientManager() - // M15.1: stamp `client_kind` on analytics super-properties for every - // request originating from a registered Socket.IO client. Agent-fork + // Stamp `client_kind` on analytics super-properties for every request + // originating from a registered Socket.IO client. Agent-fork // connections bypass the wrap (return undefined) so daemon-internal // task lifecycle emits stay envelope-clean. transportServer.setGetClientKind((clientId) => { diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index e66810bcf..73deb0a43 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -290,9 +290,9 @@ export async function setupFeatureHandlers({ }).setup() new AuthHandler({ - // M15.1: thread the analytics client so the auth handler can emit - // auth_login / auth_logout on identity transitions. M6's Mixpanel - // alias() path keys off the auth_login event. + // Thread the analytics client so the auth handler can emit + // auth_login / auth_logout on identity transitions. The Mixpanel + // forwarder's alias() path keys off the auth_login event. analyticsClient, authService: new OAuthService(authConfig), authStateStore, diff --git a/src/server/infra/transport/handlers/auth-handler.ts b/src/server/infra/transport/handlers/auth-handler.ts index 74ff89d0e..434de6dd3 100644 --- a/src/server/infra/transport/handlers/auth-handler.ts +++ b/src/server/infra/transport/handlers/auth-handler.ts @@ -50,11 +50,10 @@ function toUserDTO(user: User): UserDTO { export interface AuthHandlerDeps { /** - * M15.1: optional. When provided, the handler emits `auth_login` / + * Optional. When provided, the handler emits `auth_login` / * `auth_logout` analytics events on identity transitions. Optional so - * that legacy construction (and unit tests that don't care about - * analytics) doesn't need to thread the dep through. Wired in - * `feature-handlers.ts`. + * legacy construction (and unit tests that don't care about analytics) + * doesn't need to thread the dep through. Wired in `feature-handlers.ts`. */ analyticsClient?: IAnalyticsClient authService: IAuthService @@ -151,7 +150,7 @@ export class AuthHandler { } /** - * M15.1: analytics emit helper. Mirrors the try/processLog pattern from + * Analytics emit helper. Mirrors the try/processLog pattern from * `analytics-hook.ts` so analytics failures never affect command outcomes. */ private emitAnalytics(event: E, ...rest: PropsArg): void { @@ -190,9 +189,10 @@ export class AuthHandler { // new token without waiting for the next 5-second poll cycle. await this.authStateStore.loadToken() - // M15.1: emit AFTER loadToken so the per-event identity resolver - // stamps the row with the new authenticated user_id (M6's alias - // path keys off `{name: auth_login, outcome: success}`). + // Emit AFTER loadToken so the per-event identity resolver stamps + // the row with the new authenticated user_id (the Mixpanel + // forwarder's alias path keys off `{name: auth_login, outcome: + // success}`). this.emitAnalytics(AnalyticsEventNames.AUTH_LOGIN, {outcome: 'success'}) this.transport.broadcast(AuthEvents.LOGIN_COMPLETED, { @@ -205,7 +205,7 @@ export class AuthHandler { user: toUserDTO(user), }) } catch (error) { - // M15.1: emit the failure terminal so the funnel sees both halves. + // Emit the failure terminal so the funnel sees both halves. // Identity is still anonymous (token never committed). `failure_kind` // is a coarse tag — never leak `error.message` here (would risk PII). // eslint-disable-next-line camelcase @@ -311,7 +311,7 @@ export class AuthHandler { await this.tokenStore.save(authToken) await this.authStateStore.loadToken() - // M15.1: emit AFTER loadToken (same rationale as the OAuth path). + // Emit AFTER loadToken (same identity-stamping rationale as the OAuth path). this.emitAnalytics(AnalyticsEventNames.AUTH_LOGIN, {outcome: 'success'}) this.transport.broadcast(AuthEvents.STATE_CHANGED, { @@ -321,7 +321,7 @@ export class AuthHandler { return {success: true, userEmail: user.email} } catch (error) { - // M15.1: failure-path emit covers api-key auth failures (invalid key, + // Failure-path emit covers api-key auth failures (invalid key, // network error, user fetch failure). Stays anonymous — no token was // committed. // eslint-disable-next-line camelcase @@ -340,22 +340,27 @@ export class AuthHandler { await this.disconnectByteRoverProvider() await this.authStateStore.loadToken() - // M15.1: emit AFTER cleanup but on the success terminal. The - // pre-transition flush hook fires when loadToken() above changes - // identity, draining any pending events under the logged-in - // identity. After loadToken() identity is anonymous, so this - // success row stamps anonymously — which correctly reflects "logout - // succeeded for the now-anonymous session." Downstream consumers - // join on `device_id` to attribute the logout back to the user. + // Emit on the success terminal (single-emit guarantee — a + // success emit at the START would double-fire with the catch + // branch when a later step throws). By the time we reach here + // loadToken() has already flipped identity to anonymous, so the + // success row stamps `{device_id}` only. The OLD-identity events + // (any pending tracks under the logged-in user) ship separately: + // wire-analytics-auth-pre-transition.ts hooks `onBeforeAuthChange` + // and awaits `flush()` before loadToken commits the identity + // change, draining them under the logged-in identity. Downstream + // consumers join `auth_logout` rows back to the user via + // `device_id`. this.emitAnalytics(AnalyticsEventNames.AUTH_LOGOUT, {outcome: 'success'}) this.transport.broadcast(AuthEvents.STATE_CHANGED, {isAuthorized: false}) return {success: true} } catch { - // M15.1: failure-path emit covers token-clear / provider-disconnect / - // state-reload errors. Identity may be either logged-in (if clear - // failed first) or anonymous (if clear succeeded but a later step - // failed) — both are correct for diagnostic purposes. + // Failure-path emit covers token-clear / provider-disconnect / + // state-reload errors. Identity at trackAsync-resolve time may be + // logged-in (clear failed first) or anonymous (clear succeeded but a + // later step failed); both are valid for diagnostic purposes. + // `failure_kind` is a coarse tag — never raw `error.message`. // eslint-disable-next-line camelcase this.emitAnalytics(AnalyticsEventNames.AUTH_LOGOUT, {failure_kind: 'logout_flow', outcome: 'failure'}) diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts index 1f38f7e88..eba5462d6 100644 --- a/src/server/infra/transport/handlers/global-config-handler.ts +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -148,7 +148,7 @@ export class GlobalConfigHandler { const updated = current.withAnalytics(analytics) await this.globalConfigStore.write(updated) - // M15.1: emit BEFORE flipping `cachedAnalytics` so AnalyticsClient.isEnabled + // Emit BEFORE flipping `cachedAnalytics` so AnalyticsClient.isEnabled // (which reads the cache) still resolves true at track time and the row // enters the queue. After this line the cache flips to false and any // subsequent track() is no-op'd. analytics_enabled is intentionally NOT diff --git a/src/server/infra/transport/socket-io-transport-server.ts b/src/server/infra/transport/socket-io-transport-server.ts index 4b39657bd..f2211fc02 100644 --- a/src/server/infra/transport/socket-io-transport-server.ts +++ b/src/server/infra/transport/socket-io-transport-server.ts @@ -46,9 +46,9 @@ export class SocketIOTransportServer implements ITransportServer { private connectionHandlers: ConnectionHandler[] = [] private disconnectionHandlers: ConnectionHandler[] = [] /** - * M15.1: optional lookup that resolves a Socket.IO clientId to its - * registered `ClientType`. When set and the lookup returns a non-undefined - * value, every incoming request handler invocation is wrapped in + * Optional lookup that resolves a Socket.IO clientId to its registered + * `ClientType`. When set and the lookup returns a non-undefined value, + * every incoming request handler invocation is wrapped in * `clientKindContext.run({client_kind}, ...)` so SuperPropertiesResolver * can stamp `client_kind` on the analytics envelope. Pre-filter the * `agent` ClientType at the caller (return undefined) so agent-fork @@ -153,7 +153,8 @@ export class SocketIOTransportServer implements ITransportServer { /** * Register a lookup that maps a socket clientId to its ClientType. - * Used by M15.1 to stamp `client_kind` on analytics super-properties. + * Used to stamp `client_kind` on analytics super-properties so handler + * emits inherit the originating client kind without per-handler wiring. * Setter (not constructor injection) because ClientManager is constructed * AFTER the transport server in brv-server.ts boot order. */ @@ -291,7 +292,7 @@ export class SocketIOTransportServer implements ITransportServer { private registerEventHandler(socket: Socket, event: string, handler: StoredRequestHandler): void { socket.on(event, async (data: unknown, callback?: (response: unknown) => void) => { try { - // M15.1: wrap the handler in clientKindContext so SuperPropertiesResolver + // Wrap the handler in clientKindContext so SuperPropertiesResolver // can stamp `client_kind` on any analytics event emitted during this // handler invocation. Skip the wrap when no lookup is registered or the // lookup returns undefined (agent-fork bypass / unregistered sockets). diff --git a/test/unit/infra/transport/handlers/auth-handler.test.ts b/test/unit/infra/transport/handlers/auth-handler.test.ts index c7a973338..bd84120ad 100644 --- a/test/unit/infra/transport/handlers/auth-handler.test.ts +++ b/test/unit/infra/transport/handlers/auth-handler.test.ts @@ -96,6 +96,21 @@ function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: ReturnType} } +/** + * `failure_kind` discipline: the emitted tag MUST be a coarse enum-like + * value — non-empty, ≤64 chars, snake_case (lowercase letters + + * underscores). Forbids whitespace, newlines, capital letters, symbols — + * catches a developer accidentally passing `getErrorMessage(error)` or + * `error.message` as the tag. + */ +function assertFailureKindDiscipline(value: unknown, label: string): void { + expect(value, `${label}: failure_kind must be a string`).to.be.a('string') + const tag = value as string + expect(tag.length, `${label}: failure_kind must be non-empty`).to.be.greaterThan(0) + expect(tag.length, `${label}: failure_kind must be ≤64 chars (got ${tag.length})`).to.be.lessThanOrEqual(64) + expect(tag, `${label}: failure_kind must be snake_case (a-z + _), got "${tag}"`).to.match(/^[a-z][a-z_]*$/) +} + function createMockProviderConfigStore( options: {isConnected?: boolean} = {}, ): SinonStubbedInstance { @@ -539,7 +554,7 @@ describe('AuthHandler — setupExternalAuthSync', () => { }) }) - describe('analytics emits (M15.1)', () => { + describe('analytics emits', () => { it('emits auth_logout with outcome=success on the happy logout path', async () => { const analyticsClient = makeFakeAnalyticsClient() createHandler({analyticsClient}) @@ -575,7 +590,7 @@ describe('AuthHandler — setupExternalAuthSync', () => { expect(trackCalls.length, 'auth_logout fires exactly once on failure').to.equal(1) const props = trackCalls[0].args[1] as {failure_kind?: string; outcome: string} expect(props.outcome).to.equal('failure') - expect(props.failure_kind, 'failure_kind is a coarse tag, never a raw error message').to.be.a('string') + assertFailureKindDiscipline(props.failure_kind, 'auth_logout failure emit') }) it('does not throw when analyticsClient.track throws on logout (analytics failures are swallowed)', async () => { @@ -650,7 +665,7 @@ describe('AuthHandler — setupExternalAuthSync', () => { expect(trackCalls.length, 'auth_login fires exactly once on API-key failure').to.equal(1) const props = trackCalls[0].args[1] as {failure_kind?: string; outcome: string} expect(props.outcome).to.equal('failure') - expect(props.failure_kind).to.be.a('string') + assertFailureKindDiscipline(props.failure_kind, 'auth_login API-key failure emit') }) }) }) diff --git a/test/unit/infra/transport/handlers/global-config-handler.test.ts b/test/unit/infra/transport/handlers/global-config-handler.test.ts index f47268d6c..05e27c4e7 100644 --- a/test/unit/infra/transport/handlers/global-config-handler.test.ts +++ b/test/unit/infra/transport/handlers/global-config-handler.test.ts @@ -26,7 +26,7 @@ function makeAnalyticsClientStub(): {abort: ReturnType} { return {abort: stub()} } -// M15.1: full analytics client double for the analytics_disabled emit tests. +// Full analytics client double for the analytics_disabled emit tests. // Same module-scope hoist rationale as makeAnalyticsClientStub above. function makeTrackingClient(): { abort: ReturnType @@ -369,7 +369,7 @@ describe('GlobalConfigHandler', () => { }) }) - describe('M15.1 analytics_disabled emit', () => { + describe('analytics_disabled emit', () => { it('emits analytics_disabled exactly once on enable→disable transition', async () => { const analyticsClient = makeTrackingClient() const handlerWithClient = new GlobalConfigHandler({analyticsClient, globalConfigStore: store, transport}) diff --git a/test/unit/infra/transport/socket-io-transport-server.test.ts b/test/unit/infra/transport/socket-io-transport-server.test.ts index f7e169b7a..89fb68396 100644 --- a/test/unit/infra/transport/socket-io-transport-server.test.ts +++ b/test/unit/infra/transport/socket-io-transport-server.test.ts @@ -448,7 +448,7 @@ describe('SocketIOTransportServer', () => { }) }) - describe('client_kind context wrap (M15.1)', () => { + describe('client_kind context wrap', () => { it('exposes the registered ClientType inside the request handler via getClientKindFromContext', async () => { const typeByClientId = new Map() server.setGetClientKind((clientId) => typeByClientId.get(clientId)) diff --git a/test/unit/server/infra/analytics/super-properties-resolver.test.ts b/test/unit/server/infra/analytics/super-properties-resolver.test.ts index 56cb4ad42..4fef8b664 100644 --- a/test/unit/server/infra/analytics/super-properties-resolver.test.ts +++ b/test/unit/server/infra/analytics/super-properties-resolver.test.ts @@ -53,7 +53,7 @@ describe('SuperPropertiesResolver', () => { }) }) - describe('client_kind (M15.1)', () => { + describe('client_kind', () => { it('omits client_kind when no clientKindContext scope is active', async () => { const resolver = new SuperPropertiesResolver(makeStubStore(), () => '1.2.3') diff --git a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts index 71f29e692..e469d22eb 100644 --- a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts @@ -157,4 +157,167 @@ describe('AnalyticsHandler', () => { expect(caught, 'handler must NOT propagate track() errors').to.equal(undefined) }) + + /** + * Regression coverage for every per-event `case` branch in `dispatch()`. + * The base tests above cover the dispatch PATTERN via one sample event; + * if a future refactor drops a `case` branch the event would fall + * through silently (no error, no track call). This parameterized test + * exercises every catalog event with a minimal valid payload and asserts + * the dispatch reaches `track()`. + */ + describe('per-event dispatch coverage — every new event name reaches track()', () => { + const validHashHex = 'a'.repeat(64) + // Per-event minimal payloads that satisfy each schema. Lifecycle events + // (33 of 36) carry `outcome: 'success'`; 3 observation events stay + // outcome-less. Payloads are intentionally narrow — broader fixture + // coverage lives in privacy-fixture.test.ts. + const cases: Array<{event: AnalyticsEventName; properties?: Record}> = [ + {event: AnalyticsEventNames.ANALYTICS_DISABLED, properties: {}}, + {event: AnalyticsEventNames.AUTH_LOGIN, properties: {outcome: 'success'}}, + {event: AnalyticsEventNames.AUTH_LOGOUT, properties: {outcome: 'success'}}, + { + event: AnalyticsEventNames.BRV_INIT, + properties: {had_existing_brv_dir: false, outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.CONNECTOR_INSTALLED, + properties: {agent_target: 'claude-code', connector_id: 'rules', outcome: 'success'}, + }, + { + event: AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED, + properties: { + file_relative_path_hash: validHashHex, + outcome: 'success', + project_path_hash: validHashHex, + }, + }, + {event: AnalyticsEventNames.DAEMON_RESET_EXECUTED, properties: {outcome: 'success', reset_scope: 'project'}}, + { + event: AnalyticsEventNames.HUB_PACKAGE_INSTALLED, + properties: {outcome: 'success', package_identifier: 'team/space'}, + }, + { + event: AnalyticsEventNames.HUB_REGISTRY_ADDED, + properties: {is_default: true, outcome: 'success', registry_kind: 'byterover'}, + }, + {event: AnalyticsEventNames.HUB_REGISTRY_REMOVED, properties: {outcome: 'success', registry_kind: 'byterover'}}, + {event: AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED, properties: {mode: 'auto', outcome: 'success'}}, + {event: AnalyticsEventNames.ONBOARDING_COMPLETED, properties: {outcome: 'success'}}, + { + event: AnalyticsEventNames.REVIEW_APPROVED, + properties: {operation_kind: 'add', outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.REVIEW_REJECTED, + properties: {operation_kind: 'add', outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.REVIEW_TOGGLED, + properties: {outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.SETTING_CHANGED, + properties: {outcome: 'success', setting_key: 'agentPool.maxSize', value_kind: 'integer'}, + }, + { + event: AnalyticsEventNames.SETTING_RESET, + properties: {outcome: 'success', setting_key: 'agentPool.maxSize', value_kind: 'integer'}, + }, + { + event: AnalyticsEventNames.SOURCE_ADDED, + properties: {outcome: 'success', project_path_hash: validHashHex}, + }, + {event: AnalyticsEventNames.SOURCE_REMOVED, properties: {outcome: 'success', project_path_hash: validHashHex}}, + { + event: AnalyticsEventNames.SPACE_SWITCHED, + properties: {from_space_id: 'a', outcome: 'success'}, + }, + {event: AnalyticsEventNames.VC_BRANCHED, properties: {outcome: 'success', project_path_hash: validHashHex}}, + {event: AnalyticsEventNames.VC_CHECKED_OUT, properties: {outcome: 'success', project_path_hash: validHashHex}}, + { + event: AnalyticsEventNames.VC_CLONED, + properties: {outcome: 'success', remote_kind: 'byterover'}, + }, + { + event: AnalyticsEventNames.VC_COMMIT, + properties: {had_message: true, outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.VC_DISCARDED, + properties: {discard_scope: 'file', outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.VC_FETCHED, + properties: {outcome: 'success', project_path_hash: validHashHex, remote_kind: 'byterover'}, + }, + { + event: AnalyticsEventNames.VC_INIT, + properties: {had_existing_git_dir: false, outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.VC_MERGED, + properties: {outcome: 'success', project_path_hash: validHashHex}, + }, + { + event: AnalyticsEventNames.VC_PULLED, + properties: { + branch_name_hash: validHashHex, + outcome: 'success', + project_path_hash: validHashHex, + remote_kind: 'byterover', + }, + }, + { + event: AnalyticsEventNames.VC_PUSHED, + properties: { + branch_name_hash: validHashHex, + outcome: 'success', + project_path_hash: validHashHex, + remote_kind: 'byterover', + }, + }, + { + event: AnalyticsEventNames.VC_REMOTE_CHANGED, + properties: { + change_kind: 'added', + outcome: 'success', + project_path_hash: validHashHex, + remote_kind: 'byterover', + }, + }, + { + event: AnalyticsEventNames.VC_RESET_EXECUTED, + properties: {outcome: 'success', project_path_hash: validHashHex, reset_mode: 'soft'}, + }, + { + event: AnalyticsEventNames.WEBUI_SESSION_ENDED, + properties: {session_duration_ms: 5000, started_at_unix_ms: 1_700_000_000_000}, + }, + {event: AnalyticsEventNames.WEBUI_SESSION_STARTED, properties: {started_at_unix_ms: 1_700_000_000_000}}, + { + event: AnalyticsEventNames.WORKTREE_ADDED, + properties: {outcome: 'success', project_path_hash: validHashHex}, + }, + {event: AnalyticsEventNames.WORKTREE_REMOVED, properties: {outcome: 'success', project_path_hash: validHashHex}}, + ] + + for (const {event, properties} of cases) { + it(`dispatches ${event} to analyticsClient.track`, async () => { + const transport = createMockTransportServer() + const analyticsClient = makeMockAnalyticsClient() + new AnalyticsHandler({analyticsClient, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler + await handler({event, properties}, 'client-1') + + const calls = analyticsClient.trackCalls.filter((c) => c.event === event) + expect(calls.length, `dispatch case missing or dropped for ${event}`).to.equal(1) + }) + } + + it('coverage matches schema count (36 new events covered)', () => { + expect(cases.length, 'must enumerate all 36 new event names').to.equal(36) + }) + }) }) diff --git a/test/unit/shared/analytics/event-names.test.ts b/test/unit/shared/analytics/event-names.test.ts index 5bcc6d634..ab027da10 100644 --- a/test/unit/shared/analytics/event-names.test.ts +++ b/test/unit/shared/analytics/event-names.test.ts @@ -3,7 +3,7 @@ import {expect} from 'chai' import {type AnalyticsEventName, AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' describe('AnalyticsEventNames', () => { - it('should expose exactly the forty-six shipped event names (M15.1 adds 36)', () => { + it('should expose exactly the forty-six shipped event names', () => { expect(Object.keys(AnalyticsEventNames).sort()).to.deep.equal([ 'ANALYTICS_DISABLED', 'AUTH_LOGIN', From da6abefb9579a752fc39eb245126f35f356011fa Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 27 May 2026 16:26:25 +0700 Subject: [PATCH 55/87] =?UTF-8?q?feat:=20[ENG-2966]=20M15.2=20activation?= =?UTF-8?q?=20sweep=20=E2=80=94=20init=20+=20connectors=20+=20hub=20emits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add analytics emits only at handlers' existing terminals — success returns, existing catch branches, and existing early-return validation paths. No new try/catch wrappers or control-flow changes. Touched handlers + emit sites: - init-handler.handleExecute: brv_init success at the existing success return. - init-handler.handleLocalInit: brv_init success at both existing return terminals (alreadyInitialized branch + normal branch). - connectors-handler.handleInstall: connector_installed success when result.success, failure when result.success=false, and two failure emits at the existing invalid_agent / invalid_connector validation early-returns. - hub-handler.handleInstall: hub_package_installed failure emits at the existing entry-not-found / invalid-agent / multiple-matches early-returns. - hub-handler.performInstall: hub_package_installed success at the existing success return; failure inside the existing catch. - hub-handler.handleRegistryAdd: hub_registry_added success at the existing success return; failure at the existing reserved-name early-return and inside the existing catch. is_default relaxed to optional in the schema since the request shape doesn't carry it. - hub-handler.handleRegistryRemove: hub_registry_removed success at the existing success return; failure inside the existing catch. Failure-path emits for sites where no catch already exists (brv_init auth errors, connector switchConnector throws) are intentionally NOT added — adding them would require new try/catch wrappers, which is out of scope for this analytics-only pass. New util: hashProjectPath (sha256 hex of absolute path), used by brv_init's project_path_hash field. Onboarding events deferred (no onboarding-handler exists on this branch). --- src/server/infra/process/feature-handlers.ts | 3 + .../transport/handlers/connectors-handler.ts | 72 +++++- .../infra/transport/handlers/hub-handler.ts | 87 ++++++- .../infra/transport/handlers/init-handler.ts | 61 ++++- src/server/utils/hash-path.ts | 17 ++ .../analytics/events/hub-registry-added.ts | 10 +- .../handlers/connectors-handler.test.ts | 167 +++++++++++++ .../transport/handlers/hub-handler.test.ts | 229 ++++++++++++++++++ .../transport/handlers/init-handler.test.ts | 88 ++++++- test/unit/server/utils/hash-path.test.ts | 33 +++ 10 files changed, 755 insertions(+), 12 deletions(-) create mode 100644 src/server/utils/hash-path.ts create mode 100644 test/unit/infra/transport/handlers/connectors-handler.test.ts create mode 100644 test/unit/infra/transport/handlers/hub-handler.test.ts create mode 100644 test/unit/server/utils/hash-path.test.ts diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 73deb0a43..4ebd063ad 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -460,6 +460,7 @@ export async function setupFeatureHandlers({ }).setup() new ConnectorsHandler({ + analyticsClient, connectorManagerFactory, resolveProjectPath, transport, @@ -471,6 +472,7 @@ export async function setupFeatureHandlers({ const hubKeychainStore = createHubKeychainStore() await new HubHandler({ + analyticsClient, hubInstallService, hubKeychainStore, hubRegistryConfigStore, @@ -480,6 +482,7 @@ export async function setupFeatureHandlers({ }).setup() new InitHandler({ + analyticsClient, broadcastToProject, cogitPullService, connectorManagerFactory, diff --git a/src/server/infra/transport/handlers/connectors-handler.ts b/src/server/infra/transport/handlers/connectors-handler.ts index 455cb29e1..d96daab68 100644 --- a/src/server/infra/transport/handlers/connectors-handler.ts +++ b/src/server/infra/transport/handlers/connectors-handler.ts @@ -1,8 +1,12 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' import type {ConnectorDTO} from '../../../../shared/transport/types/dto.js' import type {ConnectorType} from '../../../../shared/types/connector-type.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {IConnectorManager} from '../../../core/interfaces/connectors/i-connector-manager.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { ConnectorEvents, type ConnectorGetAgentConfigPathsRequest, @@ -14,10 +18,16 @@ import { } from '../../../../shared/transport/events/connector-events.js' import {isConnectorType} from '../../../../shared/types/connector-type.js' import {AGENT_CONNECTOR_CONFIG, isAgent} from '../../../core/domain/entities/agent.js' +import {processLog} from '../../../utils/process-logger.js' import {mapAgentsToDTOs} from './agent-dto-mapper.js' import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' export interface ConnectorsHandlerDeps { + /** + * Optional. When provided, the handler emits `connector_installed` + * analytics events at both terminals. + */ + analyticsClient?: IAnalyticsClient connectorManagerFactory: (projectRoot: string) => IConnectorManager resolveProjectPath: ProjectPathResolver transport: ITransportServer @@ -28,11 +38,13 @@ export interface ConnectorsHandlerDeps { * Business logic for connector management — no terminal/UI calls. */ export class ConnectorsHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly connectorManagerFactory: (projectRoot: string) => IConnectorManager private readonly resolveProjectPath: ProjectPathResolver private readonly transport: ITransportServer constructor(deps: ConnectorsHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.connectorManagerFactory = deps.connectorManagerFactory this.resolveProjectPath = deps.resolveProjectPath this.transport = deps.transport @@ -56,6 +68,22 @@ export class ConnectorsHandler { ) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog( + `[Connectors] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + private handleGetAgentConfigPaths( data: ConnectorGetAgentConfigPathsRequest, clientId: string, @@ -80,18 +108,60 @@ export class ConnectorsHandler { private async handleInstall(data: ConnectorInstallRequest, clientId: string): Promise { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - const connectorManager = this.connectorManagerFactory(projectPath) + // Wire-side fields are always strings; coerce defensively so the + // emit doesn't carry undefined/null when the validation guards reject. + const agentTarget = String(data.agentId) + const connectorId = String(data.connectorType) if (!isAgent(data.agentId)) { + this.emitAnalytics(AnalyticsEventNames.CONNECTOR_INSTALLED, { + // eslint-disable-next-line camelcase + agent_target: agentTarget, + // eslint-disable-next-line camelcase + connector_id: connectorId, + // eslint-disable-next-line camelcase + failure_kind: 'invalid_agent', + outcome: 'failure', + }) return {message: `Unsupported agent: ${data.agentId}`, success: false} } if (!isConnectorType(data.connectorType)) { + this.emitAnalytics(AnalyticsEventNames.CONNECTOR_INSTALLED, { + // eslint-disable-next-line camelcase + agent_target: agentTarget, + // eslint-disable-next-line camelcase + connector_id: connectorId, + // eslint-disable-next-line camelcase + failure_kind: 'invalid_connector', + outcome: 'failure', + }) return {message: `Unsupported connector type: ${data.connectorType}`, success: false} } + const connectorManager = this.connectorManagerFactory(projectPath) const result = await connectorManager.switchConnector(data.agentId, data.connectorType) + if (result.success) { + this.emitAnalytics(AnalyticsEventNames.CONNECTOR_INSTALLED, { + // eslint-disable-next-line camelcase + agent_target: agentTarget, + // eslint-disable-next-line camelcase + connector_id: connectorId, + outcome: 'success', + }) + } else { + this.emitAnalytics(AnalyticsEventNames.CONNECTOR_INSTALLED, { + // eslint-disable-next-line camelcase + agent_target: agentTarget, + // eslint-disable-next-line camelcase + connector_id: connectorId, + // eslint-disable-next-line camelcase + failure_kind: 'install_failed', + outcome: 'failure', + }) + } + return { configPath: result.installResult.configPath, manualInstructions: result.installResult.manualInstructions, diff --git a/src/server/infra/transport/handlers/hub-handler.ts b/src/server/infra/transport/handlers/hub-handler.ts index 8b29dd183..18d18a943 100644 --- a/src/server/infra/transport/handlers/hub-handler.ts +++ b/src/server/infra/transport/handlers/hub-handler.ts @@ -1,5 +1,8 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' import type {AuthScheme} from '../../../../shared/transport/types/auth-scheme.js' import type {HubEntryDTO} from '../../../../shared/transport/types/dto.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {HubInstallAuthParams, IHubInstallService} from '../../../core/interfaces/hub/i-hub-install-service.js' import type {IHubKeychainStore} from '../../../core/interfaces/hub/i-hub-keychain-store.js' import type {IHubRegistryConfigStore} from '../../../core/interfaces/hub/i-hub-registry-config-store.js' @@ -7,6 +10,7 @@ import type {IHubRegistryService} from '../../../core/interfaces/hub/i-hub-regis import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' import type {ProjectPathResolver} from './handler-types.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { HubEvents, type HubInstallRequest, @@ -20,6 +24,7 @@ import { type HubRegistryRemoveResponse, } from '../../../../shared/transport/events/hub-events.js' import {type Agent, isAgent} from '../../../core/domain/entities/agent.js' +import {processLog} from '../../../utils/process-logger.js' import {CompositeHubRegistryService} from '../../hub/composite-hub-registry-service.js' import {HubRegistryService} from '../../hub/hub-registry-service.js' @@ -28,6 +33,11 @@ const OFFICIAL_REGISTRY_NAME = 'official' const RESERVED_REGISTRY_NAMES = new Set(['brv', 'byterover', 'campfire', 'campfirein', 'official']) export interface HubHandlerDeps { + /** + * Optional. When provided, the handler emits `hub_package_installed` / + * `hub_registry_added` / `hub_registry_removed` analytics events. + */ + analyticsClient?: IAnalyticsClient hubInstallService: IHubInstallService hubKeychainStore: IHubKeychainStore hubRegistryConfigStore: IHubRegistryConfigStore @@ -38,6 +48,7 @@ export interface HubHandlerDeps { } export class HubHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly hubInstallService: IHubInstallService private readonly hubKeychainStore: IHubKeychainStore private readonly hubRegistryConfigStore: IHubRegistryConfigStore @@ -48,6 +59,7 @@ export class HubHandler { private readonly transport: ITransportServer constructor(deps: HubHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.hubInstallService = deps.hubInstallService this.hubKeychainStore = deps.hubKeychainStore this.hubRegistryConfigStore = deps.hubRegistryConfigStore @@ -83,9 +95,35 @@ export class HubHandler { this.transport.onRequest(HubEvents.REGISTRY_LIST, () => this.handleRegistryList()) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Hub] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + + private emitInstallFailure(packageIdentifier: string, failureKind: string): void { + this.emitAnalytics(AnalyticsEventNames.HUB_PACKAGE_INSTALLED, { + // eslint-disable-next-line camelcase + failure_kind: failureKind, + outcome: 'failure', + // eslint-disable-next-line camelcase + package_identifier: packageIdentifier, + }) + } + private async handleInstall(data: HubInstallRequest, clientId: string): Promise { + const packageIdentifier = data.entryId const agent = data.agent && isAgent(data.agent) ? data.agent : undefined if (data.agent && !agent) { + this.emitInstallFailure(packageIdentifier, 'invalid_agent') return {installedFiles: [], installedPath: '', message: `Invalid agent: ${data.agent}`, success: false} } @@ -103,17 +141,19 @@ export class HubHandler { switch (matches.length) { case 0: { + this.emitInstallFailure(packageIdentifier, 'resolve') return {installedFiles: [], installedPath: '', message: `Entry not found: ${data.entryId}`, success: false} } case 1: { - // Single match: proceed with install + // Single match: proceed with install. performInstall emits success/failure. return this.performInstall(matches[0], projectPath, agent, scope) } default: { // Multiple matches: detect duplicates const registryNames = matches.map((m) => m.registry ?? 'unknown').join(', ') + this.emitInstallFailure(packageIdentifier, 'resolve') return { installedFiles: [], installedPath: '', @@ -131,8 +171,16 @@ export class HubHandler { } private async handleRegistryAdd(data: HubRegistryAddRequest): Promise { + const registryKind = data.name try { if (RESERVED_REGISTRY_NAMES.has(data.name.toLowerCase())) { + this.emitAnalytics(AnalyticsEventNames.HUB_REGISTRY_ADDED, { + // eslint-disable-next-line camelcase + failure_kind: 'validation', + outcome: 'failure', + // eslint-disable-next-line camelcase + registry_kind: registryKind, + }) return {message: `Registry name '${data.name}' is reserved`, success: false} } @@ -162,8 +210,23 @@ export class HubHandler { await this.rebuildRegistryService() + // `is_default` omitted — request shape doesn't carry it; schema marks + // the field optional precisely for this reason. + this.emitAnalytics(AnalyticsEventNames.HUB_REGISTRY_ADDED, { + outcome: 'success', + // eslint-disable-next-line camelcase + registry_kind: registryKind, + }) + return {message: `Registry '${data.name}' added successfully`, success: true} } catch (error) { + this.emitAnalytics(AnalyticsEventNames.HUB_REGISTRY_ADDED, { + // eslint-disable-next-line camelcase + failure_kind: 'config_write', + outcome: 'failure', + // eslint-disable-next-line camelcase + registry_kind: registryKind, + }) return { message: `Failed to add registry: ${error instanceof Error ? error.message : 'Unknown error'}`, success: false, @@ -215,14 +278,28 @@ export class HubHandler { } private async handleRegistryRemove(data: HubRegistryRemoveRequest): Promise { + const registryKind = data.name try { await this.hubRegistryConfigStore.removeRegistry(data.name) await this.hubKeychainStore.deleteToken(data.name) await this.rebuildRegistryService() + this.emitAnalytics(AnalyticsEventNames.HUB_REGISTRY_REMOVED, { + outcome: 'success', + // eslint-disable-next-line camelcase + registry_kind: registryKind, + }) + return {message: `Registry '${data.name}' removed successfully`, success: true} } catch (error) { + this.emitAnalytics(AnalyticsEventNames.HUB_REGISTRY_REMOVED, { + // eslint-disable-next-line camelcase + failure_kind: 'config_write', + outcome: 'failure', + // eslint-disable-next-line camelcase + registry_kind: registryKind, + }) return { message: `Failed to remove registry: ${error instanceof Error ? error.message : 'Unknown error'}`, success: false, @@ -251,6 +328,13 @@ export class HubHandler { const result = await this.hubInstallService.install({agent, auth, entry, projectPath, scope}) const registryLabel = entry.registry ? ` [${entry.registry}]` : '' + + this.emitAnalytics(AnalyticsEventNames.HUB_PACKAGE_INSTALLED, { + outcome: 'success', + // eslint-disable-next-line camelcase + package_identifier: entry.id, + }) + return { installedFiles: result.installedFiles, installedPath: result.installedPath, @@ -258,6 +342,7 @@ export class HubHandler { success: true, } } catch (error) { + this.emitInstallFailure(entry.id, 'install_failed') return { installedFiles: [], installedPath: '', diff --git a/src/server/infra/transport/handlers/init-handler.ts b/src/server/infra/transport/handlers/init-handler.ts index f1452284e..b1ec7c459 100644 --- a/src/server/infra/transport/handlers/init-handler.ts +++ b/src/server/infra/transport/handlers/init-handler.ts @@ -1,3 +1,6 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITokenStore} from '../../../core/interfaces/auth/i-token-store.js' import type {IConnectorManager} from '../../../core/interfaces/connectors/i-connector-manager.js' import type {IContextTreeService} from '../../../core/interfaces/context-tree/i-context-tree-service.js' @@ -9,6 +12,7 @@ import type {ITeamService} from '../../../core/interfaces/services/i-team-servic import type {IProjectConfigStore} from '../../../core/interfaces/storage/i-project-config-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { InitEvents, type InitExecuteRequest, @@ -26,6 +30,8 @@ import {BrvConfig} from '../../../core/domain/entities/brv-config.js' import {NotAuthenticatedError, SpaceNotFoundError} from '../../../core/domain/errors/task-error.js' import {syncConfigToXdg} from '../../../utils/config-xdg-sync.js' import {getErrorMessage} from '../../../utils/error-helpers.js' +import {hashProjectPath} from '../../../utils/hash-path.js' +import {processLog} from '../../../utils/process-logger.js' import {ensureProjectInitialized} from '../../config/auto-init.js' import {mapAgentsToDTOs} from './agent-dto-mapper.js' import { @@ -36,6 +42,11 @@ import { } from './handler-types.js' export interface InitHandlerDeps { + /** + * Optional. When provided, the handler emits `brv_init` analytics + * events at both the success terminal and every catch branch. + */ + analyticsClient?: IAnalyticsClient broadcastToProject: ProjectBroadcaster cogitPullService: ICogitPullService connectorManagerFactory: (projectRoot: string) => IConnectorManager @@ -56,6 +67,7 @@ export interface InitHandlerDeps { * The TUI orchestrates the multi-step UX flow, calling granular events. */ export class InitHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly broadcastToProject: ProjectBroadcaster private readonly cogitPullService: ICogitPullService private readonly connectorManagerFactory: (projectRoot: string) => IConnectorManager @@ -70,6 +82,7 @@ export class InitHandler { private readonly transport: ITransportServer constructor(deps: InitHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.broadcastToProject = deps.broadcastToProject this.cogitPullService = deps.cogitPullService this.connectorManagerFactory = deps.connectorManagerFactory @@ -102,6 +115,20 @@ export class InitHandler { ) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Init] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + private async handleExecute(data: InitExecuteRequest, clientId: string): Promise { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) await guardAgainstGitVc({contextTreeService: this.contextTreeService, projectPath}) @@ -111,8 +138,11 @@ export class InitHandler { throw new NotAuthenticatedError() } - // Check for existing config - if ((await this.projectConfigStore.exists(projectPath)) && !data.force) { + // Naming-only refactor: capture the existing exists() result so the success + // emit below can include `had_existing_brv_dir` without changing call order + // or adding a second filesystem read. + const hadExistingBrvDir = await this.projectConfigStore.exists(projectPath) + if (hadExistingBrvDir && !data.force) { throw new Error('Project already initialized. Use force to re-initialize.') } @@ -188,6 +218,13 @@ export class InitHandler { success: true, }) + this.emitAnalytics(AnalyticsEventNames.BRV_INIT, { + // eslint-disable-next-line camelcase + had_existing_brv_dir: hadExistingBrvDir, + outcome: 'success', + // eslint-disable-next-line camelcase + project_path_hash: hashProjectPath(projectPath), + }) return {success: true} } @@ -236,9 +273,16 @@ export class InitHandler { private async handleLocalInit(data: InitLocalRequest, clientId: string): Promise { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - - const exists = await this.projectConfigStore.exists(projectPath) - if (exists && !data.force) { + const hadExistingBrvDir = await this.projectConfigStore.exists(projectPath) + + if (hadExistingBrvDir && !data.force) { + this.emitAnalytics(AnalyticsEventNames.BRV_INIT, { + // eslint-disable-next-line camelcase + had_existing_brv_dir: true, + outcome: 'success', + // eslint-disable-next-line camelcase + project_path_hash: hashProjectPath(projectPath), + }) return {alreadyInitialized: true, success: true} } @@ -247,6 +291,13 @@ export class InitHandler { projectPath, ) + this.emitAnalytics(AnalyticsEventNames.BRV_INIT, { + // eslint-disable-next-line camelcase + had_existing_brv_dir: hadExistingBrvDir, + outcome: 'success', + // eslint-disable-next-line camelcase + project_path_hash: hashProjectPath(projectPath), + }) return {alreadyInitialized: false, success: true} } } diff --git a/src/server/utils/hash-path.ts b/src/server/utils/hash-path.ts new file mode 100644 index 000000000..3f65f2740 --- /dev/null +++ b/src/server/utils/hash-path.ts @@ -0,0 +1,17 @@ +import {createHash} from 'node:crypto' + +/** + * SHA-256 hex digest of a path string, used by analytics emits that want + * to identify a project / file / source without leaking the raw absolute + * path. Raw paths are on `FORBIDDEN_FIELD_NAMES` (and are PII-adjacent at + * volume); the hash gives downstream consumers a stable join key without + * revealing the source. + * + * Verbatim hash, no normalization — trailing slashes, case, and symlink + * resolution are caller-side concerns. Callers SHOULD pass the canonical + * absolute path their handler resolved (e.g. via `resolveProjectPath`) + * so the hash is stable across emits for the same project. + */ +export function hashProjectPath(path: string): string { + return createHash('sha256').update(path).digest('hex') +} diff --git a/src/shared/analytics/events/hub-registry-added.ts b/src/shared/analytics/events/hub-registry-added.ts index 32af6e8f7..3faee4cfd 100644 --- a/src/shared/analytics/events/hub-registry-added.ts +++ b/src/shared/analytics/events/hub-registry-added.ts @@ -4,14 +4,16 @@ import {z} from 'zod' /** * Per-event schema for `hub_registry_added`. * - * Adds a registry source to the Context Hub config. - * `registry_kind` classifies the registry type; `is_default` flags whether - * it was added as the default. `outcome` covers both terminals. + * Adds a registry source to the Context Hub config. `registry_kind` + * classifies the registry type; `is_default` flags whether it was added + * as the default. `is_default` is optional because the current handler + * request shape doesn't carry it — emitters that don't know the value + * MAY omit. `outcome` covers both terminals. */ export const HubRegistryAddedSchema = z .object({ failure_kind: z.string().min(1).max(64).optional(), - is_default: z.boolean(), + is_default: z.boolean().optional(), outcome: z.enum(['success', 'failure']), registry_kind: z.string().min(1), }) diff --git a/test/unit/infra/transport/handlers/connectors-handler.test.ts b/test/unit/infra/transport/handlers/connectors-handler.test.ts new file mode 100644 index 000000000..03a2418dd --- /dev/null +++ b/test/unit/infra/transport/handlers/connectors-handler.test.ts @@ -0,0 +1,167 @@ + +import {expect} from 'chai' +import {restore, stub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {ConnectorsHandler} from '../../../../../src/server/infra/transport/handlers/connectors-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {ConnectorEvents} from '../../../../../src/shared/transport/events/connector-events.js' +import {createMockTransportServer, type MockTransportServer} from '../../../../helpers/mock-factories.js' + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: ReturnType} { + const trackSpy = stub() + return { + abort: stub(), + flush: stub().resolves({events: []}), + getRuntimeState: stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: ReturnType} +} + +describe('ConnectorsHandler — connector_installed analytics', () => { + let transport: MockTransportServer + + beforeEach(() => { + transport = createMockTransportServer() + }) + + afterEach(() => { + restore() + }) + + type SwitchOutcome = 'failure' | 'success' | 'throw' + function createHandler(opts: { + analyticsClient?: IAnalyticsClient + switchOutcome?: SwitchOutcome + }): {connectorManagerFactory: ReturnType} { + const installResult = {configPath: '/cfg', manualInstructions: '', requiresManualSetup: false} + let switchStub: ReturnType + switch (opts.switchOutcome ?? 'success') { + case 'failure': { + switchStub = stub().resolves({installResult, message: 'failed', success: false}) + break + } + + case 'throw': { + switchStub = stub().rejects(new Error('switch boom')) + break + } + + default: { + switchStub = stub().resolves({installResult, message: 'ok', success: true}) + } + } + + const connectorManagerFactory = stub().returns({ + getAllInstalledConnectors: stub().resolves(new Map()), + getConnector: stub(), + getDefaultConnectorType: stub(), + getSupportedConnectorTypes: stub().returns([]), + switchConnector: switchStub, + }) + new ConnectorsHandler({ + analyticsClient: opts.analyticsClient, + connectorManagerFactory: connectorManagerFactory as never, + resolveProjectPath: stub().returns('/proj') as never, + transport, + }).setup() + return {connectorManagerFactory} + } + + async function callInstall(data: {agentId: string; connectorType: string}): Promise { + const handler = transport._handlers.get(ConnectorEvents.INSTALL) + expect(handler, 'connectors:install handler should be registered').to.exist + return handler!(data, 'client-1') + } + + it('emits connector_installed outcome=success when switchConnector resolves success=true', async () => { + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient, switchOutcome: 'success'}) + + await callInstall({agentId: 'Claude Code', connectorType: 'rules'}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.CONNECTOR_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {agent_target: string; connector_id: string; outcome: string} + expect(props.outcome).to.equal('success') + expect(props.agent_target).to.equal('Claude Code') + expect(props.connector_id).to.equal('rules') + }) + + it('emits connector_installed outcome=failure with failure_kind=install_failed when switchConnector returns success=false', async () => { + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient, switchOutcome: 'failure'}) + + await callInstall({agentId: 'Claude Code', connectorType: 'rules'}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.CONNECTOR_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('install_failed') + }) + + it('emits connector_installed outcome=failure with failure_kind=invalid_agent on bad agentId', async () => { + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient}) + + const result = await callInstall({agentId: 'not-a-real-agent', connectorType: 'rules'}) + expect(result).to.deep.include({success: false}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.CONNECTOR_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('invalid_agent') + }) + + it('emits connector_installed outcome=failure with failure_kind=invalid_connector on bad connectorType', async () => { + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient}) + + const result = await callInstall({agentId: 'Claude Code', connectorType: 'not-a-real-connector'}) + expect(result).to.deep.include({success: false}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.CONNECTOR_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('invalid_connector') + }) + + it('does NOT emit when switchConnector throws (no handler-level catch — error propagates uncaught)', async () => { + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient, switchOutcome: 'throw'}) + + let threw = false + try { + await callInstall({agentId: 'Claude Code', connectorType: 'rules'}) + } catch { + threw = true + } + + expect(threw, 'thrown errors should propagate to caller').to.equal(true) + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.CONNECTOR_INSTALLED) + expect(calls.length, 'no emit on thrown failure without an existing catch').to.equal(0) + }) + + it('is a no-op when no analyticsClient is injected (backward-compat)', async () => { + createHandler({switchOutcome: 'success'}) + + const result = await callInstall({agentId: 'Claude Code', connectorType: 'rules'}) + expect(result).to.deep.include({success: true}) + }) +}) diff --git a/test/unit/infra/transport/handlers/hub-handler.test.ts b/test/unit/infra/transport/handlers/hub-handler.test.ts new file mode 100644 index 000000000..2e221ae58 --- /dev/null +++ b/test/unit/infra/transport/handlers/hub-handler.test.ts @@ -0,0 +1,229 @@ + +import {expect} from 'chai' +import {restore, stub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {HubEntryDTO} from '../../../../../src/shared/transport/types/dto.js' + +import {HubHandler} from '../../../../../src/server/infra/transport/handlers/hub-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {HubEvents} from '../../../../../src/shared/transport/events/hub-events.js' +import {createMockTransportServer, type MockTransportServer} from '../../../../helpers/mock-factories.js' + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: ReturnType} { + const trackSpy = stub() + return { + abort: stub(), + flush: stub().resolves({events: []}), + getRuntimeState: stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: ReturnType} +} + +function buildEntry(overrides: Partial = {}): HubEntryDTO { + return { + description: 'test entry', + files: [], + id: 'team/pkg', + name: 'pkg', + registry: 'official', + type: 'skill', + ...overrides, + } as HubEntryDTO +} + +describe('HubHandler analytics emits', () => { + let transport: MockTransportServer + + beforeEach(() => { + transport = createMockTransportServer() + }) + + afterEach(() => { + restore() + }) + + type InstallOutcome = 'success' | 'throw' + async function createHandler(opts: { + analyticsClient?: IAnalyticsClient + entries?: HubEntryDTO[] + installOutcome?: InstallOutcome + registryAddOutcome?: 'success' | 'throw_validate' | 'throw_write' + registryRemoveOutcome?: 'success' | 'throw' + }): Promise<{handler: HubHandler}> { + const installFn = + opts.installOutcome === 'throw' + ? stub().rejects(new Error('install boom')) + : stub().resolves({installedFiles: [], installedPath: '/p', message: 'ok'}) + + const registries = [ + {authScheme: 'none' as const, name: 'private', url: 'https://example.com'}, + ] + const removeStub = + opts.registryRemoveOutcome === 'throw' + ? stub().rejects(new Error('rm boom')) + : stub().resolves() + + const hubRegistryConfigStore = { + addRegistry: + opts.registryAddOutcome === 'throw_write' + ? stub().rejects(new Error('write boom')) + : stub().resolves(), + getRegistries: stub().resolves(registries), + removeRegistry: removeStub, + } + const hubKeychainStore = {deleteToken: stub().resolves(), getToken: stub().resolves(), setToken: stub().resolves()} + const hubInstallService = {install: installFn} + + const handler = new HubHandler({ + analyticsClient: opts.analyticsClient, + hubInstallService: hubInstallService as never, + hubKeychainStore: hubKeychainStore as never, + hubRegistryConfigStore: hubRegistryConfigStore as never, + officialRegistryUrl: 'https://hub.example.com', + resolveProjectPath: stub().returns('/proj') as never, + transport, + }) + + // Stub the dynamic registry service before setup() instead of relying on + // the real composite service network calls. + const entries = opts.entries ?? [buildEntry()] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(handler as any).hubRegistryService = { + getEntries: stub().resolves({entries, version: '1'}), + getEntriesById: stub().resolves(entries), + } + // Suppress rebuildRegistryService' real path + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(handler as any).rebuildRegistryService = stub().resolves() + await handler.setup() + return {handler} + } + + async function callInstall(data: Record): Promise { + const h = transport._handlers.get(HubEvents.INSTALL) + expect(h, 'hub:install handler should be registered').to.exist + return h!(data, 'client-1') + } + + async function callRegistryAdd(data: Record): Promise { + const h = transport._handlers.get(HubEvents.REGISTRY_ADD) + expect(h, 'hub:registryAdd handler should be registered').to.exist + return h!(data, 'client-1') + } + + async function callRegistryRemove(data: Record): Promise { + const h = transport._handlers.get(HubEvents.REGISTRY_REMOVE) + expect(h, 'hub:registryRemove handler should be registered').to.exist + return h!(data, 'client-1') + } + + describe('hub_package_installed', () => { + it('emits outcome=success when install succeeds', async () => { + const analyticsClient = makeFakeAnalyticsClient() + await createHandler({analyticsClient}) + + await callInstall({entryId: 'team/pkg'}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.HUB_PACKAGE_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; package_identifier: string} + expect(props.outcome).to.equal('success') + expect(props.package_identifier).to.equal('team/pkg') + }) + + it('emits outcome=failure with failure_kind=resolve when entry not found', async () => { + const analyticsClient = makeFakeAnalyticsClient() + await createHandler({analyticsClient, entries: []}) + + await callInstall({entryId: 'team/missing'}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.HUB_PACKAGE_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('resolve') + }) + + it('emits outcome=failure with failure_kind=install_failed when install throws', async () => { + const analyticsClient = makeFakeAnalyticsClient() + await createHandler({analyticsClient, installOutcome: 'throw'}) + + await callInstall({entryId: 'team/pkg'}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.HUB_PACKAGE_INSTALLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('install_failed') + }) + }) + + describe('hub_registry_added', () => { + it('emits outcome=failure with failure_kind=validation on reserved name', async () => { + const analyticsClient = makeFakeAnalyticsClient() + await createHandler({analyticsClient}) + + const result = await callRegistryAdd({name: 'official', url: 'https://x'}) + expect(result).to.deep.include({success: false}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.HUB_REGISTRY_ADDED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string; registry_kind: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('validation') + expect(props.registry_kind).to.equal('official') + }) + }) + + describe('hub_registry_removed', () => { + it('emits outcome=success when remove succeeds', async () => { + const analyticsClient = makeFakeAnalyticsClient() + await createHandler({analyticsClient}) + + const result = await callRegistryRemove({name: 'private'}) + expect(result).to.deep.include({success: true}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.HUB_REGISTRY_REMOVED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; registry_kind: string} + expect(props.outcome).to.equal('success') + expect(props.registry_kind).to.equal('private') + }) + + it('emits outcome=failure with failure_kind=config_write when remove throws', async () => { + const analyticsClient = makeFakeAnalyticsClient() + await createHandler({analyticsClient, registryRemoveOutcome: 'throw'}) + + const result = await callRegistryRemove({name: 'private'}) + expect(result).to.deep.include({success: false}) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.HUB_REGISTRY_REMOVED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('config_write') + }) + }) + + it('is a no-op when no analyticsClient is injected (backward-compat)', async () => { + await createHandler({}) + + const result = await callInstall({entryId: 'team/pkg'}) + expect(result).to.deep.include({success: true}) + }) +}) diff --git a/test/unit/infra/transport/handlers/init-handler.test.ts b/test/unit/infra/transport/handlers/init-handler.test.ts index 56b8b42da..21ab09c18 100644 --- a/test/unit/infra/transport/handlers/init-handler.test.ts +++ b/test/unit/infra/transport/handlers/init-handler.test.ts @@ -3,11 +3,28 @@ import type {SinonStub} from 'sinon' import {expect} from 'chai' import {restore, stub} from 'sinon' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + import {GitVcInitializedError} from '../../../../../src/server/core/domain/errors/task-error.js' import {InitHandler} from '../../../../../src/server/infra/transport/handlers/init-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' import {InitEvents} from '../../../../../src/shared/transport/events/init-events.js' import {createMockTransportServer, type MockTransportServer} from '../../../../helpers/mock-factories.js' +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: ReturnType} { + const trackSpy = stub() + return { + abort: stub(), + flush: stub().resolves({events: []}), + getRuntimeState: stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: ReturnType} +} + +const sha256HexRegex = /^[0-9a-f]{64}$/ + // ==================== Tests ==================== describe('InitHandler', () => { @@ -46,8 +63,9 @@ describe('InitHandler', () => { restore() }) - function createHandler(): void { + function createHandler(overrides: {analyticsClient?: IAnalyticsClient} = {}): void { const handler = new InitHandler({ + analyticsClient: overrides.analyticsClient, broadcastToProject: stub() as never, cogitPullService: {pull: stub()} as never, connectorManagerFactory: stub() as never, @@ -139,4 +157,72 @@ describe('InitHandler', () => { expect(result).to.have.property('success', true) }) }) + + describe('brv_init analytics emits', () => { + it('emits brv_init outcome=success on local init success with had_existing_brv_dir=false', async () => { + contextTreeService.hasGitRepo.resolves(false) + projectConfigStore.exists.resolves(false) + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient}) + + await callLocalInitHandler() + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.BRV_INIT) + expect(calls.length, 'brv_init fires exactly once on local init success').to.equal(1) + const props = calls[0].args[1] as {had_existing_brv_dir: boolean; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.had_existing_brv_dir).to.equal(false) + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits brv_init outcome=success with had_existing_brv_dir=true when already initialized', async () => { + contextTreeService.hasGitRepo.resolves(false) + projectConfigStore.exists.resolves(true) + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient}) + + const result = await callLocalInitHandler() + expect(result).to.have.property('alreadyInitialized', true) + + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.BRV_INIT) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {had_existing_brv_dir: boolean; outcome: string} + expect(props.outcome).to.equal('success') + expect(props.had_existing_brv_dir).to.equal(true) + }) + + it('does NOT emit brv_init on auth-missing execute (no handler-level catch — error propagates uncaught)', async () => { + contextTreeService.hasGitRepo.resolves(false) + projectConfigStore.exists.resolves(false) + tokenStore.load.resolves() // returns undefined → NotAuthenticatedError + const analyticsClient = makeFakeAnalyticsClient() + createHandler({analyticsClient}) + + let threw = false + try { + await callExecuteHandler() + } catch { + threw = true + } + + expect(threw, 'execute should throw the original error').to.equal(true) + const calls = analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.BRV_INIT) + expect(calls.length, 'no emit on failure paths without an existing catch').to.equal(0) + }) + + it('is a no-op when no analyticsClient is injected (backward-compat)', async () => { + contextTreeService.hasGitRepo.resolves(false) + projectConfigStore.exists.resolves(false) + createHandler() // no analyticsClient + + const result = await callLocalInitHandler() + expect(result).to.have.property('success', true) + }) + }) }) diff --git a/test/unit/server/utils/hash-path.test.ts b/test/unit/server/utils/hash-path.test.ts new file mode 100644 index 000000000..e3fca8cad --- /dev/null +++ b/test/unit/server/utils/hash-path.test.ts @@ -0,0 +1,33 @@ +import {expect} from 'chai' + +import {hashProjectPath} from '../../../../src/server/utils/hash-path.js' + +describe('hashProjectPath', () => { + it('returns a 64-character lowercase hex sha256 digest', () => { + const hash = hashProjectPath('/Users/test/project') + expect(hash).to.match(/^[0-9a-f]{64}$/) + }) + + it('is deterministic — same input yields same hash', () => { + const a = hashProjectPath('/Users/test/project') + const b = hashProjectPath('/Users/test/project') + expect(a).to.equal(b) + }) + + it('differs across different inputs', () => { + const a = hashProjectPath('/Users/test/project') + const b = hashProjectPath('/Users/test/other') + expect(a).to.not.equal(b) + }) + + it('hashes the empty string without throwing', () => { + const hash = hashProjectPath('') + expect(hash).to.match(/^[0-9a-f]{64}$/) + }) + + it('treats trailing slash as a distinct path (verbatim hash, no normalization)', () => { + const a = hashProjectPath('/Users/test/project') + const b = hashProjectPath('/Users/test/project/') + expect(a).to.not.equal(b) + }) +}) From 3a297dc64c0f6594d57a0168c1ce5d369a08998e Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 27 May 2026 17:28:46 +0700 Subject: [PATCH 56/87] feat: [ENG-2962] M15.3 VC + project-structure analytics sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-event analytics emits at every success terminal across the VC, worktree, source, and space handlers. 17 events total: - vc-handler: vc_init, vc_commit, vc_cloned, vc_branched, vc_checked_out, vc_merged, vc_reset_executed, vc_discarded, vc_pushed, vc_pulled, vc_fetched, vc_remote_changed - worktree-handler: worktree_added, worktree_removed - source-handler: source_added, source_removed - space-handler: space_switched Each handler gains an optional analyticsClient dep + a private emitAnalytics helper that wraps client.track in try/processLog so analytics failures cannot affect command outcomes. classifyRemoteKind shared in vc-handler maps remote URLs to byterover/external segments via the configured gitRemoteBaseUrl. Paths and branch names are sha256-hashed via hashProjectPath; no raw paths leak. Strict additive-only: zero new try/catch wrappers, zero control-flow changes. The single non-pure-additive edit is vc-handler's handleBranch dispatcher converting a tail-return into await+emit+return for the create branch path — semantically identical Promise propagation. Tests: 21 vc-handler-analytics + 4 worktree-handler + 4 source-handler + 4 space-handler analytics emit assertions. Full suite green (9144 passing). --- src/server/infra/process/feature-handlers.ts | 6 +- .../transport/handlers/source-handler.ts | 38 ++ .../infra/transport/handlers/space-handler.ts | 52 ++ .../infra/transport/handlers/vc-handler.ts | 194 +++++++- .../transport/handlers/worktree-handler.ts | 44 ++ .../transport/handlers/source-handler.test.ts | 129 +++++ .../transport/handlers/space-handler.test.ts | 97 ++++ .../handlers/vc-handler-analytics.test.ts | 460 ++++++++++++++++++ .../handlers/worktree-handler.test.ts | 139 ++++++ 9 files changed, 1156 insertions(+), 3 deletions(-) create mode 100644 test/unit/infra/transport/handlers/source-handler.test.ts create mode 100644 test/unit/infra/transport/handlers/vc-handler-analytics.test.ts create mode 100644 test/unit/infra/transport/handlers/worktree-handler.test.ts diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 4ebd063ad..347b0afea 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -445,6 +445,7 @@ export async function setupFeatureHandlers({ }).setup() new SpaceHandler({ + analyticsClient, broadcastToProject, cogitPullService, contextTreeMerger, @@ -498,6 +499,7 @@ export async function setupFeatureHandlers({ }).setup() new VcHandler({ + analyticsClient, broadcastToProject, contextTreeService, gitRemoteBaseUrl: envConfig.gitRemoteBaseUrl, @@ -521,8 +523,8 @@ export async function setupFeatureHandlers({ }).setup() // Worktree & source handlers - new WorktreeHandler({resolveProjectPath, transport}).setup() - new SourceHandler({resolveProjectPath, transport}).setup() + new WorktreeHandler({analyticsClient, resolveProjectPath, transport}).setup() + new SourceHandler({analyticsClient, resolveProjectPath, transport}).setup() log('Feature handlers registered') diff --git a/src/server/infra/transport/handlers/source-handler.ts b/src/server/infra/transport/handlers/source-handler.ts index 61864443d..16fc132f5 100644 --- a/src/server/infra/transport/handlers/source-handler.ts +++ b/src/server/infra/transport/handlers/source-handler.ts @@ -1,5 +1,9 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { type SourceAddRequest, type SourceAddResponse, @@ -10,18 +14,23 @@ import { type SourceRemoveResponse, } from '../../../../shared/transport/events/source-events.js' import {addSource, listSourceStatuses, removeSource} from '../../../core/domain/source/source-operations.js' +import {hashProjectPath} from '../../../utils/hash-path.js' +import {processLog} from '../../../utils/process-logger.js' import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' export interface SourceHandlerDeps { + analyticsClient?: IAnalyticsClient resolveProjectPath: ProjectPathResolver transport: ITransportServer } export class SourceHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly resolveProjectPath: ProjectPathResolver private readonly transport: ITransportServer constructor(deps: SourceHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.resolveProjectPath = deps.resolveProjectPath this.transport = deps.transport } @@ -32,6 +41,14 @@ export class SourceHandler { async (data, clientId) => { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) const result = addSource(projectPath, data.targetPath, data.alias) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SOURCE_ADDED, { + ...(result.success ? {} : {failure_kind: 'add_failed'}), + outcome: result.success ? 'success' : 'failure', + project_path_hash: hashProjectPath(projectPath), + ...(result.success ? {source_origin_hash: hashProjectPath(data.targetPath)} : {}), + }) + /* eslint-enable camelcase */ return { message: result.message, success: result.success, @@ -44,6 +61,13 @@ export class SourceHandler { async (data, clientId) => { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) const result = removeSource(projectPath, data.aliasOrPath) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SOURCE_REMOVED, { + ...(result.success ? {} : {failure_kind: 'remove_failed'}), + outcome: result.success ? 'success' : 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return { message: result.message, success: result.success, @@ -63,4 +87,18 @@ export class SourceHandler { }, ) } + + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Source] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } } diff --git a/src/server/infra/transport/handlers/space-handler.ts b/src/server/infra/transport/handlers/space-handler.ts index 04be9df5f..ba5a9adbb 100644 --- a/src/server/infra/transport/handlers/space-handler.ts +++ b/src/server/infra/transport/handlers/space-handler.ts @@ -1,3 +1,6 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITokenStore} from '../../../core/interfaces/auth/i-token-store.js' import type {IContextTreeMerger} from '../../../core/interfaces/context-tree/i-context-tree-merger.js' import type {IContextTreeService} from '../../../core/interfaces/context-tree/i-context-tree-service.js' @@ -9,6 +12,7 @@ import type {ITeamService} from '../../../core/interfaces/services/i-team-servic import type {IProjectConfigStore} from '../../../core/interfaces/storage/i-project-config-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import {PullEvents} from '../../../../shared/transport/events/pull-events.js' import { SpaceEvents, @@ -25,6 +29,7 @@ import { SpaceNotFoundError, } from '../../../core/domain/errors/task-error.js' import {syncConfigToXdg} from '../../../utils/config-xdg-sync.js' +import {processLog} from '../../../utils/process-logger.js' import { guardAgainstGitVc, hasAnyChanges, @@ -34,6 +39,7 @@ import { } from './handler-types.js' export interface SpaceHandlerDeps { + analyticsClient?: IAnalyticsClient broadcastToProject: ProjectBroadcaster cogitPullService: ICogitPullService contextTreeMerger: IContextTreeMerger @@ -53,6 +59,7 @@ export interface SpaceHandlerDeps { * Business logic for space listing and switching — no terminal/UI calls. */ export class SpaceHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly broadcastToProject: ProjectBroadcaster private readonly cogitPullService: ICogitPullService private readonly contextTreeMerger: IContextTreeMerger @@ -67,6 +74,7 @@ export class SpaceHandler { private readonly transport: ITransportServer constructor(deps: SpaceHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.broadcastToProject = deps.broadcastToProject this.cogitPullService = deps.cogitPullService this.contextTreeMerger = deps.contextTreeMerger @@ -89,6 +97,20 @@ export class SpaceHandler { ) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Space] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + private async handleList(clientId: string): Promise { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) @@ -140,6 +162,16 @@ export class SpaceHandler { // No-op: switching to the currently active space if (existingConfig.spaceId === data.spaceId) { + if (existingConfig.spaceId) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SPACE_SWITCHED, { + from_space_id: existingConfig.spaceId, + outcome: 'success', + to_space_id: data.spaceId, + }) + /* eslint-enable camelcase */ + } + return { config: { spaceId: existingConfig.spaceId, @@ -258,6 +290,16 @@ export class SpaceHandler { // Pull failed and config was rolled back — return the old config with success: false if (pullError) { + if (existingConfig.spaceId) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SPACE_SWITCHED, { + failure_kind: 'pull_failed', + from_space_id: existingConfig.spaceId, + outcome: 'failure', + }) + /* eslint-enable camelcase */ + } + return { config: { spaceId: existingConfig.spaceId, @@ -271,6 +313,16 @@ export class SpaceHandler { } } + if (existingConfig.spaceId && newConfig.spaceId) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SPACE_SWITCHED, { + from_space_id: existingConfig.spaceId, + outcome: 'success', + to_space_id: newConfig.spaceId, + }) + /* eslint-enable camelcase */ + } + return { config: { spaceId: newConfig.spaceId, diff --git a/src/server/infra/transport/handlers/vc-handler.ts b/src/server/infra/transport/handlers/vc-handler.ts index 6b6e7240c..0bab4d3b2 100644 --- a/src/server/infra/transport/handlers/vc-handler.ts +++ b/src/server/infra/transport/handlers/vc-handler.ts @@ -1,6 +1,9 @@ import fs from 'node:fs' import {basename, join} from 'node:path' +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITokenStore} from '../../../core/interfaces/auth/i-token-store.js' import type {IContextTreeService} from '../../../core/interfaces/context-tree/i-context-tree-service.js' import type {GitCommit, GitDiffSide, IGitService} from '../../../core/interfaces/services/i-git-service.js' @@ -10,6 +13,7 @@ import type {IProjectConfigStore} from '../../../core/interfaces/storage/i-proje import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' import type {IVcGitConfig, IVcGitConfigStore} from '../../../core/interfaces/vc/i-vc-git-config-store.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { type IVcAddRequest, type IVcAddResponse, @@ -62,6 +66,8 @@ import {GitAuthError, GitError} from '../../../core/domain/errors/git-error.js' import {NotAuthenticatedError} from '../../../core/domain/errors/task-error.js' import {VcError} from '../../../core/domain/errors/vc-error.js' import {ensureContextTreeGitignore, ensureGitignoreEntries} from '../../../utils/gitignore.js' +import {hashProjectPath} from '../../../utils/hash-path.js' +import {processLog} from '../../../utils/process-logger.js' import {generateContextTreeIndex, regenerateContextTreeIndex} from '../../context-tree/index-generator.js' import {buildCogitRemoteUrl, isValidBranchName, parseUserFacingUrl} from '../../git/cogit-url.js' import {type ProjectBroadcaster, type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' @@ -120,6 +126,13 @@ function resolveDiffSides(mode: VcDiffMode): {from: GitDiffSide; to: GitDiffSide } export interface IVcHandlerDeps { + /** + * Optional. When provided, the handler emits per-vc-event analytics at + * existing success terminals. Failure emits are NOT added in this pass + * because every catch block in this handler throws (no return), and the + * additive-only rule forbids new try/catch wrappers. + */ + analyticsClient?: IAnalyticsClient broadcastToProject: ProjectBroadcaster contextTreeService: IContextTreeService gitRemoteBaseUrl: string @@ -138,6 +151,7 @@ export interface IVcHandlerDeps { * Handles vc:* events (Version Control commands). */ export class VcHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly broadcastToProject: ProjectBroadcaster private readonly contextTreeService: IContextTreeService private readonly gitRemoteBaseUrl: string @@ -152,6 +166,7 @@ export class VcHandler { private readonly webAppUrl: string constructor(deps: IVcHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.broadcastToProject = deps.broadcastToProject this.gitRemoteBaseUrl = deps.gitRemoteBaseUrl this.contextTreeService = deps.contextTreeService @@ -325,6 +340,15 @@ export class VcHandler { ) } + /** + * Classify a remote URL into 'byterover' or 'external' for analytics + * segmentation. Matches the daemon's configured `gitRemoteBaseUrl` prefix. + */ + private classifyRemoteKind(url: string | undefined): 'byterover' | 'external' { + if (!url) return 'external' + return this.gitRemoteBaseUrl && url.startsWith(this.gitRemoteBaseUrl) ? 'byterover' : 'external' + } + private async computeDiff(directory: string, path: string, side: VcDiffSide): Promise { if (side === 'staged') { const [head, stage] = await Promise.all([ @@ -342,6 +366,20 @@ export class VcHandler { return {newContent: workingTree, oldContent: stage ?? '', path} } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Vc] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + /** * When force is NOT set, checks for uncommitted changes and throws * VcError(UNCOMMITTED_CHANGES) if the working tree is dirty. @@ -421,7 +459,18 @@ export class VcHandler { // but transport payloads are untrusted — validate at the boundary. if (data.action === 'create' || data.action === 'delete') { if (!data.name) throw new VcError('Branch name is required.', VcErrorCode.INVALID_BRANCH_NAME) - if (data.action === 'create') return this.handleBranchCreate(directory, data.name, data.startPoint) + if (data.action === 'create') { + const created = await this.handleBranchCreate(directory, data.name, data.startPoint) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_BRANCHED, { + from_default_branch: data.startPoint === undefined || data.startPoint === 'main', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + return created + } + return this.handleBranchDelete(directory, data.name) } @@ -586,6 +635,14 @@ export class VcHandler { throw error } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_CHECKED_OUT, { + branch_kind: 'created', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + return {branch: data.branch, created: true, previousBranch} } @@ -630,6 +687,14 @@ export class VcHandler { throw error } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_CHECKED_OUT, { + branch_kind: 'existing', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + return {branch: data.branch, created: false, previousBranch} } @@ -727,6 +792,14 @@ export class VcHandler { throw new VcError(`Clone failed: ${msg}`, VcErrorCode.CLONE_FAILED) } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_CLONED, { + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(cloneUrl), + }) + /* eslint-enable camelcase */ + return { gitDir: join(contextTreeDir, '.git'), spaceName, @@ -763,6 +836,14 @@ export class VcHandler { message: data.message, }) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_COMMIT, { + had_message: Boolean(data.message), + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + return {message: commit.message, sha: commit.sha} } @@ -906,6 +987,14 @@ export class VcHandler { }), ) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_DISCARDED, { + discard_scope: filePaths.length > 1 ? 'all' : 'file', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + return {count: results.filter(Boolean).length} } @@ -941,6 +1030,14 @@ export class VcHandler { throw new VcError(message, VcErrorCode.FETCH_FAILED) } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_FETCHED, { + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(remotes.find((r) => r.remote === remote)?.url), + }) + /* eslint-enable camelcase */ + return {remote} } @@ -961,6 +1058,14 @@ export class VcHandler { // 4. Add .brv entries to project .gitignore (prevents `git add .` fatal error from nested .git) await ensureGitignoreEntries(projectPath) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_INIT, { + had_existing_git_dir: reinitialized, + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + return { gitDir: join(contextTreeDir, '.git'), reinitialized, @@ -1050,6 +1155,13 @@ export class VcHandler { directory, message: data.message, }) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_MERGED, { + had_fast_forward: false, + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return {action: 'continue'} } @@ -1071,6 +1183,13 @@ export class VcHandler { // Self-merge check const currentBranch = await this.gitService.getCurrentBranch({directory}) if (currentBranch && data.branch === currentBranch) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_MERGED, { + had_fast_forward: true, + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return {action: 'merge', alreadyUpToDate: true, branch: data.branch} } @@ -1109,6 +1228,13 @@ export class VcHandler { directory, message: data.message ?? `Merge branch '${data.branch}'`, }) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_MERGED, { + had_fast_forward: false, + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return {action: 'merge', branch: data.branch} } @@ -1116,11 +1242,25 @@ export class VcHandler { } if (result.alreadyUpToDate) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_MERGED, { + had_fast_forward: true, + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return {action: 'merge', alreadyUpToDate: true, branch: data.branch} } // Merge changed the topic set — refresh the derived navigation index. await this.regenerateIndexBestEffort(directory, projectPath) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_MERGED, { + had_fast_forward: false, + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return {action: 'merge', branch: data.branch} } @@ -1175,6 +1315,14 @@ export class VcHandler { directory, message: `Merge branch '${branch}' of ${remote}`, }) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_PULLED, { + branch_name_hash: hashProjectPath(branch), + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(remotes.find((r) => r.remote === remote)?.url), + }) + /* eslint-enable camelcase */ return {alreadyUpToDate: false, branch} } @@ -1215,6 +1363,15 @@ export class VcHandler { await this.regenerateIndexBestEffort(directory, projectPath) } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_PULLED, { + branch_name_hash: hashProjectPath(branch), + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(remotes.find((r) => r.remote === remote)?.url), + }) + /* eslint-enable camelcase */ + return {alreadyUpToDate, branch} } @@ -1294,6 +1451,15 @@ export class VcHandler { throw new VcError(message, VcErrorCode.PUSH_FAILED) } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_PUSHED, { + branch_name_hash: hashProjectPath(branch), + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(remotes.find((r) => r.remote === 'origin')?.url), + }) + /* eslint-enable camelcase */ + return {alreadyUpToDate, branch, upstreamSet} } @@ -1328,6 +1494,15 @@ export class VcHandler { await this.gitService.removeRemote({directory, remote: 'origin'}) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_REMOTE_CHANGED, { + change_kind: 'removed', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(existingUrl), + }) + /* eslint-enable camelcase */ + return {action: 'remove'} } @@ -1378,6 +1553,15 @@ export class VcHandler { await this.projectConfigStore.write(updated, projectPath) } + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_REMOTE_CHANGED, { + change_kind: data.subcommand === 'add' ? 'added' : 'url_set', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + remote_kind: this.classifyRemoteKind(resolved.url), + }) + /* eslint-enable camelcase */ + return {action: data.subcommand === 'add' ? 'add' : 'set-url', url: resolved.url} } @@ -1417,6 +1601,14 @@ export class VcHandler { const isUnstage = Boolean(data.filePaths) || (mode === 'mixed' && (!data.ref || data.ref === 'HEAD')) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.VC_RESET_EXECUTED, { + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + reset_mode: mode, + }) + /* eslint-enable camelcase */ + return { filesUnstaged: isUnstage ? result.filesChanged : undefined, headSha: isUnstage ? undefined : result.headSha, diff --git a/src/server/infra/transport/handlers/worktree-handler.ts b/src/server/infra/transport/handlers/worktree-handler.ts index e79215e34..77ce1f751 100644 --- a/src/server/infra/transport/handlers/worktree-handler.ts +++ b/src/server/infra/transport/handlers/worktree-handler.ts @@ -1,5 +1,9 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { type WorktreeAddRequest, type WorktreeAddResponse, @@ -9,19 +13,24 @@ import { type WorktreeRemoveRequest, type WorktreeRemoveResponse, } from '../../../../shared/transport/events/worktree-events.js' +import {hashProjectPath} from '../../../utils/hash-path.js' +import {processLog} from '../../../utils/process-logger.js' import {addWorktree, findParentProject, listWorktrees, removeWorktree, resolveProject} from '../../project/resolve-project.js' import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' export interface WorktreeHandlerDeps { + analyticsClient?: IAnalyticsClient resolveProjectPath: ProjectPathResolver transport: ITransportServer } export class WorktreeHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly resolveProjectPath: ProjectPathResolver private readonly transport: ITransportServer constructor(deps: WorktreeHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.resolveProjectPath = deps.resolveProjectPath this.transport = deps.transport } @@ -45,11 +54,25 @@ export class WorktreeHandler { if (parent) { projectPath = parent } else if (!projectPath) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.WORKTREE_ADDED, { + failure_kind: 'no_parent_project', + outcome: 'failure', + project_path_hash: hashProjectPath(data.worktreePath), + }) + /* eslint-enable camelcase */ return {message: 'No parent project found for the target directory.', success: false} } } const result = addWorktree(projectPath, data.worktreePath, {force: data.force}) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.WORKTREE_ADDED, { + ...(result.success ? {} : {failure_kind: 'add_failed'}), + outcome: result.success ? 'success' : 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ return { backedUp: result.backedUp, message: result.message, @@ -63,6 +86,13 @@ export class WorktreeHandler { async (data) => { const targetPath = data.worktreePath const result = removeWorktree(targetPath) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.WORKTREE_REMOVED, { + ...(result.success ? {} : {failure_kind: 'remove_failed'}), + outcome: result.success ? 'success' : 'failure', + project_path_hash: hashProjectPath(targetPath), + }) + /* eslint-enable camelcase */ return { message: result.message, success: result.success, @@ -95,4 +125,18 @@ export class WorktreeHandler { }, ) } + + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Worktree] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } } diff --git a/test/unit/infra/transport/handlers/source-handler.test.ts b/test/unit/infra/transport/handlers/source-handler.test.ts new file mode 100644 index 000000000..d473a3eb5 --- /dev/null +++ b/test/unit/infra/transport/handlers/source-handler.test.ts @@ -0,0 +1,129 @@ + +import {expect} from 'chai' +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {SourceHandler} from '../../../../../src/server/infra/transport/handlers/source-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {SourceEvents} from '../../../../../src/shared/transport/events/source-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +const sha256HexRegex = /^[0-9a-f]{64}$/ + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('SourceHandler analytics emits', () => { + let sandbox: SinonSandbox + let requestHandlers: Record + let transport: Stubbed + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + let projectDir: string + let sourceDir: string + + beforeEach(() => { + sandbox = createSandbox() + requestHandlers = {} + transport = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + + projectDir = mkdtempSync(join(tmpdir(), 'brv-src-proj-')) + mkdirSync(join(projectDir, '.brv'), {recursive: true}) + writeFileSync(join(projectDir, '.brv', 'config.json'), '{}') + sourceDir = mkdtempSync(join(tmpdir(), 'brv-src-target-')) + mkdirSync(join(sourceDir, '.brv'), {recursive: true}) + writeFileSync(join(sourceDir, '.brv', 'config.json'), '{}') + + analyticsClient = makeFakeAnalyticsClient() + new SourceHandler({ + analyticsClient, + resolveProjectPath: sandbox.stub().returns(projectDir) as never, + transport, + }).setup() + }) + + afterEach(() => { + sandbox.restore() + rmSync(projectDir, {force: true, recursive: true}) + rmSync(sourceDir, {force: true, recursive: true}) + }) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits source_added outcome=success with source_origin_hash on add success', async () => { + const handler = requestHandlers[SourceEvents.ADD] + await handler({targetPath: sourceDir}, 'client-1') + const calls = emits(AnalyticsEventNames.SOURCE_ADDED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as { + outcome: string + project_path_hash: string + source_origin_hash?: string + } + expect(props.outcome).to.equal('success') + expect(props.project_path_hash).to.match(sha256HexRegex) + expect(props.source_origin_hash).to.match(sha256HexRegex) + }) + + it('emits source_added outcome=failure when target is not a BRV project', async () => { + const handler = requestHandlers[SourceEvents.ADD] + const notBrvDir = mkdtempSync(join(tmpdir(), 'brv-src-bad-')) + try { + await handler({targetPath: notBrvDir}, 'client-1') + const calls = emits(AnalyticsEventNames.SOURCE_ADDED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('add_failed') + } finally { + rmSync(notBrvDir, {force: true, recursive: true}) + } + }) + + it('emits source_removed outcome=failure on non-existent alias', async () => { + const handler = requestHandlers[SourceEvents.REMOVE] + await handler({aliasOrPath: 'nonexistent'}, 'client-1') + const calls = emits(AnalyticsEventNames.SOURCE_REMOVED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('remove_failed') + }) + + it('does NOT emit on list', async () => { + const handler = requestHandlers[SourceEvents.LIST] + await handler({}, 'client-1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) +}) diff --git a/test/unit/infra/transport/handlers/space-handler.test.ts b/test/unit/infra/transport/handlers/space-handler.test.ts index 39970f0de..6798b2b74 100644 --- a/test/unit/infra/transport/handlers/space-handler.test.ts +++ b/test/unit/infra/transport/handlers/space-handler.test.ts @@ -3,6 +3,7 @@ import type {SinonStubbedInstance} from 'sinon' import {expect} from 'chai' import {restore, stub} from 'sinon' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' import type {ITokenStore} from '../../../../../src/server/core/interfaces/auth/i-token-store.js' import type {IContextTreeMerger} from '../../../../../src/server/core/interfaces/context-tree/i-context-tree-merger.js' import type {IContextTreeService} from '../../../../../src/server/core/interfaces/context-tree/i-context-tree-service.js' @@ -30,6 +31,7 @@ import { ProjectNotInitError, } from '../../../../../src/server/core/domain/errors/task-error.js' import {SpaceHandler} from '../../../../../src/server/infra/transport/handlers/space-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' import {PullEvents} from '../../../../../src/shared/transport/events/pull-events.js' import {SpaceEvents} from '../../../../../src/shared/transport/events/space-events.js' @@ -993,4 +995,99 @@ describe('SpaceHandler', () => { expect(result.teams).to.exist }) }) + + describe('space_switched analytics emits', () => { + let analyticsClient: IAnalyticsClient & {trackSpy: ReturnType} + + function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: ReturnType} { + const trackSpy = stub() + return { + abort: stub(), + flush: stub().resolves({events: []}), + getRuntimeState: stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: ReturnType} + } + + function createHandlerWithAnalytics(): void { + const handler = new SpaceHandler({ + analyticsClient, + broadcastToProject, + cogitPullService, + contextTreeMerger, + contextTreeService: contextTreeService as unknown as IContextTreeService, + contextTreeSnapshotService, + contextTreeWriterService, + projectConfigStore, + resolveProjectPath, + spaceService, + teamService, + tokenStore, + transport, + }) + handler.setup() + } + + function emitsOf(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c: {args: unknown[]}) => c.args[0] === name) + } + + beforeEach(() => { + analyticsClient = makeFakeAnalyticsClient() + }) + + it('emits space_switched outcome=success on switch to a different space', async () => { + setupSwitchMocks() + createHandlerWithAnalytics() + + await callSwitchHandler({spaceId: 'space-2'}) + + const calls = emitsOf(AnalyticsEventNames.SPACE_SWITCHED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {from_space_id: string; outcome: string; to_space_id?: string} + expect(props.outcome).to.equal('success') + expect(props.from_space_id).to.equal('space-1') + expect(props.to_space_id).to.equal('space-2') + }) + + it('emits space_switched outcome=success on no-op (same space) when existing space is set', async () => { + setupSwitchMocks() + createHandlerWithAnalytics() + + await callSwitchHandler({spaceId: 'space-1'}) + + const calls = emitsOf(AnalyticsEventNames.SPACE_SWITCHED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {from_space_id: string; to_space_id?: string} + expect(props.from_space_id).to.equal('space-1') + expect(props.to_space_id).to.equal('space-1') + }) + + it('does NOT emit space_switched on first-time connect (no existing space)', async () => { + setupSwitchMocks(createLocalOnlyConfig()) + createHandlerWithAnalytics() + + await callSwitchHandler({spaceId: 'space-2'}) + + const calls = emitsOf(AnalyticsEventNames.SPACE_SWITCHED) + expect(calls.length).to.equal(0) + }) + + it('emits space_switched outcome=failure when pull throws', async () => { + setupSwitchMocks() + cogitPullService.pull.rejects(new Error('network down')) + createHandlerWithAnalytics() + + await callSwitchHandler({spaceId: 'space-2'}) + + const calls = emitsOf(AnalyticsEventNames.SPACE_SWITCHED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; from_space_id: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('pull_failed') + expect(props.from_space_id).to.equal('space-1') + }) + }) }) diff --git a/test/unit/infra/transport/handlers/vc-handler-analytics.test.ts b/test/unit/infra/transport/handlers/vc-handler-analytics.test.ts new file mode 100644 index 000000000..8c9b58063 --- /dev/null +++ b/test/unit/infra/transport/handlers/vc-handler-analytics.test.ts @@ -0,0 +1,460 @@ + +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {ITokenStore} from '../../../../../src/server/core/interfaces/auth/i-token-store.js' +import type {IContextTreeService} from '../../../../../src/server/core/interfaces/context-tree/i-context-tree-service.js' +import type {IGitService} from '../../../../../src/server/core/interfaces/services/i-git-service.js' +import type {ISpaceService} from '../../../../../src/server/core/interfaces/services/i-space-service.js' +import type {ITeamService} from '../../../../../src/server/core/interfaces/services/i-team-service.js' +import type {IProjectConfigStore} from '../../../../../src/server/core/interfaces/storage/i-project-config-store.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' +import type {IVcGitConfigStore} from '../../../../../src/server/core/interfaces/vc/i-vc-git-config-store.js' + +import {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' +import {VcHandler} from '../../../../../src/server/infra/transport/handlers/vc-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {VcEvents} from '../../../../../src/shared/transport/events/vc-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +const PROJECT_PATH = '/fake/proj' +const CLIENT_ID = 'client-1' +const sha256HexRegex = /^[0-9a-f]{64}$/ + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +interface VcDeps { + analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + contextTreeService: Stubbed + gitService: Stubbed + projectConfigStore: Stubbed + requestHandlers: Record + resolveProjectPath: SinonStub + spaceService: Stubbed + teamService: Stubbed + tokenStore: Stubbed + transport: Stubbed + vcGitConfigStore: Stubbed +} + +function makeDeps(sandbox: SinonSandbox): VcDeps { + const requestHandlers: Record = {} + const transport: Stubbed = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + + const gitService: Stubbed = { + abortMerge: sandbox.stub().resolves(), + add: sandbox.stub().resolves(), + addRemote: sandbox.stub().resolves(), + checkout: sandbox.stub().resolves(), + clone: sandbox.stub().resolves(), + commit: sandbox.stub().resolves({ + author: {email: 'test@example.com', name: 'Test User'}, + message: 'test', + sha: 'abc123', + timestamp: new Date(), + }), + createBranch: sandbox.stub().resolves(), + deleteBranch: sandbox.stub().resolves(), + fetch: sandbox.stub().resolves(), + getAheadBehind: sandbox.stub().resolves({ahead: 0, behind: 0}), + getBlobContent: sandbox.stub().resolves(), + getBlobContents: sandbox.stub().resolves({}), + getConflicts: sandbox.stub().resolves([]), + getCurrentBranch: sandbox.stub().resolves('main'), + getFilesWithConflictMarkers: sandbox.stub().resolves([]), + getRemoteUrl: sandbox.stub().resolves(), + getTextBlob: sandbox.stub().resolves(), + getTrackingBranch: sandbox.stub().resolves({remote: 'origin', remoteBranch: 'main'}), + hashBlob: sandbox.stub().resolves('0000'), + init: sandbox.stub().resolves(), + isAncestor: sandbox.stub().resolves(true), + isEmptyRepository: sandbox.stub().resolves(false), + isInitialized: sandbox.stub().resolves(true), + listBranches: sandbox.stub().resolves([{isCurrent: true, isRemote: false, name: 'main'}]), + listChangedFiles: sandbox.stub().resolves([]), + listRemotes: sandbox.stub().resolves([{remote: 'origin', url: 'https://byterover.dev/team/space.git'}]), + log: sandbox.stub().resolves([{sha: 'abc', timestamp: new Date()} as never]), + merge: sandbox.stub().resolves({success: true}), + pull: sandbox.stub().resolves({success: true}), + push: sandbox.stub().resolves({success: true}), + removeRemote: sandbox.stub().resolves(), + reset: sandbox.stub().resolves({filesChanged: 0, headSha: 'abc'}), + setTrackingBranch: sandbox.stub().resolves(), + status: sandbox.stub().resolves({files: [{path: 'a.md', staged: true, status: 'modified'}], isClean: false}), + } + + const contextTreeService: Stubbed = { + delete: sandbox.stub().resolves(), + exists: sandbox.stub().resolves(false), + hasGitRepo: sandbox.stub().resolves(false), + initialize: sandbox.stub().resolves(`${PROJECT_PATH}/.brv/context-tree`), + resolvePath: sandbox.stub().returns(`${PROJECT_PATH}/.brv/context-tree`), + } + + const vcGitConfigStore: Stubbed = { + get: sandbox.stub().resolves({email: 'a@b.dev', name: 'A B'}), + set: sandbox.stub().resolves(), + } + + const token = new AuthToken({ + accessToken: 'a', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'r', + sessionKey: 's', + userEmail: 'a@b.dev', + userId: 'u1', + }) + + const tokenStore: Stubbed = { + clear: sandbox.stub().resolves(), + load: sandbox.stub().resolves(token), + save: sandbox.stub().resolves(), + } + + return { + analyticsClient: makeFakeAnalyticsClient(), + contextTreeService, + gitService, + projectConfigStore: { + exists: sandbox.stub().resolves(false), + getModifiedTime: sandbox.stub().resolves(), + read: sandbox.stub().resolves(), + write: sandbox.stub().resolves(), + }, + requestHandlers, + resolveProjectPath: sandbox.stub().returns(PROJECT_PATH), + spaceService: {getSpaces: sandbox.stub().resolves({spaces: [], total: 0})}, + teamService: {getTeams: sandbox.stub().resolves({teams: [], total: 0})}, + tokenStore, + transport, + vcGitConfigStore, + } +} + +function makeHandler(deps: VcDeps): VcHandler { + const handler = new VcHandler({ + analyticsClient: deps.analyticsClient, + broadcastToProject: createSandbox().stub() as never, + contextTreeService: deps.contextTreeService, + gitRemoteBaseUrl: 'https://byterover.dev', + gitService: deps.gitService, + projectConfigStore: deps.projectConfigStore, + resolveProjectPath: deps.resolveProjectPath as never, + spaceService: deps.spaceService, + teamService: deps.teamService, + tokenStore: deps.tokenStore, + transport: deps.transport, + vcGitConfigStore: deps.vcGitConfigStore, + webAppUrl: 'https://app.byterover.dev', + }) + handler.setup() + return handler +} + +function invoke(deps: VcDeps, event: string, data: unknown): Promise { + return deps.requestHandlers[event](data, CLIENT_ID) as Promise +} + +function emitsOf(deps: VcDeps, name: string): Array<{args: unknown[]}> { + return deps.analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) +} + +describe('VcHandler analytics emits', () => { + let sandbox: SinonSandbox + let deps: VcDeps + + beforeEach(() => { + sandbox = createSandbox() + deps = makeDeps(sandbox) + deps.gitService.isInitialized.resolves(false) + makeHandler(deps) + }) + + afterEach(() => sandbox.restore()) + + it('emits vc_init outcome=success with had_existing_git_dir=false on fresh init', async () => { + await invoke(deps, VcEvents.INIT, {}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_INIT) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {had_existing_git_dir: boolean; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.had_existing_git_dir).to.equal(false) + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits vc_init had_existing_git_dir=true when repo already exists', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.INIT, {}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_INIT) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {had_existing_git_dir: boolean; outcome: string} + expect(props.had_existing_git_dir).to.equal(true) + }) + + it('emits vc_commit on commit success with had_message=true', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.COMMIT, {message: 'feat: x'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_COMMIT) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {had_message: boolean; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.had_message).to.equal(true) + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits vc_fetched with remote_kind=byterover when remote points at the configured base', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.FETCH, {}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_FETCHED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; remote_kind: string} + expect(props.outcome).to.equal('success') + expect(props.remote_kind).to.equal('byterover') + }) + + it('emits vc_fetched with remote_kind=external when remote is unknown', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.listRemotes.resolves([{remote: 'origin', url: 'https://github.com/foo/bar.git'}]) + await invoke(deps, VcEvents.FETCH, {}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_FETCHED) + const props = calls[0].args[1] as {remote_kind: string} + expect(props.remote_kind).to.equal('external') + }) + + it('emits vc_pushed with branch_name_hash + remote_kind on push success', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.PUSH, {branch: 'feat/x'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_PUSHED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {branch_name_hash: string; outcome: string; remote_kind: string} + expect(props.outcome).to.equal('success') + expect(props.branch_name_hash).to.match(sha256HexRegex) + expect(props.remote_kind).to.equal('byterover') + }) + + it('emits vc_pulled with branch_name_hash + remote_kind on pull success', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.PULL, {}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_PULLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {branch_name_hash: string; remote_kind: string} + expect(props.branch_name_hash).to.match(sha256HexRegex) + expect(props.remote_kind).to.equal('byterover') + }) + + it('emits vc_reset_executed with reset_mode echoed from request', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.RESET, {mode: 'hard'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_RESET_EXECUTED) + const props = calls[0].args[1] as {outcome: string; reset_mode: string} + expect(props.outcome).to.equal('success') + expect(props.reset_mode).to.equal('hard') + }) + + it('emits vc_discarded with discard_scope=file on single-path discard', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.DISCARD, {filePaths: ['a.md']}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_DISCARDED) + const props = calls[0].args[1] as {discard_scope: string; outcome: string} + expect(props.discard_scope).to.equal('file') + expect(props.outcome).to.equal('success') + }) + + it('emits vc_discarded with discard_scope=all on multi-path discard', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.DISCARD, {filePaths: ['a.md', 'b.md']}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_DISCARDED) + const props = calls[0].args[1] as {discard_scope: string} + expect(props.discard_scope).to.equal('all') + }) + + it('emits vc_branched on create-branch dispatcher path', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.BRANCH, {action: 'create', name: 'feat/x'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_BRANCHED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {from_default_branch: boolean; outcome: string} + expect(props.outcome).to.equal('success') + expect(props.from_default_branch).to.equal(true) + }) + + it('does NOT emit vc_branched on list/delete branch actions', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.listBranches.resolves([ + {isCurrent: false, isRemote: false, name: 'feat/x'}, + {isCurrent: true, isRemote: false, name: 'main'}, + ]) + await invoke(deps, VcEvents.BRANCH, {action: 'list'}) + await invoke(deps, VcEvents.BRANCH, {action: 'delete', name: 'feat/x'}) + expect(emitsOf(deps, AnalyticsEventNames.VC_BRANCHED).length).to.equal(0) + }) + + it('emits vc_checked_out with branch_kind=existing on plain checkout', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.CHECKOUT, {branch: 'main'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_CHECKED_OUT) + const props = calls[0].args[1] as {branch_kind: string} + expect(props.branch_kind).to.equal('existing') + }) + + it('emits vc_checked_out with branch_kind=created on -b checkout', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.listBranches.resolves([]) + await invoke(deps, VcEvents.CHECKOUT, {branch: 'feat/x', create: true}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_CHECKED_OUT) + const props = calls[0].args[1] as {branch_kind: string} + expect(props.branch_kind).to.equal('created') + }) + + it('emits vc_cloned outcome=success with project_path_hash + remote_kind', async () => { + // Fresh repo so clone runs (not "already initialized" early return) + deps.gitService.isInitialized.resolves(false) + deps.teamService.getTeams.resolves({ + teams: [{displayName: 'T', id: 'tid', isActive: true, isDefault: false, name: 'teambao', slug: 'teambao'}], + total: 1, + }) + deps.spaceService.getSpaces.resolves({ + spaces: [{ + id: 'sid', + isDefault: false, + name: 'space1', + slug: 'space1', + teamId: 'tid', + teamName: 'teambao', + teamSlug: 'teambao', + }], + total: 1, + }) + await invoke(deps, VcEvents.CLONE, {url: 'https://byterover.dev/teambao/space1.git'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_CLONED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; project_path_hash: string; remote_kind: string} + expect(props.outcome).to.equal('success') + expect(props.project_path_hash).to.match(sha256HexRegex) + expect(props.remote_kind).to.equal('byterover') + }) + + it('emits vc_merged on successful merge (fall-through path)', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.status.resolves({files: [], isClean: true}) + deps.gitService.getCurrentBranch.resolves('main') + deps.gitService.listBranches.resolves([ + {isCurrent: true, isRemote: false, name: 'main'}, + {isCurrent: false, isRemote: false, name: 'feat/x'}, + ]) + deps.gitService.merge.resolves({alreadyUpToDate: false, success: true}) + await invoke(deps, VcEvents.MERGE, {action: 'merge', branch: 'feat/x'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_MERGED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {had_fast_forward?: boolean; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.had_fast_forward).to.equal(false) + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits vc_merged had_fast_forward=true on self-merge no-op', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.status.resolves({files: [], isClean: true}) + deps.gitService.getCurrentBranch.resolves('main') + await invoke(deps, VcEvents.MERGE, {action: 'merge', branch: 'main'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_MERGED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {had_fast_forward?: boolean; outcome: string} + expect(props.had_fast_forward).to.equal(true) + }) + + it('emits vc_remote_changed change_kind=added on remote add', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.getRemoteUrl.resolves() + deps.teamService.getTeams.resolves({ + teams: [{displayName: 'T', id: 'tid', isActive: true, isDefault: false, name: 'teambao', slug: 'teambao'}], + total: 1, + }) + deps.spaceService.getSpaces.resolves({ + spaces: [{ + id: 'sid', + isDefault: false, + name: 'space1', + slug: 'space1', + teamId: 'tid', + teamName: 'teambao', + teamSlug: 'teambao', + }], + total: 1, + }) + await invoke(deps, VcEvents.REMOTE, {subcommand: 'add', url: 'https://byterover.dev/teambao/space1.git'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_REMOTE_CHANGED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {change_kind: string; remote_kind: string} + expect(props.change_kind).to.equal('added') + expect(props.remote_kind).to.equal('byterover') + }) + + it('emits vc_remote_changed with change_kind=removed on remove subcommand', async () => { + deps.gitService.isInitialized.resolves(true) + deps.gitService.getRemoteUrl.resolves('https://github.com/foo/bar.git') + await invoke(deps, VcEvents.REMOTE, {subcommand: 'remove'}) + const calls = emitsOf(deps, AnalyticsEventNames.VC_REMOTE_CHANGED) + const props = calls[0].args[1] as {change_kind: string; remote_kind: string} + expect(props.change_kind).to.equal('removed') + expect(props.remote_kind).to.equal('external') + }) + + it('does NOT emit vc_remote_changed on remote show', async () => { + deps.gitService.isInitialized.resolves(true) + await invoke(deps, VcEvents.REMOTE, {subcommand: 'show'}) + expect(emitsOf(deps, AnalyticsEventNames.VC_REMOTE_CHANGED).length).to.equal(0) + }) + + it('is a no-op when analyticsClient is not injected', async () => { + sandbox.restore() + sandbox = createSandbox() + deps = makeDeps(sandbox) + deps.gitService.isInitialized.resolves(false) + const handler = new VcHandler({ + broadcastToProject: sandbox.stub() as never, + contextTreeService: deps.contextTreeService, + gitRemoteBaseUrl: 'https://byterover.dev', + gitService: deps.gitService, + projectConfigStore: deps.projectConfigStore, + resolveProjectPath: deps.resolveProjectPath as never, + spaceService: deps.spaceService, + teamService: deps.teamService, + tokenStore: deps.tokenStore, + transport: deps.transport, + vcGitConfigStore: deps.vcGitConfigStore, + webAppUrl: 'https://app.byterover.dev', + }) + handler.setup() + await invoke(deps, VcEvents.INIT, {}) + // No analytics client injected → trackSpy never called + expect(deps.analyticsClient.trackSpy.called).to.equal(false) + }) +}) diff --git a/test/unit/infra/transport/handlers/worktree-handler.test.ts b/test/unit/infra/transport/handlers/worktree-handler.test.ts new file mode 100644 index 000000000..b8d2bb751 --- /dev/null +++ b/test/unit/infra/transport/handlers/worktree-handler.test.ts @@ -0,0 +1,139 @@ + +import {expect} from 'chai' +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {WorktreeHandler} from '../../../../../src/server/infra/transport/handlers/worktree-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {WorktreeEvents} from '../../../../../src/shared/transport/events/worktree-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +const sha256HexRegex = /^[0-9a-f]{64}$/ + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('WorktreeHandler analytics emits', () => { + let sandbox: SinonSandbox + let requestHandlers: Record + let transport: Stubbed + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + let projectDir: string + let worktreeDir: string + + beforeEach(() => { + sandbox = createSandbox() + requestHandlers = {} + transport = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + + // Create a real project dir with .brv/config.json so addWorktree sees it as a BRV project + projectDir = mkdtempSync(join(tmpdir(), 'brv-wt-proj-')) + mkdirSync(join(projectDir, '.brv'), {recursive: true}) + writeFileSync(join(projectDir, '.brv', 'config.json'), '{}') + worktreeDir = mkdtempSync(join(tmpdir(), 'brv-wt-target-')) + + analyticsClient = makeFakeAnalyticsClient() + const resolveProjectPath = sandbox.stub().returns(projectDir) + new WorktreeHandler({ + analyticsClient, + resolveProjectPath: resolveProjectPath as never, + transport, + }).setup() + }) + + afterEach(() => { + sandbox.restore() + rmSync(projectDir, {force: true, recursive: true}) + rmSync(worktreeDir, {force: true, recursive: true}) + }) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits worktree_added outcome=success on add success', async () => { + const handler = requestHandlers[WorktreeEvents.ADD] + await handler({worktreePath: worktreeDir}, 'client-1') + const calls = emits(AnalyticsEventNames.WORKTREE_ADDED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits worktree_removed outcome=failure when target does not exist', async () => { + const handler = requestHandlers[WorktreeEvents.REMOVE] + const nonexistent = join(tmpdir(), `brv-wt-noexist-${Date.now()}`) + await handler({worktreePath: nonexistent}, 'client-1') + const calls = emits(AnalyticsEventNames.WORKTREE_REMOVED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('remove_failed') + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('does NOT emit on list', async () => { + const handler = requestHandlers[WorktreeEvents.LIST] + await handler({}, 'client-1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) + + it('is a no-op when analyticsClient is not injected', async () => { + const requestHandlersLocal: Record = {} + const transportLocal: Stubbed = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlersLocal[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + new WorktreeHandler({ + resolveProjectPath: sandbox.stub().returns(projectDir) as never, + transport: transportLocal, + }).setup() + const handler = requestHandlersLocal[WorktreeEvents.ADD] + await handler({worktreePath: worktreeDir}, 'client-1') + // No throw, no spy invocations on the injected client either + expect(analyticsClient.trackSpy.called).to.equal(false) + }) +}) From 6f24ed9d5f5c9ee85be38c3ddbd178dc4ae6eda7 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 27 May 2026 20:28:45 +0700 Subject: [PATCH 57/87] feat: [ENG-2967] M15.4 HITL review + settings + context-tree + reset analytics sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire 7 analytics events across 4 daemon transport handlers per the M15.1 lifecycle outcome taxonomy (success + failure_kind): - settings-handler: setting_changed, setting_reset - reset-handler: daemon_reset_executed - context-tree-handler: context_tree_file_edited (UPDATE_FILE only) - review-handler: review_toggled, review_approved, review_rejected Settings already had try/catch; pure additive emits inside existing branches. The other 3 handlers gain try { body; emit success; return } catch (e) { emit failure (classified failure_kind); throw e } wrappers — every catch re-throws the same error instance, preserving caller behavior exactly. resolveRequiredProjectPath stays outside each try so client-not- registered errors propagate unchanged. failure_kind values come from per-handler vocabularies (UnknownSettingKey, InvalidSettingValue, ContextTreeNotInitialized, etc.) — never raw error.message content; setting_changed regression test asserts raw value is absent from payload and failure_kind. context-tree-handler's byte_delta needs a pre-write stat to compute; that stat is gated behind this.analyticsClient so the handler's disk-I/O profile is identical to the original when analytics is off. review-handler.handleDecideTask extracts the original body into a private runDecideTask method (verbatim, plus a per-op type field on PendingOp consumed only by emits). Outer wrapper emits aggregate failure on throw; inner emits fire per-file with operation_kind derived from op.type. Tests: 22 new analytics tests across 4 new files. Full suite green (9166 passing, +22 from baseline 9144). --- src/server/infra/process/feature-handlers.ts | 11 +- .../handlers/context-tree-handler.ts | 83 ++++- .../infra/transport/handlers/reset-handler.ts | 92 ++++-- .../transport/handlers/review-handler.ts | 310 ++++++++++++------ .../transport/handlers/settings-handler.ts | 65 ++++ .../context-tree-handler-analytics.test.ts | 156 +++++++++ .../handlers/reset-handler-analytics.test.ts | 136 ++++++++ .../handlers/review-handler-analytics.test.ts | 211 ++++++++++++ .../settings-handler-analytics.test.ts | 151 +++++++++ 9 files changed, 1086 insertions(+), 129 deletions(-) create mode 100644 test/unit/infra/transport/handlers/context-tree-handler-analytics.test.ts create mode 100644 test/unit/infra/transport/handlers/reset-handler-analytics.test.ts create mode 100644 test/unit/infra/transport/handlers/review-handler-analytics.test.ts create mode 100644 test/unit/infra/transport/handlers/settings-handler-analytics.test.ts diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 347b0afea..fa8afa57d 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -177,7 +177,8 @@ export async function setupFeatureHandlers({ // Global handlers (no project context needed) new ConfigHandler({transport}).setup() - new SettingsHandler({store: settingsStore, transport}).setup() + // SettingsHandler is constructed below, after analyticsClient is built, + // so it can receive the optional analyticsClient dep for M15.4 emits. // GlobalConfig: handler retains a sync-cached `analytics` flag so M2.5's // AnalyticsClient.isEnabled can be a sync getter (file reads are async). @@ -272,6 +273,11 @@ export async function setupFeatureHandlers({ // (TUI, oclif, MCP, webui) to the same singleton. new AnalyticsHandler({analyticsClient, transport}).setup() + // Global SettingsHandler (no project context). Deferred from line 180 so + // analyticsClient is in scope for M15.4 `setting_changed` / `setting_reset` + // emits. + new SettingsHandler({analyticsClient, store: settingsStore, transport}).setup() + // M11.2: webui-facing read API. Shares the same JsonlAnalyticsStore instance // as the AnalyticsClient so reads see exactly what trackAsync persisted. new AnalyticsListHandler({jsonlStore: jsonlAnalyticsStore, transport}).setup() @@ -425,6 +431,7 @@ export async function setupFeatureHandlers({ }).setup() new ResetHandler({ + analyticsClient, contextTreeService, contextTreeSnapshotService, curateLogStoreFactory: (projectPath) => new FileCurateLogStore({baseDir: getProjectDataDir(projectPath)}), @@ -434,6 +441,7 @@ export async function setupFeatureHandlers({ }).setup() new ReviewHandler({ + analyticsClient, curateLogStoreFactory: (projectPath) => new FileCurateLogStore({baseDir: getProjectDataDir(projectPath)}), onResolved({projectPath, taskId}) { broadcastToProject(projectPath, ReviewEvents.NOTIFY, {pendingCount: 0, taskId}) @@ -515,6 +523,7 @@ export async function setupFeatureHandlers({ }).setup() new ContextTreeHandler({ + analyticsClient, contextFileReader, contextTreeService, gitService, diff --git a/src/server/infra/transport/handlers/context-tree-handler.ts b/src/server/infra/transport/handlers/context-tree-handler.ts index b9a174c58..386fa8e01 100644 --- a/src/server/infra/transport/handlers/context-tree-handler.ts +++ b/src/server/infra/transport/handlers/context-tree-handler.ts @@ -1,11 +1,15 @@ -import {mkdir, readdir, writeFile} from 'node:fs/promises' +import {mkdir, readdir, stat, writeFile} from 'node:fs/promises' import {dirname, join, relative} from 'node:path' +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {IContextFileReader} from '../../../core/interfaces/context-tree/i-context-file-reader.js' import type {IContextTreeService} from '../../../core/interfaces/context-tree/i-context-tree-service.js' import type {IGitService} from '../../../core/interfaces/services/i-git-service.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { ContextTreeEvents, type ContextTreeGetFileMetadataRequest, @@ -21,6 +25,8 @@ import { type ContextTreeUpdateFileResponse, } from '../../../../shared/transport/events/context-tree-events.js' import {ARCHIVE_DIR, DEFAULT_BRANCH, README_FILE, SNAPSHOT_FILE} from '../../../constants.js' +import {hashProjectPath} from '../../../utils/hash-path.js' +import {processLog} from '../../../utils/process-logger.js' import {isExcludedFromSync} from '../../context-tree/derived-artifact.js' import {toUnixPath} from '../../context-tree/path-utils.js' import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' @@ -29,6 +35,7 @@ const DEFAULT_HISTORY_LIMIT = 10 const SCAN_SKIP_NAMES = new Set(['.git', '.gitignore', ARCHIVE_DIR, SNAPSHOT_FILE]) export interface ContextTreeHandlerDeps { + analyticsClient?: IAnalyticsClient contextFileReader: IContextFileReader contextTreeService: IContextTreeService gitService: Pick @@ -37,6 +44,7 @@ export interface ContextTreeHandlerDeps { } export class ContextTreeHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly contextFileReader: IContextFileReader private readonly contextTreeService: IContextTreeService private readonly gitService: Pick @@ -44,6 +52,7 @@ export class ContextTreeHandler { private readonly transport: ITransportServer constructor(deps: ContextTreeHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.contextFileReader = deps.contextFileReader this.contextTreeService = deps.contextTreeService this.gitService = deps.gitService @@ -78,6 +87,20 @@ export class ContextTreeHandler { ) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[ContextTree] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + private async handleGetFile( data: ContextTreeGetFileRequest, clientId: string, @@ -187,16 +210,46 @@ export class ContextTreeHandler { const contextTreeDir = this.contextTreeService.resolvePath(projectPath) const fullPath = join(contextTreeDir, data.path) - // Guard against path traversal - const resolved = relative(contextTreeDir, fullPath) - if (resolved.startsWith('..') || resolved.startsWith('/')) { - throw new Error('Path traversal not allowed') + try { + // Guard against path traversal + const resolved = relative(contextTreeDir, fullPath) + if (resolved.startsWith('..') || resolved.startsWith('/')) { + throw new Error('Path traversal not allowed') + } + + // Only stat the file when analytics is on — needed for byte_delta. + // Skipping when analyticsClient is undefined keeps this handler's + // disk-I/O profile identical to the original implementation. + const baselineSize = this.analyticsClient + ? await stat(fullPath) + .then((s) => s.size) + .catch(() => 0) + : 0 + + await mkdir(dirname(fullPath), {recursive: true}) + await writeFile(fullPath, data.content, 'utf8') + + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED, { + byte_delta: Buffer.byteLength(data.content, 'utf8') - baselineSize, + file_relative_path_hash: hashProjectPath(data.path), + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + + return {success: true} + } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED, { + failure_kind: classifyUpdateFileFailure(error), + file_relative_path_hash: hashProjectPath(data.path), + outcome: 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + throw error } - - await mkdir(dirname(fullPath), {recursive: true}) - await writeFile(fullPath, data.content, 'utf8') - - return {success: true} } /** Resolves project path from explicit request field or client registration fallback. */ @@ -255,3 +308,13 @@ export class ContextTreeHandler { }) } } + +function classifyUpdateFileFailure(error: unknown): string { + if (error instanceof Error && error.message.includes('traversal')) return 'conflict' + if (error instanceof Error && 'code' in error) { + const code = String((error as {code: unknown}).code) + if (code.startsWith('E')) return 'fs_access' + } + + return 'unknown' +} diff --git a/src/server/infra/transport/handlers/reset-handler.ts b/src/server/infra/transport/handlers/reset-handler.ts index 96c27337e..6c11e2238 100644 --- a/src/server/infra/transport/handlers/reset-handler.ts +++ b/src/server/infra/transport/handlers/reset-handler.ts @@ -1,14 +1,20 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {IContextTreeService} from '../../../core/interfaces/context-tree/i-context-tree-service.js' import type {IContextTreeSnapshotService} from '../../../core/interfaces/context-tree/i-context-tree-snapshot-service.js' import type {ICurateLogStore} from '../../../core/interfaces/storage/i-curate-log-store.js' import type {IReviewBackupStore} from '../../../core/interfaces/storage/i-review-backup-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import {ResetEvents, type ResetExecuteResponse} from '../../../../shared/transport/events/reset-events.js' -import {ContextTreeNotInitializedError} from '../../../core/domain/errors/task-error.js' +import {ContextTreeNotInitializedError, GitVcInitializedError} from '../../../core/domain/errors/task-error.js' +import {processLog} from '../../../utils/process-logger.js' import {guardAgainstGitVc, type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' export interface ResetHandlerDeps { + analyticsClient?: IAnalyticsClient contextTreeService: IContextTreeService contextTreeSnapshotService: IContextTreeSnapshotService curateLogStoreFactory: (projectPath: string) => ICurateLogStore @@ -22,6 +28,7 @@ export interface ResetHandlerDeps { * Deletes and re-initializes the context tree — no terminal/UI calls. */ export class ResetHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly contextTreeService: IContextTreeService private readonly contextTreeSnapshotService: IContextTreeSnapshotService private readonly curateLogStoreFactory: (projectPath: string) => ICurateLogStore @@ -30,6 +37,7 @@ export class ResetHandler { private readonly transport: ITransportServer constructor(deps: ResetHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.contextTreeService = deps.contextTreeService this.contextTreeSnapshotService = deps.contextTreeSnapshotService this.curateLogStoreFactory = deps.curateLogStoreFactory @@ -65,32 +73,74 @@ export class ResetHandler { await Promise.all(updates.map((u) => store.batchUpdateOperationReviewStatus(u.id, u.pendingIndices))) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Reset] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + private async handleExecute(clientId: string): Promise { - const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - await guardAgainstGitVc({contextTreeService: this.contextTreeService, projectPath}) + try { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + await guardAgainstGitVc({contextTreeService: this.contextTreeService, projectPath}) - const exists = await this.contextTreeService.exists(projectPath) - if (!exists) { - throw new ContextTreeNotInitializedError() - } + const exists = await this.contextTreeService.exists(projectPath) + if (!exists) { + throw new ContextTreeNotInitializedError() + } - await this.contextTreeService.delete(projectPath) - await this.contextTreeService.initialize(projectPath) - await this.contextTreeSnapshotService.initEmptySnapshot(projectPath) + await this.contextTreeService.delete(projectPath) + await this.contextTreeService.initialize(projectPath) + await this.contextTreeSnapshotService.initEmptySnapshot(projectPath) - // Best-effort: clear review backups and pending review statuses so /status starts fresh - try { - await this.reviewBackupStoreFactory(projectPath).clear() - } catch { - // Backup cleanup must never block the reset response - } + // Best-effort: clear review backups and pending review statuses so /status starts fresh + try { + await this.reviewBackupStoreFactory(projectPath).clear() + } catch { + // Backup cleanup must never block the reset response + } - try { - await this.clearPendingReviews(projectPath) - } catch { - // Review status cleanup must never block the reset response + try { + await this.clearPendingReviews(projectPath) + } catch { + // Review status cleanup must never block the reset response + } + + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.DAEMON_RESET_EXECUTED, { + outcome: 'success', + reset_scope: 'project', + }) + /* eslint-enable camelcase */ + return {success: true} + } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.DAEMON_RESET_EXECUTED, { + failure_kind: classifyResetFailure(error), + outcome: 'failure', + reset_scope: 'project', + }) + /* eslint-enable camelcase */ + throw error } + } +} - return {success: true} +function classifyResetFailure(error: unknown): string { + if (error instanceof ContextTreeNotInitializedError) return 'not_initialized' + if (error instanceof GitVcInitializedError) return 'git_vc_active' + if (error instanceof Error && 'code' in error) { + const code = String((error as {code: unknown}).code) + if (code.startsWith('E')) return 'fs_access' } + + return 'unknown' } diff --git a/src/server/infra/transport/handlers/review-handler.ts b/src/server/infra/transport/handlers/review-handler.ts index d7e684bbb..fc3201994 100644 --- a/src/server/infra/transport/handlers/review-handler.ts +++ b/src/server/infra/transport/handlers/review-handler.ts @@ -1,11 +1,15 @@ import {mkdir, unlink, writeFile} from 'node:fs/promises' import {dirname, join, relative} from 'node:path' +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ICurateLogStore} from '../../../core/interfaces/storage/i-curate-log-store.js' import type {IProjectConfigStore} from '../../../core/interfaces/storage/i-project-config-store.js' import type {IReviewBackupStore} from '../../../core/interfaces/storage/i-review-backup-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import { type AgentChangeOperation, type ReviewDecideTaskRequest, @@ -20,6 +24,8 @@ import { type ReviewSetDisabledResponse, } from '../../../../shared/transport/events/review-events.js' import {BRV_DIR, CONTEXT_TREE_DIR} from '../../../constants.js' +import {hashProjectPath} from '../../../utils/hash-path.js' +import {processLog} from '../../../utils/process-logger.js' import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' // ── Types ──────────────────────────────────────────────────────────────────── @@ -28,6 +34,7 @@ type CurateLogStoreFactory = (projectPath: string) => ICurateLogStore type ReviewBackupStoreFactory = (projectPath: string) => IReviewBackupStore export interface ReviewHandlerDeps { + analyticsClient?: IAnalyticsClient curateLogStoreFactory: CurateLogStoreFactory /** Called after all pending ops for a task are decided. Used to notify TUI clients. */ onResolved?: (info: {projectPath: string; taskId: string}) => void @@ -41,6 +48,7 @@ type PendingOp = { additionalFilePaths?: string[] logId: string operationIndex: number + type: string } // ── Helpers ────────────────────────────────────────────────────────────────── @@ -66,6 +74,7 @@ function projectContextTreeFilePath(absoluteFilePath: string | undefined, contex * Mirrors the per-file logic in review-api-handler.ts but operates at task scope. */ export class ReviewHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly curateLogStoreFactory: CurateLogStoreFactory private readonly onResolved: ReviewHandlerDeps['onResolved'] private readonly projectConfigStore: IProjectConfigStore @@ -74,6 +83,7 @@ export class ReviewHandler { private readonly transport: ITransportServer constructor(deps: ReviewHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.curateLogStoreFactory = deps.curateLogStoreFactory this.onResolved = deps.onResolved this.projectConfigStore = deps.projectConfigStore @@ -109,11 +119,171 @@ export class ReviewHandler { ) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Review] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + private async handleDecideTask( {decision, filePaths: filterPaths, taskId}: ReviewDecideTaskRequest, clientId: string, ): Promise { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + const decisionEvent = decision === 'approved' ? AnalyticsEventNames.REVIEW_APPROVED : AnalyticsEventNames.REVIEW_REJECTED + + try { + return await this.runDecideTask({decision, filterPaths, projectPath, taskId}) + } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(decisionEvent, { + failure_kind: classifyReviewFailure(error), + operation_kind: 'unknown', + outcome: 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + throw error + } + } + + private async handleGetDisabled(clientId: string): Promise { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + const config = await this.projectConfigStore.read(projectPath) + if (!config) { + throw new Error(`Project not initialized: ${projectPath}. Run \`brv init\` first.`) + } + + return {reviewDisabled: config.reviewDisabled === true} + } + + private async handleListOperations(clientId: string): Promise { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + const config = await this.projectConfigStore.read(projectPath) + if (!config) { + throw new Error(`Project not initialized: ${projectPath}. Run \`brv init\` first.`) + } + + if (config.reviewDisabled === true) return {operations: []} + + const contextTreeDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR) + const store = this.curateLogStoreFactory(projectPath) + const entries = await store.list({limit: 200, status: ['completed']}) + + const operations: AgentChangeOperation[] = [] + for (const entry of entries) { + for (const op of entry.operations) { + if (op.status === 'failed') continue + const filePath = projectContextTreeFilePath(op.filePath, contextTreeDir) + if (!filePath) continue + + const projected: AgentChangeOperation = { + filePath, + opCreatedAt: entry.startedAt, + taskId: entry.taskId, + type: op.type, + } + if (op.impact) projected.impact = op.impact + if (op.reason) projected.reason = op.reason + if (op.summary) projected.summary = op.summary + if (op.reviewStatus) projected.reviewStatus = op.reviewStatus + operations.push(projected) + } + } + + return {operations} + } + + private async handlePending(clientId: string): Promise { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + const contextTreeDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR) + const store = this.curateLogStoreFactory(projectPath) + const entries = await store.list({status: ['completed']}) + + const taskMap = new Map() + + for (const entry of entries) { + for (const op of entry.operations) { + // Skip failed ops (e.g. validation errors) — they were never applied to disk + if (op.reviewStatus !== 'pending' || op.status === 'failed') continue + + let ops = taskMap.get(entry.taskId) + if (!ops) { + ops = [] + taskMap.set(entry.taskId, ops) + } + + const pendingOp: ReviewPendingOperation = {path: op.path, type: op.type} + const filePath = projectContextTreeFilePath(op.filePath, contextTreeDir) + if (filePath) pendingOp.filePath = filePath + + if (op.impact) pendingOp.impact = op.impact + if (op.reason) pendingOp.reason = op.reason + if (op.previousSummary) pendingOp.previousSummary = op.previousSummary + if (op.summary) pendingOp.summary = op.summary + ops.push(pendingOp) + } + } + + const tasks: ReviewPendingTask[] = [...taskMap.entries()].map(([taskId, operations]) => ({ + operations, + taskId, + })) + const pendingCount = tasks.reduce((sum, t) => sum + t.operations.length, 0) + + return {pendingCount, tasks} + } + + private async handleSetDisabled( + {reviewDisabled}: ReviewSetDisabledRequest, + clientId: string, + ): Promise { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + try { + const config = await this.projectConfigStore.read(projectPath) + if (!config) { + throw new Error(`Project not initialized: ${projectPath}. Run \`brv init\` first.`) + } + + const updated = config.withReviewDisabled(reviewDisabled) + await this.projectConfigStore.write(updated, projectPath) + + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.REVIEW_TOGGLED, { + new_state: reviewDisabled ? 'disabled' : 'enabled', + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + + return {reviewDisabled} + } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.REVIEW_TOGGLED, { + failure_kind: classifyReviewFailure(error), + outcome: 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + throw error + } + } + + private async runDecideTask(params: { + decision: 'approved' | 'rejected' + filterPaths?: string[] + projectPath: string + taskId: string + }): Promise { + const {decision, filterPaths, projectPath, taskId} = params const contextTreeDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR) const store = this.curateLogStoreFactory(projectPath) @@ -139,7 +309,7 @@ export class ReviewHandler { pendingByPath.set(rel, ops) } - ops.push({additionalFilePaths: op.additionalFilePaths, logId: entry.id, operationIndex: i}) + ops.push({additionalFilePaths: op.additionalFilePaths, logId: entry.id, operationIndex: i, type: String(op.type)}) } } @@ -221,109 +391,55 @@ export class ReviewHandler { // Best-effort notification — never block the response } - const totalCount = fileResults.reduce((sum, {ops}) => sum + ops.length, 0) - return {files: fileResults.map(({path, reverted}) => ({path, reverted})), totalCount} - } - - private async handleGetDisabled(clientId: string): Promise { - const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - const config = await this.projectConfigStore.read(projectPath) - if (!config) { - throw new Error(`Project not initialized: ${projectPath}. Run \`brv init\` first.`) - } - - return {reviewDisabled: config.reviewDisabled === true} - } - - private async handleListOperations(clientId: string): Promise { - const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - const config = await this.projectConfigStore.read(projectPath) - if (!config) { - throw new Error(`Project not initialized: ${projectPath}. Run \`brv init\` first.`) - } - - if (config.reviewDisabled === true) return {operations: []} - - const contextTreeDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR) - const store = this.curateLogStoreFactory(projectPath) - const entries = await store.list({limit: 200, status: ['completed']}) - - const operations: AgentChangeOperation[] = [] - for (const entry of entries) { - for (const op of entry.operations) { - if (op.status === 'failed') continue - const filePath = projectContextTreeFilePath(op.filePath, contextTreeDir) - if (!filePath) continue - - const projected: AgentChangeOperation = { - filePath, - opCreatedAt: entry.startedAt, - taskId: entry.taskId, - type: op.type, + const decisionEvent = decision === 'approved' ? AnalyticsEventNames.REVIEW_APPROVED : AnalyticsEventNames.REVIEW_REJECTED + if (fileResults.length === 0) { + /* eslint-disable camelcase */ + this.emitAnalytics(decisionEvent, { + failure_kind: 'not_found', + operation_kind: 'unknown', + outcome: 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ + } else { + for (const fileResult of fileResults) { + for (const op of fileResult.ops) { + /* eslint-disable camelcase */ + this.emitAnalytics(decisionEvent, { + operation_kind: op.type.toLowerCase(), + outcome: 'success', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ } - if (op.impact) projected.impact = op.impact - if (op.reason) projected.reason = op.reason - if (op.summary) projected.summary = op.summary - if (op.reviewStatus) projected.reviewStatus = op.reviewStatus - operations.push(projected) } - } - - return {operations} - } - - private async handlePending(clientId: string): Promise { - const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - const contextTreeDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR) - const store = this.curateLogStoreFactory(projectPath) - const entries = await store.list({status: ['completed']}) - - const taskMap = new Map() - - for (const entry of entries) { - for (const op of entry.operations) { - // Skip failed ops (e.g. validation errors) — they were never applied to disk - if (op.reviewStatus !== 'pending' || op.status === 'failed') continue - let ops = taskMap.get(entry.taskId) - if (!ops) { - ops = [] - taskMap.set(entry.taskId, ops) - } - - const pendingOp: ReviewPendingOperation = {path: op.path, type: op.type} - const filePath = projectContextTreeFilePath(op.filePath, contextTreeDir) - if (filePath) pendingOp.filePath = filePath - - if (op.impact) pendingOp.impact = op.impact - if (op.reason) pendingOp.reason = op.reason - if (op.previousSummary) pendingOp.previousSummary = op.previousSummary - if (op.summary) pendingOp.summary = op.summary - ops.push(pendingOp) + // Per-file rejections from Promise.allSettled — emit failure rows + const rejectedCount = settled.filter((r) => r.status === 'rejected').length + for (let i = 0; i < rejectedCount; i++) { + /* eslint-disable camelcase */ + this.emitAnalytics(decisionEvent, { + failure_kind: 'snapshot_missing', + operation_kind: 'unknown', + outcome: 'failure', + project_path_hash: hashProjectPath(projectPath), + }) + /* eslint-enable camelcase */ } } - const tasks: ReviewPendingTask[] = [...taskMap.entries()].map(([taskId, operations]) => ({ - operations, - taskId, - })) - const pendingCount = tasks.reduce((sum, t) => sum + t.operations.length, 0) - - return {pendingCount, tasks} + const totalCount = fileResults.reduce((sum, {ops}) => sum + ops.length, 0) + return {files: fileResults.map(({path, reverted}) => ({path, reverted})), totalCount} } +} - private async handleSetDisabled( - {reviewDisabled}: ReviewSetDisabledRequest, - clientId: string, - ): Promise { - const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - const config = await this.projectConfigStore.read(projectPath) - if (!config) { - throw new Error(`Project not initialized: ${projectPath}. Run \`brv init\` first.`) - } - - const updated = config.withReviewDisabled(reviewDisabled) - await this.projectConfigStore.write(updated, projectPath) - return {reviewDisabled} +function classifyReviewFailure(error: unknown): string { + if (error instanceof Error && error.message.includes('not initialized')) return 'unknown' + if (error instanceof Error && 'code' in error) { + const code = String((error as {code: unknown}).code) + if (code.startsWith('E')) return 'config_write' } + + if (error instanceof Error && /write|ENOENT|EACCES|EPERM|disk/.test(error.message)) return 'config_write' + return 'unknown' } diff --git a/src/server/infra/transport/handlers/settings-handler.ts b/src/server/infra/transport/handlers/settings-handler.ts index d5b6ff197..41a493d15 100644 --- a/src/server/infra/transport/handlers/settings-handler.ts +++ b/src/server/infra/transport/handlers/settings-handler.ts @@ -1,3 +1,5 @@ +import type {AnalyticsEventName} from '../../../../shared/analytics/event-names.js' +import type {PropsArg} from '../../../../shared/analytics/events/index.js' import type { SettingsErrorDTO, SettingsGetRequest, @@ -11,14 +13,18 @@ import type { SettingsSetResponse, } from '../../../../shared/transport/events/settings-events.js' import type {SettingDescriptor, SettingItem} from '../../../core/domain/entities/settings.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ISettingsStore} from '../../../core/interfaces/storage/i-settings-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import {SettingsEvents} from '../../../../shared/transport/events/settings-events.js' import {findSettingDescriptor, SETTINGS_REGISTRY} from '../../../core/domain/entities/settings.js' +import {processLog} from '../../../utils/process-logger.js' import {InvalidSettingValueError, UnknownSettingKeyError} from '../../storage/settings-validator.js' export interface SettingsHandlerDeps { + readonly analyticsClient?: IAnalyticsClient readonly store: ISettingsStore readonly transport: ITransportServer } @@ -30,10 +36,12 @@ export interface SettingsHandlerDeps { * leak across the wire. */ export class SettingsHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly store: ISettingsStore private readonly transport: ITransportServer public constructor(deps: SettingsHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.store = deps.store this.transport = deps.transport } @@ -70,8 +78,25 @@ export class SettingsHandler { async (data) => { try { await this.store.set(data.key, data.value) + const descriptor = findSettingDescriptor(data.key) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { + outcome: 'success', + setting_key: data.key, + value_changed_from_default: descriptor ? data.value !== descriptor.default : undefined, + value_kind: descriptor?.type ?? 'integer', + }) + /* eslint-enable camelcase */ return {ok: true, restartRequired: true} } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { + failure_kind: classifySettingsFailure(error), + outcome: 'failure', + setting_key: data.key, + value_kind: findSettingDescriptor(data.key)?.type ?? 'integer', + }) + /* eslint-enable camelcase */ return {error: errorToDTO(error, data.key, data.value), ok: false} } }, @@ -82,13 +107,53 @@ export class SettingsHandler { async (data) => { try { await this.store.reset(data.key) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_RESET, { + outcome: 'success', + setting_key: data.key, + value_kind: findSettingDescriptor(data.key)?.type ?? 'integer', + }) + /* eslint-enable camelcase */ return {ok: true, restartRequired: true} } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_RESET, { + failure_kind: classifySettingsFailure(error), + outcome: 'failure', + setting_key: data.key, + value_kind: findSettingDescriptor(data.key)?.type ?? 'integer', + }) + /* eslint-enable camelcase */ return {error: errorToDTO(error, data.key), ok: false} } }, ) } + + /** + * Analytics emit helper. Mirrors the try/processLog pattern from other + * handlers so analytics failures never affect command outcomes. + */ + private emitAnalytics(event: E, ...rest: PropsArg): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(event, ...rest) + } catch (error) { + processLog(`[Settings] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } +} + +function classifySettingsFailure(error: unknown): string { + if (error instanceof UnknownSettingKeyError) return 'unknown_key' + if (error instanceof InvalidSettingValueError) return 'validation' + if (error instanceof Error && 'code' in error) { + const code = String((error as {code: unknown}).code) + if (code.startsWith('E')) return 'config_write' + } + + return 'unknown' } function toItemDTO(item: SettingItem): SettingsItemDTO { diff --git a/test/unit/infra/transport/handlers/context-tree-handler-analytics.test.ts b/test/unit/infra/transport/handlers/context-tree-handler-analytics.test.ts new file mode 100644 index 000000000..8200eac53 --- /dev/null +++ b/test/unit/infra/transport/handlers/context-tree-handler-analytics.test.ts @@ -0,0 +1,156 @@ +import {expect} from 'chai' +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {IContextFileReader} from '../../../../../src/server/core/interfaces/context-tree/i-context-file-reader.js' +import type {IContextTreeService} from '../../../../../src/server/core/interfaces/context-tree/i-context-tree-service.js' +import type {IGitService} from '../../../../../src/server/core/interfaces/services/i-git-service.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {ContextTreeHandler} from '../../../../../src/server/infra/transport/handlers/context-tree-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {ContextTreeEvents} from '../../../../../src/shared/transport/events/context-tree-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +const sha256HexRegex = /^[0-9a-f]{64}$/ + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('ContextTreeHandler analytics emits', () => { + let sandbox: SinonSandbox + let requestHandlers: Record + let transport: Stubbed + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + let projectDir: string + let contextTreeDir: string + + beforeEach(() => { + sandbox = createSandbox() + requestHandlers = {} + transport = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + + projectDir = mkdtempSync(join(tmpdir(), 'brv-ct-proj-')) + contextTreeDir = join(projectDir, '.brv', 'context-tree') + + const contextTreeService = { + delete: sandbox.stub().resolves(), + exists: sandbox.stub().resolves(true), + hasGitRepo: sandbox.stub().resolves(false), + initialize: sandbox.stub().resolves(contextTreeDir), + resolvePath: sandbox.stub().returns(contextTreeDir), + } as unknown as IContextTreeService + + const contextFileReader: IContextFileReader = {read: sandbox.stub().resolves()} as never + const gitService = {log: sandbox.stub().resolves([])} as unknown as Pick + + analyticsClient = makeFakeAnalyticsClient() + new ContextTreeHandler({ + analyticsClient, + contextFileReader, + contextTreeService, + gitService, + resolveProjectPath: sandbox.stub().returns(projectDir), + transport, + }).setup() + }) + + afterEach(() => { + sandbox.restore() + rmSync(projectDir, {force: true, recursive: true}) + }) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits context_tree_file_edited outcome=success with byte_delta + hashed paths', async () => { + await requestHandlers[ContextTreeEvents.UPDATE_FILE]({content: 'new-content', path: 'topic.md'}, 'c1') + const calls = emits(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as { + byte_delta?: number + file_relative_path_hash: string + outcome: string + project_path_hash: string + } + expect(props.outcome).to.equal('success') + expect(props.project_path_hash).to.match(sha256HexRegex) + expect(props.file_relative_path_hash).to.match(sha256HexRegex) + expect(props.byte_delta).to.equal(11) + }) + + it('emits context_tree_file_edited outcome=success with negative byte_delta on shrink', async () => { + // Pre-create the file so we have a baseline + const {mkdirSync} = await import('node:fs') + mkdirSync(contextTreeDir, {recursive: true}) + writeFileSync(join(contextTreeDir, 'topic.md'), 'old much longer content here') + + await requestHandlers[ContextTreeEvents.UPDATE_FILE]({content: 'tiny', path: 'topic.md'}, 'c1') + const calls = emits(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED) + const props = calls[0].args[1] as {byte_delta?: number} + expect(props.byte_delta).to.be.lessThan(0) + }) + + it('emits context_tree_file_edited outcome=failure failure_kind=conflict on path traversal', async () => { + try { + await requestHandlers[ContextTreeEvents.UPDATE_FILE]({content: 'x', path: '../../etc/passwd'}, 'c1') + expect.fail('should have thrown') + } catch (error) { + expect((error as Error).message).to.include('traversal') + } + + const calls = emits(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('conflict') + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('regression: raw path never appears in emit (only sha256 hash)', async () => { + const secretPath = 'super-secret-topic-name.md' + await requestHandlers[ContextTreeEvents.UPDATE_FILE]({content: 'x', path: secretPath}, 'c1') + const calls = emits(AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED) + const props = calls[0].args[1] as Record + expect(JSON.stringify(props)).to.not.include('super-secret-topic-name.md') + }) + + it('does NOT emit on read-only events (GET_FILE / GET_NODES / GET_HISTORY)', async () => { + try { + await requestHandlers[ContextTreeEvents.GET_NODES]({}, 'c1') + } catch { + // swallow — readonly handler may error in this stubbed env + } + + expect(analyticsClient.trackSpy.called).to.equal(false) + }) +}) diff --git a/test/unit/infra/transport/handlers/reset-handler-analytics.test.ts b/test/unit/infra/transport/handlers/reset-handler-analytics.test.ts new file mode 100644 index 000000000..774e68d05 --- /dev/null +++ b/test/unit/infra/transport/handlers/reset-handler-analytics.test.ts @@ -0,0 +1,136 @@ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {IContextTreeService} from '../../../../../src/server/core/interfaces/context-tree/i-context-tree-service.js' +import type {IContextTreeSnapshotService} from '../../../../../src/server/core/interfaces/context-tree/i-context-tree-snapshot-service.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {ContextTreeNotInitializedError} from '../../../../../src/server/core/domain/errors/task-error.js' +import {ResetHandler} from '../../../../../src/server/infra/transport/handlers/reset-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {ResetEvents} from '../../../../../src/shared/transport/events/reset-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('ResetHandler analytics emits', () => { + let sandbox: SinonSandbox + let requestHandlers: Record + let transport: Stubbed + let contextTreeService: Stubbed + let contextTreeSnapshotService: Stubbed + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + + beforeEach(() => { + sandbox = createSandbox() + requestHandlers = {} + transport = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + contextTreeService = { + delete: sandbox.stub().resolves(), + exists: sandbox.stub().resolves(true), + hasGitRepo: sandbox.stub().resolves(false), + initialize: sandbox.stub().resolves('/proj/.brv/context-tree'), + resolvePath: sandbox.stub().returns('/proj/.brv/context-tree'), + } + contextTreeSnapshotService = { + getChanges: sandbox.stub(), + getCurrentState: sandbox.stub(), + getSnapshotState: sandbox.stub(), + hasSnapshot: sandbox.stub(), + initEmptySnapshot: sandbox.stub().resolves(), + saveSnapshot: sandbox.stub(), + saveSnapshotFromState: sandbox.stub(), + } + analyticsClient = makeFakeAnalyticsClient() + new ResetHandler({ + analyticsClient, + contextTreeService, + contextTreeSnapshotService, + curateLogStoreFactory: () => ({ + batchUpdateOperationReviewStatus: sandbox.stub().resolves(), + list: sandbox.stub().resolves([]), + }) as never, + resolveProjectPath: sandbox.stub().returns('/proj') as never, + reviewBackupStoreFactory: () => ({clear: sandbox.stub().resolves()}) as never, + transport, + }).setup() + }) + + afterEach(() => sandbox.restore()) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits daemon_reset_executed outcome=success with reset_scope=project', async () => { + await requestHandlers[ResetEvents.EXECUTE](undefined, 'c1') + const calls = emits(AnalyticsEventNames.DAEMON_RESET_EXECUTED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; reset_scope: string} + expect(props.outcome).to.equal('success') + expect(props.reset_scope).to.equal('project') + }) + + it('emits daemon_reset_executed outcome=failure failure_kind=not_initialized when context tree absent', async () => { + contextTreeService.exists.resolves(false) + try { + await requestHandlers[ResetEvents.EXECUTE](undefined, 'c1') + expect.fail('should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(ContextTreeNotInitializedError) + } + + const calls = emits(AnalyticsEventNames.DAEMON_RESET_EXECUTED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('not_initialized') + }) + + it('is a no-op when analyticsClient is not injected', async () => { + const local: Record = {} + const tLocal = {...transport, onRequest: sandbox.stub().callsFake((e: string, h: RequestHandler) => { + local[e] = h + })} as never + new ResetHandler({ + contextTreeService, + contextTreeSnapshotService, + curateLogStoreFactory: () => ({ + batchUpdateOperationReviewStatus: sandbox.stub().resolves(), + list: sandbox.stub().resolves([]), + }) as never, + resolveProjectPath: sandbox.stub().returns('/proj') as never, + reviewBackupStoreFactory: () => ({clear: sandbox.stub().resolves()}) as never, + transport: tLocal, + }).setup() + await local[ResetEvents.EXECUTE](undefined, 'c1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) +}) diff --git a/test/unit/infra/transport/handlers/review-handler-analytics.test.ts b/test/unit/infra/transport/handlers/review-handler-analytics.test.ts new file mode 100644 index 000000000..45dc4a9c2 --- /dev/null +++ b/test/unit/infra/transport/handlers/review-handler-analytics.test.ts @@ -0,0 +1,211 @@ +import {expect} from 'chai' +import {mkdtempSync, rmSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {BRV_CONFIG_VERSION} from '../../../../../src/server/constants.js' +import {BrvConfig} from '../../../../../src/server/core/domain/entities/brv-config.js' +import {ReviewHandler} from '../../../../../src/server/infra/transport/handlers/review-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {ReviewEvents} from '../../../../../src/shared/transport/events/review-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +const sha256HexRegex = /^[0-9a-f]{64}$/ + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +function makeConfig(): BrvConfig { + return new BrvConfig({ + createdAt: '2024-01-01T00:00:00.000Z', + cwd: '/proj', + version: BRV_CONFIG_VERSION, + }) +} + +describe('ReviewHandler analytics emits', () => { + let sandbox: SinonSandbox + let requestHandlers: Record + let transport: Stubbed + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + let projectDir: string + let projectConfigStore: {exists: SinonStub; getModifiedTime: SinonStub; read: SinonStub; write: SinonStub} + + beforeEach(() => { + sandbox = createSandbox() + requestHandlers = {} + transport = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + + projectDir = mkdtempSync(join(tmpdir(), 'brv-review-proj-')) + + projectConfigStore = { + exists: sandbox.stub().resolves(true), + getModifiedTime: sandbox.stub().resolves(), + read: sandbox.stub().resolves(makeConfig()), + write: sandbox.stub().resolves(), + } + + analyticsClient = makeFakeAnalyticsClient() + }) + + afterEach(() => { + sandbox.restore() + rmSync(projectDir, {force: true, recursive: true}) + }) + + function makeHandler(opts: {curateLog?: never[]; injectClient?: boolean} = {}): void { + const entries = opts.curateLog ?? [] + new ReviewHandler({ + analyticsClient: opts.injectClient === false ? undefined : analyticsClient, + curateLogStoreFactory: () => ({ + batchUpdateOperationReviewStatus: sandbox.stub().resolves(), + list: sandbox.stub().resolves(entries), + }) as never, + projectConfigStore: projectConfigStore as never, + resolveProjectPath: sandbox.stub().returns(projectDir) as never, + reviewBackupStoreFactory: () => ({ + clear: sandbox.stub().resolves(), + delete: sandbox.stub().resolves(), + read: sandbox.stub().resolves(null), + }) as never, + transport, + }).setup() + } + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits review_toggled outcome=success with new_state=disabled on disable', async () => { + makeHandler() + await requestHandlers[ReviewEvents.SET_DISABLED]({reviewDisabled: true}, 'c1') + const calls = emits(AnalyticsEventNames.REVIEW_TOGGLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {new_state?: string; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.new_state).to.equal('disabled') + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits review_toggled new_state=enabled on enable', async () => { + makeHandler() + await requestHandlers[ReviewEvents.SET_DISABLED]({reviewDisabled: false}, 'c1') + const calls = emits(AnalyticsEventNames.REVIEW_TOGGLED) + const props = calls[0].args[1] as {new_state?: string} + expect(props.new_state).to.equal('enabled') + }) + + it('emits review_toggled outcome=failure failure_kind=config_write on write failure', async () => { + projectConfigStore.write.rejects(new Error('disk full')) + makeHandler() + try { + await requestHandlers[ReviewEvents.SET_DISABLED]({reviewDisabled: true}, 'c1') + expect.fail('should throw') + } catch { + // expected + } + + const calls = emits(AnalyticsEventNames.REVIEW_TOGGLED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('config_write') + }) + + it('emits review_approved per file with operation_kind on approve', async () => { + const contextTreeDir = join(projectDir, '.brv', 'context-tree') + const fakeEntry = { + id: 'log-1', + operations: [ + { + filePath: join(contextTreeDir, 'topic-a.md'), + path: 'topic-a', + reviewStatus: 'pending', + status: 'completed', + type: 'ADD', + }, + ], + startedAt: 1, + status: 'completed', + taskId: 't1', + } + makeHandler({curateLog: [fakeEntry] as never}) + await requestHandlers[ReviewEvents.DECIDE_TASK]({decision: 'approved', taskId: 't1'}, 'c1') + const calls = emits(AnalyticsEventNames.REVIEW_APPROVED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {operation_kind: string; outcome: string; project_path_hash: string} + expect(props.outcome).to.equal('success') + expect(props.operation_kind).to.equal('add') + expect(props.project_path_hash).to.match(sha256HexRegex) + }) + + it('emits review_rejected per file with operation_kind on reject', async () => { + const contextTreeDir = join(projectDir, '.brv', 'context-tree') + const fakeEntry = { + id: 'log-1', + operations: [ + { + filePath: join(contextTreeDir, 'topic-a.md'), + path: 'topic-a', + reviewStatus: 'pending', + status: 'completed', + type: 'DELETE', + }, + ], + startedAt: 1, + status: 'completed', + taskId: 't1', + } + makeHandler({curateLog: [fakeEntry] as never}) + await requestHandlers[ReviewEvents.DECIDE_TASK]({decision: 'rejected', taskId: 't1'}, 'c1') + const calls = emits(AnalyticsEventNames.REVIEW_REJECTED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {operation_kind: string; outcome: string} + expect(props.operation_kind).to.equal('delete') + }) + + it('emits review_approved outcome=failure failure_kind=not_found when taskId has no pending ops', async () => { + makeHandler({curateLog: [] as never}) + await requestHandlers[ReviewEvents.DECIDE_TASK]({decision: 'approved', taskId: 'nope'}, 'c1') + const calls = emits(AnalyticsEventNames.REVIEW_APPROVED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('not_found') + }) + + it('is a no-op when analyticsClient is not injected', async () => { + makeHandler({injectClient: false}) + await requestHandlers[ReviewEvents.SET_DISABLED]({reviewDisabled: true}, 'c1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) +}) diff --git a/test/unit/infra/transport/handlers/settings-handler-analytics.test.ts b/test/unit/infra/transport/handlers/settings-handler-analytics.test.ts new file mode 100644 index 000000000..9f6909669 --- /dev/null +++ b/test/unit/infra/transport/handlers/settings-handler-analytics.test.ts @@ -0,0 +1,151 @@ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {ISettingsStore} from '../../../../../src/server/core/interfaces/storage/i-settings-store.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {SETTINGS_KEYS} from '../../../../../src/server/core/domain/entities/settings.js' +import {InvalidSettingValueError, UnknownSettingKeyError} from '../../../../../src/server/infra/storage/settings-validator.js' +import {SettingsHandler} from '../../../../../src/server/infra/transport/handlers/settings-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {SettingsEvents} from '../../../../../src/shared/transport/events/settings-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('SettingsHandler analytics emits', () => { + let sandbox: SinonSandbox + let requestHandlers: Record + let transport: Stubbed + let store: Stubbed + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + + beforeEach(() => { + sandbox = createSandbox() + requestHandlers = {} + transport = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub(), + isRunning: sandbox.stub(), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + store = { + get: sandbox.stub(), + list: sandbox.stub().resolves([]), + readStartupSnapshot: sandbox.stub().resolves({}), + reset: sandbox.stub().resolves(), + set: sandbox.stub().resolves(), + } + analyticsClient = makeFakeAnalyticsClient() + new SettingsHandler({analyticsClient, store, transport}).setup() + }) + + afterEach(() => sandbox.restore()) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits setting_changed outcome=success with value_kind + value_changed_from_default', async () => { + const handler = requestHandlers[SettingsEvents.SET] + await handler({key: SETTINGS_KEYS.AGENT_POOL_MAX_SIZE, value: 42}, 'c1') + const calls = emits(AnalyticsEventNames.SETTING_CHANGED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as { + outcome: string + setting_key: string + value_changed_from_default?: boolean + value_kind: string + } + expect(props.outcome).to.equal('success') + expect(props.setting_key).to.equal(SETTINGS_KEYS.AGENT_POOL_MAX_SIZE) + expect(props.value_kind).to.equal('integer') + expect(props.value_changed_from_default).to.equal(true) + }) + + it('emits setting_changed outcome=failure failure_kind=unknown_key on UnknownSettingKeyError', async () => { + store.set.rejects(new UnknownSettingKeyError('bogus.key')) + const handler = requestHandlers[SettingsEvents.SET] + await handler({key: 'bogus.key', value: 1}, 'c1') + const calls = emits(AnalyticsEventNames.SETTING_CHANGED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string; setting_key: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('unknown_key') + expect(props.setting_key).to.equal('bogus.key') + }) + + it('emits setting_changed outcome=failure failure_kind=validation on InvalidSettingValueError', async () => { + store.set.rejects(new InvalidSettingValueError(SETTINGS_KEYS.AGENT_POOL_MAX_SIZE, 9999, 'too big')) + const handler = requestHandlers[SettingsEvents.SET] + await handler({key: SETTINGS_KEYS.AGENT_POOL_MAX_SIZE, value: 9999}, 'c1') + const calls = emits(AnalyticsEventNames.SETTING_CHANGED) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('validation') + }) + + it('emits setting_reset outcome=success', async () => { + const handler = requestHandlers[SettingsEvents.RESET] + await handler({key: SETTINGS_KEYS.AGENT_POOL_MAX_SIZE}, 'c1') + const calls = emits(AnalyticsEventNames.SETTING_RESET) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {outcome: string; setting_key: string; value_kind: string} + expect(props.outcome).to.equal('success') + expect(props.value_kind).to.equal('integer') + }) + + it('emits setting_reset outcome=failure failure_kind=unknown_key on UnknownSettingKeyError', async () => { + store.reset.rejects(new UnknownSettingKeyError('bogus.key')) + const handler = requestHandlers[SettingsEvents.RESET] + await handler({key: 'bogus.key'}, 'c1') + const calls = emits(AnalyticsEventNames.SETTING_RESET) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('unknown_key') + }) + + it('regression: setting_changed payload never includes raw value or message', async () => { + const secretValue = 'super-secret-string-leak-check' as unknown as number + store.set.rejects(new Error('boom: super-secret-string-leak-check')) + const handler = requestHandlers[SettingsEvents.SET] + await handler({key: SETTINGS_KEYS.AGENT_POOL_MAX_SIZE, value: secretValue}, 'c1') + const calls = emits(AnalyticsEventNames.SETTING_CHANGED) + const props = calls[0].args[1] as Record + const json = JSON.stringify(props) + expect(json).to.not.include('super-secret-string-leak-check') + expect(json).to.not.include('boom:') + }) + + it('is a no-op when analyticsClient is not injected', async () => { + const local: Record = {} + const transportLocal = {...transport, onRequest: sandbox.stub().callsFake((e: string, h: RequestHandler) => { + local[e] = h + })} as never + new SettingsHandler({store, transport: transportLocal}).setup() + await local[SettingsEvents.SET]({key: SETTINGS_KEYS.AGENT_POOL_MAX_SIZE, value: 1}, 'c1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) +}) From 8061c94e2f90eb4c4d00c50184e5285a23cd03df Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Wed, 27 May 2026 21:09:23 +0700 Subject: [PATCH 58/87] feat: [ENG-2963] M15.5 WebUI session lifecycle emits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit webui_session_started when a browser dashboard connects via Socket.IO, webui_session_ended when it disconnects. Two events let downstream analytics distinguish 'user ran brv webui' (M13's cli_invocation) from 'a browser actually attached and used the dashboard'. ClientManager gains an optional setAnalyticsClient setter (deferred injection because ClientManager is built in brv-server before analyticsClient exists inside setupFeatureHandlers). When set, register fires webui_session_started for type='webui' clients and unregister fires webui_session_ended with started_at_unix_ms (the existing ClientInfo.connectedAt — no new field needed) plus session_duration_ms (clamped at 0 to defend against clock skew). Both emits are wrapped in clientKindContext.run({client_kind: 'webui'}) so SuperPropertiesResolver stamps client_kind on the envelope. The emits originate inside the daemon, bypassing the transport layer's own clientKindContext wrap — explicit wrap at the emit site is required. Reconnect-edge: when the same clientId re-registers and the prior client was webui, emit ended for the OLD client before constructing the new ClientInfo so analytics doesn't orphan an unmatched started. register/unregister bookkeeping behavior is otherwise unchanged (clientConnectedCallback gate, projectClients index updates, all preserved verbatim). 11 new analytics tests including: register/unregister payloads, started_at_unix_ms = connectedAt invariant, non-webui regression, client_kind in scope (both sync and across-await), reconnect emits ended-for-OLD + started-for-NEW, no-op when client absent, throw isolation, clock-skew clamp, FORBIDDEN_FIELD_NAMES regression. Full suite green (9177 passing, +11 from baseline). --- src/server/infra/client/client-manager.ts | 90 ++++++++ src/server/infra/daemon/brv-server.ts | 1 + src/server/infra/process/feature-handlers.ts | 13 ++ .../client/client-manager-analytics.test.ts | 193 ++++++++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 test/unit/infra/client/client-manager-analytics.test.ts diff --git a/src/server/infra/client/client-manager.ts b/src/server/infra/client/client-manager.ts index 42008fb5b..19245b54a 100644 --- a/src/server/infra/client/client-manager.ts +++ b/src/server/infra/client/client-manager.ts @@ -14,11 +14,18 @@ */ import type {ClientType} from '../../core/domain/client/client-info.js' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' import type {IClientManager, ProjectEmptyCallback} from '../../core/interfaces/client/i-client-manager.js' +import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' import {ClientInfo} from '../../core/domain/client/client-info.js' +import {hashProjectPath} from '../../utils/hash-path.js' +import {processLog} from '../../utils/process-logger.js' +import {clientKindContext} from '../transport/client-kind-context.js' export class ClientManager implements IClientManager { + /** Optional analytics client for M15.5 WebUI session events */ + private analyticsClient: IAnalyticsClient | undefined /** Callback for when a client registers */ private clientConnectedCallback?: () => void /** Callback for when a client unregisters */ @@ -87,6 +94,12 @@ export class ClientManager implements IClientManager { this.removeFromProjectIndex(clientId, existing.projectPath) } + // M15.5: on reconnect of a webui client, close out the prior session so + // analytics doesn't orphan an unmatched started event. + if (existing?.type === 'webui') { + this.emitWebuiSessionEnded(existing) + } + const client = new ClientInfo({ connectedAt: Date.now(), id: clientId, @@ -99,6 +112,12 @@ export class ClientManager implements IClientManager { this.addToProjectIndex(clientId, projectPath) } + // M15.5: WebUI session lifecycle. Fires AFTER `clients.set` so any + // analytics-side hook can still look the client up by id. + if (type === 'webui') { + this.emitWebuiSessionStarted(client) + } + // Only notify idle timeout policy for new clients, not re-registrations. // Re-registrations replace the existing entry without unregister, so firing // clientConnectedCallback again would desync IdleTimeoutPolicy.clientCount. @@ -114,10 +133,25 @@ export class ClientManager implements IClientManager { client.setAgentName(agentName) } + /** + * M15.5: register the analytics client. Setter pattern because + * ClientManager is constructed in brv-server.ts before analyticsClient + * exists (which is built inside setupFeatureHandlers). + */ + setAnalyticsClient(client: IAnalyticsClient): void { + this.analyticsClient = client + } + unregister(clientId: string): void { const client = this.clients.get(clientId) if (!client) return + // M15.5: emit BEFORE clients.delete so we can still read client.type / + // .connectedAt / .projectPath. + if (client.type === 'webui') { + this.emitWebuiSessionEnded(client) + } + this.clients.delete(clientId) if (client.projectPath) { @@ -178,6 +212,62 @@ export class ClientManager implements IClientManager { } } + /** + * M15.5: emit webui_session_ended. Wrapped in clientKindContext so + * SuperPropertiesResolver stamps client_kind='webui' on the envelope + * (daemon-internal emit bypasses the transport wrap). try/processLog + * pattern so analytics failures never block connection bookkeeping. + */ + private emitWebuiSessionEnded(client: ClientInfo): void { + const {analyticsClient} = this + if (!analyticsClient) return + // Clamp at 0 to defend against clock skew (e.g. NTP adjustment between + // register and unregister). The schema enforces `nonnegative()`; a + // negative value would otherwise leak through this direct-track path + // (which bypasses the wire-side safeParse in AnalyticsHandler). + const sessionDurationMs = Math.max(0, Date.now() - client.connectedAt) + // eslint-disable-next-line camelcase + clientKindContext.run({client_kind: 'webui'}, () => { + try { + /* eslint-disable camelcase */ + analyticsClient.track(AnalyticsEventNames.WEBUI_SESSION_ENDED, { + ...(client.projectPath === undefined ? {} : {project_path_hash: hashProjectPath(client.projectPath)}), + session_duration_ms: sessionDurationMs, + started_at_unix_ms: client.connectedAt, + }) + /* eslint-enable camelcase */ + } catch (error) { + processLog( + `[ClientManager] analytics track webui_session_ended failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }) + } + + /** + * M15.5: emit webui_session_started. See emitWebuiSessionEnded for the + * clientKindContext rationale. + */ + private emitWebuiSessionStarted(client: ClientInfo): void { + const {analyticsClient} = this + if (!analyticsClient) return + // eslint-disable-next-line camelcase + clientKindContext.run({client_kind: 'webui'}, () => { + try { + /* eslint-disable camelcase */ + analyticsClient.track(AnalyticsEventNames.WEBUI_SESSION_STARTED, { + ...(client.projectPath === undefined ? {} : {project_path_hash: hashProjectPath(client.projectPath)}), + started_at_unix_ms: client.connectedAt, + }) + /* eslint-enable camelcase */ + } catch (error) { + processLog( + `[ClientManager] analytics track webui_session_started failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }) + } + private removeFromProjectIndex(clientId: string, projectPath: string): void { const members = this.projectClients.get(projectPath) if (!members) return diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index daab7fe3c..aaea99837 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -648,6 +648,7 @@ async function main(): Promise { broadcastToProject(projectPath, event, data) { broadcastToProjectRoom(projectRegistry, projectRouter, projectPath, event, data) }, + clientManager, getActiveProjectPaths: () => clientManager.getActiveProjects(), log, projectRegistry, diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index fa8afa57d..f1fee4f94 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -19,6 +19,7 @@ import type {IBillingConfigStore} from '../../core/interfaces/storage/i-billing- import type {ISettingsStore} from '../../core/interfaces/storage/i-settings-store.js' import type {ITransportServer} from '../../core/interfaces/transport/i-transport-server.js' import type {AnalyticsFlushScheduler} from '../analytics/analytics-flush-scheduler.js' +import type {ClientManager} from '../client/client-manager.js' import type {ProjectBroadcaster, ProjectPathResolver} from '../transport/handlers/handler-types.js' import {ReviewEvents} from '../../../shared/transport/events/review-events.js' @@ -102,6 +103,13 @@ export interface FeatureHandlersOptions { authStateStore: IAuthStateStore billingConfigStoreFactory: (projectPath: string) => IBillingConfigStore broadcastToProject: ProjectBroadcaster + /** + * M15.5: optional ClientManager. When provided, setupFeatureHandlers + * wires the analyticsClient into it so WebUI session lifecycle events + * (`webui_session_started` / `webui_session_ended`) can fire from + * register/unregister. + */ + clientManager?: ClientManager getActiveProjectPaths: () => string[] log: (msg: string) => void projectRegistry: IProjectRegistry @@ -147,6 +155,7 @@ export async function setupFeatureHandlers({ authStateStore, billingConfigStoreFactory, broadcastToProject, + clientManager, getActiveProjectPaths, log, projectRegistry, @@ -269,6 +278,10 @@ export async function setupFeatureHandlers({ // `abort()` to cancel any in-flight HTTP. globalConfigHandler.setAnalyticsClient(analyticsClient) + // M15.5: hook the analytics client into ClientManager so register/unregister + // can fire webui_session_started / webui_session_ended for browser sessions. + clientManager?.setAnalyticsClient(analyticsClient) + // M2.6: route incoming analytics:track events from non-forked clients // (TUI, oclif, MCP, webui) to the same singleton. new AnalyticsHandler({analyticsClient, transport}).setup() diff --git a/test/unit/infra/client/client-manager-analytics.test.ts b/test/unit/infra/client/client-manager-analytics.test.ts new file mode 100644 index 000000000..df156e655 --- /dev/null +++ b/test/unit/infra/client/client-manager-analytics.test.ts @@ -0,0 +1,193 @@ + + +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub, useFakeTimers} from 'sinon' + +import type {IAnalyticsClient} from '../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {ClientManager} from '../../../../src/server/infra/client/client-manager.js' +import {getClientKindFromContext} from '../../../../src/server/infra/transport/client-kind-context.js' +import {AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' +import {FORBIDDEN_FIELD_NAMES} from '../../../../src/shared/analytics/forbidden-field-names.js' + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('ClientManager WebUI session analytics emits', () => { + let sandbox: SinonSandbox + let manager: ClientManager + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + + beforeEach(() => { + sandbox = createSandbox() + analyticsClient = makeFakeAnalyticsClient() + manager = new ClientManager() + manager.setAnalyticsClient(analyticsClient) + }) + + afterEach(() => sandbox.restore()) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('emits webui_session_started with started_at_unix_ms = client.connectedAt on webui register', () => { + const before = Date.now() + manager.register('sock-1', 'webui', '/proj/a') + const after = Date.now() + + const calls = emits(AnalyticsEventNames.WEBUI_SESSION_STARTED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {project_path_hash?: string; started_at_unix_ms: number} + expect(props.started_at_unix_ms).to.be.at.least(before) + expect(props.started_at_unix_ms).to.be.at.most(after) + expect(props.project_path_hash).to.match(/^[0-9a-f]{64}$/) + // client.connectedAt MUST equal the emitted started_at_unix_ms (join key) + expect(props.started_at_unix_ms).to.equal(manager.getClient('sock-1')!.connectedAt) + }) + + it('emits webui_session_started WITHOUT project_path_hash when no projectPath', () => { + manager.register('sock-1', 'webui') + const calls = emits(AnalyticsEventNames.WEBUI_SESSION_STARTED) + const props = calls[0].args[1] as {project_path_hash?: string; started_at_unix_ms: number} + expect(props.project_path_hash).to.equal(undefined) + }) + + it('emits webui_session_ended with started_at_unix_ms + session_duration_ms on webui unregister', () => { + const clock = useFakeTimers(1_000_000_000) + try { + manager.register('sock-1', 'webui', '/proj/a') + const started = manager.getClient('sock-1')!.connectedAt + clock.tick(7500) + manager.unregister('sock-1') + + const calls = emits(AnalyticsEventNames.WEBUI_SESSION_ENDED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as { + project_path_hash?: string + session_duration_ms: number + started_at_unix_ms: number + } + expect(props.started_at_unix_ms).to.equal(started) + expect(props.session_duration_ms).to.equal(7500) + expect(props.project_path_hash).to.match(/^[0-9a-f]{64}$/) + } finally { + clock.restore() + } + }) + + it('does NOT emit either event for non-webui types (cli/tui/mcp/extension/agent)', () => { + const types: Array<'agent' | 'cli' | 'extension' | 'mcp' | 'tui'> = ['agent', 'cli', 'extension', 'mcp', 'tui'] + for (const [i, t] of types.entries()) { + manager.register(`sock-${i}`, t, t === 'mcp' ? undefined : `/proj/${i}`) + manager.unregister(`sock-${i}`) + } + + expect(emits(AnalyticsEventNames.WEBUI_SESSION_STARTED).length).to.equal(0) + expect(emits(AnalyticsEventNames.WEBUI_SESSION_ENDED).length).to.equal(0) + }) + + it('emit fires inside clientKindContext.run({client_kind: webui}) wrap', () => { + let observed: string | undefined + analyticsClient.trackSpy.callsFake(() => { + observed = getClientKindFromContext() + }) + manager.register('sock-1', 'webui', '/proj/a') + expect(observed).to.equal('webui') + }) + + it('reconnect: emits ended for OLD client + started for NEW client when same id re-registers as webui', () => { + const clock = useFakeTimers(1_000_000_000) + try { + manager.register('sock-1', 'webui', '/proj/a') + const firstConnectedAt = manager.getClient('sock-1')!.connectedAt + clock.tick(2000) + + manager.register('sock-1', 'webui', '/proj/b') + + const endedCalls = emits(AnalyticsEventNames.WEBUI_SESSION_ENDED) + expect(endedCalls.length).to.equal(1) + const endedProps = endedCalls[0].args[1] as {session_duration_ms: number; started_at_unix_ms: number} + expect(endedProps.started_at_unix_ms).to.equal(firstConnectedAt) + expect(endedProps.session_duration_ms).to.equal(2000) + + const startedCalls = emits(AnalyticsEventNames.WEBUI_SESSION_STARTED) + expect(startedCalls.length).to.equal(2) + } finally { + clock.restore() + } + }) + + it('is a no-op when analyticsClient is not injected', () => { + const m = new ClientManager() + m.register('sock-1', 'webui', '/proj/a') + m.unregister('sock-1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) + + it('analytics track throwing does NOT escape register/unregister', () => { + analyticsClient.trackSpy.throws(new Error('analytics down')) + expect(() => manager.register('sock-1', 'webui', '/proj/a')).to.not.throw() + expect(() => manager.unregister('sock-1')).to.not.throw() + }) + + it('clamps session_duration_ms at 0 when clock skews backward between register and unregister', () => { + // Simulate NTP correction: register at t=1000, unregister at t=500 (earlier) + const dateNowStub = sandbox.stub(Date, 'now') + dateNowStub.onFirstCall().returns(1000) + dateNowStub.onSecondCall().returns(500) + manager.register('sock-1', 'webui', '/proj/a') + manager.unregister('sock-1') + + const calls = emits(AnalyticsEventNames.WEBUI_SESSION_ENDED) + const props = calls[0].args[1] as {session_duration_ms: number} + expect(props.session_duration_ms).to.equal(0) + }) + + it('context propagates across async resolver boundary (production path simulation)', async () => { + // Simulate production flow where track() returns sync but the resolver + // reads getClientKindFromContext() AFTER an await — matching + // super-properties-resolver.ts. Verifies AsyncLocalStorage propagation + // across the microtask queue, not just sync-immediate reads. + let observed: string | undefined + const observedPromise = new Promise((resolve) => { + analyticsClient.trackSpy.callsFake(() => { + // mimic AnalyticsClient.track → trackAsync → await resolver.resolve() + Promise.resolve() + .then(async () => 0) + .then(() => { + observed = getClientKindFromContext() + resolve() + }) + .catch(() => resolve()) + }) + }) + manager.register('sock-1', 'webui', '/proj/a') + await observedPromise + expect(observed).to.equal('webui') + }) + + it('regression: neither emit payload includes any FORBIDDEN_FIELD_NAMES key', () => { + manager.register('sock-1', 'webui', '/proj/a') + manager.unregister('sock-1') + const allEmits = [ + ...emits(AnalyticsEventNames.WEBUI_SESSION_STARTED), + ...emits(AnalyticsEventNames.WEBUI_SESSION_ENDED), + ] + for (const call of allEmits) { + const props = call.args[1] as Record + for (const key of Object.keys(props)) { + expect(FORBIDDEN_FIELD_NAMES, `field ${key} must not be forbidden`).to.not.include(key) + } + } + }) +}) From f7c691bf906922d038d5c7306d37fe25e00eb5cf Mon Sep 17 00:00:00 2001 From: cuongdo-byterover Date: Wed, 27 May 2026 21:34:29 +0700 Subject: [PATCH 59/87] Feat/eng 2964 (#722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: [ENG-2969] M14.1 extend TASK_TYPE_VALUES with v4.0 tool-mode task types Adds curate-tool-mode, query-tool-mode, dream-scan, dream-finalize to TaskTypes + TASK_TYPE_VALUES. After ENG-2925's rename the daemon dispatches these four types but the analytics enum still predates v4.0 — task_created / task_completed / task_failed silently rejected every tool-mode emit at the Zod boundary. - TaskTypes keeps the legacy 'curate' / 'query' / 'dream' values for back-compat with any constructor still building those payloads - per-event schemas (task_created / task_completed / task_failed) pick up the new types automatically via z.enum(TASK_TYPE_VALUES) - M12 per-flavor schemas (curate_run_completed / query_completed) still hardcode their own literals and continue to reject tool-mode types here — M14.2 migrates them to the canonical enum as a follow-up TDD: - new task-types tests assert TaskCreated/Completed/Failed accept all four new types - regression tests pin the M12 schemas' continued rejection so M14.2 has a clear flip point * fix: [ENG-2970] M14.2 relax curate_run_completed + query_completed task_type to TASK_TYPE_VALUES Migrates the two M12 per-flavor schemas from hardcoded literal task_type values to the canonical TASK_TYPE_VALUES enum so v4.0 tool-mode tasks round-trip the wire boundary instead of being silently Zod-rejected inside AnalyticsHook. - curate_run_completed: z.enum(['curate', 'curate-folder']) → z.enum(TASK_TYPE_VALUES). curate-tool-mode payloads now validate. - query_completed: z.literal('query') → z.enum(TASK_TYPE_VALUES). query-tool-mode payloads now validate. The schemas no longer structurally constrain task_type to the curate or query family; the hook is trusted to only emit each event for the right flavor. Docblocks call out the widening for the next maintainer. TDD: - curate-run-completed.test asserts curate-tool-mode + legacy values both succeed; an unknown task_type still rejects - query-completed.test mirrors the same coverage for query-tool-mode - task-types.test M14.1 regression assertions flipped from rejection to acceptance for the M12 schemas; M14.1 docblock comments updated Without this, M14.3's hook code would land but the M12 emits that fire alongside the new generic task_* emits would silently disappear on every tool-mode task — that's the bug operators noticed in Mixpanel. * feat: [ENG-2971] M14.3 wire task_created + task_completed + task_failed in AnalyticsHook Adds the three generic funnel-event emits described in M14's customer ask. Every daemon task (curate / curate-folder / curate-tool-mode (aliased from curate-html-direct) / query / query-tool-mode / dream-scan / dream-finalize / search) now produces, in order: task_created onTaskCreate (funnel entry — unconditional) ...optional per-op + M12 per-flavor terminal emit task_completed onTaskCompleted (terminal-event-last on success) task_failed onTaskError / onTaskCancelled (terminal-event-last) Three coupled changes shipped together so the wire stays consistent: 1. analytics-hook.ts grows three emits and a `toAnalyticsTaskType` alias-translator. The daemon still dispatches the pre-ENG-2925 name `curate-html-direct`; analytics canonicalises it to the post-rename `curate-tool-mode` so the wire enum matches TASK_TYPE_VALUES. Once ENG-2925 lands, the alias becomes a no-op identity. 2. CURATE_TASK_TYPES + QUERY_TASK_TYPES gain the tool-mode names so M12 per-flavor state init kicks in for tool-mode curates / queries. M12 counters stay all-zero on tool-mode today (no LLM tool calls fire) — that's a separate follow-up (FU-1 in plans/analytics-m14/follow-ups). 3. Both M12 payload builders route `task_type` through the same alias so curate-tool-mode tasks emit `task_type='curate-tool-mode'` on the curate_run_completed / query_completed events too. TDD: - new analytics-hook-m14.test.ts: 15 simulation tests covering every task type, the curate-html-direct → curate-tool-mode alias, has_files / has_folder semantics, both terminal paths, and the ordering invariant - existing analytics-hook.test.ts updated to filter out the new generic emits via `filterM12()` so M12-focused assertions keep their intent - integration stress tests updated for the new 13-event sequence (task_created → ops → curate_run_completed → task_completed) - repo full suite: 9121 passing, 0 failing * refactor: [ENG-2971] M14.3 review fix — relative paths, required keywords/tags, structured related_paths Tightens the curate / query M12 payloads per code review: curate_operation_applied: - rename absolute_path → relative_path (project-relative via path.relative against the task's projectPath; falls back to identity when projectPath is unset so search tasks at the daemon root still emit a usable string) - keywords / tags promoted from optional to required arrays (default []) so the wire shape stays uniform regardless of frontmatter read success query_completed.read_paths_with_metadata: - same absolute_path → relative_path rename - same keywords / tags promotion (required arrays, default []) - flat related: string[] → structured related_paths: [{relative_path, keywords, tags}] so each linked topic carries its own metadata slot. Keywords/tags default to [] until a future FU cascade-reads each linked file's frontmatter — the wire shape doesn't need to change when that lands. Hook implementation: - new `toRelativePath(filePath, projectPath)` helper using node:path.relative - CurateTaskAnalyticsState stores projectPath captured at onTaskCreate so per-op emits can relativize without threading task through processToolResult - all four payload sites (curate-op, curate-run-completed, query-completed read_paths_with_metadata) route through the helper Inspection test added at test/unit/.../analytics-hook-toolmode-inspection that pretty-prints every event + payload for curate-tool-mode / query / query-tool-mode flows — gives PMs a single place to verify the wire shape end-to-end. Privacy win: relative paths drop the /Users/{name} prefix from every file-touched event, keeping host-identifiable PII off the analytics wire while still letting PMs reason about which file inside a project an operation touched. Tests: full repo 9132 passing. * feat: [ENG-2964] M15.6 wire AnalyticsHook into lifecycleHooks + failure_kind on task_failed Delivers M15.6's customer-facing piece: the actual wiring of AnalyticsHook into the daemon's lifecycleHooks[] (which M14.3 built emit logic for but never plumbed) plus the coarse failure_kind classifier on task_failed. Per the M14 / M15.6 alignment (Option A): M14.1-M14.3 ship as the foundation. M15.6 adds the wiring + failure_kind. M15.6's stated "scaffolded only" stance on task_created + per-flavor schemas is intentionally relaxed — M14 already produces those events and the review-tightening on curate_operation_applied / read_paths_with_metadata delivers PM-visible value that the M15.6 description deferred. Changes: src/server/infra/daemon/brv-server.ts: - import AnalyticsHook - construct it BEFORE TransportHandlers so it can land in lifecycleHooks[] alongside curate-log / query-log / task-history (now 4 peers, not 3) - the isAnalyticsEnabled gate from setupFeatureHandlers binds in via a closure ref that defers lookup until emit-time - capture setupFeatureHandlers's return value (was discarded), then setAnalyticsClient(analyticsClient) on the pre-registered hook src/shared/analytics/events/task-failed.ts: - new FailureKindValues + FailureKind type: 'cancelled' | 'timeout' | 'agent_error' | 'unknown' (coarse vocab, ≤64 chars, never raw error.message) - failure_kind added to TaskFailedSchema as a required field - docblock notes the M15.6 privacy contract src/server/infra/process/analytics-hook.ts: - new classifyFailureKind(errorMessage) helper. Substring sentinels for 'timeout' / 'timed out' / 'deadline exceeded' → 'timeout'; 'agent' / 'llm' / 'provider' / 'tool' → 'agent_error'; default 'unknown'. Raw error string NEVER ends up on the wire. - emitTaskFailed now takes a FailureKind argument - onTaskCancelled → emitTaskFailed(..., 'cancelled') - onTaskError → emitTaskFailed(..., classifyFailureKind(errorMessage)) TDD: - task-failed.test: 3 new tests pin the failure_kind shape (accepts the 4 canonical values, rejects out-of-vocab, rejects missing) - analytics-hook-m14.test: 3 new tests verify the classifier paths land on the wire — 'kaboom' → 'unknown', timeout strings → 'timeout', agent strings → 'agent_error' - new analytics-hook-lifecycle-wiring.test integration covers the end-to-end task:create → analyticsHook.onTaskCreate path through a real TaskRouter, plus all four task-type variants going through task_created → task_completed in sequence Full suite: 9145 passing, 0 failing. Lint clean. * refactor: [ENG-2964] M15.6 address PR #722 review comments (7 of 7) #1 classifyFailureKind — word-boundary regex + pinned precedence: Substring matching let 'tooltip' / 'engagement' / 'urgent' bucket into agent_error. Tightened to /\b(agent|llm|provider|tool)\b/ and /\b(timeout|timed out|deadline exceeded)\b/. Docblock pins precedence (timeout > agent_error > unknown) so future if-order shuffles can't silently rebucket the funnel. #2 toRelativePath outside-project guard (privacy bug): path.relative('/proj','/Users/dev/other/x.md') returned '../../Users/dev/other/x.md' — still encoded the host layout. Same hole when projectPath was undefined (fell back to raw absolute path). Now returns '/' in both cases; preserves enough signal for backend grouping without becoming PII. Same guard also runs against absolute-path tails (Windows drive-letter switches). #3 toAnalyticsTaskType drift guard: Replaced `daemonType as TaskType` (per CLAUDE.md anti-cast rule) with a TASK_TYPE_SET membership check + processLog warning + 'unknown' sentinel fallback. A future un-enumerated dispatch now lands a debuggable warning at the daemon instead of disappearing at the backend Zod check. #4 dumpEvents test pollution: Gated behind DUMP_ANALYTICS=1. `npm test` runs the shape assertions silently; opt-in dump still works via `DUMP_ANALYTICS=1 npx mocha test/unit/.../analytics-hook-toolmode-inspection.test.ts`. #5 FU-1 forward-compat comment: Added a block-comment note above the tool-mode "operations_*: 0" assertions so when FU-1 lands and the counters flip non-zero, the failure reads as "feature, update the test" rather than a regression. #6 curate_operation_applied.related asymmetry: Added a TODO(M15.x) marker on the `related` field flagging the wire-shape asymmetry with `query_completed.read_paths_with_metadata .related_paths` (structured). Restructure is consumer-migration territory — own ticket. #7 brv-server.ts loud-fail assertion: Added an explicit throw if setupFeatureHandlers returns without analyticsClient. Future refactors that drop the field will explode here instead of silently no-op'ing every emit forever. Tests added to lock in #1, #2, #3 behavior so the review intent doesn't drift. Existing stress-test fixtures moved from `/A/op-N.md` under-the-root paths to `/proj/A/op-N.md` so they exercise the in-project case (out-of-project is now its own focused test). Full suite: 9227 passing, 0 failing. Lint clean. * fix: [ENG-2964] M15.6 drift sentinel must be on the wire vocabulary (PR #722 re-review) Re-review caught a real bug in the previous fixup: `toAnalyticsTaskType` returned `'unknown' as TaskType` for un-enumerated daemon types, but `'unknown'` was NOT in `TASK_TYPE_VALUES`. The docblock promised "keeps the event on the wire instead of silently failing the Zod check at the backend" — but the wire-side `z.enum(TASK_TYPE_VALUES)` actually still rejected the row. The local Sinon trackStub doesn't run Zod, which is why the m14 test passed even though the runtime path was broken. Also the `as TaskType` cast moved sites but didn't disappear — the exact anti-pattern the original review flagged. Fix: - Add `TaskTypes.UNKNOWN: 'unknown'` to the canonical vocabulary. - Append `TaskTypes.UNKNOWN` to `TASK_TYPE_VALUES` so every task_* schema accepts the sentinel via `z.enum(TASK_TYPE_VALUES)`. - Introduce `isCanonicalTaskType()` predicate so `toAnalyticsTaskType` narrows without the `as TaskType` cast. Sentinel returned as `TaskTypes.UNKNOWN` directly. - Update `task-types.test` to assert the new `UNKNOWN` key/value pair. Existing negative test for an unknown task_type already uses `'not-a-real-type'` (not `'unknown'`), so it stays green. Cosmetic: dropped the extra-space drift in the OUTSIDE_PROJECT_PATH docblock noted in the same re-review. Tests: 9227 passing, 0 failing. --- src/server/infra/daemon/brv-server.ts | 33 +- src/server/infra/process/analytics-hook.ts | 203 +++++++++-- .../infra/process/curate-log-handler.ts | 7 +- src/server/infra/process/query-log-handler.ts | 5 +- .../events/curate-operation-applied.ts | 24 +- .../analytics/events/curate-run-completed.ts | 10 +- .../analytics/events/query-completed.ts | 47 ++- src/shared/analytics/events/task-failed.ts | 23 +- src/shared/analytics/task-types.ts | 18 + test/integration/analytics/transport.test.ts | 6 +- .../analytics-hook-async-stress.test.ts | 54 +-- .../analytics-hook-lifecycle-wiring.test.ts | 254 ++++++++++++++ .../infra/analytics/analytics-client.test.ts | 8 +- .../analytics/no-op-analytics-client.test.ts | 4 +- .../infra/process/analytics-hook-m14.test.ts | 317 ++++++++++++++++++ ...analytics-hook-toolmode-inspection.test.ts | 274 +++++++++++++++ .../infra/process/analytics-hook.test.ts | 202 ++++++----- .../handlers/analytics-handler.test.ts | 10 +- .../events/curate-operation-applied.test.ts | 21 +- .../events/curate-run-completed.test.ts | 9 +- .../analytics/events/query-completed.test.ts | 93 +++-- .../analytics/events/task-failed.test.ts | 21 +- test/unit/shared/analytics/task-types.test.ts | 129 ++++++- 23 files changed, 1577 insertions(+), 195 deletions(-) create mode 100644 test/integration/infra/process/analytics-hook-lifecycle-wiring.test.ts create mode 100644 test/unit/server/infra/process/analytics-hook-m14.test.ts create mode 100644 test/unit/server/infra/process/analytics-hook-toolmode-inspection.test.ts diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index daab7fe3c..d70918111 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -55,6 +55,7 @@ import {createBillingStateHandler} from '../billing/billing-state-endpoint.js' import {ClientManager} from '../client/client-manager.js' import {ProjectConfigStore} from '../config/file-config-store.js' import {readContextTreeRemoteUrl} from '../context-tree/read-context-tree-remote.js' +import {AnalyticsHook} from '../process/analytics-hook.js' import {broadcastToProjectRoom} from '../process/broadcast-utils.js' import {CurateLogHandler} from '../process/curate-log-handler.js' import {setupFeatureHandlers} from '../process/feature-handlers.js' @@ -367,6 +368,18 @@ async function main(): Promise { // same instances this hook writes to. const taskHistoryHook = new TaskHistoryHook({getStore: getTaskHistoryStore}) + // M15.6: AnalyticsHook is the 4th lifecycle peer alongside curate-log / + // query-log / task-history. It emits task_created / task_completed / + // task_failed (and M12 per-flavor events for curate / query) into the + // daemon's IAnalyticsClient. The client + isAnalyticsEnabled gate come + // from setupFeatureHandlers later in this function; the closure below + // defers the lookup so the hook can be constructed in time to land in + // lifecycleHooks[] but still observe the live config. + let isAnalyticsEnabledRef: () => boolean = () => true + const analyticsHook = new AnalyticsHook({ + isEnabled: () => isAnalyticsEnabledRef(), + }) + // Provider config/keychain stores — shared between feature handlers and state endpoint. // Hoisted ahead of `new TransportHandlers` so the resolveActiveProvider callback below // can close over them and call resolveProviderConfig synchronously at task-create time. @@ -427,7 +440,7 @@ async function main(): Promise { const config = await new ProjectConfigStore().read(projectPath) return config?.reviewDisabled === true }, - lifecycleHooks: [curateLogHandler, queryLogHandler, taskHistoryHook], + lifecycleHooks: [curateLogHandler, queryLogHandler, taskHistoryHook, analyticsHook], projectRegistry, projectRouter, // Stamp the active provider/model snapshot onto every created task so the @@ -642,7 +655,7 @@ async function main(): Promise { // Feature handlers (auth, init, status, push, pull, etc.) require async OIDC discovery. // Placed after daemon:getState so the debug endpoint is available immediately, // without waiting for OIDC discovery (~400ms). - await setupFeatureHandlers({ + const featureHandlers = await setupFeatureHandlers({ authStateStore, billingConfigStoreFactory, broadcastToProject(projectPath, event, data) { @@ -660,6 +673,22 @@ async function main(): Promise { webuiPort: webuiServer?.getPort(), }) + // M15.6: now that setupFeatureHandlers has constructed the real + // IAnalyticsClient + isAnalyticsEnabled callback, late-bind them into + // the AnalyticsHook that was pre-registered in lifecycleHooks[]. Any + // task_* emits queued during the boot window between hook construction + // and this line silently no-op (matches `setAnalyticsClient`'s docblock + // contract — no tasks are active during daemon boot). + isAnalyticsEnabledRef = featureHandlers.isAnalyticsEnabled + // PR #722 review: explode loudly if a future refactor drops + // analyticsClient from the result shape — silently no-op'ing every + // emit forever is the worst failure mode for telemetry plumbing. + if (!featureHandlers.analyticsClient) { + throw new Error('setupFeatureHandlers returned without analyticsClient — AnalyticsHook cannot bind') + } + + analyticsHook.setAnalyticsClient(featureHandlers.analyticsClient) + // Load auth token AFTER feature handlers are registered. // AuthHandler's onAuthChanged/onAuthExpired callbacks must be wired first // so that loadToken() triggers proper broadcasts to TUI and agents. diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index 6d886a3c3..e08b23077 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -1,10 +1,13 @@ /* eslint-disable camelcase */ import {readFile as readFileAsync} from 'node:fs/promises' +import {basename, isAbsolute as isAbsolutePath, relative as relativePath} from 'node:path' import type {AnalyticsEventName} from '../../../shared/analytics/event-names.js' import type {CurateRunCompletedProps} from '../../../shared/analytics/events/curate-run-completed.js' import type {PropsArg} from '../../../shared/analytics/events/index.js' import type {QueryCompletedProps} from '../../../shared/analytics/events/query-completed.js' +import type {FailureKind} from '../../../shared/analytics/events/task-failed.js' +import type {TaskType} from '../../../shared/analytics/task-types.js' import type {LlmToolResultEvent} from '../../core/domain/transport/schemas.js' import type {TaskInfo} from '../../core/domain/transport/task-info.js' import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' @@ -12,12 +15,98 @@ import type {ITaskLifecycleHook} from '../../core/interfaces/process/i-task-life import type {QueryResultMetadata} from './query-log-handler.js' import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' +import {TaskTypes} from '../../../shared/analytics/task-types.js' import {parseFrontmatter} from '../../core/domain/knowledge/markdown-writer.js' import {extractCurateOperations} from '../../utils/curate-result-parser.js' import {processLog} from '../../utils/process-logger.js' import {CURATE_TASK_TYPES} from './curate-log-handler.js' import {QUERY_TASK_TYPES} from './query-log-handler.js' +/** + * Set of canonical task types accepted by the wire schema. Membership check + * runs in `toAnalyticsTaskType` to gate emits against the daemon dispatching + * a string TASK_TYPE_VALUES doesn't enumerate. + */ +const ANALYTICS_TASK_TYPE_SET: ReadonlySet = new Set(Object.values(TaskTypes) as TaskType[]) + +const isCanonicalTaskType = (value: string): value is TaskType => (ANALYTICS_TASK_TYPE_SET as Set).has(value) + +/** + * Translate the daemon's runtime task type string to the canonical + * analytics wire value. The daemon still dispatches the pre-ENG-2925 + * name `'curate-html-direct'`; analytics emits the post-rename + * `'curate-tool-mode'`. Once the rename PR lands, the alias becomes + * dead code and can be inlined. + * + * Drift guard (PR #722 review re-review): if the daemon dispatches a + * type that isn't enumerated in `TASK_TYPE_VALUES`, fall back to + * `TaskTypes.UNKNOWN` (which is in the wire vocabulary, so the backend + * accepts the row) and log a daemon-side breadcrumb. The previous + * implementation cast a non-enumerated string back to `TaskType`, + * which silently failed the backend Zod check. + */ +function toAnalyticsTaskType(daemonType: string): TaskType { + if (daemonType === 'curate-html-direct') return TaskTypes.CURATE_TOOL_MODE + if (isCanonicalTaskType(daemonType)) return daemonType + processLog(`AnalyticsHook: unknown task type '${daemonType}' — falling back to '${TaskTypes.UNKNOWN}'`) + return TaskTypes.UNKNOWN +} + +/** + * Stable sentinel for paths that can't be safely emitted as project- + * relative — either outside the project root or the project root itself + * is unknown. The backend can group these without leaking host layout. + */ +const OUTSIDE_PROJECT_PATH = '' + +/** + * Convert an absolute filesystem path to a project-relative path for the + * analytics wire. Keeps emits free of `/Users/{name}` PII while still + * letting PMs reason about which file inside a project an operation touched. + * + * PR #722 review: `path.relative('/proj', '/Users/dev/other/x.md')` yields + * `'../../Users/dev/other/x.md'` — still encodes the host layout. When the + * relative path escapes the project root (or projectPath is unset), surface + * a stable sentinel + basename rather than the raw absolute path. The + * sentinel preserves enough signal for backend grouping without becoming + * PII. + */ +function toRelativePath(filePath: string, projectPath?: string): string { + if (!projectPath) return `${OUTSIDE_PROJECT_PATH}/${basename(filePath)}` + const rel = relativePath(projectPath, filePath) + // `path.relative` returns '' when paths are identical — defensively + // surface a leaf token rather than emit a zero-length wire string that + // would fail `z.string().min(1)`. + if (rel === '') return '.' + // Anything that escapes the project root (`../foo`) or stays absolute + // (Windows drive letter switches) is treated as outside-project. + if (rel.startsWith('..') || isAbsolutePath(rel)) { + return `${OUTSIDE_PROJECT_PATH}/${basename(filePath)}` + } + + return rel +} + +/** + * Classify a daemon-side error message into a coarse failure_kind tag. + * + * Precedence (PR #722 review — pinned so the if-order can't silently rebucket + * the funnel later): `timeout` > `agent_error` > `unknown`. A message + * containing both `'timeout'` and `'agent'` classifies as `'timeout'`. + * + * Word-boundary matching keeps unrelated tokens (`'tooltip'`, `'engagement'`, + * `'urgent'`) from bumping into the `agent_error` bucket. The raw message + * NEVER ends up on the analytics wire — only the canonical tag. + */ +const TIMEOUT_PATTERN = /\b(timeout|timed out|deadline exceeded)\b/ +const AGENT_ERROR_PATTERN = /\b(agent|llm|provider|tool)\b/ +function classifyFailureKind(errorMessage: string): FailureKind { + const m = errorMessage.toLowerCase() + if (TIMEOUT_PATTERN.test(m)) return 'timeout' + if (AGENT_ERROR_PATTERN.test(m)) return 'agent_error' + return 'unknown' +} + // `CURATE_TASK_TYPES` is exported as a readonly tuple; wrap in a Set // for cast-free `.has()` lookups against TaskInfo.type (string). const CURATE_TASK_TYPE_SET: ReadonlySet = new Set(CURATE_TASK_TYPES) @@ -67,6 +156,8 @@ type CurateCounters = { type CurateTaskAnalyticsState = { counters: CurateCounters flavor: 'curate' + /** Captured at onTaskCreate so onToolResult emits can relativize op.filePath. */ + projectPath?: string taskType: CurateTaskTypeLiteral } @@ -142,36 +233,57 @@ export class AnalyticsHook implements ITaskLifecycleHook { async onTaskCancelled(taskId: string, task: TaskInfo): Promise { await this.dispatchTerminal(taskId, task, 'cancelled') + this.emitTaskFailed(taskId, task, 'cancelled') } async onTaskCompleted(taskId: string, _result: string, task: TaskInfo): Promise { const state = this.tasks.get(taskId) - if (!state) return + if (state) { + // Drain any in-flight per-op processing so CURATE_OPERATION_APPLIED emits + // land BEFORE the run-completion emit on the wire. The chain never + // rejects (see `onToolResult`), so this await is safe. + await this.pendingByTask.get(taskId) + + if (state.flavor === 'curate') { + const outcome = state.counters.failed > 0 ? 'partial' : 'completed' + this.emit( + AnalyticsEventNames.CURATE_RUN_COMPLETED, + this.buildCurateRunPayload({outcome, state, task, taskId}), + ) + } else { + this.emit( + AnalyticsEventNames.QUERY_COMPLETED, + await this.buildQueryCompletedPayload({outcome: 'completed', state, task, taskId}), + ) + } + } - // Drain any in-flight per-op processing so CURATE_OPERATION_APPLIED emits - // land BEFORE the run-completion emit on the wire. The chain never - // rejects (see `onToolResult`), so this await is safe. - await this.pendingByTask.get(taskId) - if (state.flavor === 'curate') { - const outcome = state.counters.failed > 0 ? 'partial' : 'completed' - this.emit( - AnalyticsEventNames.CURATE_RUN_COMPLETED, - this.buildCurateRunPayload({outcome, state, task, taskId}), - ) - } else { - this.emit( - AnalyticsEventNames.QUERY_COMPLETED, - await this.buildQueryCompletedPayload({outcome: 'completed', state, task, taskId}), - ) - } + // M14.3 generic funnel emit. Fires for EVERY task type AFTER any + // per-flavor M12 emit (terminal-event-last convention). + this.emit(AnalyticsEventNames.TASK_COMPLETED, { + duration_ms: this.durationMs(task), + task_id: taskId, + task_type: toAnalyticsTaskType(task.type), + }) } async onTaskCreate(task: TaskInfo): Promise { + // M14.3 generic funnel-entry emit. Fires for EVERY task type BEFORE + // the M12 per-flavor state init so the entry event lands even if + // state setup throws downstream. + this.emit(AnalyticsEventNames.TASK_CREATED, { + has_files: (task.files?.length ?? 0) > 0, + has_folder: typeof task.folderPath === 'string' && task.folderPath.length > 0, + task_id: task.taskId, + task_type: toAnalyticsTaskType(task.type), + }) + if (isCurateLiteral(task.type)) { this.tasks.set(task.taskId, { counters: {added: 0, deleted: 0, failed: 0, merged: 0, pendingReview: 0, updated: 0}, flavor: 'curate', + projectPath: task.projectPath, taskType: task.type, }) return @@ -182,8 +294,9 @@ export class AnalyticsHook implements ITaskLifecycleHook { } } - async onTaskError(taskId: string, _errorMessage: string, task: TaskInfo): Promise { + async onTaskError(taskId: string, errorMessage: string, task: TaskInfo): Promise { await this.dispatchTerminal(taskId, task, 'error') + this.emitTaskFailed(taskId, task, classifyFailureKind(errorMessage)) } async onToolResult(taskId: string, payload: LlmToolResultEvent): Promise { @@ -246,7 +359,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { outcome, pending_review_count: state.counters.pendingReview, task_id: taskId, - task_type: state.taskType, + task_type: toAnalyticsTaskType(state.taskType), } } @@ -303,17 +416,24 @@ export class AnalyticsHook implements ITaskLifecycleHook { // M12.3: harvest per-path frontmatter on the same async read path used // for curate emits. Entries whose file is unreadable / has no frontmatter - // carry `absolute_path` alone (the three array fields stay absent). - // `Promise.all` preserves input-array order in the result regardless of - // which read settles first. + // carry empty keywords / tags / related_paths arrays — the wire shape + // is uniform regardless of read success. `Promise.all` preserves + // input-array order in the result regardless of which read settles first. const readPathsWithMetadata = await Promise.all( cappedPaths.map(async (p) => { const fm = await this.readFrontmatterFields(p) return { - absolute_path: p, - ...(fm.keywords ? {keywords: fm.keywords} : {}), - ...(fm.related ? {related: fm.related} : {}), - ...(fm.tags ? {tags: fm.tags} : {}), + keywords: fm.keywords ?? [], + // M14 review tightening: each related entry is structured so a + // later FU can populate the linked file's own keywords/tags + // without changing the wire shape. + related_paths: (fm.related ?? []).map((r) => ({ + keywords: [], + relative_path: r, + tags: [], + })), + relative_path: toRelativePath(p, task.projectPath), + tags: fm.tags ?? [], } }), ) @@ -331,7 +451,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { read_tool_call_count: readToolCallCount, search_call_count: searchCallCount, task_id: taskId, - task_type: 'query', + task_type: toAnalyticsTaskType(task.type), ...(tier === undefined ? {} : {tier}), } } @@ -371,6 +491,26 @@ export class AnalyticsHook implements ITaskLifecycleHook { } } + /** + * M14.3 generic terminal-failure emit. Fired by both onTaskError and + * onTaskCancelled AFTER dispatchTerminal so M12 per-flavor failure + * emits land first on the wire. Cancellation maps to task_failed + * (not a distinct event) per the schema's docblock. + * + * M15.6: failure_kind is a coarse classifier passed by the caller — + * 'cancelled' from onTaskCancelled, classified-from-errorMessage from + * onTaskError (see classifyFailureKind). Raw error.message MUST NOT + * leak into the emit; only the canonical FailureKind tag does. + */ + private emitTaskFailed(taskId: string, task: TaskInfo, failureKind: FailureKind): void { + this.emit(AnalyticsEventNames.TASK_FAILED, { + duration_ms: this.durationMs(task), + failure_kind: failureKind, + task_id: taskId, + task_type: toAnalyticsTaskType(task.type), + }) + } + private async processToolResult(taskId: string, payload: LlmToolResultEvent): Promise { const state = this.tasks.get(taskId) if (!state || state.flavor !== 'curate') return @@ -423,20 +563,21 @@ export class AnalyticsHook implements ITaskLifecycleHook { // M12.3: read post-op frontmatter for ADD / UPDATE / MERGE-target / // UPSERT. DELETE skips the read (file is gone). Frontmatter fields - // stay absent when the read fails (ENOENT, EACCES, malformed YAML). + // default to empty arrays when the read fails (ENOENT, EACCES, + // malformed YAML) so the wire shape stays uniform. // eslint-disable-next-line no-await-in-loop -- emit order MUST match op order const frontmatter = op.type === 'DELETE' ? {} : await this.readFrontmatterFields(op.filePath) this.emit(AnalyticsEventNames.CURATE_OPERATION_APPLIED, { - absolute_path: op.filePath, ...(op.confidence ? {confidence: op.confidence} : {}), ...(op.impact ? {impact: op.impact} : {}), - ...(frontmatter.keywords ? {keywords: frontmatter.keywords} : {}), + keywords: frontmatter.keywords ?? [], knowledge_path: op.path, needs_review: op.needsReview ?? false, operation_type: op.type, ...(frontmatter.related ? {related: frontmatter.related} : {}), - ...(frontmatter.tags ? {tags: frontmatter.tags} : {}), + relative_path: toRelativePath(op.filePath, state.projectPath), + tags: frontmatter.tags ?? [], task_id: taskId, }) } diff --git a/src/server/infra/process/curate-log-handler.ts b/src/server/infra/process/curate-log-handler.ts index 8f15e777d..aaf36ba59 100644 --- a/src/server/infra/process/curate-log-handler.ts +++ b/src/server/infra/process/curate-log-handler.ts @@ -50,7 +50,12 @@ function telemetryFields(record: CurateUsageRecord | undefined): { } } -export const CURATE_TASK_TYPES = ['curate', 'curate-folder'] as const +// `curate-html-direct` is the pre-ENG-2925 name still dispatched by the +// daemon; `curate-tool-mode` is the post-rename name. Both are listed +// so M12 state init in AnalyticsHook kicks in for tool-mode curates. +// The analytics wire canonicalizes both to `curate-tool-mode` via +// `toAnalyticsTaskType` in `analytics-hook.ts`. +export const CURATE_TASK_TYPES = ['curate', 'curate-folder', 'curate-html-direct', 'curate-tool-mode'] as const // ── Summary computation ─────────────────────────────────────────────────────── diff --git a/src/server/infra/process/query-log-handler.ts b/src/server/infra/process/query-log-handler.ts index 2fec074dd..16a561774 100644 --- a/src/server/infra/process/query-log-handler.ts +++ b/src/server/infra/process/query-log-handler.ts @@ -44,7 +44,10 @@ type TaskState = { queryResult?: QueryResultMetadata } -export const QUERY_TASK_TYPES: ReadonlySet = new Set(['query']) +// `query-tool-mode` is the v4.0 daemon dispatch name; legacy `query` is +// kept for back-compat. Both names enable M12 state init in AnalyticsHook +// (and matching query-log persistence here). +export const QUERY_TASK_TYPES: ReadonlySet = new Set(['query', 'query-tool-mode']) // ── QueryLogHandler ────────────────────────────────────────────────────────── diff --git a/src/shared/analytics/events/curate-operation-applied.ts b/src/shared/analytics/events/curate-operation-applied.ts index 12f4a0564..8ed21b180 100644 --- a/src/shared/analytics/events/curate-operation-applied.ts +++ b/src/shared/analytics/events/curate-operation-applied.ts @@ -5,24 +5,32 @@ import {z} from 'zod' * Per-event schema for `curate_operation_applied`. * * Emitted by the daemon's `AnalyticsHook` (M12.2) once per successful curate - * operation. Each operation carries the affected file's absolute path, its - * knowledge-tree address, review/impact metadata, and (M12.3) the file's - * current-state frontmatter values for tags / keywords / related. + * operation. Each operation carries the affected file's project-relative + * path, its knowledge-tree address, review/impact metadata, and (M12.3) the + * file's current-state frontmatter values for tags / keywords / related. * - * All three frontmatter arrays are optional and absent on DELETE operations - * (the file is gone post-op) and on read failures (defensive). + * Review tightening (M14 follow-up): + * - `absolute_path` → `relative_path` for privacy + portability across hosts + * - `keywords` / `tags` are now required arrays (default empty) so consumers + * don't have to special-case the "field absent" shape + * - `related` stays optional and absent on DELETE / read-failure (file is + * gone or unreadable, no related-link source to harvest from) */ export const CurateOperationAppliedSchema = z .object({ - absolute_path: z.string().min(1), confidence: z.enum(['high', 'low']).optional(), impact: z.enum(['high', 'low']).optional(), - keywords: z.array(z.string().max(256)).max(50).optional(), + keywords: z.array(z.string().max(256)).max(50), knowledge_path: z.string().min(1), needs_review: z.boolean(), operation_type: z.enum(['ADD', 'UPDATE', 'DELETE', 'MERGE', 'UPSERT']), + // TODO(M15.x): harmonise with the sibling `query_completed.read_paths_ + // _with_metadata[].related_paths` structured shape — current asymmetry + // forces consumers to special-case parsing `related` between the two + // events. Restructuring is its own ticket (consumer migration concern). related: z.array(z.string().max(256)).max(50).optional(), - tags: z.array(z.string().max(256)).max(50).optional(), + relative_path: z.string().min(1), + tags: z.array(z.string().max(256)).max(50), task_id: z.string().min(1), }) .strict() diff --git a/src/shared/analytics/events/curate-run-completed.ts b/src/shared/analytics/events/curate-run-completed.ts index 8e1702f6c..57f223eb9 100644 --- a/src/shared/analytics/events/curate-run-completed.ts +++ b/src/shared/analytics/events/curate-run-completed.ts @@ -1,12 +1,20 @@ /* eslint-disable camelcase */ import {z} from 'zod' +import {TASK_TYPE_VALUES} from '../task-types.js' + /** * Per-event schema for `curate_run_completed`. * * Emitted by the daemon's `AnalyticsHook` (M12.2) at curate task terminal * states (completed / partial / cancelled / error). Carries per-task * operation counters so PMs can aggregate curate volume + outcome over time. + * + * M14.2 migrated `task_type` from a literal ['curate', 'curate-folder'] + * enum to the canonical `TASK_TYPE_VALUES` tuple so v4.0 tool-mode types + * (curate-tool-mode) round-trip the wire boundary. The hook is expected + * to only emit this event for curate flavors; the schema no longer + * structurally enforces that and trusts the caller. */ export const CurateRunCompletedSchema = z .object({ @@ -19,7 +27,7 @@ export const CurateRunCompletedSchema = z outcome: z.enum(['completed', 'partial', 'cancelled', 'error']), pending_review_count: z.number().int().nonnegative(), task_id: z.string().min(1), - task_type: z.enum(['curate', 'curate-folder']), + task_type: z.enum(TASK_TYPE_VALUES), }) .strict() diff --git a/src/shared/analytics/events/query-completed.ts b/src/shared/analytics/events/query-completed.ts index b861608f4..d7bc2865e 100644 --- a/src/shared/analytics/events/query-completed.ts +++ b/src/shared/analytics/events/query-completed.ts @@ -1,18 +1,43 @@ /* eslint-disable camelcase */ import {z} from 'zod' +import {TASK_TYPE_VALUES} from '../task-types.js' + +/** + * Per related-path metadata. Each related entry is a project-relative + * knowledge path captured from a read file's frontmatter `related` list, + * carrying its own keywords / tags so PMs can see what the linked-from + * topics actually cover. + * + * keywords / tags default to `[]` when the related file isn't on disk or + * when analytics is disabled (no enrichment read happens). The shape is + * structured here so a later FU can fill keywords/tags without a wire + * format change. + */ +const RelatedPathWithMetadataSchema = z + .object({ + keywords: z.array(z.string().max(256)).max(50), + relative_path: z.string().min(1), + tags: z.array(z.string().max(256)).max(50), + }) + .strict() + /** * Per-file structure inside `query_completed.read_paths_with_metadata`. - * Frontmatter arrays are optional and absent when the daemon cannot read - * the file (ENOENT, parse failure) — `absolute_path` alone still tells - * PMs which file the agent touched. + * + * Review tightening (M14 follow-up): + * - `absolute_path` → `relative_path` for privacy + portability + * - `keywords` / `tags` are now required arrays (default `[]`) so the + * "field absent" wire shape goes away + * - flat `related: string[]` → structured `related_paths: [{relative_path, + * keywords, tags}]` so each linked topic carries its own metadata */ const ReadPathWithMetadataSchema = z .object({ - absolute_path: z.string().min(1), - keywords: z.array(z.string().max(256)).max(50).optional(), - related: z.array(z.string().max(256)).max(50).optional(), - tags: z.array(z.string().max(256)).max(50).optional(), + keywords: z.array(z.string().max(256)).max(50), + related_paths: z.array(RelatedPathWithMetadataSchema).max(50), + relative_path: z.string().min(1), + tags: z.array(z.string().max(256)).max(50), }) .strict() @@ -23,6 +48,12 @@ const ReadPathWithMetadataSchema = z * states (completed / cancelled / error). Carries duration, retrieval * tier hit, doc counts, and (M12.3) the per-file structure for the top-N * (max 10) files the agent read during the query. + * + * M14.2 migrated `task_type` from `z.literal('query')` to the canonical + * `TASK_TYPE_VALUES` tuple so v4.0 tool-mode types (query-tool-mode) + * round-trip the wire boundary. The hook is expected to only emit this + * event for query flavors; the schema no longer structurally enforces + * that and trusts the caller. */ export const QueryCompletedSchema = z .object({ @@ -35,7 +66,7 @@ export const QueryCompletedSchema = z read_tool_call_count: z.number().int().nonnegative(), search_call_count: z.number().int().nonnegative(), task_id: z.string().min(1), - task_type: z.literal('query'), + task_type: z.enum(TASK_TYPE_VALUES), tier: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(), }) .strict() diff --git a/src/shared/analytics/events/task-failed.ts b/src/shared/analytics/events/task-failed.ts index a48b823ca..d4cf0cd3f 100644 --- a/src/shared/analytics/events/task-failed.ts +++ b/src/shared/analytics/events/task-failed.ts @@ -3,6 +3,22 @@ import {z} from 'zod' import {TASK_TYPE_VALUES} from '../task-types.js' +/** + * Coarse-vocabulary classification of why a task ended in a non-success + * state. Strictly enumerated so consumers can group failures without + * having to parse raw error messages. Every value is ≤64 chars and + * carries no PII. + * + * - `cancelled` — onTaskCancelled lifecycle path; user-initiated abort. + * - `timeout` — error message indicates the agent / LLM exceeded a budget. + * - `agent_error` — error message indicates a recognised agent-side fault + * (provider rejection, tool failure, schema reject, etc.). + * - `unknown` — anything else; the hook MUST default here rather than + * widening the enum on a hunch. + */ +export const FailureKindValues = ['cancelled', 'timeout', 'agent_error', 'unknown'] as const +export type FailureKind = (typeof FailureKindValues)[number] + /** * Per-event schema for `task_failed`. * @@ -10,13 +26,14 @@ import {TASK_TYPE_VALUES} from '../task-types.js' * captured here: they may contain file paths, secrets, or user content. * Strict mode rejects any attempt to add `error_message` / `stack` later. * - * Adding `error_class` / `error_code` would require extending - * `ITaskLifecycleHook.onTaskError` to deliver the structured error object, - * which is a separate ticket. + * `failure_kind` (M15.6) is a coarse-vocabulary tag the daemon classifies + * the error into. Producers MUST emit one of the canonical values; the + * hook never forwards raw `error.message` text under any field name. */ export const TaskFailedSchema = z .object({ duration_ms: z.number().int().nonnegative(), + failure_kind: z.enum(FailureKindValues), task_id: z.string().min(1), task_type: z.enum(TASK_TYPE_VALUES), }) diff --git a/src/shared/analytics/task-types.ts b/src/shared/analytics/task-types.ts index d14e60186..7a1058af8 100644 --- a/src/shared/analytics/task-types.ts +++ b/src/shared/analytics/task-types.ts @@ -12,9 +12,22 @@ export const TaskTypes = { CURATE: 'curate', CURATE_FOLDER: 'curate-folder', + CURATE_TOOL_MODE: 'curate-tool-mode', DREAM: 'dream', + DREAM_FINALIZE: 'dream-finalize', + DREAM_SCAN: 'dream-scan', QUERY: 'query', + QUERY_TOOL_MODE: 'query-tool-mode', SEARCH: 'search', + /** + * Drift sentinel — emitted by `AnalyticsHook.toAnalyticsTaskType` when the + * daemon dispatches a type that isn't enumerated above. Lives in the + * canonical vocabulary so the wire-side `z.enum(TASK_TYPE_VALUES)` accepts + * the row at the backend instead of dropping it. The daemon-side + * `processLog` warning is the actual signal — `'unknown'` on the wire is + * the breadcrumb the backend can group on. + */ + UNKNOWN: 'unknown', } as const export type TaskType = (typeof TaskTypes)[keyof typeof TaskTypes] @@ -27,7 +40,12 @@ export type TaskType = (typeof TaskTypes)[keyof typeof TaskTypes] export const TASK_TYPE_VALUES = [ TaskTypes.CURATE, TaskTypes.CURATE_FOLDER, + TaskTypes.CURATE_TOOL_MODE, TaskTypes.DREAM, + TaskTypes.DREAM_FINALIZE, + TaskTypes.DREAM_SCAN, TaskTypes.QUERY, + TaskTypes.QUERY_TOOL_MODE, TaskTypes.SEARCH, + TaskTypes.UNKNOWN, ] as const diff --git a/test/integration/analytics/transport.test.ts b/test/integration/analytics/transport.test.ts index 7e8bba997..157cc90f6 100644 --- a/test/integration/analytics/transport.test.ts +++ b/test/integration/analytics/transport.test.ts @@ -99,10 +99,12 @@ describe('analytics:track transport round-trip integration (M2.6)', () => { { event: AnalyticsEventNames.CURATE_OPERATION_APPLIED, properties: { - absolute_path: '/tmp/x.md', + keywords: [], knowledge_path: 'kg/x.md', needs_review: false, operation_type: 'ADD', + relative_path: 'tmp/x.md', + tags: [], task_id: 't-1', }, }, @@ -118,7 +120,7 @@ describe('analytics:track transport round-trip integration (M2.6)', () => { expect(event.identity).to.deep.equal({device_id: validDeviceId}) // User-supplied properties preserved end-to-end - expect(event.properties.absolute_path).to.equal('/tmp/x.md') + expect(event.properties.relative_path).to.equal('tmp/x.md') expect(event.properties.operation_type).to.equal('ADD') // All five super-properties stamped on receipt diff --git a/test/integration/infra/process/analytics-hook-async-stress.test.ts b/test/integration/infra/process/analytics-hook-async-stress.test.ts index 669e9d336..313018b1a 100644 --- a/test/integration/infra/process/analytics-hook-async-stress.test.ts +++ b/test/integration/infra/process/analytics-hook-async-stress.test.ts @@ -15,6 +15,7 @@ import {expect} from 'chai' import {randomUUID} from 'node:crypto' +import {relative as relativePath} from 'node:path' import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' import type {LlmToolResultEvent} from '../../../../src/server/core/domain/transport/schemas.js' @@ -248,7 +249,7 @@ describe('AnalyticsHook async stress (integration through TaskRouter)', () => { await createCurateTask(taskId) const opSpecs = Array.from({length: 20}, (_, i) => ({ - filePath: `/A/op-${String(i).padStart(2, '0')}.md`, + filePath: `/proj/A/op-${String(i).padStart(2, '0')}.md`, path: `notes/A/op-${i}`, })) @@ -260,11 +261,12 @@ describe('AnalyticsHook async stress (integration through TaskRouter)', () => { // readFile call order must match arrival order (proves per-task queue). expect(readFileCallOrder, 'readFile call order = arrival order').to.deep.equal(opSpecs.map((s) => s.filePath)) - // Emit order must match arrival order. + // Emit order must match arrival order. M14 review: relative_path is + // relativized against the curate task's projectPath ('/proj'). const emits = getCurateOpEmits(taskId) expect(emits).to.have.lengthOf(20) for (const [i, emit] of emits.entries()) { - expect(emit.absolute_path, `emit #${i} arrival order`).to.equal(opSpecs[i].filePath) + expect(emit.relative_path, `emit #${i} arrival order`).to.equal(relativePath('/proj', opSpecs[i].filePath)) } }) @@ -273,11 +275,11 @@ describe('AnalyticsHook async stress (integration through TaskRouter)', () => { await createCurateTask('task-Y') const xSpecs = Array.from({length: 15}, (_, i) => ({ - filePath: `/X/op-${String(i).padStart(2, '0')}.md`, + filePath: `/proj/X/op-${String(i).padStart(2, '0')}.md`, path: `notes/X/op-${i}`, })) const ySpecs = Array.from({length: 15}, (_, i) => ({ - filePath: `/Y/op-${String(i).padStart(2, '0')}.md`, + filePath: `/proj/Y/op-${String(i).padStart(2, '0')}.md`, path: `notes/Y/op-${i}`, })) @@ -297,8 +299,8 @@ describe('AnalyticsHook async stress (integration through TaskRouter)', () => { expect(xEmits).to.have.lengthOf(15) expect(yEmits).to.have.lengthOf(15) for (let i = 0; i < 15; i++) { - expect(xEmits[i].absolute_path, `X emit #${i}`).to.equal(xSpecs[i].filePath) - expect(yEmits[i].absolute_path, `Y emit #${i}`).to.equal(ySpecs[i].filePath) + expect(xEmits[i].relative_path, `X emit #${i}`).to.equal(relativePath('/proj', xSpecs[i].filePath)) + expect(yEmits[i].relative_path, `Y emit #${i}`).to.equal(relativePath('/proj', ySpecs[i].filePath)) } }) @@ -307,7 +309,7 @@ describe('AnalyticsHook async stress (integration through TaskRouter)', () => { await createCurateTask(taskId) const specs = Array.from({length: 50}, (_, i) => ({ - filePath: `/Z/op-${String(i).padStart(2, '0')}.md`, + filePath: `/proj/Z/op-${String(i).padStart(2, '0')}.md`, path: `notes/Z/op-${i}`, })) @@ -322,21 +324,25 @@ describe('AnalyticsHook async stress (integration through TaskRouter)', () => { const sequence = getEmitSequenceForTask(taskId) - // Exactly 50 per-op emits + 1 terminal emit, terminal LAST. + // M14.3: TASK_CREATED → 50 per-op → CURATE_RUN_COMPLETED → TASK_COMPLETED. expect( sequence.filter((s) => s === AnalyticsEventNames.CURATE_OPERATION_APPLIED), 'exactly 50 per-op emits', ).to.have.lengthOf(50) expect( sequence.filter((s) => s === AnalyticsEventNames.CURATE_RUN_COMPLETED), - 'exactly 1 terminal emit', + 'exactly 1 M12 terminal emit', ).to.have.lengthOf(1) - expect(sequence.at(-1), 'terminal is last in sequence').to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(sequence.at(-1), 'M14.3 task_completed is last in sequence').to.equal(AnalyticsEventNames.TASK_COMPLETED) + expect( + sequence.at(-2), + 'M12 curate_run_completed lands immediately before the M14.3 terminal', + ).to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) // And per-op emit order matches arrival order. const opEmits = getCurateOpEmits(taskId) for (let i = 0; i < 50; i++) { - expect(opEmits[i].absolute_path, `op #${i} arrival order`).to.equal(specs[i].filePath) + expect(opEmits[i].relative_path, `op #${i} arrival order`).to.equal(relativePath('/proj', specs[i].filePath)) } }) @@ -349,15 +355,15 @@ describe('AnalyticsHook async stress (integration through TaskRouter)', () => { const specsByTask: Record> = { 'task-P': Array.from({length: 10}, (_, i) => ({ - filePath: `/P/op-${String(i).padStart(2, '0')}.md`, + filePath: `/proj/P/op-${String(i).padStart(2, '0')}.md`, path: `notes/P/op-${i}`, })), 'task-Q': Array.from({length: 10}, (_, i) => ({ - filePath: `/Q/op-${String(i).padStart(2, '0')}.md`, + filePath: `/proj/Q/op-${String(i).padStart(2, '0')}.md`, path: `notes/Q/op-${i}`, })), 'task-R': Array.from({length: 10}, (_, i) => ({ - filePath: `/R/op-${String(i).padStart(2, '0')}.md`, + filePath: `/proj/R/op-${String(i).padStart(2, '0')}.md`, path: `notes/R/op-${i}`, })), } @@ -375,18 +381,24 @@ describe('AnalyticsHook async stress (integration through TaskRouter)', () => { await Promise.all([...opPromises, ...terminalPromises]) - // Every task must end with CURATE_RUN_COMPLETED preceded by 10 per-op emits in arrival order. + // Every task: TASK_CREATED → 10 per-op → CURATE_RUN_COMPLETED → TASK_COMPLETED. for (const id of taskIds) { const sequence = getEmitSequenceForTask(id) - expect(sequence, `${id} sequence length`).to.have.lengthOf(11) + expect(sequence, `${id} sequence length`).to.have.lengthOf(13) + expect(sequence[0], `${id}: first is task_created`).to.equal(AnalyticsEventNames.TASK_CREATED) expect( - sequence.slice(0, 10).every((s) => s === AnalyticsEventNames.CURATE_OPERATION_APPLIED), - `${id}: first 10 are per-op emits`, + sequence.slice(1, 11).every((s) => s === AnalyticsEventNames.CURATE_OPERATION_APPLIED), + `${id}: 10 per-op emits between task_created and the terminals`, ).to.equal(true) - expect(sequence[10], `${id}: last is run-completed`).to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(sequence[11], `${id}: M12 run-completed precedes the M14.3 terminal`).to.equal( + AnalyticsEventNames.CURATE_RUN_COMPLETED, + ) + expect(sequence[12], `${id}: M14.3 task_completed is last`).to.equal(AnalyticsEventNames.TASK_COMPLETED) const opEmits = getCurateOpEmits(id) for (let i = 0; i < 10; i++) { - expect(opEmits[i].absolute_path, `${id} op #${i} arrival order`).to.equal(specsByTask[id][i].filePath) + expect(opEmits[i].relative_path, `${id} op #${i} arrival order`).to.equal( + relativePath('/proj', specsByTask[id][i].filePath), + ) } } }) diff --git a/test/integration/infra/process/analytics-hook-lifecycle-wiring.test.ts b/test/integration/infra/process/analytics-hook-lifecycle-wiring.test.ts new file mode 100644 index 000000000..e1feed7bd --- /dev/null +++ b/test/integration/infra/process/analytics-hook-lifecycle-wiring.test.ts @@ -0,0 +1,254 @@ +/** + * M15.6 end-to-end wiring test — drives a real TaskRouter with AnalyticsHook + * registered as a lifecycle hook (mirroring the brv-server.ts:430 wire) and + * asserts that the generic task_created / task_completed / task_failed + * events flow through correctly. Plus failure_kind classification. + * + * Mirrors the async-stress harness but focuses on lifecycle wiring rather + * than per-op order. Stubs the transport + agent pool; the rest is real + * AnalyticsHook + TaskRouter glue. + */ + + +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {TaskInfo} from '../../../../src/server/core/domain/transport/task-info.js' +import type {IAgentPool, SubmitTaskResult} from '../../../../src/server/core/interfaces/agent/i-agent-pool.js' +import type {IAnalyticsClient} from '../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {IProjectRegistry} from '../../../../src/server/core/interfaces/project/i-project-registry.js' +import type {IProjectRouter} from '../../../../src/server/core/interfaces/routing/i-project-router.js' +import type { + ITransportServer, + RequestHandler, +} from '../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {TransportTaskEventNames} from '../../../../src/server/core/domain/transport/schemas.js' +import {AnalyticsHook} from '../../../../src/server/infra/process/analytics-hook.js' +import {TaskRouter} from '../../../../src/server/infra/process/task-router.js' +import {AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' + +function makeStubTransport(sandbox: SinonSandbox): { + requestHandlers: Map + transport: ITransportServer +} { + const requestHandlers = new Map() + const transport: ITransportServer = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub().returns(3000), + isRunning: sandbox.stub().returns(true), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers.set(event, handler) + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + return {requestHandlers, transport} +} + +function makeStubAgentPool(sandbox: SinonSandbox): IAgentPool { + return { + getEntries: sandbox.stub().returns([]), + getSize: sandbox.stub().returns(0), + handleAgentDisconnected: sandbox.stub(), + hasAgent: sandbox.stub().returns(false), + markIdle: sandbox.stub(), + notifyTaskCompleted: sandbox.stub(), + shutdown: sandbox.stub().resolves(), + submitTask: sandbox.stub().resolves({success: true} as SubmitTaskResult), + } +} + +function makeStubProjectRegistry(sandbox: SinonSandbox): IProjectRegistry { + const projectInfo = { + projectPath: '/proj', + registeredAt: Date.now(), + sanitizedPath: '_proj', + storagePath: '/data/proj', + } + return { + get: sandbox.stub().returns(projectInfo), + getAll: sandbox.stub().returns(new Map()), + register: sandbox.stub().returns(projectInfo), + unregister: sandbox.stub().returns(true), + } +} + +function makeStubProjectRouter(sandbox: SinonSandbox): IProjectRouter { + return { + addToProjectRoom: sandbox.stub(), + broadcastToProject: sandbox.stub(), + getProjectMembers: sandbox.stub().returns([]), + removeFromProjectRoom: sandbox.stub(), + } +} + +function makeAnalyticsClient(sandbox: SinonSandbox): {client: IAnalyticsClient; trackStub: SinonStub} { + const trackStub = sandbox.stub() + const client: IAnalyticsClient = { + abort: sandbox.stub(), + flush: sandbox.stub().resolves(), + getRuntimeState: sandbox.stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sandbox.stub().resolves(), + track: trackStub, + } + return {client, trackStub} +} + +const buildTaskInfo = (taskId: string, type: string): TaskInfo => + ({ + clientId: 'client-1', + completedAt: Date.now(), + content: 'demo', + createdAt: Date.now() - 1000, + projectPath: '/proj', + status: 'completed', + taskId, + type, + }) as unknown as TaskInfo + +describe('AnalyticsHook lifecycle wiring (M15.6 — through TaskRouter)', () => { + let sandbox: SinonSandbox + let trackStub: SinonStub + let analyticsHook: AnalyticsHook + let createHandler: RequestHandler + + beforeEach(() => { + sandbox = createSandbox() + const {requestHandlers, transport} = makeStubTransport(sandbox) + const bundle = makeAnalyticsClient(sandbox) + trackStub = bundle.trackStub + + analyticsHook = new AnalyticsHook() + analyticsHook.setAnalyticsClient(bundle.client) + + // The wire from brv-server.ts:430: AnalyticsHook is the 4th peer hook. + // The other three are intentionally omitted here so the test focuses on + // AnalyticsHook's emit surface in isolation. + const router = new TaskRouter({ + agentPool: makeStubAgentPool(sandbox), + getAgentForProject: () => 'agent-1', + lifecycleHooks: [analyticsHook], + projectRegistry: makeStubProjectRegistry(sandbox), + projectRouter: makeStubProjectRouter(sandbox), + resolveClientProjectPath: () => '/proj', + transport, + }) + router.setup() + + const create = requestHandlers.get(TransportTaskEventNames.CREATE) + if (!create) throw new Error('expected task:create handler to be registered') + createHandler = create + }) + + afterEach(() => { + sandbox.restore() + }) + + it('task_created fires immediately on task:create with the correct task_type', async () => { + await createHandler( + {content: 'curate me', projectPath: '/proj', taskId: 'task-create-fire', type: 'curate'}, + 'client-1', + ) + + const created = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_CREATED) + expect(created, 'task_created should fire on create').to.not.equal(undefined) + const props = created?.args[1] as Record + expect(props.task_id).to.equal('task-create-fire') + expect(props.task_type).to.equal('curate') + expect(props.has_files).to.equal(false) + expect(props.has_folder).to.equal(false) + }) + + it('task_completed fires after the agent reports completion (curate task)', async () => { + const taskId = 'task-curate-success' + await createHandler({content: 'curate', projectPath: '/proj', taskId, type: 'curate'}, 'client-1') + await analyticsHook.onTaskCompleted(taskId, '', buildTaskInfo(taskId, 'curate')) + + const completed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_COMPLETED) + expect(completed).to.not.equal(undefined) + const props = completed?.args[1] as Record + expect(props.task_id).to.equal(taskId) + expect(props.task_type).to.equal('curate') + expect(props.duration_ms).to.be.a('number') + }) + + it('task_failed carries failure_kind="cancelled" on user cancellation', async () => { + const taskId = 'task-cancel' + await createHandler({content: 'curate', projectPath: '/proj', taskId, type: 'curate'}, 'client-1') + await analyticsHook.onTaskCancelled(taskId, buildTaskInfo(taskId, 'curate')) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect(failed).to.not.equal(undefined) + const props = failed?.args[1] as Record + expect(props.task_id).to.equal(taskId) + expect(props.task_type).to.equal('curate') + expect(props.failure_kind).to.equal('cancelled') + }) + + it('task_failed classifies a timeout error message into failure_kind="timeout"', async () => { + const taskId = 'task-timeout' + await createHandler({content: 'curate', projectPath: '/proj', taskId, type: 'curate'}, 'client-1') + await analyticsHook.onTaskError(taskId, 'agentic loop deadline exceeded', buildTaskInfo(taskId, 'curate')) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('timeout') + }) + + it('task_failed classifies an agent error message into failure_kind="agent_error"', async () => { + const taskId = 'task-agent-err' + await createHandler({content: 'curate', projectPath: '/proj', taskId, type: 'curate'}, 'client-1') + await analyticsHook.onTaskError(taskId, 'llm provider rejected the request', buildTaskInfo(taskId, 'curate')) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('agent_error') + }) + + it('task_failed defaults failure_kind="unknown" when nothing recognises the error string', async () => { + const taskId = 'task-unknown' + await createHandler({content: 'curate', projectPath: '/proj', taskId, type: 'curate'}, 'client-1') + await analyticsHook.onTaskError(taskId, 'kaboom', buildTaskInfo(taskId, 'curate')) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('unknown') + }) + + it('every tool-mode task type fires both task_created and the right terminal', async () => { + const cases = [ + {expectedTaskType: 'curate-tool-mode', taskId: 'tm-curate', type: 'curate-html-direct'}, + {expectedTaskType: 'query-tool-mode', taskId: 'tm-query', type: 'query-tool-mode'}, + {expectedTaskType: 'dream-scan', taskId: 'tm-dream-scan', type: 'dream-scan'}, + {expectedTaskType: 'dream-finalize', taskId: 'tm-dream-finalize', type: 'dream-finalize'}, + ] as const + + for (const c of cases) { + // eslint-disable-next-line no-await-in-loop + await createHandler({content: 'demo', projectPath: '/proj', taskId: c.taskId, type: c.type}, 'client-1') + // eslint-disable-next-line no-await-in-loop + await analyticsHook.onTaskCompleted(c.taskId, '', buildTaskInfo(c.taskId, c.type)) + } + + for (const c of cases) { + const created = trackStub.getCalls().find( + (call) => + call.args[0] === AnalyticsEventNames.TASK_CREATED && + (call.args[1] as Record).task_id === c.taskId, + ) + const completed = trackStub.getCalls().find( + (call) => + call.args[0] === AnalyticsEventNames.TASK_COMPLETED && + (call.args[1] as Record).task_id === c.taskId, + ) + expect(created, `${c.taskId}: task_created`).to.not.equal(undefined) + expect(completed, `${c.taskId}: task_completed`).to.not.equal(undefined) + expect((created?.args[1] as Record).task_type).to.equal(c.expectedTaskType) + expect((completed?.args[1] as Record).task_type).to.equal(c.expectedTaskType) + } + }) +}) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index 023bdeaf6..906614ec0 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -23,10 +23,12 @@ import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-nam */ function makeCurateOpProps(overrides: Partial = {}): CurateOperationAppliedProps { return { - absolute_path: '/tmp/file.md', + keywords: [], knowledge_path: 'cli_architecture/test.md', needs_review: false, operation_type: 'ADD', + relative_path: 'tmp/file.md', + tags: [], task_id: 'task-1', ...overrides, } @@ -233,7 +235,7 @@ describe('AnalyticsClient', () => { }) const before = Date.now() - const opProps = makeCurateOpProps({absolute_path: '/tmp/merge-fixture.md'}) + const opProps = makeCurateOpProps({relative_path: 'tmp/merge-fixture.md'}) client.track(AnalyticsEventNames.CURATE_OPERATION_APPLIED, opProps) await flushMicrotasks() const after = Date.now() @@ -248,7 +250,7 @@ describe('AnalyticsClient', () => { expect(event.timestamp).to.be.at.most(after) // user properties merged through - expect(event.properties.absolute_path).to.equal('/tmp/merge-fixture.md') + expect(event.properties.relative_path).to.equal('tmp/merge-fixture.md') expect(event.properties.operation_type).to.equal('ADD') // all 5 super properties stamped expect(event.properties.cli_version).to.equal('3.10.3') diff --git a/test/unit/server/infra/analytics/no-op-analytics-client.test.ts b/test/unit/server/infra/analytics/no-op-analytics-client.test.ts index dd88cb076..f178efa2b 100644 --- a/test/unit/server/infra/analytics/no-op-analytics-client.test.ts +++ b/test/unit/server/infra/analytics/no-op-analytics-client.test.ts @@ -15,10 +15,12 @@ describe('NoOpAnalyticsClient', () => { // CURATE_OPERATION_APPLIED with a minimal valid payload. expect(() => client.track(AnalyticsEventNames.CURATE_OPERATION_APPLIED, { - absolute_path: '/tmp/x.md', + keywords: [], knowledge_path: 'kg/x.md', needs_review: false, operation_type: 'ADD', + relative_path: 'tmp/x.md', + tags: [], task_id: 't-1', }), ).to.not.throw() diff --git a/test/unit/server/infra/process/analytics-hook-m14.test.ts b/test/unit/server/infra/process/analytics-hook-m14.test.ts new file mode 100644 index 000000000..85b782293 --- /dev/null +++ b/test/unit/server/infra/process/analytics-hook-m14.test.ts @@ -0,0 +1,317 @@ +import {expect} from 'chai' +import sinon from 'sinon' + +import type {LlmToolResultEvent} from '../../../../../src/server/core/domain/transport/schemas.js' +import type {TaskInfo} from '../../../../../src/server/core/domain/transport/task-info.js' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsHook} from '../../../../../src/server/infra/process/analytics-hook.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' + +const FIXED_NOW = 1_700_000_000_000 + +const buildClient = (): {client: IAnalyticsClient; trackStub: sinon.SinonStub} => { + const trackStub = sinon.stub() + const client: IAnalyticsClient = { + abort() { + /* noop */ + }, + flush: sinon.stub().resolves(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sinon.stub().resolves(), + track: trackStub, + } + return {client, trackStub} +} + +const buildTask = (type: string, overrides: Partial = {}): TaskInfo => + ({ + clientId: 'c1', + completedAt: FIXED_NOW + 1234, + content: 'whatever', + createdAt: FIXED_NOW, + folderPath: undefined, + projectPath: '/project', + taskId: `task-${type}-1`, + toolCalls: [], + type, + ...overrides, + }) as TaskInfo + +const buildCurateOpToolResult = (): LlmToolResultEvent => + ({ + callId: 'call-1', + result: JSON.stringify({ + applied: [ + {filePath: '/a.md', needsReview: false, path: 'a', status: 'success', type: 'ADD'}, + ], + }), + sessionId: 's1', + taskId: 'task-curate-1', + timestamp: FIXED_NOW, + toolName: 'curate' as const, + }) as unknown as LlmToolResultEvent + +const eventSequence = (trackStub: sinon.SinonStub): string[] => + trackStub.getCalls().map((c) => c.args[0] as string) + +describe('AnalyticsHook M14.3 generic task_* emit simulation', () => { + let hook: AnalyticsHook + let trackStub: sinon.SinonStub + + beforeEach(() => { + const bundle = buildClient() + trackStub = bundle.trackStub + hook = new AnalyticsHook() + hook.setAnalyticsClient(bundle.client) + }) + + describe('curate task: full success lifecycle (curate-tool-mode rename simulated)', () => { + it('emits task_created on entry, then per-op + curate_run_completed + task_completed on terminal', async () => { + // Daemon dispatches the pre-ENG-2925 name 'curate-html-direct'; + // analytics is expected to alias-translate to 'curate-tool-mode'. + const task = buildTask('curate-html-direct', {taskId: 'task-curate-1'}) + + await hook.onTaskCreate(task) + await hook.onToolResult(task.taskId, buildCurateOpToolResult()) + await hook.onTaskCompleted(task.taskId, '', task) + + expect(eventSequence(trackStub)).to.deep.equal([ + AnalyticsEventNames.TASK_CREATED, + AnalyticsEventNames.CURATE_OPERATION_APPLIED, + AnalyticsEventNames.CURATE_RUN_COMPLETED, + AnalyticsEventNames.TASK_COMPLETED, + ]) + }) + + it('aliases curate-html-direct → curate-tool-mode on the wire (task_type field)', async () => { + const task = buildTask('curate-html-direct', {taskId: 'task-curate-1'}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const created = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_CREATED) + const completed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_COMPLETED) + expect((created?.args[1] as Record).task_type).to.equal('curate-tool-mode') + expect((completed?.args[1] as Record).task_type).to.equal('curate-tool-mode') + }) + + it('emits task_created has_files=true / has_folder=true when set on TaskInfo', async () => { + const task = buildTask('curate-folder', { + files: ['/a.ts', '/b.ts'], + folderPath: '/some/folder', + taskId: 'task-curate-2', + }) + await hook.onTaskCreate(task) + + const created = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_CREATED) + const props = created?.args[1] as Record + expect(props.has_files).to.equal(true) + expect(props.has_folder).to.equal(true) + expect(props.task_type).to.equal('curate-folder') + }) + + it('emits task_created has_files=false / has_folder=false when both are unset', async () => { + const task = buildTask('curate', {files: undefined, folderPath: undefined, taskId: 'task-curate-3'}) + await hook.onTaskCreate(task) + + const created = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_CREATED) + const props = created?.args[1] as Record + expect(props.has_files).to.equal(false) + expect(props.has_folder).to.equal(false) + }) + }) + + describe('query task: terminal emits include task_completed last', () => { + it('emits task_created → query_completed → task_completed in order', async () => { + const task = buildTask('query-tool-mode', {taskId: 'task-query-1'}) + + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + expect(eventSequence(trackStub)).to.deep.equal([ + AnalyticsEventNames.TASK_CREATED, + AnalyticsEventNames.QUERY_COMPLETED, + AnalyticsEventNames.TASK_COMPLETED, + ]) + }) + + it('emits the same query-tool-mode task_type across all three events', async () => { + const task = buildTask('query-tool-mode', {taskId: 'task-query-1'}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + for (const call of trackStub.getCalls()) { + const props = call.args[1] as Record + expect(props.task_type, `${call.args[0] as string} carried wrong task_type`).to.equal('query-tool-mode') + } + }) + }) + + describe('dream-scan / dream-finalize / search: only task_* emits fire (no M12 per-flavor)', () => { + for (const taskType of ['dream-scan', 'dream-finalize', 'search'] as const) { + it(`${taskType}: task_created → task_completed (no curate/query M12 emit)`, async () => { + const task = buildTask(taskType, {taskId: `task-${taskType}-1`}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + expect(eventSequence(trackStub)).to.deep.equal([ + AnalyticsEventNames.TASK_CREATED, + AnalyticsEventNames.TASK_COMPLETED, + ]) + }) + + it(`${taskType}: onTaskError emits task_created then task_failed (no curate/query M12 emit)`, async () => { + const task = buildTask(taskType, {taskId: `task-${taskType}-2`}) + await hook.onTaskCreate(task) + await hook.onTaskError(task.taskId, 'something blew up', task) + + expect(eventSequence(trackStub)).to.deep.equal([ + AnalyticsEventNames.TASK_CREATED, + AnalyticsEventNames.TASK_FAILED, + ]) + }) + } + }) + + describe('failure + cancellation both surface as task_failed', () => { + it('curate onTaskError emits curate_run_completed(outcome=error) then task_failed', async () => { + const task = buildTask('curate', {taskId: 'task-curate-err'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskError(task.taskId, 'kaboom', task) + + expect(eventSequence(trackStub)).to.deep.equal([ + AnalyticsEventNames.CURATE_RUN_COMPLETED, + AnalyticsEventNames.TASK_FAILED, + ]) + }) + + it('curate onTaskCancelled also emits task_failed (no distinct cancellation event)', async () => { + const task = buildTask('curate', {taskId: 'task-curate-cancel'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskCancelled(task.taskId, task) + + expect(eventSequence(trackStub)).to.deep.equal([ + AnalyticsEventNames.CURATE_RUN_COMPLETED, + AnalyticsEventNames.TASK_FAILED, + ]) + }) + + it('task_failed payload carries duration_ms + task_id + canonical task_type + failure_kind', async () => { + const task = buildTask('query', {taskId: 'task-query-err'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskError(task.taskId, 'kaboom', task) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + const props = failed?.args[1] as Record + expect(props.task_id).to.equal('task-query-err') + expect(props.task_type).to.equal('query') + expect(props.duration_ms).to.equal(1234) + // 'kaboom' classifies to 'unknown' — no recognised sentinel substring + expect(props.failure_kind).to.equal('unknown') + }) + + it('failure_kind is "cancelled" on onTaskCancelled regardless of state', async () => { + const task = buildTask('curate', {taskId: 'task-cancel-fk'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskCancelled(task.taskId, task) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('cancelled') + }) + + it('failure_kind is "timeout" when the error message names a timeout', async () => { + const task = buildTask('search', {taskId: 'task-timeout'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskError(task.taskId, 'request timed out after 30s', task) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('timeout') + }) + + it('failure_kind is "agent_error" when the error message points at the agent layer', async () => { + const task = buildTask('search', {taskId: 'task-agent-err'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskError(task.taskId, 'provider rejected the LLM call', task) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('agent_error') + }) + + it('classifier uses word-boundary matching: "tooltip" / "engagement" do NOT bucket into agent_error (PR #722)', async () => { + const task = buildTask('search', {taskId: 'task-tooltip'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskError(task.taskId, 'could not render tooltip in engagement panel', task) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('unknown') + }) + + it('classifier precedence pinned: timeout wins over agent_error when both substrings present (PR #722)', async () => { + const task = buildTask('search', {taskId: 'task-both'}) + await hook.onTaskCreate(task) + trackStub.resetHistory() + await hook.onTaskError(task.taskId, 'llm provider timeout after 30s', task) + + const failed = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_FAILED) + expect((failed?.args[1] as Record).failure_kind).to.equal('timeout') + }) + }) + + describe('toAnalyticsTaskType drift guard (PR #722)', () => { + it('emits the "unknown" sentinel for an un-enumerated daemon task type instead of silently failing the wire enum', async () => { + const task = buildTask('not-a-real-daemon-type', {taskId: 'task-drift'}) + await hook.onTaskCreate(task) + + const created = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_CREATED) + expect((created?.args[1] as Record).task_type).to.equal('unknown') + }) + }) + + describe('toRelativePath outside-project guard (PR #722)', () => { + it('replaces escaping ../ paths with /basename sentinel', async () => { + const task = buildTask('curate', {projectPath: '/Users/dev/proj', taskId: 'task-outside'}) + await hook.onTaskCreate(task) + const result: LlmToolResultEvent = { + callId: 'c1', + result: JSON.stringify({ + applied: [{filePath: '/tmp/x.md', needsReview: false, path: 'x', status: 'success', type: 'ADD'}], + }), + sessionId: 's1', + taskId: 'task-outside', + timestamp: FIXED_NOW, + toolName: 'curate' as const, + } as unknown as LlmToolResultEvent + await hook.onToolResult('task-outside', result) + + const op = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.CURATE_OPERATION_APPLIED) + expect((op?.args[1] as Record).relative_path).to.equal('/x.md') + }) + + it('replaces raw absolute path with /basename when projectPath is undefined', async () => { + const task = buildTask('curate', {projectPath: undefined, taskId: 'task-no-proj'}) + await hook.onTaskCreate(task) + const result: LlmToolResultEvent = { + callId: 'c1', + result: JSON.stringify({ + applied: [{filePath: '/home/u/secret.md', needsReview: false, path: 'x', status: 'success', type: 'ADD'}], + }), + sessionId: 's1', + taskId: 'task-no-proj', + timestamp: FIXED_NOW, + toolName: 'curate' as const, + } as unknown as LlmToolResultEvent + await hook.onToolResult('task-no-proj', result) + + const op = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.CURATE_OPERATION_APPLIED) + expect((op?.args[1] as Record).relative_path).to.equal('/secret.md') + }) + }) +}) diff --git a/test/unit/server/infra/process/analytics-hook-toolmode-inspection.test.ts b/test/unit/server/infra/process/analytics-hook-toolmode-inspection.test.ts new file mode 100644 index 000000000..723f7ac21 --- /dev/null +++ b/test/unit/server/infra/process/analytics-hook-toolmode-inspection.test.ts @@ -0,0 +1,274 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import sinon from 'sinon' + +import type {LlmToolResultEvent} from '../../../../../src/server/core/domain/transport/schemas.js' +import type {TaskInfo} from '../../../../../src/server/core/domain/transport/task-info.js' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {QueryResultMetadata} from '../../../../../src/server/infra/process/query-log-handler.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsHook} from '../../../../../src/server/infra/process/analytics-hook.js' + +const NOW = 1_700_000_000_000 + +const buildClient = (): {client: IAnalyticsClient; trackStub: sinon.SinonStub} => { + const trackStub = sinon.stub() + const client: IAnalyticsClient = { + abort() { + /* noop */ + }, + flush: sinon.stub().resolves(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sinon.stub().resolves(), + track: trackStub, + } + return {client, trackStub} +} + +const buildToolModeCurateTask = (overrides: Partial = {}): TaskInfo => + ({ + clientId: 'agent-1', + completedAt: NOW + 4321, + content: 'JSON envelope describing the html the calling agent already wrote', + createdAt: NOW, + // Daemon today still dispatches the pre-ENG-2925 name; analytics + // aliases it to 'curate-tool-mode' on the wire via toAnalyticsTaskType. + projectPath: '/Users/dev/example-project', + taskId: 'task-curate-tm-1', + type: 'curate-html-direct', + ...overrides, + }) as TaskInfo + +const buildToolModeQueryTask = (overrides: Partial = {}): TaskInfo => + ({ + clientId: 'agent-1', + completedAt: NOW + 987, + content: 'how does the auth middleware work', + createdAt: NOW, + projectPath: '/Users/dev/example-project', + taskId: 'task-query-tm-1', + toolCalls: [], + type: 'query-tool-mode', + ...overrides, + }) as TaskInfo + +async function fakeReadFileForInspection(filePath: string): Promise { + if (filePath === '/Users/dev/example-project/.brv/notes/auth.md') { + return '---\nkeywords: ["jwt", "session"]\nrelated: ["auth/middleware", "users"]\ntags: ["security"]\n---\nbody\n' + } + + return '---\n---\nempty\n' +} + +/** + * PR #722 review: gated behind `DUMP_ANALYTICS=1` so `npm test` stays quiet + * by default. The shape assertions in each `it()` still execute; the dump + * is an opt-in diagnostic for inspecting payloads (`DUMP_ANALYTICS=1 npx + * mocha test/unit/.../analytics-hook-toolmode-inspection.test.ts`). + */ +const DUMP_ENABLED = process.env.DUMP_ANALYTICS === '1' + +const dumpEvents = (label: string, trackStub: sinon.SinonStub): void => { + if (!DUMP_ENABLED) return + console.log(`\n┌─ ${label} ${'─'.repeat(Math.max(0, 70 - label.length))}`) + for (const [i, call] of trackStub.getCalls().entries()) { + const eventName = call.args[0] as string + const props = call.args[1] as Record + console.log(`│ [${i}] ${eventName}`) + console.log(`│ ${JSON.stringify(props, null, 2).replaceAll('\n', '\n│ ')}`) + } + + console.log(`└${'─'.repeat(72)}\n`) +} + +describe('analytics-hook tool-mode event inspection (M14)', () => { + it('curate-tool-mode: prints every event + payload the daemon emits to analytics', async () => { + const {client, trackStub} = buildClient() + const hook = new AnalyticsHook() + hook.setAnalyticsClient(client) + + const task = buildToolModeCurateTask() + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + dumpEvents('curate-tool-mode — success path', trackStub) + + // Sanity: every event carries the canonical post-rename task_type + for (const call of trackStub.getCalls()) { + const props = call.args[1] as Record + expect(props.task_type, `${call.args[0] as string} task_type`).to.equal('curate-tool-mode') + } + + // Counters all-zero today because onToolResult never fires for + // tool-mode (no LLM tool calls) — that's the FU-1 follow-up. + // + // FU-1 forward-compat note (PR #722 review): once FU-1 lands and the + // daemon synthesises a curate op from `task.result`, these asserts + // will flip from `=== 0` to non-zero. That is a FEATURE, not a + // regression — update the expectations in the FU-1 PR. + const runCompleted = trackStub.getCalls().find((c) => c.args[0] === 'curate_run_completed') + const counters = runCompleted?.args[1] as Record + expect(counters.operations_added).to.equal(0) + expect(counters.operations_updated).to.equal(0) + expect(counters.operations_deleted).to.equal(0) + expect(counters.operations_merged).to.equal(0) + expect(counters.operations_failed).to.equal(0) + expect(counters.pending_review_count).to.equal(0) + }) + + it('curate-tool-mode: error path — prints curate_run_completed(outcome=error) + task_failed', async () => { + const {client, trackStub} = buildClient() + const hook = new AnalyticsHook() + hook.setAnalyticsClient(client) + + const task = buildToolModeCurateTask({taskId: 'task-curate-tm-err'}) + await hook.onTaskCreate(task) + await hook.onTaskError(task.taskId, 'writer rejected: path-exists', task) + + dumpEvents('curate-tool-mode — error path', trackStub) + }) + + it('curate-tool-mode: with a successful tool-result op (FU-1 forward-look — what counters WOULD look like)', async () => { + const {client, trackStub} = buildClient() + const hook = new AnalyticsHook() + hook.setAnalyticsClient(client) + + const task = buildToolModeCurateTask({taskId: 'task-curate-tm-fu1'}) + await hook.onTaskCreate(task) + + // Simulate a curate-op as if FU-1 had synthesised one from task.result + // (today's tool-mode path doesn't fire onToolResult — FU-1 fixes that). + const simulatedOp: LlmToolResultEvent = { + callId: 'sim-1', + result: JSON.stringify({ + applied: [ + { + filePath: '/Users/dev/example-project/.brv/notes/auth.md', + needsReview: false, + path: 'auth', + status: 'success', + type: 'ADD', + }, + ], + }), + sessionId: 'sim-session', + taskId: task.taskId, + timestamp: NOW, + toolName: 'curate' as const, + } as unknown as LlmToolResultEvent + await hook.onToolResult(task.taskId, simulatedOp) + await hook.onTaskCompleted(task.taskId, '', task) + + dumpEvents('curate-tool-mode — FU-1 forward-look (single synthetic op)', trackStub) + }) + + it('query-tool-mode: prints every event + payload the daemon emits to analytics', async () => { + const {client, trackStub} = buildClient() + const hook = new AnalyticsHook() + hook.setAnalyticsClient(client) + + const task = buildToolModeQueryTask() + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + dumpEvents('query-tool-mode — success path (no setQueryResult)', trackStub) + + for (const call of trackStub.getCalls()) { + const props = call.args[1] as Record + expect(props.task_type, `${call.args[0] as string} task_type`).to.equal('query-tool-mode') + } + + // No setQueryResult call → matched_doc_count + read_doc_count both 0, + // tier omitted, read_paths_with_metadata omitted. That's the + // empty-metadata state FU-1's query half closes. + const queryCompleted = trackStub.getCalls().find((c) => c.args[0] === 'query_completed') + const props = queryCompleted?.args[1] as Record + expect(props.matched_doc_count).to.equal(0) + expect(props.read_doc_count).to.equal(0) + expect(props.cache_hit).to.equal(false) + expect(props.tier).to.equal(undefined) + expect(props.read_paths_with_metadata).to.equal(undefined) + }) + + it('query-tool-mode: with setQueryResult (forward-look from FU-1) — populated metadata', async () => { + const {client, trackStub} = buildClient() + const hook = new AnalyticsHook() + hook.setAnalyticsClient(client) + + const task = buildToolModeQueryTask({taskId: 'task-query-tm-fu1'}) + await hook.onTaskCreate(task) + + const metadata: QueryResultMetadata = { + matchedDocs: [], + searchMetadata: {resultCount: 4, topScore: 0.82, totalFound: 4}, + tier: 2, + timing: {durationMs: 987}, + } as QueryResultMetadata + hook.setQueryResult(task.taskId, metadata) + + await hook.onTaskCompleted(task.taskId, '', task) + + dumpEvents('query-tool-mode — FU-1 forward-look (setQueryResult populated)', trackStub) + }) + + it('query (legacy): read_paths_with_metadata carries structured related_paths + relative_path + keywords/tags arrays', async () => { + const trackStub = sinon.stub() + const client: IAnalyticsClient = { + abort() { + /* noop */ + }, + flush: sinon.stub().resolves(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sinon.stub().resolves(), + track: trackStub, + } + const hook = new AnalyticsHook({readFile: fakeReadFileForInspection}) + hook.setAnalyticsClient(client) + + const task = buildToolModeQueryTask({ + taskId: 'task-query-paths-1', + toolCalls: [ + { + args: {filePath: '/Users/dev/example-project/.brv/notes/auth.md'}, + sessionId: 's1', + status: 'completed', + timestamp: NOW, + toolName: 'read_file', + }, + ], + type: 'query', + } as Partial) + + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + dumpEvents('query (legacy) — read_paths_with_metadata + related_paths structure', trackStub) + + const queryCompleted = trackStub.getCalls().find((c) => c.args[0] === 'query_completed') + const props = queryCompleted?.args[1] as Record + const paths = props.read_paths_with_metadata as Array> + expect(paths).to.have.lengthOf(1) + + const entry = paths[0] + expect(entry.relative_path).to.equal('.brv/notes/auth.md') + expect(entry.keywords).to.deep.equal(['jwt', 'session']) + expect(entry.tags).to.deep.equal(['security']) + expect(entry.related_paths).to.deep.equal([ + {keywords: [], relative_path: 'auth/middleware', tags: []}, + {keywords: [], relative_path: 'users', tags: []}, + ]) + }) + + it('query-tool-mode: error path', async () => { + const {client, trackStub} = buildClient() + const hook = new AnalyticsHook() + hook.setAnalyticsClient(client) + + const task = buildToolModeQueryTask({taskId: 'task-query-tm-err'}) + await hook.onTaskCreate(task) + await hook.onTaskError(task.taskId, 'connector unreachable', task) + + dumpEvents('query-tool-mode — error path', trackStub) + }) +}) diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts index 87bae7141..6e3ee8051 100644 --- a/test/unit/server/infra/process/analytics-hook.test.ts +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -114,6 +114,22 @@ describe('AnalyticsHook', () => { hook.setAnalyticsClient(bundle.client) }) + // M14.3 added unconditional task_created / task_completed / task_failed + // emits on every lifecycle callback. Pre-M14.3 tests asserted only the + // M12 per-flavor curate_*/query_completed emits; filter the stub calls + // so existing assertions stay focused on M12 behavior. New M14.3 + // coverage lives in `analytics-hook-m14.test.ts`. + const filterM12 = (stub: sinon.SinonStub): sinon.SinonSpyCall[] => + stub.getCalls().filter((c) => { + const eventName = c.args[0] + return ( + eventName !== AnalyticsEventNames.TASK_CREATED && + eventName !== AnalyticsEventNames.TASK_COMPLETED && + eventName !== AnalyticsEventNames.TASK_FAILED + ) + }) + const m12Calls = (): sinon.SinonSpyCall[] => filterM12(trackStub) + describe('curate task flow', () => { it('emits curate_operation_applied per successful op + bumps matching counter; no event for failed op', async () => { const task = buildCurateTask() @@ -126,18 +142,20 @@ describe('AnalyticsHook', () => { ]) await hook.onToolResult(task.taskId, payload) - expect(trackStub.callCount).to.equal(2) - expect(trackStub.firstCall.args[0]).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) - const firstProps = trackStub.firstCall.args[1] as Record - expect(firstProps.absolute_path).to.equal('/a.md') + expect(m12Calls()).to.have.lengthOf(2) + expect(m12Calls()[0].args[0]).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) + const firstProps = m12Calls()[0].args[1] as Record + // buildCurateTask sets projectPath:'/project'; /a.md escapes the + // project root → PR #722 outside-project sentinel + basename. + expect(firstProps.relative_path).to.equal('/a.md') expect(firstProps.knowledge_path).to.equal('notes/a') expect(firstProps.operation_type).to.equal('ADD') expect(firstProps.needs_review).to.equal(false) - expect(firstProps).to.not.have.property('tags') - expect(firstProps).to.not.have.property('keywords') + expect(firstProps.tags).to.deep.equal([]) + expect(firstProps.keywords).to.deep.equal([]) expect(firstProps).to.not.have.property('related') - const secondProps = trackStub.secondCall.args[1] as Record + const secondProps = m12Calls()[1].args[1] as Record expect(secondProps.needs_review).to.equal(true) expect(secondProps.operation_type).to.equal('UPDATE') }) @@ -157,9 +175,9 @@ describe('AnalyticsHook', () => { await hook.onTaskCompleted(task.taskId, '', task) - expect(trackStub.calledOnce).to.equal(true) - expect(trackStub.firstCall.args[0]).to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) - const props = trackStub.firstCall.args[1] as Record + expect(m12Calls()).to.have.lengthOf(1) + expect(m12Calls()[0].args[0]).to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) + const props = m12Calls()[0].args[1] as Record expect(props.task_id).to.equal(task.taskId) expect(props.task_type).to.equal('curate') expect(props.outcome).to.equal('completed') @@ -186,7 +204,7 @@ describe('AnalyticsHook', () => { await hook.onTaskCompleted(task.taskId, '', task) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record expect(props.outcome).to.equal('partial') expect(props.operations_failed).to.equal(1) }) @@ -198,7 +216,7 @@ describe('AnalyticsHook', () => { await hook.onTaskError(task.taskId, 'boom', task) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record expect(props.outcome).to.equal('error') }) @@ -209,7 +227,7 @@ describe('AnalyticsHook', () => { await hook.onTaskCancelled(task.taskId, task) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record expect(props.outcome).to.equal('cancelled') }) @@ -227,7 +245,7 @@ describe('AnalyticsHook', () => { await hook.onTaskCompleted(task.taskId, '', task) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record expect(props.operations_added).to.equal(1) expect(props.operations_updated).to.equal(1) }) @@ -246,7 +264,7 @@ describe('AnalyticsHook', () => { await hook.onTaskCompleted(task.taskId, '', task) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record expect(props.pending_review_count).to.equal(2) }) @@ -255,7 +273,7 @@ describe('AnalyticsHook', () => { await hook.onTaskCreate(task) await hook.onTaskCompleted(task.taskId, '', task) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record expect(props.task_type).to.equal('curate-folder') }) @@ -267,7 +285,9 @@ describe('AnalyticsHook', () => { buildToolResult([{needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), ) - expect(trackStub.called).to.equal(false) + // No curate_operation_applied for an op missing filePath. (TASK_CREATED + // still fires from onTaskCreate — filtered out via m12Calls().) + expect(m12Calls()).to.have.lengthOf(0) }) }) @@ -298,9 +318,9 @@ describe('AnalyticsHook', () => { } as QueryResultMetadata) await hook.onTaskCompleted(task.taskId, '', task) - expect(trackStub.calledOnce).to.equal(true) - expect(trackStub.firstCall.args[0]).to.equal(AnalyticsEventNames.QUERY_COMPLETED) - const props = trackStub.firstCall.args[1] as Record + expect(m12Calls()).to.have.lengthOf(1) + expect(m12Calls()[0].args[0]).to.equal(AnalyticsEventNames.QUERY_COMPLETED) + const props = m12Calls()[0].args[1] as Record expect(props.task_id).to.equal(task.taskId) expect(props.task_type).to.equal('query') expect(props.outcome).to.equal('completed') @@ -313,13 +333,18 @@ describe('AnalyticsHook', () => { expect(props.matched_doc_count).to.equal(7) const paths = props.read_paths_with_metadata as Array> expect(paths).to.have.lengthOf(3) - // sorted lexicographically - expect(paths.map((p) => p.absolute_path)).to.deep.equal(['/a.md', '/b.md', '/c.md']) - // each entry has only absolute_path, no metadata in M12.2 + // sorted lexicographically; relativized against projectPath:'/project' + expect(paths.map((p) => p.relative_path)).to.deep.equal([ + '/a.md', + '/b.md', + '/c.md', + ]) + // each entry has empty keywords/tags arrays and an empty related_paths + // list — no frontmatter source files exist in this in-memory test. for (const entry of paths) { - expect(entry).to.not.have.property('tags') - expect(entry).to.not.have.property('keywords') - expect(entry).to.not.have.property('related') + expect(entry.tags).to.deep.equal([]) + expect(entry.keywords).to.deep.equal([]) + expect(entry.related_paths).to.deep.equal([]) } }) @@ -336,7 +361,7 @@ describe('AnalyticsHook', () => { await hook.onTaskCreate(task) await hook.onTaskCompleted(task.taskId, '', task) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record const paths = props.read_paths_with_metadata as Array> expect(paths).to.have.lengthOf(10) expect(props.read_doc_count).to.equal(15) // distinct count NOT capped @@ -357,7 +382,7 @@ describe('AnalyticsHook', () => { } as QueryResultMetadata) await localHook.onTaskCompleted(task.taskId, '', task) - const props = localBundle.trackStub.firstCall.args[1] as Record + const props = filterM12(localBundle.trackStub)[0].args[1] as Record expect(props.cache_hit).to.equal(true) }) } @@ -377,7 +402,7 @@ describe('AnalyticsHook', () => { } as QueryResultMetadata) await localHook.onTaskCompleted(task.taskId, '', task) - const props = localBundle.trackStub.firstCall.args[1] as Record + const props = filterM12(localBundle.trackStub)[0].args[1] as Record expect(props.cache_hit).to.equal(false) }) } @@ -387,7 +412,7 @@ describe('AnalyticsHook', () => { await hook.onTaskCreate(task) await hook.onTaskCompleted(task.taskId, '', task) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record expect(props.tier).to.equal(undefined) expect(props.cache_hit).to.equal(false) expect(props.matched_doc_count).to.equal(0) @@ -398,7 +423,7 @@ describe('AnalyticsHook', () => { await hook.onTaskCreate(task) await hook.onTaskCompleted(task.taskId, '', task) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record expect(props).to.not.have.property('read_paths_with_metadata') // Sanity: counts are zero, not omitted. expect(props.read_doc_count).to.equal(0) @@ -411,7 +436,7 @@ describe('AnalyticsHook', () => { await hook.onTaskError(task.taskId, 'boom', task) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record expect(props.outcome).to.equal('error') }) @@ -421,7 +446,7 @@ describe('AnalyticsHook', () => { await hook.onTaskCancelled(task.taskId, task) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record expect(props.outcome).to.equal('cancelled') }) }) @@ -435,18 +460,19 @@ describe('AnalyticsHook', () => { hook.cleanup(curate.taskId) hook.cleanup(query.taskId) - // After cleanup, terminal hooks should be no-ops + // After cleanup, M12 per-flavor emits must NOT fire (no state to read). + // M14.3 generic TASK_COMPLETED still fires unconditionally — filtered. trackStub.resetHistory() await hook.onTaskCompleted(curate.taskId, '', curate) await hook.onTaskCompleted(query.taskId, '', query) - expect(trackStub.called).to.equal(false) + expect(m12Calls()).to.have.lengthOf(0) }) - it('ignores unknown task types (no state created)', async () => { + it('ignores unknown task types (no M12 state created; only generic task_* emits fire)', async () => { const task = buildCurateTask({taskId: 'task-unknown', type: 'unknown' as TaskInfo['type']}) await hook.onTaskCreate(task) await hook.onTaskCompleted(task.taskId, '', task) - expect(trackStub.called).to.equal(false) + expect(m12Calls()).to.have.lengthOf(0) }) it('swallows analyticsClient.track throws (does not propagate)', async () => { @@ -497,13 +523,13 @@ describe('AnalyticsHook', () => { buildToolResult([{filePath, needsReview: false, path: 'a', status: 'success', type: 'ADD'}]), ) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record expect(props.tags).to.deep.equal(['t1', 't2']) expect(props.keywords).to.deep.equal(['x', 'y']) expect(props.related).to.deep.equal(['z']) }) - it('omits tags/keywords/related on DELETE ops (file gone post-op)', async () => { + it('keywords/tags default to empty arrays on DELETE ops (file gone post-op); related stays omitted', async () => { const filePath = join(tmpDir, 'gone.md') const task = buildCurateTask() await hook.onTaskCreate(task) @@ -512,13 +538,13 @@ describe('AnalyticsHook', () => { buildToolResult([{filePath, needsReview: false, path: 'gone', status: 'success', type: 'DELETE'}]), ) - const props = trackStub.firstCall.args[1] as Record - expect(props).to.not.have.property('tags') - expect(props).to.not.have.property('keywords') + const props = m12Calls()[0].args[1] as Record + expect(props.tags).to.deep.equal([]) + expect(props.keywords).to.deep.equal([]) expect(props).to.not.have.property('related') }) - it('omits tags/keywords/related when filePath cannot be read (ENOENT)', async () => { + it('keywords/tags default to empty arrays when filePath cannot be read (ENOENT)', async () => { const filePath = join(tmpDir, 'missing.md') const task = buildCurateTask() await hook.onTaskCreate(task) @@ -527,11 +553,12 @@ describe('AnalyticsHook', () => { buildToolResult([{filePath, needsReview: false, path: 'm', status: 'success', type: 'UPDATE'}]), ) - const props = trackStub.firstCall.args[1] as Record - expect(props).to.not.have.property('tags') + const props = m12Calls()[0].args[1] as Record + expect(props.tags).to.deep.equal([]) + expect(props.keywords).to.deep.equal([]) }) - it('omits tags/keywords/related on malformed YAML (no throw)', async () => { + it('keywords/tags default to empty arrays on malformed YAML (no throw)', async () => { const filePath = join(tmpDir, 'bad.md') writeFileSync(filePath, '---\nthis is: not [valid YAML\n---\nbody', 'utf8') @@ -542,8 +569,9 @@ describe('AnalyticsHook', () => { buildToolResult([{filePath, needsReview: false, path: 'b', status: 'success', type: 'UPDATE'}]), ) - const props = trackStub.firstCall.args[1] as Record - expect(props).to.not.have.property('tags') + const props = m12Calls()[0].args[1] as Record + expect(props.tags).to.deep.equal([]) + expect(props.keywords).to.deep.equal([]) }) it('caps arrays at 50 entries and strings at 256 chars per entry', async () => { @@ -559,13 +587,13 @@ describe('AnalyticsHook', () => { buildToolResult([{filePath, needsReview: false, path: 'h', status: 'success', type: 'UPDATE'}]), ) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record const tags = props.tags as string[] expect(tags).to.have.lengthOf(50) expect(tags[0]).to.have.lengthOf(256) }) - it('skips file reads entirely when isEnabled() returns false', async () => { + it('skips file reads entirely when isEnabled() returns false; keywords/tags fall back to []', async () => { const filePath = join(tmpDir, 'gated.md') writeMarkdown(filePath, {tags: ['should-not-appear']}) @@ -580,8 +608,9 @@ describe('AnalyticsHook', () => { buildToolResult([{filePath, needsReview: false, path: 'g', status: 'success', type: 'UPDATE'}]), ) - const props = disabledBundle.trackStub.firstCall.args[1] as Record - expect(props).to.not.have.property('tags') + const props = filterM12(disabledBundle.trackStub)[0].args[1] as Record + expect(props.tags).to.deep.equal([]) + expect(props.keywords).to.deep.equal([]) }) }) @@ -592,7 +621,9 @@ describe('AnalyticsHook', () => { writeMarkdown(a, {tags: ['ta']}) writeMarkdown(b, {keywords: ['kb']}) + // Pin projectPath to tmpDir so relative_path == 'a.md' / 'b.md'. const task = buildQueryTask({ + projectPath: tmpDir, toolCalls: [ {args: {filePath: a}, sessionId: 's', status: 'completed', timestamp: 1, toolName: 'read_file'}, {args: {filePath: b}, sessionId: 's', status: 'completed', timestamp: 2, toolName: 'read_file'}, @@ -602,21 +633,22 @@ describe('AnalyticsHook', () => { await hook.onTaskCreate(task) await hook.onTaskCompleted(task.taskId, '', task) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record const paths = props.read_paths_with_metadata as Array> - const byPath = Object.fromEntries(paths.map((p) => [p.absolute_path, p])) - expect(byPath[a].tags).to.deep.equal(['ta']) - expect(byPath[a]).to.not.have.property('keywords') - expect(byPath[b].keywords).to.deep.equal(['kb']) - expect(byPath[b]).to.not.have.property('tags') + const byPath = Object.fromEntries(paths.map((p) => [p.relative_path, p])) + expect(byPath['a.md'].tags).to.deep.equal(['ta']) + expect(byPath['a.md'].keywords).to.deep.equal([]) + expect(byPath['b.md'].keywords).to.deep.equal(['kb']) + expect(byPath['b.md'].tags).to.deep.equal([]) }) - it('mixed readable + ENOENT paths: each entry independently has/omits metadata', async () => { + it('mixed readable + ENOENT paths: each entry has keywords/tags arrays (populated or empty)', async () => { const real = join(tmpDir, 'real.md') const missing = join(tmpDir, 'missing.md') writeMarkdown(real, {tags: ['ok']}) const task = buildQueryTask({ + projectPath: tmpDir, toolCalls: [ {args: {filePath: real}, sessionId: 's', status: 'completed', timestamp: 1, toolName: 'read_file'}, {args: {filePath: missing}, sessionId: 's', status: 'completed', timestamp: 2, toolName: 'read_file'}, @@ -626,11 +658,12 @@ describe('AnalyticsHook', () => { await hook.onTaskCreate(task) await hook.onTaskCompleted(task.taskId, '', task) - const props = trackStub.firstCall.args[1] as Record + const props = m12Calls()[0].args[1] as Record const paths = props.read_paths_with_metadata as Array> - const byPath = Object.fromEntries(paths.map((p) => [p.absolute_path, p])) - expect(byPath[real].tags).to.deep.equal(['ok']) - expect(byPath[missing]).to.not.have.property('tags') + const byPath = Object.fromEntries(paths.map((p) => [p.relative_path, p])) + expect(byPath['real.md'].tags).to.deep.equal(['ok']) + expect(byPath['missing.md'].tags).to.deep.equal([]) + expect(byPath['missing.md'].keywords).to.deep.equal([]) }) it('skips per-path file reads when isEnabled() returns false', async () => { @@ -651,9 +684,10 @@ describe('AnalyticsHook', () => { await disabledHook.onTaskCreate(task) await disabledHook.onTaskCompleted(task.taskId, '', task) - const props = disabledBundle.trackStub.firstCall.args[1] as Record + const props = filterM12(disabledBundle.trackStub)[0].args[1] as Record const paths = props.read_paths_with_metadata as Array> - expect(paths[0]).to.not.have.property('tags') + expect(paths[0].tags).to.deep.equal([]) + expect(paths[0].keywords).to.deep.equal([]) }) }) }) @@ -690,11 +724,12 @@ describe('AnalyticsHook', () => { await Promise.all([p1, p2]) - expect(bundle.trackStub.callCount).to.equal(2) - const first = bundle.trackStub.firstCall.args[1] as Record - const second = bundle.trackStub.secondCall.args[1] as Record - expect(first.absolute_path, 'first emit must be op1').to.equal('/op1.md') - expect(second.absolute_path, 'second emit must be op2').to.equal('/op2.md') + expect(filterM12(bundle.trackStub)).to.have.lengthOf(2) + const first = filterM12(bundle.trackStub)[0].args[1] as Record + const second = filterM12(bundle.trackStub)[1].args[1] as Record + // buildCurateTask projectPath:'/project'; absolute paths relativize with '../' prefix + expect(first.relative_path, 'first emit must be op1').to.equal('/op1.md') + expect(second.relative_path, 'second emit must be op2').to.equal('/op2.md') }) it('onTaskCompleted waits for in-flight onToolResult work before emitting CURATE_RUN_COMPLETED', async () => { @@ -717,15 +752,16 @@ describe('AnalyticsHook', () => { const opPromise = orderHook.onToolResult(task.taskId, payload) const completePromise = orderHook.onTaskCompleted(task.taskId, '', task) - // Neither emit can have fired yet — read is still pending. - expect(bundle.trackStub.called, 'no emit before read settles').to.equal(false) + // Neither M12 emit can have fired yet — read is still pending. (TASK_CREATED + // from M14.3 already fired during onTaskCreate but doesn't gate on the read.) + expect(filterM12(bundle.trackStub), 'no M12 emit before read settles').to.have.lengthOf(0) d.resolve(buildFrontmatterDoc('tag-x')) await Promise.all([opPromise, completePromise]) - expect(bundle.trackStub.callCount).to.equal(2) - expect(bundle.trackStub.firstCall.args[0]).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) - expect(bundle.trackStub.secondCall.args[0]).to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(filterM12(bundle.trackStub)).to.have.lengthOf(2) + expect(filterM12(bundle.trackStub)[0].args[0]).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) + expect(filterM12(bundle.trackStub)[1].args[0]).to.equal(AnalyticsEventNames.CURATE_RUN_COMPLETED) }) it('readFile rejection is swallowed: emit fires with frontmatter fields omitted; daemon does not crash', async () => { @@ -743,11 +779,12 @@ describe('AnalyticsHook', () => { await errorHook.onToolResult(task.taskId, payload) - expect(bundle.trackStub.calledOnce).to.equal(true) - const props = bundle.trackStub.firstCall.args[1] as Record - expect(props.absolute_path).to.equal('/missing.md') - expect(props).to.not.have.property('keywords') - expect(props).to.not.have.property('tags') + expect(filterM12(bundle.trackStub)).to.have.lengthOf(1) + const props = filterM12(bundle.trackStub)[0].args[1] as Record + // /missing.md escapes the '/project' root — PR #722 outside-project sentinel. + expect(props.relative_path).to.equal('/missing.md') + expect(props.keywords).to.deep.equal([]) + expect(props.tags).to.deep.equal([]) expect(props).to.not.have.property('related') }) @@ -769,7 +806,10 @@ describe('AnalyticsHook', () => { // directly, but the assertion below catches the leak: a new task with the same // id observes a fresh in-memory state. await cleanupHook.onTaskCreate(task) - expect(bundle.trackStub.callCount, 'no replay after cleanup').to.equal(2) + // M12 emits: 1 curate_operation_applied + 1 curate_run_completed = 2. + // Re-creating the task after cleanup must NOT replay either; it only + // adds another TASK_CREATED (filtered out below). + expect(filterM12(bundle.trackStub), 'no replay after cleanup').to.have.lengthOf(2) }) }) }) diff --git a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts index e469d22eb..ca2461497 100644 --- a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts @@ -64,10 +64,12 @@ describe('AnalyticsHandler', () => { const payload: AnalyticsTrackPayload = { event: AnalyticsEventNames.CURATE_OPERATION_APPLIED, properties: { - absolute_path: '/tmp/a.md', + keywords: [], knowledge_path: 'kg/a.md', needs_review: false, operation_type: 'ADD', + relative_path: 'tmp/a.md', + tags: [], task_id: 't-1', }, } @@ -76,10 +78,12 @@ describe('AnalyticsHandler', () => { expect(analyticsClient.trackCalls).to.have.lengthOf(1) expect(analyticsClient.trackCalls[0].event).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) expect(analyticsClient.trackCalls[0].properties).to.deep.equal({ - absolute_path: '/tmp/a.md', + keywords: [], knowledge_path: 'kg/a.md', needs_review: false, operation_type: 'ADD', + relative_path: 'tmp/a.md', + tags: [], task_id: 't-1', }) }) @@ -116,7 +120,7 @@ describe('AnalyticsHandler', () => { new AnalyticsHandler({analyticsClient, transport}).setup() const handler = transport._handlers.get(AnalyticsEvents.TRACK) as AnalyticsTrackHandler - // CURATE_OPERATION_APPLIED requires absolute_path / knowledge_path / etc. + // CURATE_OPERATION_APPLIED requires relative_path / knowledge_path / etc. await handler({event: AnalyticsEventNames.CURATE_OPERATION_APPLIED, properties: {wrong: 'shape'}}, 'client-1') // QUERY_COMPLETED requires duration_ms / outcome / etc. await handler({event: AnalyticsEventNames.QUERY_COMPLETED, properties: {}}, 'client-1') diff --git a/test/unit/shared/analytics/events/curate-operation-applied.test.ts b/test/unit/shared/analytics/events/curate-operation-applied.test.ts index b273200d4..9d2915ef1 100644 --- a/test/unit/shared/analytics/events/curate-operation-applied.test.ts +++ b/test/unit/shared/analytics/events/curate-operation-applied.test.ts @@ -4,10 +4,12 @@ import {expect} from 'chai' import {CurateOperationAppliedSchema} from '../../../../../src/shared/analytics/events/curate-operation-applied.js' const baseValid = { - absolute_path: '/Users/dev/project/.brv/context-tree/notes/test.md', + keywords: [], knowledge_path: 'notes/test', needs_review: false, operation_type: 'ADD' as const, + relative_path: '.brv/context-tree/notes/test.md', + tags: [], task_id: 'task-uuid-123', } @@ -33,7 +35,9 @@ describe('CurateOperationAppliedSchema', () => { expect(CurateOperationAppliedSchema.safeParse({...baseValid, needs_review: true}).success).to.equal(true) }) - it('accepts payloads omitting any/all of tags, keywords, related', () => { + it('accepts payloads with keywords / tags as required arrays (default empty) and optional related', () => { + // keywords/tags are required after the M14 review tightening; the + // base payload already carries them as []. expect(CurateOperationAppliedSchema.safeParse({...baseValid}).success).to.equal(true) expect(CurateOperationAppliedSchema.safeParse({...baseValid, tags: ['a']}).success).to.equal(true) expect(CurateOperationAppliedSchema.safeParse({...baseValid, keywords: ['k']}).success).to.equal(true) @@ -43,6 +47,15 @@ describe('CurateOperationAppliedSchema', () => { ).to.equal(true) }) + it('rejects payloads missing the required keywords / tags arrays', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {keywords: _k, ...withoutKeywords} = baseValid + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {tags: _t, ...withoutTags} = baseValid + expect(CurateOperationAppliedSchema.safeParse(withoutKeywords).success).to.equal(false) + expect(CurateOperationAppliedSchema.safeParse(withoutTags).success).to.equal(false) + }) + it('accepts tags / keywords / related with exactly 50 entries each', () => { const fifty = Array.from({length: 50}, (_, i) => `entry-${i}`) expect( @@ -75,8 +88,8 @@ describe('CurateOperationAppliedSchema', () => { expect(CurateOperationAppliedSchema.safeParse({...baseValid, confidence: 'maybe'}).success).to.equal(false) }) - it('rejects empty absolute_path / knowledge_path / task_id', () => { - expect(CurateOperationAppliedSchema.safeParse({...baseValid, absolute_path: ''}).success).to.equal(false) + it('rejects empty relative_path / knowledge_path / task_id', () => { + expect(CurateOperationAppliedSchema.safeParse({...baseValid, relative_path: ''}).success).to.equal(false) expect(CurateOperationAppliedSchema.safeParse({...baseValid, knowledge_path: ''}).success).to.equal(false) expect(CurateOperationAppliedSchema.safeParse({...baseValid, task_id: ''}).success).to.equal(false) }) diff --git a/test/unit/shared/analytics/events/curate-run-completed.test.ts b/test/unit/shared/analytics/events/curate-run-completed.test.ts index d9e9e6728..5ec7cec2d 100644 --- a/test/unit/shared/analytics/events/curate-run-completed.test.ts +++ b/test/unit/shared/analytics/events/curate-run-completed.test.ts @@ -63,8 +63,13 @@ describe('CurateRunCompletedSchema', () => { expect(CurateRunCompletedSchema.safeParse({...baseValid, outcome: 'mystery'}).success).to.equal(false) }) - it('rejects out-of-enum task_type', () => { - expect(CurateRunCompletedSchema.safeParse({...baseValid, task_type: 'query'}).success).to.equal(false) + it('rejects an unknown task_type but accepts every canonical TASK_TYPE_VALUES entry', () => { + // M14.2 widened task_type from ['curate', 'curate-folder'] to the + // canonical TASK_TYPE_VALUES tuple so curate-tool-mode round-trips + // the wire boundary. Genuinely unknown values still reject. + expect(CurateRunCompletedSchema.safeParse({...baseValid, task_type: 'not-a-real-type'}).success).to.equal(false) + expect(CurateRunCompletedSchema.safeParse({...baseValid, task_type: 'curate-tool-mode'}).success).to.equal(true) + expect(CurateRunCompletedSchema.safeParse({...baseValid, task_type: 'query'}).success).to.equal(true) }) it('rejects negative counts and duration_ms', () => { diff --git a/test/unit/shared/analytics/events/query-completed.test.ts b/test/unit/shared/analytics/events/query-completed.test.ts index 69072357a..197df52ef 100644 --- a/test/unit/shared/analytics/events/query-completed.test.ts +++ b/test/unit/shared/analytics/events/query-completed.test.ts @@ -16,6 +16,13 @@ const baseValid = { task_type: 'query' as const, } +const baseEntry = { + keywords: [], + related_paths: [], + relative_path: '.brv/notes/a.md', + tags: [], +} + describe('QueryCompletedSchema', () => { describe('valid payloads', () => { it('accepts the minimal required payload with empty read_paths_with_metadata', () => { @@ -48,25 +55,52 @@ describe('QueryCompletedSchema', () => { expect(QueryCompletedSchema.safeParse({...baseValid, cache_hit: true}).success).to.equal(true) }) - it('accepts read_paths_with_metadata entries with no metadata', () => { - const entries = [{absolute_path: '/a.md'}, {absolute_path: '/b.md'}] + it('accepts read_paths_with_metadata entries with empty metadata arrays', () => { + const entries = [ + {...baseEntry, relative_path: '.brv/a.md'}, + {...baseEntry, relative_path: '.brv/b.md'}, + ] expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) }) - it('accepts entries with full optional metadata', () => { - const entries = [{absolute_path: '/a.md', keywords: ['k1'], related: ['r1'], tags: ['t1']}] + it('accepts entries with populated keywords, tags, and structured related_paths', () => { + const entries = [ + { + keywords: ['k1'], + related_paths: [{keywords: [], relative_path: 'r1', tags: []}], + relative_path: '.brv/a.md', + tags: ['t1'], + }, + ] expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) }) it('accepts read_paths_with_metadata with exactly 10 entries', () => { - const entries = Array.from({length: 10}, (_, i) => ({absolute_path: `/file-${i}.md`})) + const entries = Array.from({length: 10}, (_, i) => ({...baseEntry, relative_path: `.brv/file-${i}.md`})) expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) }) - it('accepts entries with tags / keywords / related at the 50-entry cap and 256-char cap', () => { + it('accepts entries with keywords / tags at the 50-entry cap and 256-char strings', () => { const fifty = Array.from({length: 50}, (_, i) => `entry-${i}`) const at256 = 'x'.repeat(256) - const entries = [{absolute_path: '/a.md', keywords: fifty, related: [at256], tags: fifty}] + const entries = [ + { + keywords: fifty, + related_paths: [{keywords: [], relative_path: at256, tags: []}], + relative_path: '.brv/a.md', + tags: fifty, + }, + ] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) + }) + + it('accepts related_paths with up to 50 structured entries', () => { + const fifty = Array.from({length: 50}, (_, i) => ({ + keywords: [], + relative_path: `notes/related-${i}`, + tags: [], + })) + const entries = [{...baseEntry, related_paths: fifty}] expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) }) }) @@ -90,8 +124,13 @@ describe('QueryCompletedSchema', () => { expect(QueryCompletedSchema.safeParse({...baseValid, tier: -1}).success).to.equal(false) }) - it('rejects task_type other than literal "query"', () => { - expect(QueryCompletedSchema.safeParse({...baseValid, task_type: 'curate'}).success).to.equal(false) + it('rejects an unknown task_type but accepts every canonical TASK_TYPE_VALUES entry', () => { + // M14.2 widened task_type from z.literal('query') to the canonical + // TASK_TYPE_VALUES tuple so query-tool-mode round-trips the wire + // boundary. Genuinely unknown values still reject. + expect(QueryCompletedSchema.safeParse({...baseValid, task_type: 'not-a-real-type'}).success).to.equal(false) + expect(QueryCompletedSchema.safeParse({...baseValid, task_type: 'query-tool-mode'}).success).to.equal(true) + expect(QueryCompletedSchema.safeParse({...baseValid, task_type: 'curate'}).success).to.equal(true) }) it('rejects negative or non-integer counts', () => { @@ -100,26 +139,42 @@ describe('QueryCompletedSchema', () => { }) it('rejects read_paths_with_metadata with more than 10 entries', () => { - const entries = Array.from({length: 11}, (_, i) => ({absolute_path: `/file-${i}.md`})) + const entries = Array.from({length: 11}, (_, i) => ({...baseEntry, relative_path: `.brv/file-${i}.md`})) expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) }) - it('rejects entries with empty absolute_path', () => { - const entries = [{absolute_path: ''}] + it('rejects entries with empty relative_path', () => { + const entries = [{...baseEntry, relative_path: ''}] expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) }) - it('rejects entries with more than 50 tags / keywords / related', () => { - const fiftyOne = Array.from({length: 51}, (_, i) => `entry-${i}`) - const tagsEntry = [{absolute_path: '/a.md', tags: fiftyOne}] - expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: tagsEntry}).success).to.equal( + it('rejects entries missing required keywords / tags arrays', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {keywords: _k, ...withoutKeywords} = baseEntry + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {tags: _t, ...withoutTags} = baseEntry + expect( + QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: [withoutKeywords]}).success, + ).to.equal(false) + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: [withoutTags]}).success).to.equal( false, ) }) - it('rejects entries with tag / keyword / related string longer than 256 chars', () => { + it('rejects entries with more than 50 tags / keywords', () => { + const fiftyOne = Array.from({length: 51}, (_, i) => `entry-${i}`) + const tagsEntry = [{...baseEntry, tags: fiftyOne}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: tagsEntry}).success).to.equal(false) + }) + + it('rejects entries with tag / keyword string longer than 256 chars', () => { const at257 = 'x'.repeat(257) - const entries = [{absolute_path: '/a.md', keywords: [at257]}] + const entries = [{...baseEntry, keywords: [at257]}] + expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) + }) + + it('rejects related_paths entries missing keywords / tags / relative_path', () => { + const entries = [{...baseEntry, related_paths: [{relative_path: 'r1'}]}] expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) }) @@ -128,7 +183,7 @@ describe('QueryCompletedSchema', () => { }) it('rejects unknown extra fields inside an entry (strict)', () => { - const entries = [{absolute_path: '/a.md', mystery: 'oops'}] + const entries = [{...baseEntry, mystery: 'oops'}] expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) }) }) diff --git a/test/unit/shared/analytics/events/task-failed.test.ts b/test/unit/shared/analytics/events/task-failed.test.ts index 0cf8e65bd..930bc8d69 100644 --- a/test/unit/shared/analytics/events/task-failed.test.ts +++ b/test/unit/shared/analytics/events/task-failed.test.ts @@ -1,10 +1,11 @@ /* eslint-disable camelcase */ import {expect} from 'chai' -import {TaskFailedSchema} from '../../../../../src/shared/analytics/events/task-failed.js' +import {FailureKindValues, TaskFailedSchema} from '../../../../../src/shared/analytics/events/task-failed.js' const baseValid = { duration_ms: 9000, + failure_kind: 'unknown' as const, task_id: '550e8400-e29b-41d4-a716-446655440000', task_type: 'curate' as const, } @@ -43,4 +44,22 @@ describe('TaskFailedSchema', () => { const {task_id: _, ...withoutTaskId} = baseValid expect(TaskFailedSchema.safeParse(withoutTaskId).success).to.equal(false) }) + + describe('failure_kind (M15.6)', () => { + it('accepts every canonical FailureKindValues entry', () => { + for (const kind of FailureKindValues) { + expect(TaskFailedSchema.safeParse({...baseValid, failure_kind: kind}).success).to.equal(true) + } + }) + + it('rejects an out-of-vocabulary failure_kind', () => { + expect(TaskFailedSchema.safeParse({...baseValid, failure_kind: 'oom'}).success).to.equal(false) + }) + + it('rejects missing failure_kind', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {failure_kind: _, ...withoutKind} = baseValid + expect(TaskFailedSchema.safeParse(withoutKind).success).to.equal(false) + }) + }) }) diff --git a/test/unit/shared/analytics/task-types.test.ts b/test/unit/shared/analytics/task-types.test.ts index 4126f1329..2118ad5c4 100644 --- a/test/unit/shared/analytics/task-types.test.ts +++ b/test/unit/shared/analytics/task-types.test.ts @@ -1,25 +1,45 @@ - + +/* eslint-disable camelcase */ import {expect} from 'chai' +import {CurateRunCompletedSchema} from '../../../../src/shared/analytics/events/curate-run-completed.js' +import {QueryCompletedSchema} from '../../../../src/shared/analytics/events/query-completed.js' +import {TaskCompletedSchema} from '../../../../src/shared/analytics/events/task-completed.js' +import {TaskCreatedSchema} from '../../../../src/shared/analytics/events/task-created.js' +import {TaskFailedSchema} from '../../../../src/shared/analytics/events/task-failed.js' import {TASK_TYPE_VALUES, type TaskType, TaskTypes} from '../../../../src/shared/analytics/task-types.js' describe('TaskTypes', () => { - it('should expose exactly the five daemon task types', () => { + it('should expose every v4.0 daemon task type', () => { expect(Object.keys(TaskTypes).sort()).to.deep.equal([ 'CURATE', 'CURATE_FOLDER', + 'CURATE_TOOL_MODE', 'DREAM', + 'DREAM_FINALIZE', + 'DREAM_SCAN', 'QUERY', + 'QUERY_TOOL_MODE', 'SEARCH', + // PR #722 re-review: 'unknown' is the drift sentinel emitted by + // AnalyticsHook.toAnalyticsTaskType when the daemon dispatches a + // type that isn't enumerated above. Lives in the canonical + // vocabulary so the wire-side z.enum check accepts the row. + 'UNKNOWN', ]) }) it('should map each key to the wire string used by the daemon TaskInfo.type', () => { expect(TaskTypes.CURATE).to.equal('curate') expect(TaskTypes.CURATE_FOLDER).to.equal('curate-folder') + expect(TaskTypes.CURATE_TOOL_MODE).to.equal('curate-tool-mode') + expect(TaskTypes.DREAM).to.equal('dream') + expect(TaskTypes.DREAM_FINALIZE).to.equal('dream-finalize') + expect(TaskTypes.DREAM_SCAN).to.equal('dream-scan') expect(TaskTypes.QUERY).to.equal('query') + expect(TaskTypes.QUERY_TOOL_MODE).to.equal('query-tool-mode') expect(TaskTypes.SEARCH).to.equal('search') - expect(TaskTypes.DREAM).to.equal('dream') + expect(TaskTypes.UNKNOWN).to.equal('unknown') }) it('should expose TaskType as the union of values', () => { @@ -39,4 +59,107 @@ describe('TaskTypes', () => { expect(TASK_TYPE_VALUES.length).to.be.greaterThan(0) }) }) + + describe('v4.0 tool-mode types validate through task_* schemas', () => { + const newTypes = [ + TaskTypes.CURATE_TOOL_MODE, + TaskTypes.QUERY_TOOL_MODE, + TaskTypes.DREAM_SCAN, + TaskTypes.DREAM_FINALIZE, + ] as const + + for (const taskType of newTypes) { + it(`TaskCreatedSchema accepts task_type='${taskType}'`, () => { + const parsed = TaskCreatedSchema.parse({ + has_files: false, + has_folder: false, + task_id: 't-1', + task_type: taskType, + }) + expect(parsed.task_type).to.equal(taskType) + }) + + it(`TaskCompletedSchema accepts task_type='${taskType}'`, () => { + const parsed = TaskCompletedSchema.parse({ + duration_ms: 100, + task_id: 't-1', + task_type: taskType, + }) + expect(parsed.task_type).to.equal(taskType) + }) + + it(`TaskFailedSchema accepts task_type='${taskType}'`, () => { + const parsed = TaskFailedSchema.parse({ + duration_ms: 100, + failure_kind: 'unknown' as const, + task_id: 't-1', + task_type: taskType, + }) + expect(parsed.task_type).to.equal(taskType) + }) + } + + it('rejects an unknown task_type on all three task_* schemas', () => { + const bad = { + duration_ms: 0, + has_files: false, + has_folder: false, + task_id: 't-1', + task_type: 'not-a-real-type' as unknown as TaskType, + } + expect(() => TaskCreatedSchema.parse(bad)).to.throw() + expect(() => TaskCompletedSchema.parse(bad)).to.throw() + expect(() => TaskFailedSchema.parse(bad)).to.throw() + }) + }) + + describe('M12 per-flavor schemas accept tool-mode types (M14.2)', () => { + const curatePayload = { + duration_ms: 100, + operations_added: 0, + operations_deleted: 0, + operations_failed: 0, + operations_merged: 0, + operations_updated: 0, + outcome: 'completed' as const, + pending_review_count: 0, + task_id: 't-1', + } + + const queryPayload = { + cache_hit: false, + duration_ms: 100, + matched_doc_count: 0, + outcome: 'completed' as const, + read_doc_count: 0, + read_tool_call_count: 0, + search_call_count: 0, + task_id: 't-1', + } + + it('CurateRunCompletedSchema accepts curate-tool-mode', () => { + const parsed = CurateRunCompletedSchema.parse({ + ...curatePayload, + task_type: TaskTypes.CURATE_TOOL_MODE, + }) + expect(parsed.task_type).to.equal('curate-tool-mode') + }) + + it('CurateRunCompletedSchema still accepts the legacy curate / curate-folder values', () => { + const curate = CurateRunCompletedSchema.parse({...curatePayload, task_type: TaskTypes.CURATE}) + expect(curate.task_type).to.equal('curate') + const folder = CurateRunCompletedSchema.parse({...curatePayload, task_type: TaskTypes.CURATE_FOLDER}) + expect(folder.task_type).to.equal('curate-folder') + }) + + it('QueryCompletedSchema accepts query-tool-mode', () => { + const parsed = QueryCompletedSchema.parse({...queryPayload, task_type: TaskTypes.QUERY_TOOL_MODE}) + expect(parsed.task_type).to.equal('query-tool-mode') + }) + + it('QueryCompletedSchema still accepts the legacy query value', () => { + const parsed = QueryCompletedSchema.parse({...queryPayload, task_type: TaskTypes.QUERY}) + expect(parsed.task_type).to.equal('query') + }) + }) }) From 2f418cc2b4c28a7b02041cf0d843d1301b74d941 Mon Sep 17 00:00:00 2001 From: cuongdo-byterover Date: Thu, 28 May 2026 10:20:28 +0700 Subject: [PATCH 60/87] Feat/eng 2964 (#725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: [ENG-2969] M14.1 extend TASK_TYPE_VALUES with v4.0 tool-mode task types Adds curate-tool-mode, query-tool-mode, dream-scan, dream-finalize to TaskTypes + TASK_TYPE_VALUES. After ENG-2925's rename the daemon dispatches these four types but the analytics enum still predates v4.0 — task_created / task_completed / task_failed silently rejected every tool-mode emit at the Zod boundary. - TaskTypes keeps the legacy 'curate' / 'query' / 'dream' values for back-compat with any constructor still building those payloads - per-event schemas (task_created / task_completed / task_failed) pick up the new types automatically via z.enum(TASK_TYPE_VALUES) - M12 per-flavor schemas (curate_run_completed / query_completed) still hardcode their own literals and continue to reject tool-mode types here — M14.2 migrates them to the canonical enum as a follow-up TDD: - new task-types tests assert TaskCreated/Completed/Failed accept all four new types - regression tests pin the M12 schemas' continued rejection so M14.2 has a clear flip point * fix: [ENG-2970] M14.2 relax curate_run_completed + query_completed task_type to TASK_TYPE_VALUES Migrates the two M12 per-flavor schemas from hardcoded literal task_type values to the canonical TASK_TYPE_VALUES enum so v4.0 tool-mode tasks round-trip the wire boundary instead of being silently Zod-rejected inside AnalyticsHook. - curate_run_completed: z.enum(['curate', 'curate-folder']) → z.enum(TASK_TYPE_VALUES). curate-tool-mode payloads now validate. - query_completed: z.literal('query') → z.enum(TASK_TYPE_VALUES). query-tool-mode payloads now validate. The schemas no longer structurally constrain task_type to the curate or query family; the hook is trusted to only emit each event for the right flavor. Docblocks call out the widening for the next maintainer. TDD: - curate-run-completed.test asserts curate-tool-mode + legacy values both succeed; an unknown task_type still rejects - query-completed.test mirrors the same coverage for query-tool-mode - task-types.test M14.1 regression assertions flipped from rejection to acceptance for the M12 schemas; M14.1 docblock comments updated Without this, M14.3's hook code would land but the M12 emits that fire alongside the new generic task_* emits would silently disappear on every tool-mode task — that's the bug operators noticed in Mixpanel. * feat: [ENG-2971] M14.3 wire task_created + task_completed + task_failed in AnalyticsHook Adds the three generic funnel-event emits described in M14's customer ask. Every daemon task (curate / curate-folder / curate-tool-mode (aliased from curate-html-direct) / query / query-tool-mode / dream-scan / dream-finalize / search) now produces, in order: task_created onTaskCreate (funnel entry — unconditional) ...optional per-op + M12 per-flavor terminal emit task_completed onTaskCompleted (terminal-event-last on success) task_failed onTaskError / onTaskCancelled (terminal-event-last) Three coupled changes shipped together so the wire stays consistent: 1. analytics-hook.ts grows three emits and a `toAnalyticsTaskType` alias-translator. The daemon still dispatches the pre-ENG-2925 name `curate-html-direct`; analytics canonicalises it to the post-rename `curate-tool-mode` so the wire enum matches TASK_TYPE_VALUES. Once ENG-2925 lands, the alias becomes a no-op identity. 2. CURATE_TASK_TYPES + QUERY_TASK_TYPES gain the tool-mode names so M12 per-flavor state init kicks in for tool-mode curates / queries. M12 counters stay all-zero on tool-mode today (no LLM tool calls fire) — that's a separate follow-up (FU-1 in plans/analytics-m14/follow-ups). 3. Both M12 payload builders route `task_type` through the same alias so curate-tool-mode tasks emit `task_type='curate-tool-mode'` on the curate_run_completed / query_completed events too. TDD: - new analytics-hook-m14.test.ts: 15 simulation tests covering every task type, the curate-html-direct → curate-tool-mode alias, has_files / has_folder semantics, both terminal paths, and the ordering invariant - existing analytics-hook.test.ts updated to filter out the new generic emits via `filterM12()` so M12-focused assertions keep their intent - integration stress tests updated for the new 13-event sequence (task_created → ops → curate_run_completed → task_completed) - repo full suite: 9121 passing, 0 failing * refactor: [ENG-2971] M14.3 review fix — relative paths, required keywords/tags, structured related_paths Tightens the curate / query M12 payloads per code review: curate_operation_applied: - rename absolute_path → relative_path (project-relative via path.relative against the task's projectPath; falls back to identity when projectPath is unset so search tasks at the daemon root still emit a usable string) - keywords / tags promoted from optional to required arrays (default []) so the wire shape stays uniform regardless of frontmatter read success query_completed.read_paths_with_metadata: - same absolute_path → relative_path rename - same keywords / tags promotion (required arrays, default []) - flat related: string[] → structured related_paths: [{relative_path, keywords, tags}] so each linked topic carries its own metadata slot. Keywords/tags default to [] until a future FU cascade-reads each linked file's frontmatter — the wire shape doesn't need to change when that lands. Hook implementation: - new `toRelativePath(filePath, projectPath)` helper using node:path.relative - CurateTaskAnalyticsState stores projectPath captured at onTaskCreate so per-op emits can relativize without threading task through processToolResult - all four payload sites (curate-op, curate-run-completed, query-completed read_paths_with_metadata) route through the helper Inspection test added at test/unit/.../analytics-hook-toolmode-inspection that pretty-prints every event + payload for curate-tool-mode / query / query-tool-mode flows — gives PMs a single place to verify the wire shape end-to-end. Privacy win: relative paths drop the /Users/{name} prefix from every file-touched event, keeping host-identifiable PII off the analytics wire while still letting PMs reason about which file inside a project an operation touched. Tests: full repo 9132 passing. * feat: [ENG-2964] M15.6 wire AnalyticsHook into lifecycleHooks + failure_kind on task_failed Delivers M15.6's customer-facing piece: the actual wiring of AnalyticsHook into the daemon's lifecycleHooks[] (which M14.3 built emit logic for but never plumbed) plus the coarse failure_kind classifier on task_failed. Per the M14 / M15.6 alignment (Option A): M14.1-M14.3 ship as the foundation. M15.6 adds the wiring + failure_kind. M15.6's stated "scaffolded only" stance on task_created + per-flavor schemas is intentionally relaxed — M14 already produces those events and the review-tightening on curate_operation_applied / read_paths_with_metadata delivers PM-visible value that the M15.6 description deferred. Changes: src/server/infra/daemon/brv-server.ts: - import AnalyticsHook - construct it BEFORE TransportHandlers so it can land in lifecycleHooks[] alongside curate-log / query-log / task-history (now 4 peers, not 3) - the isAnalyticsEnabled gate from setupFeatureHandlers binds in via a closure ref that defers lookup until emit-time - capture setupFeatureHandlers's return value (was discarded), then setAnalyticsClient(analyticsClient) on the pre-registered hook src/shared/analytics/events/task-failed.ts: - new FailureKindValues + FailureKind type: 'cancelled' | 'timeout' | 'agent_error' | 'unknown' (coarse vocab, ≤64 chars, never raw error.message) - failure_kind added to TaskFailedSchema as a required field - docblock notes the M15.6 privacy contract src/server/infra/process/analytics-hook.ts: - new classifyFailureKind(errorMessage) helper. Substring sentinels for 'timeout' / 'timed out' / 'deadline exceeded' → 'timeout'; 'agent' / 'llm' / 'provider' / 'tool' → 'agent_error'; default 'unknown'. Raw error string NEVER ends up on the wire. - emitTaskFailed now takes a FailureKind argument - onTaskCancelled → emitTaskFailed(..., 'cancelled') - onTaskError → emitTaskFailed(..., classifyFailureKind(errorMessage)) TDD: - task-failed.test: 3 new tests pin the failure_kind shape (accepts the 4 canonical values, rejects out-of-vocab, rejects missing) - analytics-hook-m14.test: 3 new tests verify the classifier paths land on the wire — 'kaboom' → 'unknown', timeout strings → 'timeout', agent strings → 'agent_error' - new analytics-hook-lifecycle-wiring.test integration covers the end-to-end task:create → analyticsHook.onTaskCreate path through a real TaskRouter, plus all four task-type variants going through task_created → task_completed in sequence Full suite: 9145 passing, 0 failing. Lint clean. * refactor: [ENG-2964] M15.6 address PR #722 review comments (7 of 7) #1 classifyFailureKind — word-boundary regex + pinned precedence: Substring matching let 'tooltip' / 'engagement' / 'urgent' bucket into agent_error. Tightened to /\b(agent|llm|provider|tool)\b/ and /\b(timeout|timed out|deadline exceeded)\b/. Docblock pins precedence (timeout > agent_error > unknown) so future if-order shuffles can't silently rebucket the funnel. #2 toRelativePath outside-project guard (privacy bug): path.relative('/proj','/Users/dev/other/x.md') returned '../../Users/dev/other/x.md' — still encoded the host layout. Same hole when projectPath was undefined (fell back to raw absolute path). Now returns '/' in both cases; preserves enough signal for backend grouping without becoming PII. Same guard also runs against absolute-path tails (Windows drive-letter switches). #3 toAnalyticsTaskType drift guard: Replaced `daemonType as TaskType` (per CLAUDE.md anti-cast rule) with a TASK_TYPE_SET membership check + processLog warning + 'unknown' sentinel fallback. A future un-enumerated dispatch now lands a debuggable warning at the daemon instead of disappearing at the backend Zod check. #4 dumpEvents test pollution: Gated behind DUMP_ANALYTICS=1. `npm test` runs the shape assertions silently; opt-in dump still works via `DUMP_ANALYTICS=1 npx mocha test/unit/.../analytics-hook-toolmode-inspection.test.ts`. #5 FU-1 forward-compat comment: Added a block-comment note above the tool-mode "operations_*: 0" assertions so when FU-1 lands and the counters flip non-zero, the failure reads as "feature, update the test" rather than a regression. #6 curate_operation_applied.related asymmetry: Added a TODO(M15.x) marker on the `related` field flagging the wire-shape asymmetry with `query_completed.read_paths_with_metadata .related_paths` (structured). Restructure is consumer-migration territory — own ticket. #7 brv-server.ts loud-fail assertion: Added an explicit throw if setupFeatureHandlers returns without analyticsClient. Future refactors that drop the field will explode here instead of silently no-op'ing every emit forever. Tests added to lock in #1, #2, #3 behavior so the review intent doesn't drift. Existing stress-test fixtures moved from `/A/op-N.md` under-the-root paths to `/proj/A/op-N.md` so they exercise the in-project case (out-of-project is now its own focused test). Full suite: 9227 passing, 0 failing. Lint clean. * fix: [ENG-2964] M15.6 drift sentinel must be on the wire vocabulary (PR #722 re-review) Re-review caught a real bug in the previous fixup: `toAnalyticsTaskType` returned `'unknown' as TaskType` for un-enumerated daemon types, but `'unknown'` was NOT in `TASK_TYPE_VALUES`. The docblock promised "keeps the event on the wire instead of silently failing the Zod check at the backend" — but the wire-side `z.enum(TASK_TYPE_VALUES)` actually still rejected the row. The local Sinon trackStub doesn't run Zod, which is why the m14 test passed even though the runtime path was broken. Also the `as TaskType` cast moved sites but didn't disappear — the exact anti-pattern the original review flagged. Fix: - Add `TaskTypes.UNKNOWN: 'unknown'` to the canonical vocabulary. - Append `TaskTypes.UNKNOWN` to `TASK_TYPE_VALUES` so every task_* schema accepts the sentinel via `z.enum(TASK_TYPE_VALUES)`. - Introduce `isCanonicalTaskType()` predicate so `toAnalyticsTaskType` narrows without the `as TaskType` cast. Sentinel returned as `TaskTypes.UNKNOWN` directly. - Update `task-types.test` to assert the new `UNKNOWN` key/value pair. Existing negative test for an unknown task_type already uses `'not-a-real-type'` (not `'unknown'`), so it stays green. Cosmetic: dropped the extra-space drift in the OUTSIDE_PROJECT_PATH docblock noted in the same re-review. Tests: 9227 passing, 0 failing. * fix: [ENG-2964] M15.6 restore analyticsFlushScheduler.start() + Phase A e2e E2E shake-down caught a regression my own M15.6 commit (6983e884f) introduced: when I refactored `setupFeatureHandlers`'s return capture from destructured `{analyticsClient, analyticsFlushScheduler, isAnalyticsEnabled}` to a bundled `featureHandlers` object, I dropped the call to `analyticsFlushScheduler.start()` that lived right after the destructure (originally landed in M4.3 / ENG-2645) AND lost the `analyticsFinalFlush` shutdown hook bound to the ShutdownHandler. Symptom: every `track()` call still landed in JSONL with status='pending', but the periodic 30s flush tick never armed (start() un-called) and shutdown never force-flushed (analyticsFinalFlush undefined). Events sat on disk forever. Production-affecting; the existing dev-beta.e2e.ts kept passing only because its CI run uses a real backend that the periodic flush never reached. Fix in brv-server.ts: - Re-add `let analyticsFinalFlush: (() => Promise) | undefined` before ShutdownHandler construction so the shutdown closure can reference it via late-bind (same mutable-holder pattern M4.3 used). - Pass `analyticsFinalFlush: () => analyticsFinalFlush?.() ?? Promise.resolve()` to ShutdownHandler. - After the M15.6 late-bind of setAnalyticsClient, call `featureHandlers.analyticsFlushScheduler.start()` and assign the final-flush closure (`stop() + flushFinal({timeoutMs: 3000})`). E2E coverage (Phase A — 5 cancel scenarios): - New test/e2e/analytics/lifecycle-wire.e2e.ts drives real daemon + transport client + in-process HTTP capture stub. - Each scenario: task:create → 50ms wait → task:cancel → wait for the natural 30s flush tick → assert wire payload. - Coverage: curate-html-direct (alias → curate-tool-mode on wire), curate-folder (via the same flow), query-tool-mode, dream-scan, dream-finalize, search. - Assertions: task_created + task_failed{failure_kind=cancelled} reach the wire with the right task_type. dream / search assert no per-flavor M12 row (only the generic task_* pair). - Wire-shape sanity per request: device-id header, brv-cli/* UA, super-properties (cli_version, os, node_version, environment, device_id) on every event payload. Gated by `npm run test:e2e:lifecycle` (180s timeout) — not part of default `npm test` glob. ~2m end-to-end. Matches dev-beta.e2e.ts precedent. Phase B (super-props deep checks, JSONL/HTTP parity) deferred to a follow-up commit. * test: [ENG-2964] M15.6 add DB-roundtrip e2e for analytics lifecycle Adds `npm run test:e2e:db` covering the FULL chain from `brv` CLI through real HTTP to the local telemetry container into postgres `raw_events`. Complements the Phase A `test:e2e:lifecycle` in-process capture suite — that one stops at the daemon's HTTP request body; this one proves the records actually land in the partitioned `raw_events` table with the documented event names, task_type alias translation, failure_kind, and shared device_id. Three scenarios: - curate-tool-mode (alias-translated from curate-html-direct) - query-tool-mode - search (no per-flavor M12 event, only task_created + task_failed) Test reaches postgres via `docker exec ... psql -t -A` against the existing byterover-telemetry compose stack (no host psql dependency). A `before()` precondition skips the suite with a clear migration hint when raw_events is missing, and `afterEach` preserves the scenario temp dirs on failure so daemon logs + JSONL can be inspected post-hoc. Not picked up by `npm test` (default glob excludes test/e2e/). * test: [ENG-2964] M15.6 expand DB e2e — dream/isolation/wire-shape coverage Grows `npm run test:e2e:db` from 3 → 7 scenarios. Adds dream-scan + dream-finalize roundtrips (previously absent from DB coverage), a deep wire-shape sanity check on a curate scenario, and a two-task isolation guard that confirms task_id selectors don't bleed events across concurrent runs. New per-row sanity helper (`assertRowShape`) is invoked from every scenario and pins: - schema_version = 1 - cli_version matches semver - os ∈ {darwin, linux, win32} - node_version starts with `v` - environment ∈ {development, production} - device_id non-empty + identical across all rows of a task - user_id null (anonymous emits — test never authenticates) Per-flavor payload assertions added: - curate_run_completed: outcome='cancelled', duration_ms ≥ 0, all operations_* counters and pending_review_count = 0 (we cancel before any tool calls land) - query_completed: outcome='cancelled', duration_ms ≥ 0, read_tool_call_count = 0, search_call_count = 0 - task_created: has_files=false, has_folder=false - task_failed: failure_kind='cancelled' Wire-shape sanity scenario adds: - client_timestamp ≤ received_at + 5s budget - both timestamps fall in [test_start - 10s, test_end + 10s] - properties.device_id === identity_device_id column (M4.1 stamp matches the request header) Isolation scenario: - drives a curate-tool-mode task and a query-tool-mode task in sequence against the same daemon - asserts no task_id bleed, task_type partitions cleanly along task_id, both share device_id (single daemon → single config) Cutover note: switched all e2e dispatches from 'curate-html-direct' to 'curate-tool-mode' after the proj branch merge — TaskTypeSchema only accepts the canonical name on the wire now. The legacy alias remains in AnalyticsHook's `toAnalyticsTaskType` and is exercised by the integration test directly, not via the transport. Suite runtime: ~5 min (7 scenarios × ~30s each), all green. * fix: [ENG-2964] use 'curate-tool-mode' through transport in lifecycle tests After the proj/analytics-system-tool-mode merge, TaskTypeSchema only accepts the canonical 'curate-tool-mode' over the wire — ENG-2925's rename fully landed and 'curate-html-direct' is no longer enumerated on `TaskCreateRequest` / `TaskExecute`. Tests that dispatch through TaskRouter (the integration lifecycle-wiring suite + the e2e wire capture suite) were rejected at the Zod boundary, never reached AnalyticsHook, and asserted on undefined emits. The merge resolution I picked for the integration test was wrong — I kept the alias in HEAD on the assumption it exercised the alias translation, but the transport boundary now strips it long before AnalyticsHook gets a chance to translate. The AnalyticsHook unit tests (analytics-hook-m14.test.ts, et al) still exercise the alias path by constructing TaskInfo directly and invoking `hook.onTaskCreate(task)` — those bypass TaskRouter, so 'curate-html-direct' remains a valid test input there and the `toAnalyticsTaskType` aliasing is still covered. --- package.json | 2 + src/server/infra/daemon/brv-server.ts | 22 + test/e2e/analytics/lifecycle-db.e2e.ts | 608 +++++++++++++++++++++++ test/e2e/analytics/lifecycle-wire.e2e.ts | 382 ++++++++++++++ 4 files changed, 1014 insertions(+) create mode 100644 test/e2e/analytics/lifecycle-db.e2e.ts create mode 100644 test/e2e/analytics/lifecycle-wire.e2e.ts diff --git a/package.json b/package.json index 366185a17..3888a3451 100644 --- a/package.json +++ b/package.json @@ -220,6 +220,8 @@ "prepare": "husky", "test": "mocha --forbid-only \"test/**/*.test.ts\"", "test:e2e:analytics": "npm run build && mocha --forbid-only --timeout 600000 \"test/e2e/analytics/dev-beta.e2e.ts\"", + "test:e2e:lifecycle": "npm run build && mocha --forbid-only --timeout 180000 \"test/e2e/analytics/lifecycle-wire.e2e.ts\"", + "test:e2e:db": "npm run build && mocha --forbid-only --timeout 300000 \"test/e2e/analytics/lifecycle-db.e2e.ts\"", "typecheck": "tsc --noEmit && tsc --noEmit -p src/webui/tsconfig.json", "version": "git add README.md" }, diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index ecef516de..71c756c51 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -508,10 +508,21 @@ async function main(): Promise { }, }) + // M4.3: the analytics flush scheduler is constructed inside + // setupFeatureHandlers (later), so the final-flush closure resolves + // through a mutable holder. The shutdown sequence calls this hook + // after the agent pool stops; if setupFeatureHandlers never ran + // (e.g. startup crashed early) the holder stays undefined and the + // hook is skipped. Restored by M15.6 follow-up — the original M4.3 + // wiring got dropped when the late-bind for AnalyticsHook landed. + // eslint-disable-next-line prefer-const + let analyticsFinalFlush: (() => Promise) | undefined + // 9. Create shutdown handler (agent pool shut down before transport) shutdownHandler = new ShutdownHandler({ agentIdleTimeoutPolicy, agentPool, + analyticsFinalFlush: () => analyticsFinalFlush?.() ?? Promise.resolve(), daemonResilience, heartbeatWriter, idleTimeoutPolicy, @@ -690,6 +701,17 @@ async function main(): Promise { analyticsHook.setAnalyticsClient(featureHandlers.analyticsClient) + // M4.3: start the flush scheduler AFTER the first track lands so the + // initial 30s window aligns with real traffic, and wire the shutdown + // hook now that the scheduler exists. Hook stops the scheduler first + // (no new ticks mid-shutdown) before awaiting the best-effort final + // flush against a 3s budget. + featureHandlers.analyticsFlushScheduler.start() + analyticsFinalFlush = async () => { + featureHandlers.analyticsFlushScheduler.stop() + await featureHandlers.analyticsFlushScheduler.flushFinal({timeoutMs: 3000}) + } + // Load auth token AFTER feature handlers are registered. // AuthHandler's onAuthChanged/onAuthExpired callbacks must be wired first // so that loadToken() triggers proper broadcasts to TUI and agents. diff --git a/test/e2e/analytics/lifecycle-db.e2e.ts b/test/e2e/analytics/lifecycle-db.e2e.ts new file mode 100644 index 000000000..5e2c38276 --- /dev/null +++ b/test/e2e/analytics/lifecycle-db.e2e.ts @@ -0,0 +1,608 @@ +/* eslint-disable camelcase, no-await-in-loop */ +/** + * M14 / M15.6 database-roundtrip e2e — drives a real brv daemon, ships + * task-lifecycle events over real HTTP to a local telemetry instance, + * and queries postgres `raw_events` to verify rows landed. + * + * Different from `lifecycle-wire.e2e.ts`: + * - lifecycle-wire stops at the daemon's HTTP request body (in-process + * capture stub). Proves the CLI side of the pipeline. + * - this file goes one more step — through a real telemetry process and + * into postgres. Proves the FULL chain from `brv curate` to row-in-db + * AND that telemetry promotes the documented super-props into top-level + * columns (cli_version / os / node_version / environment / schema_version). + * + * Requires (skips suite if any missing): + * - `docker ps` shows `byterover-telemetry-telemetry-1` listening on 3000 + * (the byterover-telemetry repo's `docker compose up`). + * - `docker ps` shows `byterover-telemetry-postgres-1` listening on 54329. + * - `raw_events` table exists in postgres. If telemetry was just brought + * up, run migrations once via: + * docker exec byterover-telemetry-telemetry-1 sh -c \ + * 'cd /app && node_modules/.bin/typeorm migration:run \ + * -d dist/infrastructure/persistence/typeorm/data-source.js' + * + * Run via `npm run test:e2e:db`. Not picked up by `npm test`. + * Sequential by design — each `it()` mutates `process.env`. + */ + +import {expect} from 'chai' +import {spawnSync} from 'node:child_process' +import {existsSync, mkdtempSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {dirname, join, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' + +import {resolveLocalServerMainPath} from '../../../src/server/utils/server-main-resolver.js' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(HERE, '..', '..', '..') +const BRV_BIN = join(REPO_ROOT, 'bin', 'run.js') +const DIST_DAEMON = join(REPO_ROOT, 'dist', 'server', 'infra', 'daemon', 'brv-server.js') + +const TELEMETRY_URL = process.env.E2E_TELEMETRY_URL ?? 'http://localhost:3000' +const POSTGRES_CONTAINER = process.env.E2E_POSTGRES_CONTAINER ?? 'byterover-telemetry-postgres-1' +const POSTGRES_USER = 'telemetry' +const POSTGRES_DB = 'telemetry_test' +const STUB_IAM = process.env.BRV_IAM_BASE_URL ?? 'https://dev-beta-iam.byterover.dev' + +type ScenarioEnv = { + dataDir: string + env: NodeJS.ProcessEnv + home: string +} + +/** + * Per-row projection used for the cheap event-name / task-type / device-id + * assertions. Columns map 1:1 to `raw_events` schema (see + * byterover-telemetry/.../raw-event.typeorm.entity.ts). + */ +type RawEventRow = { + cli_version: string + client_timestamp: string + device_id: string + environment: string + event_name: string + failure_kind: null | string + node_version: string + os: string + outcome: null | string + properties_json: string + received_at: string + schema_version: number + task_id: string + task_type: string + user_id: null | string +} + +function sleep(ms: number): Promise { + return new Promise((res) => { + setTimeout(res, ms) + }) +} + +function makeScenarioEnv(): ScenarioEnv { + const dataDir = mkdtempSync(join(tmpdir(), 'brv-e2e-db-')) + const home = mkdtempSync(join(tmpdir(), 'brv-home-')) + return { + dataDir, + env: { + ...process.env, + BRV_ANALYTICS_BASE_URL: TELEMETRY_URL, + BRV_DATA_DIR: dataDir, + BRV_ENV: 'development', + BRV_IAM_BASE_URL: STUB_IAM, + HOME: home, + }, + home, + } +} + +function runBrv(args: string[], env: NodeJS.ProcessEnv, timeoutMs = 30_000): {ok: boolean; reason?: string} { + const result = spawnSync(process.execPath, [BRV_BIN, ...args], {env, stdio: 'ignore', timeout: timeoutMs}) + if (result.error) return {ok: false, reason: `brv ${args.join(' ')} failed: ${result.error.message}`} + if (result.status !== 0) return {ok: false, reason: `brv ${args.join(' ')} exit ${result.status}`} + return {ok: true} +} + +function restartBrv(env: NodeJS.ProcessEnv): void { + spawnSync(process.execPath, [BRV_BIN, 'restart'], {env, stdio: 'ignore', timeout: 30_000}) +} + +async function waitFor(predicate: () => Promise, timeoutMs: number, intervalMs = 1000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (await predicate()) return true + await sleep(intervalMs) + } + + return predicate() +} + +/** + * Execute SQL against the postgres container via `docker exec`. Avoids a + * host-side `psql` dependency; the container already has it baked in. + * `-t -A` strips header + alignment so each row is a single tab-delimited + * line we can split safely. + */ +function execSql(sql: string): {ok: boolean; output: string; reason?: string} { + const result = spawnSync( + 'docker', + ['exec', '-i', POSTGRES_CONTAINER, 'psql', '-U', POSTGRES_USER, '-d', POSTGRES_DB, '-t', '-A', '-F', '\t', '-c', sql], + {encoding: 'utf8', timeout: 10_000}, + ) + if (result.error) return {ok: false, output: '', reason: result.error.message} + if (result.status !== 0) return {ok: false, output: result.stderr.trim(), reason: `psql exit ${result.status}`} + return {ok: true, output: result.stdout.trim()} +} + +/** + * Query raw_events for rows matching a task_id. Returns one row per event + * (task_created, task_failed, curate_run_completed, etc) with both the + * promoted columns (cli_version / os / ...) and the raw properties JSONB + * for deep inspection. + */ +function fetchEvents(taskIdLike: string): RawEventRow[] { + const sql = ` + SELECT event_name, + properties->>'task_id' AS task_id, + properties->>'task_type' AS task_type, + COALESCE(properties->>'failure_kind', '') AS failure_kind, + COALESCE(properties->>'outcome', '') AS outcome, + identity_device_id AS device_id, + COALESCE(identity_user_id, '') AS user_id, + cli_version, + os, + node_version, + environment, + schema_version::text, + client_timestamp::text, + received_at::text, + properties::text + FROM raw_events + WHERE properties->>'task_id' = '${taskIdLike}' + ORDER BY received_at + ` + const result = execSql(sql) + if (!result.ok) return [] + if (result.output.length === 0) return [] + const rows: RawEventRow[] = [] + for (const line of result.output.split('\n')) { + const [ + event_name, + task_id, + task_type, + failure_kind, + outcome, + device_id, + user_id, + cli_version, + os, + node_version, + environment, + schema_version, + client_timestamp, + received_at, + properties_json, + ] = line.split('\t') + rows.push({ + cli_version, + client_timestamp, + device_id, + environment, + event_name, + failure_kind: failure_kind === '' ? null : failure_kind, + node_version, + os, + outcome: outcome === '' ? null : outcome, + properties_json, + received_at, + schema_version: Number.parseInt(schema_version, 10), + task_id, + task_type, + user_id: user_id === '' ? null : user_id, + }) + } + + return rows +} + +async function checkTelemetryReachable(): Promise { + try { + // eslint-disable-next-line n/no-unsupported-features/node-builtins + const res = await fetch(`${TELEMETRY_URL}/health`, {signal: AbortSignal.timeout(3000)}) + return res.status === 200 + } catch { + return false + } +} + +function checkPostgresReachable(): boolean { + return execSql('SELECT 1').ok +} + +function checkRawEventsTableExists(): boolean { + const result = execSql("SELECT to_regclass('public.raw_events')") + if (!result.ok) return false + // to_regclass returns the table name when found, empty string when not. + return result.output.trim().length > 0 && result.output.trim() !== '' +} + +async function fireCreateAndCancel( + env: NodeJS.ProcessEnv, + task: {content: string; projectPath?: string; taskId: string; type: string}, +): Promise { + const prev = { + BRV_ANALYTICS_BASE_URL: process.env.BRV_ANALYTICS_BASE_URL, + BRV_DATA_DIR: process.env.BRV_DATA_DIR, + BRV_ENV: process.env.BRV_ENV, + BRV_IAM_BASE_URL: process.env.BRV_IAM_BASE_URL, + HOME: process.env.HOME, + } + process.env.BRV_ANALYTICS_BASE_URL = env.BRV_ANALYTICS_BASE_URL + process.env.BRV_DATA_DIR = env.BRV_DATA_DIR + process.env.BRV_ENV = env.BRV_ENV + process.env.BRV_IAM_BASE_URL = env.BRV_IAM_BASE_URL + process.env.HOME = env.HOME + try { + const {connectToDaemon} = await import('@campfirein/brv-transport-client') + const {client} = await connectToDaemon({ + clientType: 'cli', + fromDir: REPO_ROOT, + projectPath: task.projectPath ?? REPO_ROOT, + serverPath: resolveLocalServerMainPath(), + }) + + await client.requestWithAck('task:create', { + content: task.content, + projectPath: task.projectPath ?? REPO_ROOT, + taskId: task.taskId, + type: task.type, + }) + await sleep(50) + await client.requestWithAck('task:cancel', {taskId: task.taskId}) + await client.disconnect() + } finally { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } +} + +/** + * Common per-row sanity: every row carries the same shape regardless of + * event_name. Promoted columns are populated from super-props at ingest + * time — if the telemetry contract changes we want a single assertion + * point, not a per-test re-check. + */ +function assertRowShape(row: RawEventRow): void { + expect(row.schema_version, `${row.event_name}.schema_version`).to.equal(1) + expect(row.cli_version, `${row.event_name}.cli_version`).to.match(/^\d+\.\d+\.\d+/) + expect(row.os, `${row.event_name}.os`).to.be.oneOf(['darwin', 'linux', 'win32']) + expect(row.node_version, `${row.event_name}.node_version`).to.match(/^v\d+\./) + expect(row.environment, `${row.event_name}.environment`).to.be.oneOf(['development', 'production']) + expect(row.device_id, `${row.event_name}.device_id`).to.be.a('string').and.have.length.greaterThan(0) + // Anonymous emits are expected — the test never logs in, so user_id MUST be null. + expect(row.user_id, `${row.event_name}.user_id (anon)`).to.equal(null) +} + +describe('analytics lifecycle DB roundtrip e2e (M14 / M15.6)', function () { + this.timeout(120_000) + + let scenario: ScenarioEnv | undefined + const cleanupDirs: string[] = [] + + before(async function () { + if (!existsSync(BRV_BIN) || !existsSync(DIST_DAEMON)) { + console.log('[db e2e] dist missing — run `npm run build`. Skipping.') + this.skip() + } + + if (!(await checkTelemetryReachable())) { + console.log( + `[db e2e] telemetry not reachable at ${TELEMETRY_URL}.` + + ' Start it via `docker compose up -d` from byterover-telemetry. Skipping.', + ) + this.skip() + } + + if (!checkPostgresReachable()) { + console.log( + `[db e2e] postgres container '${POSTGRES_CONTAINER}' not reachable via docker exec.` + + ' Start it via `docker compose -f docker-compose.test.yml up -d postgres` from byterover-telemetry. Skipping.', + ) + this.skip() + } + + if (!checkRawEventsTableExists()) { + console.log( + '[db e2e] raw_events table missing in telemetry_test DB. Run migrations:\n' + + ' docker exec byterover-telemetry-telemetry-1 sh -c \\\n' + + " 'cd /app && node_modules/.bin/typeorm migration:run " + + "-d dist/infrastructure/persistence/typeorm/data-source.js'\n" + + 'Skipping.', + ) + this.skip() + } + }) + + beforeEach(() => { + scenario = makeScenarioEnv() + cleanupDirs.push(scenario.dataDir, scenario.home) + + expect(runBrv(['analytics', 'enable', '--yes'], scenario.env), 'analytics enable').to.deep.include({ok: true}) + expect(runBrv(['status'], scenario.env), 'daemon boot via status').to.deep.include({ok: true}) + }) + + afterEach(async function () { + if (scenario) { + restartBrv(scenario.env) + // Preserve dirs on failure so we can inspect daemon logs + JSONL. + if (this.currentTest?.state === 'failed') { + console.log(`[db e2e] preserving dataDir=${scenario.dataDir} home=${scenario.home}`) + scenario = undefined + cleanupDirs.length = 0 + return + } + + scenario = undefined + } + + await sleep(300) + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop() + if (dir !== undefined && existsSync(dir)) { + await rm(dir, {force: true, recursive: true}) + } + } + }) + + describe('curate-tool-mode roundtrip', () => { + it('emits task_created + curate_run_completed + task_failed with promoted super-prop columns', async function () { + this.timeout(90_000) + const taskId = `e2e-db-curate-tm-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: 'demo curate tool-mode db roundtrip', + taskId, + type: 'curate-tool-mode', + }) + + const ok = await waitFor(async () => fetchEvents(taskId).length >= 3, 60_000, 2000) + const rows = fetchEvents(taskId) + expect(ok, `rows for ${taskId} (saw ${rows.length}: ${rows.map((r) => r.event_name).join(',')})`).to.equal(true) + + const byName = new Map(rows.map((r) => [r.event_name, r])) + expect(byName.has('task_created'), 'task_created landed').to.equal(true) + expect(byName.has('curate_run_completed'), 'curate_run_completed landed').to.equal(true) + expect(byName.has('task_failed'), 'task_failed landed').to.equal(true) + + // After ENG-2925's rename landed, TaskTypeSchema only accepts the + // canonical 'curate-tool-mode' over the wire — the legacy alias + // ('curate-html-direct') is exercised by unit / integration tests + // against AnalyticsHook directly, not through the transport. + expect(byName.get('task_created')!.task_type).to.equal('curate-tool-mode') + expect(byName.get('task_failed')!.task_type).to.equal('curate-tool-mode') + expect(byName.get('curate_run_completed')!.task_type).to.equal('curate-tool-mode') + + // M15.6 failure_kind classifier — cancel always maps to 'cancelled'. + expect(byName.get('task_failed')!.failure_kind).to.equal('cancelled') + + // M12 curate_run_completed records the terminal outcome — cancel ⇒ 'cancelled'. + expect(byName.get('curate_run_completed')!.outcome).to.equal('cancelled') + + // Promoted super-prop columns + identity columns hold consistent values + // across all rows for this task. + for (const row of rows) assertRowShape(row) + const deviceIds = new Set(rows.map((r) => r.device_id)) + expect(deviceIds.size, 'all rows carry the same device_id').to.equal(1) + const cliVersions = new Set(rows.map((r) => r.cli_version)) + expect(cliVersions.size, 'all rows carry the same cli_version').to.equal(1) + + // duration_ms is a non-negative integer on both task_failed and curate_run_completed. + const failedProps = JSON.parse(byName.get('task_failed')!.properties_json) as {duration_ms: number} + expect(failedProps.duration_ms).to.be.a('number').and.at.least(0) + const curateProps = JSON.parse(byName.get('curate_run_completed')!.properties_json) as { + duration_ms: number + operations_added: number + operations_deleted: number + operations_failed: number + operations_merged: number + operations_updated: number + pending_review_count: number + } + expect(curateProps.duration_ms).to.be.a('number').and.at.least(0) + // Counters all 0 because we cancel before any tool calls. + expect(curateProps.operations_added, 'operations_added').to.equal(0) + expect(curateProps.operations_deleted, 'operations_deleted').to.equal(0) + expect(curateProps.operations_updated, 'operations_updated').to.equal(0) + expect(curateProps.operations_merged, 'operations_merged').to.equal(0) + expect(curateProps.operations_failed, 'operations_failed').to.equal(0) + expect(curateProps.pending_review_count, 'pending_review_count').to.equal(0) + }) + }) + + describe('query-tool-mode roundtrip', () => { + it('emits task_created + query_completed + task_failed with cancelled outcome', async function () { + this.timeout(90_000) + const taskId = `e2e-db-query-tm-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: 'demo query tool-mode db roundtrip', + taskId, + type: 'query-tool-mode', + }) + + const ok = await waitFor(async () => fetchEvents(taskId).length >= 3, 60_000, 2000) + const rows = fetchEvents(taskId) + expect(ok, `rows for ${taskId} (saw ${rows.length})`).to.equal(true) + + const byName = new Map(rows.map((r) => [r.event_name, r])) + expect(byName.has('task_created'), 'task_created landed').to.equal(true) + expect(byName.has('query_completed'), 'query_completed landed').to.equal(true) + expect(byName.has('task_failed'), 'task_failed landed').to.equal(true) + expect(byName.get('task_failed')!.task_type).to.equal('query-tool-mode') + expect(byName.get('task_failed')!.failure_kind).to.equal('cancelled') + expect(byName.get('query_completed')!.outcome).to.equal('cancelled') + + // query_completed payload structure: read/search counters are 0 + // because we cancel before any tool call. Cap-array fields are absent + // when empty (omit-when-empty, not zero-length array — keeps the + // payload small and forces consumers to treat missing as 'no data'). + const queryProps = JSON.parse(byName.get('query_completed')!.properties_json) as { + duration_ms: number + read_tool_call_count: number + search_call_count: number + } + expect(queryProps.duration_ms).to.be.a('number').and.at.least(0) + expect(queryProps.read_tool_call_count).to.equal(0) + expect(queryProps.search_call_count).to.equal(0) + + // task_created carries the has_files / has_folder funnel flags. + const createdProps = JSON.parse(byName.get('task_created')!.properties_json) as { + has_files: boolean + has_folder: boolean + } + expect(createdProps.has_files).to.equal(false) + expect(createdProps.has_folder).to.equal(false) + + for (const row of rows) assertRowShape(row) + }) + }) + + describe('dream-* roundtrip (no per-flavor M12 event)', () => { + for (const type of ['dream-scan', 'dream-finalize'] as const) { + it(`${type}: only task_created + task_failed land`, async function () { + this.timeout(90_000) + const taskId = `e2e-db-${type}-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: `demo ${type} db roundtrip`, + taskId, + type, + }) + + const ok = await waitFor(async () => fetchEvents(taskId).length >= 2, 60_000, 2000) + const rows = fetchEvents(taskId) + expect(ok, `rows for ${taskId} (saw ${rows.length})`).to.equal(true) + + const names = new Set(rows.map((r) => r.event_name)) + expect(names).to.include('task_created') + expect(names).to.include('task_failed') + // Dream task types have no per-flavor producer in AnalyticsHook. + expect(names).to.not.include('curate_run_completed') + expect(names).to.not.include('query_completed') + + const byName = new Map(rows.map((r) => [r.event_name, r])) + expect(byName.get('task_created')!.task_type).to.equal(type) + expect(byName.get('task_failed')!.task_type).to.equal(type) + expect(byName.get('task_failed')!.failure_kind).to.equal('cancelled') + for (const row of rows) assertRowShape(row) + }) + } + }) + + describe('search roundtrip (no per-flavor M12 event)', () => { + it('only task_created + task_failed land — search has no M12 producer', async function () { + this.timeout(90_000) + const taskId = `e2e-db-search-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: 'demo search db roundtrip', + taskId, + type: 'search', + }) + + const ok = await waitFor(async () => fetchEvents(taskId).length >= 2, 60_000, 2000) + const rows = fetchEvents(taskId) + expect(ok, `rows for ${taskId} (saw ${rows.length})`).to.equal(true) + + const names = new Set(rows.map((r) => r.event_name)) + expect(names).to.include('task_created') + expect(names).to.include('task_failed') + expect(names).to.not.include('curate_run_completed') + expect(names).to.not.include('query_completed') + + const byName = new Map(rows.map((r) => [r.event_name, r])) + expect(byName.get('task_failed')!.task_type).to.equal('search') + expect(byName.get('task_failed')!.failure_kind).to.equal('cancelled') + for (const row of rows) assertRowShape(row) + }) + }) + + describe('wire-shape sanity (single scenario, deep checks)', () => { + it('client_timestamp precedes received_at and every event matches the daemon-side identity', async function () { + this.timeout(90_000) + const taskId = `e2e-db-shape-${Date.now()}` + const before = Date.now() + await fireCreateAndCancel(scenario!.env, { + content: 'demo wire-shape sanity', + taskId, + type: 'curate-tool-mode', + }) + + const ok = await waitFor(async () => fetchEvents(taskId).length >= 3, 60_000, 2000) + const rows = fetchEvents(taskId) + expect(ok, `rows for ${taskId} (saw ${rows.length})`).to.equal(true) + const after = Date.now() + + // Ordering: client_timestamp ≤ received_at on every row, and both fall + // within the [before, after] window of this test. The 10s slack + // tolerates clock skew between the test host and the postgres container + // (typically <1s on docker-for-mac, but flush latency can stretch the + // received_at upper bound). + const beforeMs = before - 10_000 + const afterMs = after + 10_000 + for (const row of rows) { + const client = Date.parse(row.client_timestamp) + const received = Date.parse(row.received_at) + expect(Number.isFinite(client), `${row.event_name}.client_timestamp parseable`).to.equal(true) + expect(Number.isFinite(received), `${row.event_name}.received_at parseable`).to.equal(true) + expect(client, `${row.event_name}.client_timestamp >= test start`).to.be.at.least(beforeMs) + expect(received, `${row.event_name}.received_at <= test end`).to.be.at.most(afterMs) + // Allow a 5s budget for client → server transit + flush queueing. + expect(client - received, `${row.event_name}.client_timestamp - received_at ≤ 5s`).to.be.at.most(5000) + } + + // properties.device_id (per-event identity) MUST match the + // identity_device_id column (promoted from the request body). If these + // diverge it means M4.1's per-event identity stamp drifted from the + // request-header device id. + for (const row of rows) { + const props = JSON.parse(row.properties_json) as Record + expect(props.device_id, `${row.event_name}.properties.device_id`).to.equal(row.device_id) + expect(props.task_id).to.equal(taskId) + } + }) + }) + + describe('isolation: two tasks back-to-back', () => { + it('events for task A do not bleed into task B (task_id selector partitions correctly)', async function () { + this.timeout(120_000) + const taskA = `e2e-db-iso-A-${Date.now()}` + const taskB = `e2e-db-iso-B-${Date.now()}` + await fireCreateAndCancel(scenario!.env, {content: 'iso A', taskId: taskA, type: 'curate-tool-mode'}) + await fireCreateAndCancel(scenario!.env, {content: 'iso B', taskId: taskB, type: 'query-tool-mode'}) + + const ok = await waitFor( + async () => fetchEvents(taskA).length >= 3 && fetchEvents(taskB).length >= 3, + 90_000, + 2000, + ) + const rowsA = fetchEvents(taskA) + const rowsB = fetchEvents(taskB) + expect(ok, `rows A=${rowsA.length} B=${rowsB.length}`).to.equal(true) + + // No bleed: every row for A carries task_id A, every row for B carries task_id B. + for (const row of rowsA) expect(row.task_id).to.equal(taskA) + for (const row of rowsB) expect(row.task_id).to.equal(taskB) + // Task A is curate-tool-mode, B is query-tool-mode. The DB confirms + // task_type partitions cleanly along task_id boundaries. + expect(new Set(rowsA.map((r) => r.task_type))).to.deep.equal(new Set(['curate-tool-mode'])) + expect(new Set(rowsB.map((r) => r.task_type))).to.deep.equal(new Set(['query-tool-mode'])) + expect(rowsA.some((r) => r.event_name === 'curate_run_completed'), 'A has curate_run_completed').to.equal(true) + expect(rowsB.some((r) => r.event_name === 'query_completed'), 'B has query_completed').to.equal(true) + // Same device — both tasks ran under the same daemon / global config. + const devices = new Set([...rowsA.map((r) => r.device_id), ...rowsB.map((r) => r.device_id)]) + expect(devices.size, 'A + B share device_id').to.equal(1) + }) + }) +}) diff --git a/test/e2e/analytics/lifecycle-wire.e2e.ts b/test/e2e/analytics/lifecycle-wire.e2e.ts new file mode 100644 index 000000000..be4a3ad52 --- /dev/null +++ b/test/e2e/analytics/lifecycle-wire.e2e.ts @@ -0,0 +1,382 @@ +/* eslint-disable no-await-in-loop */ +/** + * M14 / M15.6 end-to-end wire test — drives a real `brv` daemon with + * AnalyticsHook wired into lifecycleHooks[], dispatches real `task:create` + * + `task:cancel` events via the transport client, and asserts the + * resulting analytics rows are POSTed to a stub HTTP backend with the + * documented wire shape. + * + * Scope (different from `dev-beta.e2e.ts`): + * - dev-beta.e2e.ts emits pre-formed `cli_invocation` events via + * `analytics:track` — proves the daemon's HTTP / retry / backoff path. + * - This file fires real task-lifecycle events through TaskRouter — proves + * AnalyticsHook is in `lifecycleHooks[]` (M15.6) AND that the emit + * payload (`task_type`, `failure_kind`, alias-translated tool-mode + * names) makes it through the JSONL store + HTTP sender unchanged. + * + * Run via `npm run test:e2e:lifecycle`. Not picked up by `npm test` + * (default glob skips `test/e2e/`). Sequential by design — each `it()` + * mutates `process.env`; do NOT run mocha with `--parallel`. + */ + +import {expect} from 'chai' +import {spawnSync} from 'node:child_process' +import {existsSync, mkdtempSync} from 'node:fs' +import {rm} from 'node:fs/promises' +import {createServer as createHttpServer, type Server as HttpServer} from 'node:http' +import {type AddressInfo} from 'node:net' +import {tmpdir} from 'node:os' +import {dirname, join, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' + +import {resolveLocalServerMainPath} from '../../../src/server/utils/server-main-resolver.js' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(HERE, '..', '..', '..') +const BRV_BIN = join(REPO_ROOT, 'bin', 'run.js') +const DIST_DAEMON = join(REPO_ROOT, 'dist', 'server', 'infra', 'daemon', 'brv-server.js') + +/** + * Real dev-beta IAM — daemon hits this once at boot for OIDC discovery + * (~400ms). Anonymous emits don't need a valid session, so analytics + * flows to our in-process stub regardless. Same default `dev-beta.e2e.ts` + * uses. M3.4's env validator rejects path components — root-only URL. + */ +const STUB_IAM = process.env.BRV_IAM_BASE_URL ?? 'https://dev-beta-iam.byterover.dev' + +type ScenarioEnv = { + dataDir: string + env: NodeJS.ProcessEnv + home: string +} + +type CapturedRequest = { + body: {events: Array<{identity: Record; name: string; properties: Record}>} + headers: Record +} + +function sleep(ms: number): Promise { + return new Promise((res) => { + setTimeout(res, ms) + }) +} + +function makeScenarioEnv(backendUrl: string): ScenarioEnv { + const dataDir = mkdtempSync(join(tmpdir(), 'brv-e2e-lifecycle-')) + const home = mkdtempSync(join(tmpdir(), 'brv-home-')) + return { + dataDir, + env: { + ...process.env, + BRV_ANALYTICS_BASE_URL: backendUrl, + BRV_DATA_DIR: dataDir, + BRV_ENV: 'development', + BRV_IAM_BASE_URL: STUB_IAM, + HOME: home, + }, + home, + } +} + +function runBrv(args: string[], env: NodeJS.ProcessEnv, timeoutMs = 30_000): {ok: boolean; reason?: string} { + const result = spawnSync(process.execPath, [BRV_BIN, ...args], {env, stdio: 'ignore', timeout: timeoutMs}) + if (result.error) return {ok: false, reason: `brv ${args.join(' ')} failed to spawn: ${result.error.message}`} + if (result.status !== 0) return {ok: false, reason: `brv ${args.join(' ')} exit ${result.status}`} + return {ok: true} +} + +function restartBrv(env: NodeJS.ProcessEnv): void { + spawnSync(process.execPath, [BRV_BIN, 'restart'], {env, stdio: 'ignore', timeout: 30_000}) +} + +async function waitFor(predicate: () => boolean, timeoutMs: number, intervalMs = 500): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) return true + await sleep(intervalMs) + } + + return predicate() +} + +/** + * In-process HTTP backend that records every POST body to `captured`. + * Returns 200 with `{accepted: N}` matching the M4.x contract. + */ +async function startCaptureBackend(captured: CapturedRequest[]): Promise<{close: () => Promise; url: string}> { + return new Promise((res, rej) => { + const server: HttpServer = createHttpServer((req, response) => { + let body = '' + req.on('data', (chunk) => { + body += chunk + }) + req.on('end', () => { + try { + const parsed = JSON.parse(body) as CapturedRequest['body'] + captured.push({body: parsed, headers: req.headers}) + response.writeHead(200, {'content-type': 'application/json'}) + response.end(JSON.stringify({accepted: parsed.events?.length ?? 0})) + } catch { + response.writeHead(400, {'content-type': 'application/json'}) + response.end('{"error":"bad-json"}') + } + }) + }) + server.on('error', rej) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo | null + if (!addr || typeof addr === 'string') { + rej(new Error('capture-backend: unexpected address shape')) + return + } + + res({ + close: () => + new Promise((closeRes) => { + server.close(() => closeRes()) + }), + url: `http://127.0.0.1:${addr.port}`, + }) + }) + }) +} + +/** + * Drive a real task lifecycle against the daemon: create → cancel. + * + * AnalyticsHook (registered as the 4th lifecycle peer in M15.6) fires + * `onTaskCreate` and `onTaskCancelled`, which emit `task_created` and + * `task_failed{failure_kind:'cancelled'}` rows respectively. The agent + * fork that would normally do the LLM step is intentionally stub'd by + * the cancel before it gets to run — we don't need a real provider for + * the wire-shape assertion. + */ +async function fireCreateAndCancel( + env: NodeJS.ProcessEnv, + task: {content: string; projectPath?: string; taskId: string; type: string}, +): Promise { + const prev = { + BRV_ANALYTICS_BASE_URL: process.env.BRV_ANALYTICS_BASE_URL, + BRV_DATA_DIR: process.env.BRV_DATA_DIR, + BRV_ENV: process.env.BRV_ENV, + BRV_IAM_BASE_URL: process.env.BRV_IAM_BASE_URL, + HOME: process.env.HOME, + } + process.env.BRV_ANALYTICS_BASE_URL = env.BRV_ANALYTICS_BASE_URL + process.env.BRV_DATA_DIR = env.BRV_DATA_DIR + process.env.BRV_ENV = env.BRV_ENV + process.env.BRV_IAM_BASE_URL = env.BRV_IAM_BASE_URL + process.env.HOME = env.HOME + try { + const {connectToDaemon} = await import('@campfirein/brv-transport-client') + const {client} = await connectToDaemon({ + clientType: 'cli', + fromDir: REPO_ROOT, + projectPath: task.projectPath ?? REPO_ROOT, + serverPath: resolveLocalServerMainPath(), + }) + + await client.requestWithAck('task:create', { + content: task.content, + projectPath: task.projectPath ?? REPO_ROOT, + taskId: task.taskId, + type: task.type, + }) + // Yield a tick so the create lifecycle hook completes before cancel. + await sleep(50) + await client.requestWithAck('task:cancel', {taskId: task.taskId}) + await client.disconnect() + } finally { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } +} + +function eventsByTaskId(captured: CapturedRequest[], taskId: string): Array<{name: string; properties: Record}> { + const out: Array<{name: string; properties: Record}> = [] + for (const req of captured) { + for (const ev of req.body.events ?? []) { + if ((ev.properties as {task_id?: string}).task_id === taskId) { + out.push({name: ev.name, properties: ev.properties}) + } + } + } + + return out +} + +describe('analytics lifecycle wire e2e (M14 / M15.6)', function () { + this.timeout(120_000) + + let backend: undefined | {close: () => Promise; url: string} + let captured: CapturedRequest[] + let scenario: ScenarioEnv | undefined + const cleanupDirs: string[] = [] + + before(function () { + if (!existsSync(BRV_BIN)) { + console.log(`[lifecycle e2e] ${BRV_BIN} missing — run \`npm install\`. Skipping suite.`) + this.skip() + } + + if (!existsSync(DIST_DAEMON)) { + console.log(`[lifecycle e2e] ${DIST_DAEMON} missing — run \`npm run build\`. Skipping suite.`) + this.skip() + } + }) + + beforeEach(async () => { + captured = [] + backend = await startCaptureBackend(captured) + scenario = makeScenarioEnv(backend.url) + cleanupDirs.push(scenario.dataDir, scenario.home) + + // Match dev-beta.e2e.ts: enable BEFORE boot. `analytics enable` itself + // starts a daemon via transport autostart, AND the analytics flush + // scheduler reads the enabled flag at boot time. If we boot first (with + // analytics disabled) then flip the flag, the scheduler stays dormant. + expect(runBrv(['analytics', 'enable', '--yes'], scenario.env), 'analytics enable').to.deep.include({ok: true}) + expect(runBrv(['status'], scenario.env), 'daemon boot via status').to.deep.include({ok: true}) + }) + + afterEach(async () => { + if (scenario) { + // restart === force-flush + kill — M4.4 + M5 contract. After this + // runs, every queued row has been attempted against the stub. + restartBrv(scenario.env) + scenario = undefined + } + + if (backend) { + await backend.close() + backend = undefined + } + + await sleep(300) + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop() + if (dir !== undefined && existsSync(dir)) { + await rm(dir, {force: true, recursive: true}) + } + } + }) + + /** + * Common shape check: every event in `captured` carries the super-props + * stamped by M15.1 + base wire fields (id, timestamp, identity, name, + * properties, schema_version). + */ + function assertWireShape(captured_: CapturedRequest[]): void { + expect(captured_.length, 'at least one HTTP POST').to.be.greaterThan(0) + for (const req of captured_) { + expect(req.headers['x-byterover-device-id'], 'device-id header').to.be.a('string') + expect(req.headers['user-agent']).to.match(/^brv-cli\//) + expect(req.body.events.length, 'batch has at least one event').to.be.greaterThan(0) + for (const ev of req.body.events) { + expect(ev.name, 'event name').to.be.a('string').and.have.length.greaterThan(0) + const props = ev.properties as Record + expect(props.cli_version).to.be.a('string') + expect(props.os).to.be.a('string') + expect(props.node_version).to.be.a('string') + expect(props.environment).to.be.oneOf(['development', 'production']) + expect(props.device_id).to.be.a('string') + } + } + } + + describe('P0 — curate-tool-mode', () => { + it('cancel: task_created → task_failed{failure_kind=cancelled, task_type=curate-tool-mode}', async function () { + this.timeout(90_000) + const taskId = `e2e-curate-tm-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: 'demo curate tool-mode', + taskId, + type: 'curate-tool-mode', + }) + + // Wait for the natural 30s flush tick to ship the JSONL rows over + // HTTP to the stub. brv restart in afterEach handles teardown + // (force-flushing what's left, killing the daemon). + const ok = await waitFor(() => eventsByTaskId(captured, taskId).length >= 2, 45_000, 1000) + expect(ok, `events for ${taskId} (saw ${eventsByTaskId(captured, taskId).length})`).to.equal(true) + + const events = eventsByTaskId(captured, taskId) + const names = events.map((e) => e.name).filter((n) => n === 'task_created' || n === 'task_failed') + expect(names).to.include.members(['task_created', 'task_failed']) + + const created = events.find((e) => e.name === 'task_created')! + const failed = events.find((e) => e.name === 'task_failed')! + // TaskTypeSchema only accepts the canonical 'curate-tool-mode' over the + // wire after ENG-2925 — the legacy 'curate-html-direct' alias path is + // exercised by the AnalyticsHook unit tests, not through the transport. + expect(created.properties.task_type).to.equal('curate-tool-mode') + expect(failed.properties.task_type).to.equal('curate-tool-mode') + // failure_kind classifier (M15.6). + expect(failed.properties.failure_kind).to.equal('cancelled') + // duration_ms is a non-negative integer. + expect(failed.properties.duration_ms).to.be.a('number').and.at.least(0) + assertWireShape(captured) + }) + }) + + describe('P0 — query-tool-mode', () => { + it('cancel: task_created → task_failed{failure_kind=cancelled, task_type=query-tool-mode}', async () => { + const taskId = `e2e-query-tm-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: 'demo query tool-mode', + taskId, + type: 'query-tool-mode', + }) + + restartBrv(scenario!.env) + scenario = undefined + const ok = await waitFor(() => eventsByTaskId(captured, taskId).length >= 2, 30_000, 500) + expect(ok, `events for ${taskId} (saw ${eventsByTaskId(captured, taskId).length})`).to.equal(true) + + const events = eventsByTaskId(captured, taskId) + const created = events.find((e) => e.name === 'task_created')! + const failed = events.find((e) => e.name === 'task_failed')! + expect(created.properties.task_type).to.equal('query-tool-mode') + expect(failed.properties.task_type).to.equal('query-tool-mode') + expect(failed.properties.failure_kind).to.equal('cancelled') + assertWireShape(captured) + }) + }) + + describe('P1 — other task types (dream-scan / dream-finalize / search)', () => { + for (const type of ['dream-scan', 'dream-finalize', 'search'] as const) { + it(`cancel: ${type} emits only generic task_* events (no per-flavor M12)`, async () => { + const taskId = `e2e-${type}-${Date.now()}` + await fireCreateAndCancel(scenario!.env, { + content: `demo ${type}`, + taskId, + type, + }) + + restartBrv(scenario!.env) + scenario = undefined + const ok = await waitFor(() => eventsByTaskId(captured, taskId).length >= 2, 30_000, 500) + expect(ok, `events for ${taskId} (saw ${eventsByTaskId(captured, taskId).length})`).to.equal(true) + + const events = eventsByTaskId(captured, taskId) + const eventNames = new Set(events.map((e) => e.name)) + // M15.6 stance: dream / search task types have no M12 per-flavor + // emit. Only the generic task_created + task_failed land. + expect(eventNames).to.not.include('curate_run_completed') + expect(eventNames).to.not.include('query_completed') + expect(eventNames).to.include('task_created') + expect(eventNames).to.include('task_failed') + + const created = events.find((e) => e.name === 'task_created')! + expect(created.properties.task_type).to.equal(type) + }) + } + }) + + // Phase B (JSONL/HTTP parity, super-props deep checks) deferred until + // Phase A is green. Adding them now would conflate "harness shake-down" + // failures with "wire-shape regression" failures — keep the surface + // small while we de-flake. +}) From 25bf6382b926568ad833558815bca7923ffe1683 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Thu, 28 May 2026 11:48:50 +0700 Subject: [PATCH 61/87] feat: [ENG-3001] M15.8 wire deferred-shipped analytics events Closes the producer-side gap for analytics events whose schema + wire handlers shipped in earlier milestones (M2, M4, M13, M15.5) but never had producers landed. After this change the daemon emits daemon_start on boot, cli_invocation per oclif request carrying cli_metadata, mcp_session_start/ended around MCP handshake + disconnect, and mcp_tool_called per MCP-originated tool-mode task (success or failure). Adds one new schema mcp_session_ended (catalog: 46 -> 47). Implementation notes worth pinning in commit: - mcp_session_start fires from setAgentName, not register: MCP oninitialized handshake runs AFTER the Socket.IO connect, so ConnectionMetadata cannot carry the IDE name at register time. - TaskInfo snapshots clientType + clientName at handleTaskCreate so AnalyticsHook still has identity at terminal-event time even after the MCP client disconnects mid-task. - ClientInfo._mcpSessionEmittedName freezes the name session_start emitted so a future mid-session agentName mutation cannot desync start/end correlation. - cli_invocation middleware wraps transport.onRequest with a Symbol marker so a double-attach is a no-op. - onTaskCancelled is treated as success=false in the mcp_tool_called funnel (user-cancel is a not-completed call). Onboarding events (onboarding_auto_setup_started / _completed) stay unwired: no agent->daemon skill-invocation signal exists today. Follow-up spec lives at agent-tmp/m15-8-onboarding-signal-spec.md. --- src/server/core/domain/client/client-info.ts | 28 +++ src/server/core/domain/transport/task-info.ts | 16 ++ src/server/infra/client/client-manager.ts | 83 +++++++ src/server/infra/daemon/brv-server.ts | 27 +++ src/server/infra/process/analytics-hook.ts | 42 ++++ src/server/infra/process/task-router.ts | 17 ++ .../infra/process/transport-handlers.ts | 8 + .../transport/cli-invocation-middleware.ts | 86 +++++++ .../transport/handlers/analytics-handler.ts | 8 + src/shared/analytics/event-names.ts | 1 + src/shared/analytics/events/index.ts | 3 + .../analytics/events/mcp-session-ended.ts | 25 +++ .../client-manager-mcp-analytics.test.ts | 211 ++++++++++++++++++ ...sk-router-client-identity-snapshot.test.ts | 199 +++++++++++++++++ .../analytics-hook-mcp-tool-called.test.ts | 175 +++++++++++++++ .../cli-invocation-middleware.test.ts | 187 ++++++++++++++++ .../unit/shared/analytics/event-names.test.ts | 1 + .../shared/analytics/privacy-fixture.test.ts | 1 + 18 files changed, 1118 insertions(+) create mode 100644 src/server/infra/transport/cli-invocation-middleware.ts create mode 100644 src/shared/analytics/events/mcp-session-ended.ts create mode 100644 test/unit/infra/client/client-manager-mcp-analytics.test.ts create mode 100644 test/unit/infra/process/task-router-client-identity-snapshot.test.ts create mode 100644 test/unit/server/infra/process/analytics-hook-mcp-tool-called.test.ts create mode 100644 test/unit/server/infra/transport/cli-invocation-middleware.test.ts diff --git a/src/server/core/domain/client/client-info.ts b/src/server/core/domain/client/client-info.ts index 90b48d87c..ef91ba34c 100644 --- a/src/server/core/domain/client/client-info.ts +++ b/src/server/core/domain/client/client-info.ts @@ -54,6 +54,14 @@ export class ClientInfo { public readonly type: ClientType /** Mutable: set via setAgentName() for MCP clients after MCP initialize handshake */ private _agentName: string | undefined + /** + * M15.8: frozen copy of the IDE name as emitted on `mcp_session_start`. + * Read by `mcp_session_ended` so the start/end pair always carries + * matching `client_name` even if `_agentName` were re-mutated mid-session. + * Also serves as the "session active for analytics" gate — non-undefined + * iff a `mcp_session_start` has been emitted for this ClientInfo. + */ + private _mcpSessionEmittedName: string | undefined /** Mutable: set via associateProject() for global-scope MCP clients */ private _projectPath: string | undefined @@ -88,6 +96,16 @@ export class ClientInfo { return this.type !== 'agent' } + /** + * M15.8: the `client_name` value emitted on the prior `mcp_session_start`, + * or undefined if no session-start has fired for this ClientInfo yet. + * Read by ClientManager's `mcp_session_ended` emitter so start/end pairs + * remain correlated even if `agentName` were re-mutated mid-session. + */ + get mcpSessionEmittedName(): string | undefined { + return this._mcpSessionEmittedName + } + /** * The project this client is associated with. * Undefined for global-scope MCP clients that haven't been associated yet. @@ -104,6 +122,16 @@ export class ClientInfo { this._projectPath = projectPath } + /** + * M15.8: freeze the IDE name that `mcp_session_start` was just emitted with. + * Called immediately before `analyticsClient.track('mcp_session_start')` + * in ClientManager. The matching `mcp_session_ended` reads this value + * instead of the live `agentName` to guarantee start/end correlation. + */ + markMcpSessionStartEmitted(emittedName: string): void { + this._mcpSessionEmittedName = emittedName + } + /** * Set the agent name for this MCP client. * Called after MCP initialize handshake provides clientInfo. diff --git a/src/server/core/domain/transport/task-info.ts b/src/server/core/domain/transport/task-info.ts index 131eae20d..4796d4b63 100644 --- a/src/server/core/domain/transport/task-info.ts +++ b/src/server/core/domain/transport/task-info.ts @@ -1,4 +1,5 @@ import type {ReasoningContentItem, ToolCallEvent} from '../../../../shared/transport/events/task-events.js' +import type {ClientType} from '../client/client-info.js' import type {TaskErrorData, TaskListItemStatus, TaskType} from './schemas.js' /** @@ -14,6 +15,21 @@ export type TaskInfo = { /** Client's working directory for file validation */ clientCwd?: string clientId: string + /** + * M15.8: snapshot of submitting client's IDE product name (e.g. "Cursor") + * captured at handleTaskCreate. Used by AnalyticsHook to emit + * mcp_tool_called with `client_name` even after the originating MCP + * client disconnects mid-task. Undefined for non-MCP submissions or + * when the MCP handshake had not delivered a name by task-create time. + */ + clientName?: string + /** + * M15.8: snapshot of submitting client's transport kind. Lets + * AnalyticsHook gate MCP-only emits on `clientType === 'mcp'` + * without re-querying ClientManager (which may have disconnected + * the client by task completion). + */ + clientType?: ClientType /** Set when task reaches a terminal state */ completedAt?: number content: string diff --git a/src/server/infra/client/client-manager.ts b/src/server/infra/client/client-manager.ts index 19245b54a..9d2b685c5 100644 --- a/src/server/infra/client/client-manager.ts +++ b/src/server/infra/client/client-manager.ts @@ -100,6 +100,13 @@ export class ClientManager implements IClientManager { this.emitWebuiSessionEnded(existing) } + // M15.8: same orphan-end logic for MCP. Gate on the SNAPSHOTTED start + // emit (mcpSessionEmittedName), not the live agentName — that's the only + // signal a session_start was actually emitted for this ClientInfo. + if (existing?.type === 'mcp' && existing.mcpSessionEmittedName !== undefined) { + this.emitMcpSessionEnded(existing) + } + const client = new ClientInfo({ connectedAt: Date.now(), id: clientId, @@ -130,7 +137,14 @@ export class ClientManager implements IClientManager { const client = this.clients.get(clientId) if (!client) return + // M15.8: mcp_session_start fires on the FIRST handshake (agentName + // transitions from undefined → defined). Re-handshakes (same id, name + // already set) stay idempotent and do not re-emit. + const wasFirstMcpHandshake = client.type === 'mcp' && client.agentName === undefined client.setAgentName(agentName) + if (wasFirstMcpHandshake) { + this.emitMcpSessionStarted(client) + } } /** @@ -152,6 +166,12 @@ export class ClientManager implements IClientManager { this.emitWebuiSessionEnded(client) } + // M15.8: MCP ended fires only if a session_start was previously emitted + // for this ClientInfo (snapshot field set). No start → no end. + if (client.type === 'mcp' && client.mcpSessionEmittedName !== undefined) { + this.emitMcpSessionEnded(client) + } + this.clients.delete(clientId) if (client.projectPath) { @@ -212,6 +232,69 @@ export class ClientManager implements IClientManager { } } + /** + * M15.8: emit mcp_session_ended. Mirrors emitWebuiSessionEnded. Fires on + * unregister and on reconnect orphan-end, only when a prior session-start + * was emitted for this ClientInfo (snapshot field set). Reads the SAME + * client_name the start event carried — never `client.agentName` directly, + * so future mid-session `setAgentName` mutations can't desync start/end. + */ + private emitMcpSessionEnded(client: ClientInfo): void { + const {analyticsClient} = this + if (!analyticsClient) return + const emittedName = client.mcpSessionEmittedName + if (emittedName === undefined) return + const sessionDurationMs = Math.max(0, Date.now() - client.connectedAt) + // eslint-disable-next-line camelcase + clientKindContext.run({client_kind: 'mcp'}, () => { + try { + /* eslint-disable camelcase */ + analyticsClient.track(AnalyticsEventNames.MCP_SESSION_ENDED, { + client_name: emittedName, + session_duration_ms: sessionDurationMs, + started_at_unix_ms: client.connectedAt, + }) + /* eslint-enable camelcase */ + } catch (error) { + processLog( + `[ClientManager] analytics track mcp_session_ended failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }) + } + + /** + * M15.8: emit mcp_session_start. Fires from setAgentName when the + * MCP `oninitialized` handshake delivers the IDE product name — the + * Socket.IO connect itself precedes the handshake, so register() time + * is too early. Snapshots the emitted name onto ClientInfo so the + * matching mcp_session_ended reads the same value. + */ + private emitMcpSessionStarted(client: ClientInfo): void { + const {analyticsClient} = this + if (!analyticsClient) return + const {agentName} = client + if (agentName === undefined) return + // Freeze the about-to-be-emitted name BEFORE track(). Even if track() + // throws, future mid-session agentName mutations can't change what + // emitMcpSessionEnded would emit (it reads the snapshot). + client.markMcpSessionStartEmitted(agentName) + // eslint-disable-next-line camelcase + clientKindContext.run({client_kind: 'mcp'}, () => { + try { + /* eslint-disable camelcase */ + analyticsClient.track(AnalyticsEventNames.MCP_SESSION_START, { + client_name: agentName, + }) + /* eslint-enable camelcase */ + } catch (error) { + processLog( + `[ClientManager] analytics track mcp_session_start failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }) + } + /** * M15.5: emit webui_session_ended. Wrapped in clientKindContext so * SuperPropertiesResolver stamps client_kind='webui' on the envelope diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index ecef516de..22a39439b 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -30,7 +30,9 @@ import {dirname, join} from 'node:path' import {fileURLToPath} from 'node:url' import type {BrvConfig} from '../../core/domain/entities/brv-config.js' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' import {ReviewEvents} from '../../../shared/transport/events/review-events.js' import {TaskEvents, type TaskHeartbeatEvent} from '../../../shared/transport/events/task-events.js' import { @@ -76,6 +78,7 @@ import {FileProviderConfigStore} from '../storage/file-provider-config-store.js' import {FileSettingsStore} from '../storage/file-settings-store.js' import {createProviderKeychainStore} from '../storage/provider-keychain-store.js' import {createTokenStore} from '../storage/token-store.js' +import {attachCliInvocationMiddleware} from '../transport/cli-invocation-middleware.js' import {SocketIOTransportServer} from '../transport/socket-io-transport-server.js' import {createWebUiMiddleware} from '../webui/webui-middleware.js' import {WebUiServer} from '../webui/webui-server.js' @@ -198,10 +201,22 @@ async function main(): Promise { let agentPool: AgentPool | undefined let webuiServer: undefined | WebUiServer + // M15.8 §4 — lazy holder for the analyticsClient. The CLI-invocation + // middleware is attached to the transport server BEFORE setupFeatureHandlers + // constructs the analytics client; this reference is reseated when the + // client lands so the middleware can start emitting. + let analyticsClientRef: IAnalyticsClient | undefined + try { // 4a. Construct transport server. start() is deferred to step 11 so all handlers register before sockets connect. transportServer = new SocketIOTransportServer() + // M15.8 §4 — attach cli_invocation middleware BEFORE any handler is + // registered so every incoming request flows through the wrap. The + // analytics client is not constructed yet; the lazy getter resolves it + // once setupFeatureHandlers below sets analyticsClientRef. + attachCliInvocationMiddleware(transportServer, {getAnalyticsClient: () => analyticsClientRef}) + // 4b. Start Web UI server on stable port (separate from transport) const daemonDir = dirname(fileURLToPath(import.meta.url)) const projectRoot = join(daemonDir, '..', '..', '..', '..') @@ -689,6 +704,9 @@ async function main(): Promise { } analyticsHook.setAnalyticsClient(featureHandlers.analyticsClient) + // M15.8 §4 — seat the cli_invocation middleware's analytics-client ref + // so the wrap (attached at line 211) starts emitting on subsequent requests. + analyticsClientRef = featureHandlers.analyticsClient // Load auth token AFTER feature handlers are registered. // AuthHandler's onAuthChanged/onAuthExpired callbacks must be wired first @@ -697,6 +715,15 @@ async function main(): Promise { await authStateStore.loadToken() authStateStore.startPolling() + // M15.8: emit daemon_start AFTER loadToken() so the event's identity + // envelope reflects the just-resolved auth state (anon vs authed). + // Wrapped in try so a future track-time failure cannot abort boot. + try { + featureHandlers.analyticsClient.track(AnalyticsEventNames.DAEMON_START) + } catch (error: unknown) { + log(`daemon_start emit failed: ${error instanceof Error ? error.message : String(error)}`) + } + // 11. Start idle timer + register signal handlers idleTimeoutPolicy.start() diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index e08b23077..9464af587 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -52,6 +52,17 @@ function toAnalyticsTaskType(daemonType: string): TaskType { return TaskTypes.UNKNOWN } +/** + * M15.8 — map a daemon task type to its MCP tool name. Returns undefined + * for any task that is not an MCP tool-mode flavor; callers gate emit on + * the returned value being defined. + */ +function mcpToolNameForTaskType(daemonType: string): 'brv-curate' | 'brv-query' | undefined { + if (daemonType === TaskTypes.QUERY_TOOL_MODE) return 'brv-query' + if (daemonType === TaskTypes.CURATE_TOOL_MODE) return 'brv-curate' + return undefined +} + /** * Stable sentinel for paths that can't be safely emitted as project- * relative — either outside the project root or the project root itself @@ -234,6 +245,11 @@ export class AnalyticsHook implements ITaskLifecycleHook { async onTaskCancelled(taskId: string, task: TaskInfo): Promise { await this.dispatchTerminal(taskId, task, 'cancelled') this.emitTaskFailed(taskId, task, 'cancelled') + // M15.8 — surface MCP cancellation in the dedicated funnel. The schema + // has only `success: boolean`; user-cancel is a not-completed call, so + // it shares the failure bucket with onTaskError. Without this emit the + // MCP funnel would under-count by the cancellation rate. + this.emitMcpToolCalled(task, false) } async onTaskCompleted(taskId: string, _result: string, task: TaskInfo): Promise { @@ -266,6 +282,10 @@ export class AnalyticsHook implements ITaskLifecycleHook { task_id: taskId, task_type: toAnalyticsTaskType(task.type), }) + + // M15.8 — dedicated MCP funnel emit. Fires alongside (not instead of) + // TASK_COMPLETED; MCP volume is low so the dual-event cost is accepted. + this.emitMcpToolCalled(task, true) } async onTaskCreate(task: TaskInfo): Promise { @@ -297,6 +317,8 @@ export class AnalyticsHook implements ITaskLifecycleHook { async onTaskError(taskId: string, errorMessage: string, task: TaskInfo): Promise { await this.dispatchTerminal(taskId, task, 'error') this.emitTaskFailed(taskId, task, classifyFailureKind(errorMessage)) + // M15.8 — surface MCP failure path in the dedicated funnel. + this.emitMcpToolCalled(task, false) } async onToolResult(taskId: string, payload: LlmToolResultEvent): Promise { @@ -491,6 +513,26 @@ export class AnalyticsHook implements ITaskLifecycleHook { } } + /** + * M15.8 — emit `mcp_tool_called` for MCP-originated tool-mode tasks. + * Gated on `clientType === 'mcp'` AND task type being a tool-mode flavor. + * Falls back to `'unknown'` when the MCP handshake had not delivered a + * client name by `handleTaskCreate` time (rare race; section 3 of M15.8 + * guarantees the name in steady-state). + */ + private emitMcpToolCalled(task: TaskInfo, success: boolean): void { + if (task.clientType !== 'mcp') return + const toolName = mcpToolNameForTaskType(task.type) + if (toolName === undefined) return + + this.emit(AnalyticsEventNames.MCP_TOOL_CALLED, { + client_name: task.clientName ?? 'unknown', + duration_ms: this.durationMs(task), + success, + tool_name: toolName, + }) + } + /** * M14.3 generic terminal-failure emit. Fired by both onTaskError and * onTaskCancelled AFTER dispatchTerminal so M12 per-flavor failure diff --git a/src/server/infra/process/task-router.ts b/src/server/infra/process/task-router.ts index 83130771e..655ff82a8 100644 --- a/src/server/infra/process/task-router.ts +++ b/src/server/infra/process/task-router.ts @@ -16,6 +16,7 @@ */ import type {ReasoningContentItem, ToolCallEvent} from '../../../shared/transport/events/task-events.js' +import type {ClientType} from '../../core/domain/client/client-info.js' import type { LlmChunkEvent, LlmErrorEvent, @@ -146,6 +147,13 @@ type TaskRouterOptions = { * Failures are swallowed (fail-open) so dispatch is never blocked. */ resolveActiveProvider?: () => Promise<{model?: string; provider?: string}> + /** + * M15.8: snapshot the submitting client's identity (transport type + + * IDE name) at task-create. Resolved here because the client may + * disconnect mid-task, leaving ClientManager.get() unable to recover + * the values by the time AnalyticsHook fires the terminal emit. + */ + resolveClientIdentity?: (clientId: string) => undefined | {clientName?: string; clientType?: ClientType} /** Resolves the projectPath a client registered with (from client:register). */ resolveClientProjectPath?: (clientId: string) => string | undefined transport: ITransportServer @@ -319,6 +327,7 @@ export class TaskRouter { private readonly projectRegistry: IProjectRegistry | undefined private readonly projectRouter: IProjectRouter | undefined private readonly resolveActiveProvider: TaskRouterOptions['resolveActiveProvider'] + private readonly resolveClientIdentity: TaskRouterOptions['resolveClientIdentity'] private readonly resolveClientProjectPath: ((clientId: string) => string | undefined) | undefined /** Track active tasks */ private tasks: Map = new Map() @@ -336,6 +345,7 @@ export class TaskRouter { this.projectRegistry = options.projectRegistry this.projectRouter = options.projectRouter this.resolveActiveProvider = options.resolveActiveProvider + this.resolveClientIdentity = options.resolveClientIdentity this.resolveClientProjectPath = options.resolveClientProjectPath } @@ -977,12 +987,19 @@ export class TaskRouter { // awaiting the handler. const {model, provider} = this.resolveActiveProvider ? await this.safeResolveActiveProvider() : {} + // M15.8: snapshot the submitter's identity so AnalyticsHook can emit + // mcp_tool_called for tool-mode tasks even if the MCP client disconnects + // between handleTaskCreate and the terminal task event. + const identity = this.resolveClientIdentity?.(clientId) + this.tasks.set(taskId, { clientId, content: data.content, createdAt: Date.now(), status: 'created', ...(data.clientCwd ? {clientCwd: data.clientCwd} : {}), + ...(identity?.clientName ? {clientName: identity.clientName} : {}), + ...(identity?.clientType ? {clientType: identity.clientType} : {}), ...(data.files?.length ? {files: data.files} : {}), ...(data.folderPath ? {folderPath: data.folderPath} : {}), ...(model ? {model} : {}), diff --git a/src/server/infra/process/transport-handlers.ts b/src/server/infra/process/transport-handlers.ts index 07e1c17b2..adfec6db7 100644 --- a/src/server/infra/process/transport-handlers.ts +++ b/src/server/infra/process/transport-handlers.ts @@ -93,6 +93,14 @@ export class TransportHandlers { projectRegistry: options.projectRegistry, projectRouter: options.projectRouter, resolveActiveProvider: options.resolveActiveProvider, + resolveClientIdentity(clientId) { + const client = options.clientManager?.getClient(clientId) + if (!client) return + return { + ...(client.agentName ? {clientName: client.agentName} : {}), + clientType: client.type, + } + }, resolveClientProjectPath: (clientId) => options.clientManager?.getClient(clientId)?.projectPath, transport: options.transport, }) diff --git a/src/server/infra/transport/cli-invocation-middleware.ts b/src/server/infra/transport/cli-invocation-middleware.ts new file mode 100644 index 000000000..473e73d92 --- /dev/null +++ b/src/server/infra/transport/cli-invocation-middleware.ts @@ -0,0 +1,86 @@ + +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' +import type {ITransportServer, RequestHandler} from '../../core/interfaces/transport/i-transport-server.js' + +import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' +import {CliInvocationSchema} from '../../../shared/analytics/events/cli-invocation.js' +import {processLog} from '../../utils/process-logger.js' + +export type CliInvocationMiddlewareDeps = { + /** + * Lazy getter for the analytics client. Resolved per-request because the + * client is constructed AFTER the middleware is attached (during + * setupFeatureHandlers); a value-bound dep would capture `undefined` + * forever. + */ + getAnalyticsClient: () => IAnalyticsClient | undefined +} + +type OnRequestFn = ITransportServer['onRequest'] + +/** + * Symbol marker stamped on the wrapped `onRequest` so a second + * `attachCliInvocationMiddleware(server, ...)` call can detect the prior + * attach and bail. Without this, the second call would wrap the + * already-wrapped function and double-fire `cli_invocation` per request. + * + * `Symbol.for(...)` (not `Symbol(...)`) so the marker survives module + * re-loads in test harnesses that re-import the file. + */ +const CLI_INVOCATION_ATTACHED = Symbol.for('M15.8/cli-invocation-middleware-attached') + +type MarkedOnRequest = OnRequestFn & {[CLI_INVOCATION_ATTACHED]?: true} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +/** + * M15.8 — wrap `transportServer.onRequest` so every incoming payload is + * inspected for a `cli_metadata` block. When present and Zod-valid, emit + * `cli_invocation` BEFORE forwarding to the real handler. The original + * handler is invoked even on parse failure or when analytics is off — + * analytics is opportunistic, never blocking. + * + * Idempotent: a second call on the same transport server is a no-op (a + * marker symbol on the wrapped function flags the prior attach). + */ +export function attachCliInvocationMiddleware( + transportServer: ITransportServer, + deps: CliInvocationMiddlewareDeps, +): void { + const current = transportServer.onRequest as MarkedOnRequest + if (current[CLI_INVOCATION_ATTACHED]) return + + const original = current.bind(transportServer) + const wrappedOnRequest: MarkedOnRequest = ( + event: string, + handler: RequestHandler, + ): void => { + const wrapped: RequestHandler = (data, clientId) => { + maybeEmitCliInvocation(data, deps.getAnalyticsClient()) + return handler(data, clientId) + } + + original(event, wrapped) + } + + wrappedOnRequest[CLI_INVOCATION_ATTACHED] = true + transportServer.onRequest = wrappedOnRequest +} + +function maybeEmitCliInvocation(data: unknown, client: IAnalyticsClient | undefined): void { + if (client === undefined) return + if (!isRecord(data)) return + if (!('cli_metadata' in data)) return + + const parsed = CliInvocationSchema.safeParse(data.cli_metadata) + if (!parsed.success) return + + try { + client.track(AnalyticsEventNames.CLI_INVOCATION, parsed.data) + } catch (error) { + processLog( + `cli_invocation middleware track failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} diff --git a/src/server/infra/transport/handlers/analytics-handler.ts b/src/server/infra/transport/handlers/analytics-handler.ts index 2cea36e9e..03f532c5c 100644 --- a/src/server/infra/transport/handlers/analytics-handler.ts +++ b/src/server/infra/transport/handlers/analytics-handler.ts @@ -18,6 +18,7 @@ import {HubPackageInstalledSchema} from '../../../../shared/analytics/events/hub import {HubRegistryAddedSchema} from '../../../../shared/analytics/events/hub-registry-added.js' import {HubRegistryRemovedSchema} from '../../../../shared/analytics/events/hub-registry-removed.js' import {isAnalyticsEventName} from '../../../../shared/analytics/events/index.js' +import {McpSessionEndedSchema} from '../../../../shared/analytics/events/mcp-session-ended.js' import {McpSessionStartSchema} from '../../../../shared/analytics/events/mcp-session-start.js' import {McpToolCalledSchema} from '../../../../shared/analytics/events/mcp-tool-called.js' import {OnboardingAutoSetupStartedSchema} from '../../../../shared/analytics/events/onboarding-auto-setup-started.js' @@ -212,6 +213,13 @@ export class AnalyticsHandler { break } + case AnalyticsEventNames.MCP_SESSION_ENDED: { + const props = McpSessionEndedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.MCP_SESSION_ENDED, props.data) + break + } + case AnalyticsEventNames.MCP_SESSION_START: { const props = McpSessionStartSchema.safeParse(rawProperties ?? {}) if (!props.success) return diff --git a/src/shared/analytics/event-names.ts b/src/shared/analytics/event-names.ts index 0a557f069..3a88b2f78 100644 --- a/src/shared/analytics/event-names.ts +++ b/src/shared/analytics/event-names.ts @@ -28,6 +28,7 @@ export const AnalyticsEventNames = { HUB_PACKAGE_INSTALLED: 'hub_package_installed', HUB_REGISTRY_ADDED: 'hub_registry_added', HUB_REGISTRY_REMOVED: 'hub_registry_removed', + MCP_SESSION_ENDED: 'mcp_session_ended', MCP_SESSION_START: 'mcp_session_start', MCP_TOOL_CALLED: 'mcp_tool_called', ONBOARDING_AUTO_SETUP_STARTED: 'onboarding_auto_setup_started', diff --git a/src/shared/analytics/events/index.ts b/src/shared/analytics/events/index.ts index dc394a2cf..c2169933b 100644 --- a/src/shared/analytics/events/index.ts +++ b/src/shared/analytics/events/index.ts @@ -15,6 +15,7 @@ import {type DaemonStartProps, DaemonStartSchema} from './daemon-start.js' import {type HubPackageInstalledProps, HubPackageInstalledSchema} from './hub-package-installed.js' import {type HubRegistryAddedProps, HubRegistryAddedSchema} from './hub-registry-added.js' import {type HubRegistryRemovedProps, HubRegistryRemovedSchema} from './hub-registry-removed.js' +import {type McpSessionEndedProps, McpSessionEndedSchema} from './mcp-session-ended.js' import {type McpSessionStartProps, McpSessionStartSchema} from './mcp-session-start.js' import {type McpToolCalledProps, McpToolCalledSchema} from './mcp-tool-called.js' import {type OnboardingAutoSetupStartedProps, OnboardingAutoSetupStartedSchema} from './onboarding-auto-setup-started.js' @@ -78,6 +79,7 @@ export const ALL_EVENT_SCHEMAS = { [AnalyticsEventNames.HUB_PACKAGE_INSTALLED]: HubPackageInstalledSchema, [AnalyticsEventNames.HUB_REGISTRY_ADDED]: HubRegistryAddedSchema, [AnalyticsEventNames.HUB_REGISTRY_REMOVED]: HubRegistryRemovedSchema, + [AnalyticsEventNames.MCP_SESSION_ENDED]: McpSessionEndedSchema, [AnalyticsEventNames.MCP_SESSION_START]: McpSessionStartSchema, [AnalyticsEventNames.MCP_TOOL_CALLED]: McpToolCalledSchema, [AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED]: OnboardingAutoSetupStartedSchema, @@ -132,6 +134,7 @@ export type AnyAnalyticsEvent = | {name: typeof AnalyticsEventNames.HUB_PACKAGE_INSTALLED; properties: HubPackageInstalledProps} | {name: typeof AnalyticsEventNames.HUB_REGISTRY_ADDED; properties: HubRegistryAddedProps} | {name: typeof AnalyticsEventNames.HUB_REGISTRY_REMOVED; properties: HubRegistryRemovedProps} + | {name: typeof AnalyticsEventNames.MCP_SESSION_ENDED; properties: McpSessionEndedProps} | {name: typeof AnalyticsEventNames.MCP_SESSION_START; properties: McpSessionStartProps} | {name: typeof AnalyticsEventNames.MCP_TOOL_CALLED; properties: McpToolCalledProps} | {name: typeof AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED; properties: OnboardingAutoSetupStartedProps} diff --git a/src/shared/analytics/events/mcp-session-ended.ts b/src/shared/analytics/events/mcp-session-ended.ts new file mode 100644 index 000000000..5af0cda3c --- /dev/null +++ b/src/shared/analytics/events/mcp-session-ended.ts @@ -0,0 +1,25 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `mcp_session_ended`. + * + * Mirrors `webui_session_ended` (M15.5) for the MCP transport. Fires when + * a client of type `mcp` disconnects from the daemon (or is orphan-ended + * on reconnect). Pairs with a prior `mcp_session_start` via + * `started_at_unix_ms`. `client_name` is the IDE product name captured + * during the MCP `oninitialized` handshake. + * + * IMPORTANT: NO `session_id` field — that name is on `forbidden-field-names.ts` + * and would be runtime-redacted. `started_at_unix_ms` (the connectedAt + * Date.now() value) serves as the join key. + */ +export const McpSessionEndedSchema = z + .object({ + client_name: z.string().min(1), + session_duration_ms: z.number().int().nonnegative(), + started_at_unix_ms: z.number().int().nonnegative(), + }) + .strict() + +export type McpSessionEndedProps = z.infer diff --git a/test/unit/infra/client/client-manager-mcp-analytics.test.ts b/test/unit/infra/client/client-manager-mcp-analytics.test.ts new file mode 100644 index 000000000..be7a300aa --- /dev/null +++ b/test/unit/infra/client/client-manager-mcp-analytics.test.ts @@ -0,0 +1,211 @@ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub, useFakeTimers} from 'sinon' + +import type {IAnalyticsClient} from '../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {ClientManager} from '../../../../src/server/infra/client/client-manager.js' +import {getClientKindFromContext} from '../../../../src/server/infra/transport/client-kind-context.js' +import {AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' +import {FORBIDDEN_FIELD_NAMES} from '../../../../src/shared/analytics/forbidden-field-names.js' + +function makeFakeAnalyticsClient(): IAnalyticsClient & {trackSpy: SinonStub} { + const trackSpy = createSandbox().stub() as SinonStub + return { + abort: createSandbox().stub(), + flush: createSandbox().stub().resolves({events: []}), + getRuntimeState: createSandbox().stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: createSandbox().stub().resolves(), + track: trackSpy, + trackSpy, + } as unknown as IAnalyticsClient & {trackSpy: SinonStub} +} + +describe('ClientManager MCP session analytics emits (M15.8)', () => { + let sandbox: SinonSandbox + let manager: ClientManager + let analyticsClient: IAnalyticsClient & {trackSpy: SinonStub} + + beforeEach(() => { + sandbox = createSandbox() + analyticsClient = makeFakeAnalyticsClient() + manager = new ClientManager() + manager.setAnalyticsClient(analyticsClient) + }) + + afterEach(() => sandbox.restore()) + + function emits(name: string): Array<{args: unknown[]}> { + return analyticsClient.trackSpy.getCalls().filter((c) => c.args[0] === name) + } + + it('does NOT emit mcp_session_start on register (name unknown until handshake)', () => { + manager.register('sock-1', 'mcp') + expect(emits(AnalyticsEventNames.MCP_SESSION_START).length).to.equal(0) + }) + + it('emits mcp_session_start when setAgentName lands for an MCP client', () => { + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + + const calls = emits(AnalyticsEventNames.MCP_SESSION_START) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {client_name: string} + expect(props.client_name).to.equal('Cursor') + }) + + it('does NOT re-emit mcp_session_start on duplicate setAgentName (idempotent)', () => { + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + manager.setAgentName('sock-1', 'Cursor') + expect(emits(AnalyticsEventNames.MCP_SESSION_START).length).to.equal(1) + }) + + it('does NOT emit on setAgentName for non-MCP types (cli/tui/extension/webui/agent)', () => { + const types: Array<'agent' | 'cli' | 'extension' | 'tui' | 'webui'> = [ + 'agent', + 'cli', + 'extension', + 'tui', + 'webui', + ] + for (const [i, t] of types.entries()) { + const id = `sock-${i}` + manager.register(id, t, t === 'webui' ? '/proj' : undefined) + manager.setAgentName(id, 'WhateverName') + } + + expect(emits(AnalyticsEventNames.MCP_SESSION_START).length).to.equal(0) + }) + + it('emits mcp_session_ended on unregister when agentName was set', () => { + const clock = useFakeTimers(1_700_000_000_000) + try { + manager.register('sock-1', 'mcp') + const started = manager.getClient('sock-1')!.connectedAt + manager.setAgentName('sock-1', 'Cursor') + clock.tick(8500) + manager.unregister('sock-1') + + const calls = emits(AnalyticsEventNames.MCP_SESSION_ENDED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as { + client_name: string + session_duration_ms: number + started_at_unix_ms: number + } + expect(props.client_name).to.equal('Cursor') + expect(props.started_at_unix_ms).to.equal(started) + expect(props.session_duration_ms).to.equal(8500) + } finally { + clock.restore() + } + }) + + it('does NOT emit mcp_session_ended on unregister when agentName was never set', () => { + manager.register('sock-1', 'mcp') + manager.unregister('sock-1') + expect(emits(AnalyticsEventNames.MCP_SESSION_ENDED).length).to.equal(0) + }) + + it('reconnect: emits ended for old MCP session + clears state for new register cycle', () => { + const clock = useFakeTimers(1_700_000_000_000) + try { + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + const firstConnectedAt = manager.getClient('sock-1')!.connectedAt + clock.tick(2000) + + // Reconnect: same id, fresh ClientInfo, fresh handshake. + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + + const endedCalls = emits(AnalyticsEventNames.MCP_SESSION_ENDED) + expect(endedCalls.length).to.equal(1) + const endedProps = endedCalls[0].args[1] as { + client_name: string + session_duration_ms: number + started_at_unix_ms: number + } + expect(endedProps.client_name).to.equal('Cursor') + expect(endedProps.started_at_unix_ms).to.equal(firstConnectedAt) + expect(endedProps.session_duration_ms).to.equal(2000) + + const startedCalls = emits(AnalyticsEventNames.MCP_SESSION_START) + expect(startedCalls.length).to.equal(2) + } finally { + clock.restore() + } + }) + + it('end-event carries the SAME client_name that start emitted, even if agentName were re-mutated mid-session', () => { + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') // emits start with 'Cursor' + manager.setAgentName('sock-1', 'Claude Code') // wasFirstMcpHandshake=false; mutates _agentName but does NOT re-emit start + manager.unregister('sock-1') + + const startedCalls = emits(AnalyticsEventNames.MCP_SESSION_START) + expect(startedCalls.length).to.equal(1) + expect((startedCalls[0].args[1] as {client_name: string}).client_name).to.equal('Cursor') + + const endedCalls = emits(AnalyticsEventNames.MCP_SESSION_ENDED) + expect(endedCalls.length).to.equal(1) + // CRITICAL: the end-event must carry 'Cursor' (the name emitted at start), + // NOT the post-mutation 'Claude Code' value. Otherwise backend correlation + // of start↔end via client_name silently breaks. + expect((endedCalls[0].args[1] as {client_name: string}).client_name).to.equal('Cursor') + }) + + it('clamps session_duration_ms at 0 when clock skews backward between register and unregister', () => { + const dateNowStub = sandbox.stub(Date, 'now') + dateNowStub.onFirstCall().returns(1000) // register + dateNowStub.onSecondCall().returns(500) // unregister + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + manager.unregister('sock-1') + + const calls = emits(AnalyticsEventNames.MCP_SESSION_ENDED) + const props = calls[0].args[1] as {session_duration_ms: number} + expect(props.session_duration_ms).to.equal(0) + }) + + it('emit fires inside clientKindContext.run({client_kind: mcp}) wrap', () => { + let observed: string | undefined + analyticsClient.trackSpy.callsFake(() => { + observed = getClientKindFromContext() + }) + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + expect(observed).to.equal('mcp') + }) + + it('is a no-op when analyticsClient is not injected', () => { + const m = new ClientManager() + m.register('sock-1', 'mcp') + m.setAgentName('sock-1', 'Cursor') + m.unregister('sock-1') + expect(analyticsClient.trackSpy.called).to.equal(false) + }) + + it('analytics track throwing does NOT escape setAgentName/unregister', () => { + analyticsClient.trackSpy.throws(new Error('analytics down')) + manager.register('sock-1', 'mcp') + expect(() => manager.setAgentName('sock-1', 'Cursor')).to.not.throw() + expect(() => manager.unregister('sock-1')).to.not.throw() + }) + + it('regression: neither emit payload includes any FORBIDDEN_FIELD_NAMES key', () => { + manager.register('sock-1', 'mcp') + manager.setAgentName('sock-1', 'Cursor') + manager.unregister('sock-1') + const allEmits = [ + ...emits(AnalyticsEventNames.MCP_SESSION_START), + ...emits(AnalyticsEventNames.MCP_SESSION_ENDED), + ] + for (const call of allEmits) { + const props = call.args[1] as Record + for (const key of Object.keys(props)) { + expect(FORBIDDEN_FIELD_NAMES, `field ${key} must not be forbidden`).to.not.include(key) + } + } + }) +}) diff --git a/test/unit/infra/process/task-router-client-identity-snapshot.test.ts b/test/unit/infra/process/task-router-client-identity-snapshot.test.ts new file mode 100644 index 000000000..cd93634cd --- /dev/null +++ b/test/unit/infra/process/task-router-client-identity-snapshot.test.ts @@ -0,0 +1,199 @@ +/** + * M15.8 — TaskRouter snapshots the submitting client's identity + * (clientType + clientName) onto TaskInfo at task-create time so + * AnalyticsHook can emit mcp_tool_called even if the MCP client + * disconnects mid-task. + */ +import {expect} from 'chai' +import {randomUUID} from 'node:crypto' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAgentPool, SubmitTaskResult} from '../../../../src/server/core/interfaces/agent/i-agent-pool.js' +import type {IProjectRegistry} from '../../../../src/server/core/interfaces/project/i-project-registry.js' +import type {IProjectRouter} from '../../../../src/server/core/interfaces/routing/i-project-router.js' +import type { + ITransportServer, + RequestHandler, +} from '../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {TransportTaskEventNames} from '../../../../src/server/core/domain/transport/schemas.js' +import {TaskRouter} from '../../../../src/server/infra/process/task-router.js' + +function makeProjectInfo(projectPath: string) { + return { + projectPath, + registeredAt: Date.now(), + sanitizedPath: projectPath.replaceAll('/', '_'), + storagePath: `/data${projectPath}`, + } +} + +function makeStubTransportServer(sandbox: SinonSandbox) { + const requestHandlers = new Map() + const transport: ITransportServer = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub().returns(3000), + isRunning: sandbox.stub().returns(true), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers.set(event, handler) + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + return {requestHandlers, transport} +} + +function makeStubAgentPool(sandbox: SinonSandbox): IAgentPool & {submitTask: SinonStub} { + return { + cancelQueuedTask: sandbox.stub().returns(false), + getEntries: sandbox.stub().returns([]), + getSize: sandbox.stub().returns(0), + handleAgentDisconnected: sandbox.stub(), + hasAgent: sandbox.stub().returns(false), + markIdle: sandbox.stub(), + notifyTaskCompleted: sandbox.stub(), + shutdown: sandbox.stub().resolves(), + submitTask: sandbox.stub().resolves({success: true} as SubmitTaskResult), + } +} + +function makeStubProjectRegistry(sandbox: SinonSandbox): IProjectRegistry { + return { + get: sandbox.stub().callsFake((path: string) => makeProjectInfo(path)), + getAll: sandbox.stub().returns(new Map()), + register: sandbox.stub().callsFake((path: string) => makeProjectInfo(path)), + unregister: sandbox.stub().returns(true), + } +} + +function makeStubProjectRouter(sandbox: SinonSandbox): IProjectRouter { + return { + addToProjectRoom: sandbox.stub(), + broadcastToProject: sandbox.stub(), + getProjectMembers: sandbox.stub().returns([]), + removeFromProjectRoom: sandbox.stub(), + } +} + +const makeRequest = (overrides: Record = {}) => ({ + content: 'do thing', + projectPath: '/proj', + taskId: randomUUID(), + type: 'query-tool-mode' as const, + ...overrides, +}) + +describe('TaskRouter handleTaskCreate client-identity snapshot (M15.8)', () => { + let sandbox: SinonSandbox + let transportHelper: ReturnType + let agentPool: ReturnType + let projectRegistry: ReturnType + let projectRouter: ReturnType + let getAgentForProject: SinonStub + + beforeEach(() => { + sandbox = createSandbox() + transportHelper = makeStubTransportServer(sandbox) + agentPool = makeStubAgentPool(sandbox) + projectRegistry = makeStubProjectRegistry(sandbox) + projectRouter = makeStubProjectRouter(sandbox) + getAgentForProject = sandbox.stub().returns('agent-1') + }) + + afterEach(() => sandbox.restore()) + + it('stamps clientType + clientName from resolveClientIdentity onto the stored TaskInfo', async () => { + const resolveClientIdentity = sandbox.stub().returns({clientName: 'Cursor', clientType: 'mcp'}) + const router = new TaskRouter({ + agentPool, + getAgentForProject, + projectRegistry, + projectRouter, + resolveClientIdentity, + transport: transportHelper.transport, + }) + router.setup() + + const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) + expect(handler).to.exist + const request = makeRequest() + await handler!(request, 'sock-1') + + expect(resolveClientIdentity.calledWith('sock-1')).to.equal(true) + const stored = router.getTasksForProject('/proj').find((t) => t.taskId === request.taskId) + expect(stored, 'task should be stored after handleTaskCreate').to.exist + expect(stored!.clientType).to.equal('mcp') + expect(stored!.clientName).to.equal('Cursor') + }) + + it('omits clientName when the resolver returns only clientType', async () => { + const resolveClientIdentity = sandbox.stub().returns({clientType: 'cli'}) + const router = new TaskRouter({ + agentPool, + getAgentForProject, + projectRegistry, + projectRouter, + resolveClientIdentity, + transport: transportHelper.transport, + }) + router.setup() + + const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) + const request = makeRequest() + await handler!(request, 'sock-cli') + + const stored = router.getTasksForProject('/proj').find((t) => t.taskId === request.taskId) + expect(stored).to.exist + expect(stored!.clientType).to.equal('cli') + expect(stored!.clientName).to.equal(undefined) + }) + + it('leaves both fields undefined when no resolver is configured', async () => { + const router = new TaskRouter({ + agentPool, + getAgentForProject, + projectRegistry, + projectRouter, + transport: transportHelper.transport, + }) + router.setup() + + const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) + const request = makeRequest() + await handler!(request, 'sock-x') + + const stored = router.getTasksForProject('/proj').find((t) => t.taskId === request.taskId) + expect(stored).to.exist + expect(stored!.clientType).to.equal(undefined) + expect(stored!.clientName).to.equal(undefined) + }) + + it('leaves both fields undefined when resolver returns undefined (unknown client)', async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + const resolveClientIdentity = sandbox.stub().returns(undefined) + const router = new TaskRouter({ + agentPool, + getAgentForProject, + projectRegistry, + projectRouter, + resolveClientIdentity, + transport: transportHelper.transport, + }) + router.setup() + + const handler = transportHelper.requestHandlers.get(TransportTaskEventNames.CREATE) + const request = makeRequest() + await handler!(request, 'sock-y') + + const stored = router.getTasksForProject('/proj').find((t) => t.taskId === request.taskId) + expect(stored).to.exist + expect(stored!.clientType).to.equal(undefined) + expect(stored!.clientName).to.equal(undefined) + }) +}) diff --git a/test/unit/server/infra/process/analytics-hook-mcp-tool-called.test.ts b/test/unit/server/infra/process/analytics-hook-mcp-tool-called.test.ts new file mode 100644 index 000000000..e87948ac8 --- /dev/null +++ b/test/unit/server/infra/process/analytics-hook-mcp-tool-called.test.ts @@ -0,0 +1,175 @@ +import {expect} from 'chai' +import sinon from 'sinon' + +import type {TaskInfo} from '../../../../../src/server/core/domain/transport/task-info.js' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {AnalyticsHook} from '../../../../../src/server/infra/process/analytics-hook.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {TaskTypes} from '../../../../../src/shared/analytics/task-types.js' + +const FIXED_NOW = 1_700_000_000_000 + +function buildAnalyticsClient(): {client: IAnalyticsClient; trackStub: sinon.SinonStub} { + const trackStub = sinon.stub() + return { + client: { + abort() { + /* unused here */ + }, + flush: sinon.stub().resolves(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sinon.stub().resolves(), + track: trackStub, + }, + trackStub, + } +} + +const buildMcpQueryTask = (overrides: Partial = {}): TaskInfo => + ({ + clientId: 'sock-1', + clientName: 'Cursor', + clientType: 'mcp', + completedAt: FIXED_NOW + 2500, + content: 'query', + createdAt: FIXED_NOW, + projectPath: '/proj', + taskId: 'task-1', + type: TaskTypes.QUERY_TOOL_MODE, + ...overrides, + }) as TaskInfo + +const buildMcpCurateTask = (overrides: Partial = {}): TaskInfo => + ({ + clientId: 'sock-1', + clientName: 'Claude Code', + clientType: 'mcp', + completedAt: FIXED_NOW + 8000, + content: 'curate', + createdAt: FIXED_NOW, + projectPath: '/proj', + taskId: 'task-c', + type: TaskTypes.CURATE_TOOL_MODE, + ...overrides, + }) as TaskInfo + +const mcpToolCalledCalls = (trackStub: sinon.SinonStub): sinon.SinonSpyCall[] => + trackStub.getCalls().filter((c) => c.args[0] === AnalyticsEventNames.MCP_TOOL_CALLED) + +describe('AnalyticsHook MCP_TOOL_CALLED emit (M15.8)', () => { + let trackStub: sinon.SinonStub + let hook: AnalyticsHook + + beforeEach(() => { + const bundle = buildAnalyticsClient() + trackStub = bundle.trackStub + hook = new AnalyticsHook() + hook.setAnalyticsClient(bundle.client) + }) + + it('on success: emits mcp_tool_called with tool_name=brv-query for QUERY_TOOL_MODE', async () => { + const task = buildMcpQueryTask() + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const calls = mcpToolCalledCalls(trackStub) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as { + client_name: string + duration_ms: number + success: boolean + tool_name: string + } + expect(props.tool_name).to.equal('brv-query') + expect(props.client_name).to.equal('Cursor') + expect(props.success).to.equal(true) + expect(props.duration_ms).to.equal(2500) + }) + + it('on success: emits mcp_tool_called with tool_name=brv-curate for CURATE_TOOL_MODE', async () => { + const task = buildMcpCurateTask() + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const calls = mcpToolCalledCalls(trackStub) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {client_name: string; duration_ms: number; success: boolean; tool_name: string} + expect(props.tool_name).to.equal('brv-curate') + expect(props.client_name).to.equal('Claude Code') + expect(props.success).to.equal(true) + expect(props.duration_ms).to.equal(8000) + }) + + it('on error: emits mcp_tool_called with success=false (still surfaces the call)', async () => { + const task = buildMcpQueryTask() + await hook.onTaskCreate(task) + await hook.onTaskError(task.taskId, 'boom', task) + + const calls = mcpToolCalledCalls(trackStub) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {success: boolean; tool_name: string} + expect(props.success).to.equal(false) + expect(props.tool_name).to.equal('brv-query') + }) + + it('on cancellation: emits mcp_tool_called with success=false (user-cancel is a not-completed tool call)', async () => { + const task = buildMcpQueryTask() + await hook.onTaskCreate(task) + await hook.onTaskCancelled(task.taskId, task) + + const calls = mcpToolCalledCalls(trackStub) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {client_name: string; success: boolean; tool_name: string} + expect(props.success).to.equal(false) + expect(props.tool_name).to.equal('brv-query') + expect(props.client_name).to.equal('Cursor') + }) + + it('does NOT emit when clientType is not "mcp"', async () => { + const task = buildMcpQueryTask({clientType: 'cli'}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + expect(mcpToolCalledCalls(trackStub).length).to.equal(0) + }) + + it('does NOT emit when clientType is undefined', async () => { + const task = buildMcpQueryTask({clientType: undefined}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + expect(mcpToolCalledCalls(trackStub).length).to.equal(0) + }) + + it('does NOT emit for non-tool-mode task types (e.g. CURATE, QUERY, DREAM_SCAN, SEARCH)', async () => { + const types = [TaskTypes.CURATE, TaskTypes.QUERY, TaskTypes.DREAM_SCAN, TaskTypes.SEARCH] + for (const t of types) { + const task = buildMcpQueryTask({taskId: `t-${t}`, type: t}) + // eslint-disable-next-line no-await-in-loop -- sequential setup for sinon stub assertions + await hook.onTaskCreate(task) + // eslint-disable-next-line no-await-in-loop + await hook.onTaskCompleted(task.taskId, '', task) + } + + expect(mcpToolCalledCalls(trackStub).length).to.equal(0) + }) + + it('falls back to "unknown" for client_name when the snapshot is missing', async () => { + const task = buildMcpQueryTask({clientName: undefined}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const calls = mcpToolCalledCalls(trackStub) + expect(calls.length).to.equal(1) + expect((calls[0].args[1] as {client_name: string}).client_name).to.equal('unknown') + }) + + it('duration_ms uses durationMs helper (clamps at 0 on clock skew)', async () => { + const task = buildMcpQueryTask({completedAt: FIXED_NOW - 1000}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const calls = mcpToolCalledCalls(trackStub) + expect((calls[0].args[1] as {duration_ms: number}).duration_ms).to.equal(0) + }) +}) diff --git a/test/unit/server/infra/transport/cli-invocation-middleware.test.ts b/test/unit/server/infra/transport/cli-invocation-middleware.test.ts new file mode 100644 index 000000000..0b99a0535 --- /dev/null +++ b/test/unit/server/infra/transport/cli-invocation-middleware.test.ts @@ -0,0 +1,187 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type { + ITransportServer, + RequestHandler, +} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {attachCliInvocationMiddleware} from '../../../../../src/server/infra/transport/cli-invocation-middleware.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' + +function makeStubTransport(sandbox: SinonSandbox): { + requestHandlers: Map + transport: ITransportServer +} { + const requestHandlers = new Map() + const transport: ITransportServer = { + addToRoom: sandbox.stub(), + broadcast: sandbox.stub(), + broadcastTo: sandbox.stub(), + getPort: sandbox.stub().returns(3000), + isRunning: sandbox.stub().returns(true), + onConnection: sandbox.stub(), + onDisconnection: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers.set(event, handler) + }), + removeFromRoom: sandbox.stub(), + sendTo: sandbox.stub(), + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(), + } + return {requestHandlers, transport} +} + +function validCliMetadata(overrides: Record = {}): Record { + return { + client_sent_at: 1_700_000_000_000, + command_id: 'status', + flag_names: ['format'], + is_ci: false, + is_tty: true, + package_manager: 'npm', + runtime: 'node', + ...overrides, + } +} + +describe('attachCliInvocationMiddleware (M15.8 §4)', () => { + let sandbox: SinonSandbox + let transportHelper: ReturnType + let trackStub: SinonStub + let analyticsClient: IAnalyticsClient + + beforeEach(() => { + sandbox = createSandbox() + transportHelper = makeStubTransport(sandbox) + trackStub = sandbox.stub() + analyticsClient = { + abort: sandbox.stub(), + flush: sandbox.stub().resolves({events: []}), + getRuntimeState: sandbox.stub().resolves({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sandbox.stub().resolves(), + track: trackStub, + } as unknown as IAnalyticsClient + }) + + afterEach(() => sandbox.restore()) + + it('fires cli_invocation exactly once per incoming request when cli_metadata is valid', async () => { + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + const realHandler = sandbox.stub().resolves('ok') + transportHelper.transport.onRequest('status:get', realHandler) + const handler = transportHelper.requestHandlers.get('status:get')! + + await handler({cli_metadata: validCliMetadata(), cwd: '/proj'}, 'client-1') + + expect(trackStub.calledOnce).to.equal(true) + const trackArgs = trackStub.firstCall.args + expect(trackArgs[0]).to.equal(AnalyticsEventNames.CLI_INVOCATION) + const props = trackArgs[1] as Record + expect(props.command_id).to.equal('status') + expect(props.flag_names).to.deep.equal(['format']) + expect(realHandler.calledOnce).to.equal(true) + }) + + it('does NOT emit when cli_metadata is absent', async () => { + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + const realHandler = sandbox.stub().resolves('ok') + transportHelper.transport.onRequest('daemon:state', realHandler) + const handler = transportHelper.requestHandlers.get('daemon:state')! + + await handler({cwd: '/proj'}, 'client-1') + + expect(trackStub.called).to.equal(false) + expect(realHandler.calledOnce).to.equal(true) + }) + + it('does NOT emit when cli_metadata is malformed (safeParse fails) but still forwards', async () => { + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + const realHandler = sandbox.stub().resolves('ok') + transportHelper.transport.onRequest('status:get', realHandler) + const handler = transportHelper.requestHandlers.get('status:get')! + + // Missing required field (`runtime`) + await handler({cli_metadata: {command_id: 'partial'}, cwd: '/proj'}, 'client-1') + + expect(trackStub.called).to.equal(false) + expect(realHandler.calledOnce).to.equal(true) + }) + + it('still emits when the underlying handler rejects ("user typed the command" funnel)', async () => { + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + const realHandler = sandbox.stub().rejects(new Error('boom')) + transportHelper.transport.onRequest('status:get', realHandler) + const handler = transportHelper.requestHandlers.get('status:get')! + + try { + await handler({cli_metadata: validCliMetadata()}, 'client-1') + } catch { + /* error propagates from handler; expected */ + } + + expect(trackStub.calledOnce).to.equal(true) + }) + + it('is a no-op when no analytics client has been resolved yet (boot-time race)', async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => undefined}) + + const realHandler = sandbox.stub().resolves('ok') + transportHelper.transport.onRequest('status:get', realHandler) + const handler = transportHelper.requestHandlers.get('status:get')! + + await handler({cli_metadata: validCliMetadata()}, 'client-1') + + expect(trackStub.called).to.equal(false) + expect(realHandler.calledOnce).to.equal(true) + }) + + it('swallows track() errors so analytics can never crash a real handler', async () => { + trackStub.throws(new Error('analytics down')) + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + const realHandler = sandbox.stub().resolves('ok') + transportHelper.transport.onRequest('status:get', realHandler) + const handler = transportHelper.requestHandlers.get('status:get')! + + const response = await handler({cli_metadata: validCliMetadata()}, 'client-1') + expect(response).to.equal('ok') + }) + + it('does NOT double-fire when middleware is applied once and multiple handlers register', async () => { + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + transportHelper.transport.onRequest('a:event', sandbox.stub().resolves('a')) + transportHelper.transport.onRequest('b:event', sandbox.stub().resolves('b')) + + await transportHelper.requestHandlers.get('a:event')!({cli_metadata: validCliMetadata()}, 'c') + expect(trackStub.callCount).to.equal(1) + + await transportHelper.requestHandlers.get('b:event')!({cli_metadata: validCliMetadata()}, 'c') + expect(trackStub.callCount).to.equal(2) + }) + + it('idempotent attach: applying the middleware twice does NOT double-fire cli_invocation', async () => { + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + // Second attach must be a no-op — without the guard, the wrapped onRequest + // would wrap itself, double-firing on every incoming request. + attachCliInvocationMiddleware(transportHelper.transport, {getAnalyticsClient: () => analyticsClient}) + + const realHandler = sandbox.stub().resolves('ok') + transportHelper.transport.onRequest('status:get', realHandler) + const handler = transportHelper.requestHandlers.get('status:get')! + + await handler({cli_metadata: validCliMetadata()}, 'client-1') + + expect(trackStub.callCount).to.equal(1) + expect(realHandler.calledOnce).to.equal(true) + }) +}) diff --git a/test/unit/shared/analytics/event-names.test.ts b/test/unit/shared/analytics/event-names.test.ts index ab027da10..e00c6ef29 100644 --- a/test/unit/shared/analytics/event-names.test.ts +++ b/test/unit/shared/analytics/event-names.test.ts @@ -19,6 +19,7 @@ describe('AnalyticsEventNames', () => { 'HUB_PACKAGE_INSTALLED', 'HUB_REGISTRY_ADDED', 'HUB_REGISTRY_REMOVED', + 'MCP_SESSION_ENDED', 'MCP_SESSION_START', 'MCP_TOOL_CALLED', 'ONBOARDING_AUTO_SETUP_STARTED', diff --git a/test/unit/shared/analytics/privacy-fixture.test.ts b/test/unit/shared/analytics/privacy-fixture.test.ts index 57aa7b46e..d33efefc7 100644 --- a/test/unit/shared/analytics/privacy-fixture.test.ts +++ b/test/unit/shared/analytics/privacy-fixture.test.ts @@ -130,6 +130,7 @@ describe('analytics privacy fixture (smoke)', () => { 'hub_package_installed', 'hub_registry_added', 'hub_registry_removed', + 'mcp_session_ended', 'mcp_session_start', 'mcp_tool_called', 'onboarding_auto_setup_started', From cfa89dad3e44c6827d592edae6f16f6e90a9c1fd Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Thu, 28 May 2026 14:22:51 +0700 Subject: [PATCH 62/87] fix: fix lost analytics panel when merge --- src/webui/pages/configuration/general.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/webui/pages/configuration/general.tsx b/src/webui/pages/configuration/general.tsx index a5e5c1237..14e1a6089 100644 --- a/src/webui/pages/configuration/general.tsx +++ b/src/webui/pages/configuration/general.tsx @@ -1,3 +1,4 @@ +import { AnalyticsPanel } from '../../features/analytics/components/analytics-panel' import {ConcurrencyPanel} from '../../features/settings/components/concurrency-panel' import {LlmPanel} from '../../features/settings/components/llm-panel' import {TaskHistoryPanel} from '../../features/settings/components/task-history-panel' @@ -10,6 +11,7 @@ export function GeneralSection() { + ) } From e3d56803851a8241835a98035c3ad1f124e41257 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Thu, 28 May 2026 16:25:56 +0700 Subject: [PATCH 63/87] feat: [ENG-3003] add readonly-info descriptor variant to settings framework Third SettingDescriptor variant for read-only operational snapshots (e.g. future analytics.status). SettingsHandler accepts an injected infoProviders Map so the registry stays pure data. set/reset on a readonly-info key returns a structured 'read_only' error without touching the store. LIST isolates a throwing provider to its own row (current: undefined + daemon log) so one broken snapshot cannot blank the whole settings surface. The first real consumer ships in M16.3. --- src/oclif/commands/settings/get.ts | 29 +- src/oclif/commands/settings/index.ts | 18 +- src/oclif/commands/settings/reset.ts | 19 +- src/oclif/commands/settings/set.ts | 16 + src/server/core/domain/entities/settings.ts | 37 ++- .../infra/storage/file-settings-store.ts | 53 +++- .../infra/storage/settings-validator.ts | 83 +++++- .../transport/handlers/settings-handler.ts | 222 ++++++++++++-- .../analytics/events/setting-changed.ts | 2 +- src/shared/analytics/events/setting-reset.ts | 2 +- .../transport/events/settings-events.ts | 17 +- src/shared/types/settings-row.ts | 12 +- src/shared/utils/format-readonly-info.ts | 59 ++++ src/shared/utils/format-settings.ts | 21 ++ .../settings/components/settings-page.tsx | 19 +- .../settings/utils/format-settings.ts | 10 +- .../domain/entities/settings-registry.test.ts | 61 +++- .../infra/storage/file-settings-store.test.ts | 139 +++++++++ .../infra/storage/settings-validator.test.ts | 86 ++++++ .../settings-handler-analytics.test.ts | 68 +++++ .../handlers/settings-handler.test.ts | 277 +++++++++++++++++- .../unit/shared/utils/format-settings.test.ts | 139 +++++++++ .../features/settings/format-settings.test.ts | 16 + 23 files changed, 1309 insertions(+), 96 deletions(-) create mode 100644 src/shared/utils/format-readonly-info.ts diff --git a/src/oclif/commands/settings/get.ts b/src/oclif/commands/settings/get.ts index 34ad47a6f..f73c97218 100644 --- a/src/oclif/commands/settings/get.ts +++ b/src/oclif/commands/settings/get.ts @@ -7,6 +7,7 @@ import { type SettingsItemDTO, } from '../../../shared/transport/events/settings-events.js' import {formatCount, formatDuration} from '../../../shared/utils/format-duration.js' +import {formatReadonlyInfoValue} from '../../../shared/utils/format-readonly-info.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' import {writeJsonResponse} from '../../lib/json-response.js' @@ -75,8 +76,17 @@ export default class SettingsGet extends Command { private printTextBlock(item: SettingsItemDTO): void { this.log(item.key) - this.log(` current: ${renderValue(item, item.current)}`) - this.log(` default: ${renderValue(item, item.default)}`) + if (item.type === 'readonly-info') { + this.log(` current: ${formatReadonlyInfoValue(item.key, item.current)}`) + this.log(` scope: ${item.scope ?? 'global'}`) + return + } + + this.log(` current: ${renderWritableValue(item, item.current)}`) + if (item.default !== undefined) { + this.log(` default: ${renderWritableValue(item, item.default)}`) + } + if (item.type === 'integer' && item.min !== undefined && item.max !== undefined) { const range = `${renderInteger(item, item.min)}-${renderInteger(item, item.max)}` this.log(` range: ${range}`) @@ -88,14 +98,14 @@ export default class SettingsGet extends Command { private toJsonPayload(item: SettingsItemDTO): Record { const payload: Record = { current: item.current, - default: item.default, description: item.description, key: item.key, - max: item.max, - min: item.min, restartRequired: item.restartRequired, type: item.type, } + if (item.default !== undefined) payload.default = item.default + if (item.min !== undefined) payload.min = item.min + if (item.max !== undefined) payload.max = item.max if (item.category !== undefined) payload.category = item.category if (item.unit !== undefined) payload.unit = item.unit if (item.scope !== undefined) payload.scope = item.scope @@ -103,9 +113,14 @@ export default class SettingsGet extends Command { } } -function renderValue(item: SettingsItemDTO, value: boolean | number): string { +function renderWritableValue( + item: SettingsItemDTO, + value: boolean | number | Readonly> | undefined, +): string { + if (value === undefined) return '' if (typeof value === 'boolean') return value ? 'true' : 'false' - return renderInteger(item, value) + if (typeof value === 'number') return renderInteger(item, value) + return JSON.stringify(value) } function renderInteger(item: SettingsItemDTO, value: number): string { diff --git a/src/oclif/commands/settings/index.ts b/src/oclif/commands/settings/index.ts index 9375c30e6..b91e96a43 100644 --- a/src/oclif/commands/settings/index.ts +++ b/src/oclif/commands/settings/index.ts @@ -6,6 +6,7 @@ import { type SettingsListResponse, } from '../../../shared/transport/events/settings-events.js' import {formatCount, formatDuration} from '../../../shared/utils/format-duration.js' +import {formatReadonlyInfoValue} from '../../../shared/utils/format-readonly-info.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' import {writeJsonResponse} from '../../lib/json-response.js' @@ -108,15 +109,24 @@ function groupByCategory(items: readonly SettingsItemDTO[]): Map> | undefined): string { + if (value === undefined) return '' if (typeof value === 'boolean') return value ? 'true' : 'false' - return renderInteger(item, value) + if (typeof value === 'number') return renderInteger(item, value) + // Defensive — writable descriptors never carry object payloads. If a + // future regression smuggles one in, format-via-JSON instead of NaN. + return JSON.stringify(value) } function renderInteger(item: SettingsItemDTO, value: number): string { diff --git a/src/oclif/commands/settings/reset.ts b/src/oclif/commands/settings/reset.ts index 57d00803f..f0bf68664 100644 --- a/src/oclif/commands/settings/reset.ts +++ b/src/oclif/commands/settings/reset.ts @@ -63,6 +63,22 @@ export default class SettingsReset extends Command { return } + if (descriptor.type === 'readonly-info') { + process.exitCode = 1 + const message = `Setting '${args.key}' is read-only and cannot be reset.` + if (format === 'json') { + writeJsonResponse({ + command: 'settings reset', + data: {error: {code: 'read_only', key: args.key, message}}, + success: false, + }) + } else { + this.log(message) + } + + return + } + const response = await this.resetSetting(args.key) if (response.ok) { @@ -73,7 +89,8 @@ export default class SettingsReset extends Command { success: true, }) } else { - const base = `Setting reset: ${args.key} back to default (${renderValue(descriptor, descriptor.default)}).` + const defaultDisplay = descriptor.default === undefined ? '(none)' : renderValue(descriptor, descriptor.default) + const base = `Setting reset: ${args.key} back to default (${defaultDisplay}).` this.log(descriptor.restartRequired ? `${base} Run \`brv restart\` to apply.` : base) } diff --git a/src/oclif/commands/settings/set.ts b/src/oclif/commands/settings/set.ts index bdc33eb8a..c0f4517b0 100644 --- a/src/oclif/commands/settings/set.ts +++ b/src/oclif/commands/settings/set.ts @@ -68,6 +68,22 @@ export default class SettingsSet extends Command { return } + if (descriptor.type === 'readonly-info') { + process.exitCode = 1 + const message = `Setting '${args.key}' is read-only and cannot be written.` + if (format === 'json') { + writeJsonResponse({ + command: 'settings set', + data: {error: {code: 'read_only', key: args.key, message}}, + success: false, + }) + } else { + this.log(message) + } + + return + } + const parsed = parseValue(descriptor, args.value) if (parsed.kind === 'error') { process.exitCode = 1 diff --git a/src/server/core/domain/entities/settings.ts b/src/server/core/domain/entities/settings.ts index 11a11bfe3..ade8ec897 100644 --- a/src/server/core/domain/entities/settings.ts +++ b/src/server/core/domain/entities/settings.ts @@ -49,23 +49,44 @@ export type BooleanSettingDescriptor = BaseSettingDescriptor & { } /** - * Descriptor for a single user-configurable setting. Discriminated on - * `type` so consumers narrow with a single check before reading - * type-specific fields (`min`/`max` on integers, etc). + * Descriptor for a read-only operational snapshot key (e.g. `analytics.status`). + * Carries no default, refuses `set` / `reset`, and is never persisted to + * `settings.json`. The live value is supplied by an info provider injected + * into `SettingsHandler` at construction time — descriptors stay pure data + * so the registry never crosses the `core/domain -> infra` import boundary. * - * Defaults reference the existing constants module so a constant change - * automatically updates the setting's default. + * `restartRequired` is pinned to literal `false` because a snapshot can + * never demand a daemon restart: there is no override to apply. */ -export type SettingDescriptor = BooleanSettingDescriptor | IntegerSettingDescriptor +export type ReadonlyInfoSettingDescriptor = BaseSettingDescriptor & { + readonly restartRequired: false + readonly type: 'readonly-info' +} + +/** + * Descriptor for a single registered setting. Discriminated on `type` so + * consumers narrow with a single check before reading type-specific + * fields (`min`/`max` on integers, `default` on writable variants, etc). + * + * Defaults on writable variants reference the existing constants module + * so a constant change automatically updates the setting's default. + */ +export type SettingDescriptor = + | BooleanSettingDescriptor + | IntegerSettingDescriptor + | ReadonlyInfoSettingDescriptor /** * View of one setting: the key, the user's current override (or the default * if none is set), and the registered default. Carries the union of value * shapes; consumers narrow on the corresponding descriptor's `type`. + * + * Readonly-info keys carry no `default` (snapshots have no default state) + * and may carry a structured `current` payload supplied by the provider. */ export type SettingItem = { - readonly current: boolean | number - readonly default: boolean | number + readonly current: boolean | number | Readonly> | undefined + readonly default?: boolean | number readonly key: string readonly restartRequired: boolean } diff --git a/src/server/infra/storage/file-settings-store.ts b/src/server/infra/storage/file-settings-store.ts index 46ca7a34b..f5ca13109 100644 --- a/src/server/infra/storage/file-settings-store.ts +++ b/src/server/infra/storage/file-settings-store.ts @@ -3,13 +3,17 @@ import {existsSync} from 'node:fs' import {mkdir, readFile, rename, unlink, writeFile} from 'node:fs/promises' import {join} from 'node:path' -import type {SettingItem} from '../../core/domain/entities/settings.js' +import type {SettingDescriptor, SettingItem} from '../../core/domain/entities/settings.js' import type {ISettingsStore, SettingsStartupSnapshot} from '../../core/interfaces/storage/i-settings-store.js' import {SETTINGS_FILE, SETTINGS_SCHEMA_VERSION} from '../../constants.js' import {SETTINGS_REGISTRY} from '../../core/domain/entities/settings.js' import {getGlobalDataDir} from '../../utils/global-data-path.js' -import {InvalidSettingValueError, SettingsValidator} from './settings-validator.js' +import { + InvalidSettingValueError, + ReadonlySettingKeyError, + SettingsValidator, +} from './settings-validator.js' type SettingsFile = { /** @@ -25,6 +29,13 @@ type SettingsFile = { export type FileSettingsStoreOptions = { readonly baseDir?: string + /** + * Override the descriptor registry. Defaults to the production + * `SETTINGS_REGISTRY`. Tests inject a small registry containing the + * variant under test (e.g. a `readonly-info` descriptor) so per-key + * behaviour can be exercised in isolation. + */ + readonly registry?: readonly SettingDescriptor[] readonly validator?: SettingsValidator } @@ -46,18 +57,30 @@ type RawReadResult = * atomic temp-file + rename write. Reads return defaults for any key that is * missing or invalid in the file; surfacing invalid entries (for warning * logs) is the daemon-startup loader's job, not this store's. + * + * Readonly-info descriptors are surfaced in `get` / `list` with + * `current = undefined` and no `default`; the handler is responsible for + * resolving the live snapshot via its injected info-provider map. `set` + * and `reset` on a readonly-info key throw `ReadonlySettingKeyError` + * without touching the on-disk file. */ export class FileSettingsStore implements ISettingsStore { private readonly baseDir: string + private readonly registry: readonly SettingDescriptor[] private readonly validator: SettingsValidator public constructor(options: FileSettingsStoreOptions = {}) { this.baseDir = options.baseDir ?? getGlobalDataDir() - this.validator = options.validator ?? new SettingsValidator() + this.registry = options.registry ?? SETTINGS_REGISTRY + this.validator = options.validator ?? new SettingsValidator({registry: this.registry}) } public async get(key: string): Promise { const descriptor = this.validator.validateKey(key) + if (descriptor.type === 'readonly-info') { + return {current: undefined, key: descriptor.key, restartRequired: false} + } + const overrides = await this.readOverrides() return { current: overrides[key] ?? descriptor.default, @@ -69,12 +92,18 @@ export class FileSettingsStore implements ISettingsStore { public async list(): Promise { const overrides = await this.readOverrides() - return SETTINGS_REGISTRY.map((descriptor) => ({ - current: overrides[descriptor.key] ?? descriptor.default, - default: descriptor.default, - key: descriptor.key, - restartRequired: true, - })) + return this.registry.map((descriptor) => { + if (descriptor.type === 'readonly-info') { + return {current: undefined, key: descriptor.key, restartRequired: false} + } + + return { + current: overrides[descriptor.key] ?? descriptor.default, + default: descriptor.default, + key: descriptor.key, + restartRequired: true, + } + }) } public async readStartupSnapshot(): Promise { @@ -87,7 +116,11 @@ export class FileSettingsStore implements ISettingsStore { } public async reset(key: string): Promise { - this.validator.validateKey(key) + const descriptor = this.validator.validateKey(key) + if (descriptor.type === 'readonly-info') { + throw new ReadonlySettingKeyError(key) + } + const raw = await this.readRawValues() if (!(key in raw)) return diff --git a/src/server/infra/storage/settings-validator.ts b/src/server/infra/storage/settings-validator.ts index c37fc2762..880889c6a 100644 --- a/src/server/infra/storage/settings-validator.ts +++ b/src/server/infra/storage/settings-validator.ts @@ -4,7 +4,7 @@ import type { SettingDescriptor, } from '../../core/domain/entities/settings.js' -import {findSettingDescriptor, SETTINGS_KEYS} from '../../core/domain/entities/settings.js' +import {SETTINGS_KEYS, SETTINGS_REGISTRY} from '../../core/domain/entities/settings.js' export class UnknownSettingKeyError extends Error { public readonly key: string @@ -28,6 +28,23 @@ export class InvalidSettingValueError extends Error { } } +/** + * Raised when a caller tries to mutate a `readonly-info` descriptor via + * `validate`, the store's `set` / `reset`, or any future write surface. + * Distinct from `InvalidSettingValueError` so the transport handler can + * surface a typed `code: 'read_only'` response without string-matching + * the message. + */ +export class ReadonlySettingKeyError extends Error { + public readonly key: string + + public constructor(key: string) { + super(`Setting '${key}' is read-only and cannot be written or reset.`) + this.name = 'ReadonlySettingKeyError' + this.key = key + } +} + export type PartitionedSettings = { readonly invalid: ReadonlyArray<{readonly key: string; readonly reason: string; readonly value: unknown}> readonly valid: Readonly> @@ -38,6 +55,17 @@ export type CouplingViolation = { readonly reason: string } +export type SettingsValidatorOptions = { + /** + * Override the descriptor registry. Defaults to the production + * `SETTINGS_REGISTRY`. Tests inject a small registry containing the + * variant under test (e.g. a single `readonly-info` descriptor) so + * partition + validate behaviour can be exercised without polluting + * the production registry. + */ + readonly registry?: readonly SettingDescriptor[] +} + const COUPLING_REQUEST_TIMEOUT = SETTINGS_KEYS.LLM_REQUEST_TIMEOUT_MS const COUPLING_ITERATION_BUDGET = SETTINGS_KEYS.LLM_ITERATION_BUDGET_MS @@ -50,6 +78,12 @@ const COUPLING_ITERATION_BUDGET = SETTINGS_KEYS.LLM_ITERATION_BUDGET_MS * when M3 lands; the store and the transport handler do not need to change. */ export class SettingsValidator { + private readonly registry: readonly SettingDescriptor[] + + public constructor(options: SettingsValidatorOptions = {}) { + this.registry = options.registry ?? SETTINGS_REGISTRY + } + /** * Splits a raw record (e.g. parsed from `settings.json`) into the valid * entries the daemon should apply and the invalid entries the daemon should @@ -60,14 +94,19 @@ export class SettingsValidator { const invalid: Array<{key: string; reason: string; value: unknown}> = [] for (const [key, value] of Object.entries(record)) { - const descriptor = findSettingDescriptor(key) + const descriptor = this.findDescriptor(key) if (descriptor === undefined) { invalid.push({key, reason: 'unknown settings key', value}) continue } + if (descriptor.type === 'readonly-info') { + invalid.push({key, reason: 'readonly-info key cannot be persisted', value}) + continue + } + try { - valid[key] = this.validateAgainst(descriptor, value) + valid[key] = validateWritableAgainst(descriptor, value) } catch (error) { if (error instanceof InvalidSettingValueError) { invalid.push({key, reason: error.message, value: error.value}) @@ -95,13 +134,17 @@ export class SettingsValidator { } /** - * Validates a single key/value pair. Throws on unknown key or invalid value. - * Returns the coerced value on success (integer for integer descriptors, - * boolean for boolean descriptors). + * Validates a single key/value pair. Throws on unknown key, read-only key, + * or invalid value. Returns the coerced value on success (integer for + * integer descriptors, boolean for boolean descriptors). */ public validate(key: string, value: unknown): boolean | number { const descriptor = this.validateKey(key) - return this.validateAgainst(descriptor, value) + if (descriptor.type === 'readonly-info') { + throw new ReadonlySettingKeyError(key) + } + + return validateWritableAgainst(descriptor, value) } /** @@ -113,8 +156,15 @@ export class SettingsValidator { public validateCoupling(values: Readonly>): readonly CouplingViolation[] { const violations: CouplingViolation[] = [] - const requestTimeout = values[COUPLING_REQUEST_TIMEOUT] ?? findSettingDescriptor(COUPLING_REQUEST_TIMEOUT)?.default - const iterationBudget = values[COUPLING_ITERATION_BUDGET] ?? findSettingDescriptor(COUPLING_ITERATION_BUDGET)?.default + const requestTimeoutDescriptor = this.findDescriptor(COUPLING_REQUEST_TIMEOUT) + const iterationBudgetDescriptor = this.findDescriptor(COUPLING_ITERATION_BUDGET) + const requestTimeoutDefault = + requestTimeoutDescriptor?.type === 'integer' ? requestTimeoutDescriptor.default : undefined + const iterationBudgetDefault = + iterationBudgetDescriptor?.type === 'integer' ? iterationBudgetDescriptor.default : undefined + + const requestTimeout = values[COUPLING_REQUEST_TIMEOUT] ?? requestTimeoutDefault + const iterationBudget = values[COUPLING_ITERATION_BUDGET] ?? iterationBudgetDefault if (requestTimeout !== undefined && iterationBudget !== undefined && requestTimeout > iterationBudget) { violations.push({ @@ -131,17 +181,24 @@ export class SettingsValidator { * key is not registered. */ public validateKey(key: string): SettingDescriptor { - const descriptor = findSettingDescriptor(key) + const descriptor = this.findDescriptor(key) if (descriptor === undefined) throw new UnknownSettingKeyError(key) return descriptor } - private validateAgainst(descriptor: SettingDescriptor, value: unknown): boolean | number { - if (descriptor.type === 'boolean') return validateBoolean(descriptor, value) - return validateInteger(descriptor, value) + private findDescriptor(key: string): SettingDescriptor | undefined { + return this.registry.find((d) => d.key === key) } } +function validateWritableAgainst( + descriptor: BooleanSettingDescriptor | IntegerSettingDescriptor, + value: unknown, +): boolean | number { + if (descriptor.type === 'boolean') return validateBoolean(descriptor, value) + return validateInteger(descriptor, value) +} + function validateInteger(descriptor: IntegerSettingDescriptor, value: unknown): number { if (typeof value !== 'number' || !Number.isInteger(value)) { throw new InvalidSettingValueError( diff --git a/src/server/infra/transport/handlers/settings-handler.ts b/src/server/infra/transport/handlers/settings-handler.ts index 068860847..22572c27f 100644 --- a/src/server/infra/transport/handlers/settings-handler.ts +++ b/src/server/infra/transport/handlers/settings-handler.ts @@ -19,12 +19,47 @@ import type {ITransportServer} from '../../../core/interfaces/transport/i-transp import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' import {SettingsEvents} from '../../../../shared/transport/events/settings-events.js' -import {findSettingDescriptor, SETTINGS_REGISTRY} from '../../../core/domain/entities/settings.js' +import {SETTINGS_REGISTRY} from '../../../core/domain/entities/settings.js' import {processLog} from '../../../utils/process-logger.js' -import {InvalidSettingValueError, UnknownSettingKeyError} from '../../storage/settings-validator.js' +import { + InvalidSettingValueError, + ReadonlySettingKeyError, + UnknownSettingKeyError, +} from '../../storage/settings-validator.js' + +/** + * Wire-acceptable shape for a `readonly-info` key's live value. Plain + * JSON-compatible primitive or object, or `undefined` when the + * provider has nothing to report. Strings, arrays, and functions are + * deliberately excluded so callers cannot smuggle unsupported shapes + * into the settings surface. + */ +export type ReadonlyInfoSnapshot = boolean | number | Readonly> | undefined + +/** + * Resolver for a `readonly-info` key's live value. Called by LIST and + * GET at request time. May return synchronously or via a Promise. + * Throwing is non-fatal: the handler maps a thrown provider error to + * `code: 'invalid_value'` so a single broken provider cannot crash the + * settings surface for every key. + */ +export type ReadonlyInfoProvider = () => Promise | ReadonlyInfoSnapshot export interface SettingsHandlerDeps { readonly analyticsClient?: IAnalyticsClient + /** + * Live-value resolvers for `readonly-info` keys, keyed by descriptor key. + * t3 (analytics.status) registers `'analytics.status' -> getAnalyticsStatus` + * here; in t1 the map is empty and readonly-info rows surface + * `current: undefined`. + */ + readonly infoProviders?: ReadonlyMap + /** + * Override the descriptor registry. Defaults to `SETTINGS_REGISTRY`. + * Tests inject a small registry containing the variant under test + * (e.g. a single `readonly-info` descriptor). + */ + readonly registry?: readonly SettingDescriptor[] readonly store: ISettingsStore readonly transport: ITransportServer } @@ -34,14 +69,29 @@ export interface SettingsHandlerDeps { * validation to the injected store; surfaces validator errors as typed * structured responses (`{ok: false, error: {...}}`) so no raw exceptions * leak across the wire. + * + * Readonly-info keys are gated at the top of SET and RESET — those paths + * return `code: 'read_only'` without ever touching the store. LIST and + * GET both resolve the live value via the injected `infoProviders` map; + * a missing provider yields `current: undefined` on both paths. A + * throwing provider is handled asymmetrically: GET surfaces the failure + * as a top-level `code: 'invalid_value'` response (the caller asked for + * that specific key, so the error matters), while LIST isolates the + * failure to that one row (`current: undefined`, daemon log captures the + * error) so a single broken provider cannot blank the whole settings + * surface. */ export class SettingsHandler { private readonly analyticsClient: IAnalyticsClient | undefined + private readonly infoProviders: ReadonlyMap + private readonly registry: readonly SettingDescriptor[] private readonly store: ISettingsStore private readonly transport: ITransportServer public constructor(deps: SettingsHandlerDeps) { this.analyticsClient = deps.analyticsClient + this.infoProviders = deps.infoProviders ?? new Map() + this.registry = deps.registry ?? SETTINGS_REGISTRY this.store = deps.store this.transport = deps.transport } @@ -52,12 +102,30 @@ export class SettingsHandler { async () => { const items = await this.store.list() const byKey = new Map(items.map((item) => [item.key, item])) - return { - items: SETTINGS_REGISTRY.map((descriptor) => { + // Per-row try/catch so one throwing readonly-info provider does + // not blank the whole list. Failed rows surface `current: undefined` + // (same shape as "no provider registered"); the daemon log captures + // the actual error for debugging. GET keeps its richer + // `code: 'invalid_value'` error path because GET is single-key + // and the caller asked specifically for that key. + const dtoItems = await Promise.all( + this.registry.map(async (descriptor) => { const stored = byKey.get(descriptor.key) - return descriptorToDTO(descriptor, stored?.current ?? descriptor.default) + let current: SettingItem['current'] + try { + current = await this.resolveCurrent(descriptor, stored) + } catch (error) { + processLog( + `[Settings] readonly-info provider for '${descriptor.key}' failed: ${error instanceof Error ? error.message : String(error)}`, + ) + current = undefined + } + + return descriptorToDTO(descriptor, current) }), - } + ) + + return {items: dtoItems} }, ) @@ -66,7 +134,13 @@ export class SettingsHandler { async (data) => { try { const item = await this.store.get(data.key) - return {...toItemDTO(item), ok: true} + const descriptor = this.findDescriptor(data.key) + if (descriptor === undefined) { + return {error: errorToDTO(new UnknownSettingKeyError(data.key), data.key), ok: false} + } + + const current = await this.resolveCurrent(descriptor, item) + return {...descriptorToDTO(descriptor, current), ok: true} } catch (error) { return {error: errorToDTO(error, data.key), ok: false} } @@ -76,14 +150,27 @@ export class SettingsHandler { this.transport.onRequest( SettingsEvents.SET, async (data) => { - const typeError = checkValueType(data.key, data.value) + const descriptor = this.findDescriptor(data.key) + if (descriptor?.type === 'readonly-info') { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { + failure_kind: 'read_only', + outcome: 'failure', + setting_key: data.key, + value_kind: 'readonly-info', + }) + /* eslint-enable camelcase */ + return {error: readOnlyError(data.key), ok: false} + } + + const typeError = checkValueType(descriptor, data.key, data.value) if (typeError !== undefined) { /* eslint-disable camelcase */ this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { failure_kind: 'validation', outcome: 'failure', setting_key: data.key, - value_kind: findSettingDescriptor(data.key)?.type ?? 'integer', + value_kind: writableValueKind(descriptor), }) /* eslint-enable camelcase */ return {error: typeError, ok: false} @@ -91,23 +178,24 @@ export class SettingsHandler { try { await this.store.set(data.key, data.value) - const descriptor = findSettingDescriptor(data.key) /* eslint-disable camelcase */ this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { outcome: 'success', setting_key: data.key, - value_changed_from_default: descriptor ? data.value !== descriptor.default : undefined, - value_kind: descriptor?.type ?? 'integer', + value_changed_from_default: descriptorDefault(descriptor) === undefined + ? undefined + : data.value !== descriptorDefault(descriptor), + value_kind: writableValueKind(descriptor), }) /* eslint-enable camelcase */ - return {ok: true, restartRequired: restartRequiredFor(data.key)} + return {ok: true, restartRequired: restartRequiredFor(descriptor)} } catch (error) { /* eslint-disable camelcase */ this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { failure_kind: classifySettingsFailure(error), outcome: 'failure', setting_key: data.key, - value_kind: findSettingDescriptor(data.key)?.type ?? 'integer', + value_kind: writableValueKind(descriptor), }) /* eslint-enable camelcase */ return {error: errorToDTO(error, data.key, data.value), ok: false} @@ -118,23 +206,36 @@ export class SettingsHandler { this.transport.onRequest( SettingsEvents.RESET, async (data) => { + const descriptor = this.findDescriptor(data.key) + if (descriptor?.type === 'readonly-info') { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_RESET, { + failure_kind: 'read_only', + outcome: 'failure', + setting_key: data.key, + value_kind: 'readonly-info', + }) + /* eslint-enable camelcase */ + return {error: readOnlyError(data.key), ok: false} + } + try { await this.store.reset(data.key) /* eslint-disable camelcase */ this.emitAnalytics(AnalyticsEventNames.SETTING_RESET, { outcome: 'success', setting_key: data.key, - value_kind: findSettingDescriptor(data.key)?.type ?? 'integer', + value_kind: writableValueKind(descriptor), }) /* eslint-enable camelcase */ - return {ok: true, restartRequired: restartRequiredFor(data.key)} + return {ok: true, restartRequired: restartRequiredFor(descriptor)} } catch (error) { /* eslint-disable camelcase */ this.emitAnalytics(AnalyticsEventNames.SETTING_RESET, { failure_kind: classifySettingsFailure(error), outcome: 'failure', setting_key: data.key, - value_kind: findSettingDescriptor(data.key)?.type ?? 'integer', + value_kind: writableValueKind(descriptor), }) /* eslint-enable camelcase */ return {error: errorToDTO(error, data.key), ok: false} @@ -156,9 +257,37 @@ export class SettingsHandler { processLog(`[Settings] analytics track ${event} failed: ${error instanceof Error ? error.message : String(error)}`) } } + + private findDescriptor(key: string): SettingDescriptor | undefined { + return this.registry.find((d) => d.key === key) + } + + /** + * Resolves the value to surface on the DTO's `current` field. + * + * - Writable descriptors (`boolean` / `integer`): the stored override + * (if any) wins, else the registered default. + * - Readonly-info: the injected provider, if registered. Missing + * provider -> `undefined`. Provider throws -> rethrow so the GET + * handler can map to `code: 'invalid_value'` (LIST swallows below). + */ + private async resolveCurrent( + descriptor: SettingDescriptor, + stored: SettingItem | undefined, + ): Promise { + if (descriptor.type !== 'readonly-info') { + if (stored?.current !== undefined) return stored.current + return descriptor.default + } + + const provider = this.infoProviders.get(descriptor.key) + if (provider === undefined) return undefined + return provider() + } } function classifySettingsFailure(error: unknown): string { + if (error instanceof ReadonlySettingKeyError) return 'read_only' if (error instanceof UnknownSettingKeyError) return 'unknown_key' if (error instanceof InvalidSettingValueError) return 'validation' if (error instanceof Error && 'code' in error) { @@ -169,8 +298,29 @@ function classifySettingsFailure(error: unknown): string { return 'unknown' } -function restartRequiredFor(key: string): boolean { - return findSettingDescriptor(key)?.restartRequired ?? true +function restartRequiredFor(descriptor: SettingDescriptor | undefined): boolean { + return descriptor?.restartRequired ?? true +} + +function descriptorDefault(descriptor: SettingDescriptor | undefined): boolean | number | undefined { + if (descriptor === undefined) return undefined + if (descriptor.type === 'readonly-info') return undefined + return descriptor.default +} + +function writableValueKind( + descriptor: SettingDescriptor | undefined, +): 'boolean' | 'integer' | 'readonly-info' { + if (descriptor === undefined) return 'integer' + return descriptor.type +} + +function readOnlyError(key: string): SettingsErrorDTO { + return { + code: 'read_only', + key, + message: `Setting '${key}' is read-only and cannot be written or reset.`, + } } /** @@ -185,9 +335,13 @@ function restartRequiredFor(key: string): boolean { * Range, coupling, and fractional-number violations are left to the store's * validator and still surface as `invalid_value`. */ -function checkValueType(key: string, value: boolean | number): SettingsErrorDTO | undefined { - const descriptor = findSettingDescriptor(key) +function checkValueType( + descriptor: SettingDescriptor | undefined, + key: string, + value: boolean | number, +): SettingsErrorDTO | undefined { if (descriptor === undefined) return undefined + if (descriptor.type === 'readonly-info') return readOnlyError(key) const got = typeof value if (descriptor.type === 'integer' && got !== 'number') { @@ -215,19 +369,12 @@ function checkValueType(key: string, value: boolean | number): SettingsErrorDTO return undefined } -function toItemDTO(item: SettingItem): SettingsItemDTO { - const descriptor = findSettingDescriptor(item.key) - if (descriptor === undefined) { - throw new Error(`Setting '${item.key}' resolved to no descriptor — registry/store drift`) - } - - return descriptorToDTO(descriptor, item.current) -} - -function descriptorToDTO(descriptor: SettingDescriptor, current: boolean | number): SettingsItemDTO { +function descriptorToDTO( + descriptor: SettingDescriptor, + current: SettingItem['current'], +): SettingsItemDTO { const dto: SettingsItemDTO = { current, - default: descriptor.default, description: descriptor.description, key: descriptor.key, restartRequired: descriptor.restartRequired, @@ -235,15 +382,26 @@ function descriptorToDTO(descriptor: SettingDescriptor, current: boolean | numbe } if (descriptor.category !== undefined) dto.category = descriptor.category if (descriptor.type === 'integer') { + dto.default = descriptor.default dto.min = descriptor.min dto.max = descriptor.max if (descriptor.unit !== undefined) dto.unit = descriptor.unit + } else if (descriptor.type === 'boolean') { + dto.default = descriptor.default } + // readonly-info: no default, no min/max, no unit. Intentionally omitted + // from the wire shape so the CLI / TUI render path can branch on the + // absence of `default`. + return dto } function errorToDTO(error: unknown, key: string, value?: unknown): SettingsErrorDTO { + if (error instanceof ReadonlySettingKeyError) { + return {code: 'read_only', key: error.key, message: error.message} + } + if (error instanceof UnknownSettingKeyError) { return {code: 'unknown_key', key: error.key, message: error.message} } diff --git a/src/shared/analytics/events/setting-changed.ts b/src/shared/analytics/events/setting-changed.ts index e43f03aaf..1a03f1eb2 100644 --- a/src/shared/analytics/events/setting-changed.ts +++ b/src/shared/analytics/events/setting-changed.ts @@ -15,7 +15,7 @@ export const SettingChangedSchema = z outcome: z.enum(['success', 'failure']), setting_key: z.string().min(1), value_changed_from_default: z.boolean().optional(), - value_kind: z.enum(['integer', 'boolean']), + value_kind: z.enum(['integer', 'boolean', 'readonly-info']), }) .strict() diff --git a/src/shared/analytics/events/setting-reset.ts b/src/shared/analytics/events/setting-reset.ts index e45cab887..c83eb8dce 100644 --- a/src/shared/analytics/events/setting-reset.ts +++ b/src/shared/analytics/events/setting-reset.ts @@ -11,7 +11,7 @@ export const SettingResetSchema = z failure_kind: z.string().min(1).max(64).optional(), outcome: z.enum(['success', 'failure']), setting_key: z.string().min(1), - value_kind: z.enum(['integer', 'boolean']), + value_kind: z.enum(['integer', 'boolean', 'readonly-info']), }) .strict() diff --git a/src/shared/transport/events/settings-events.ts b/src/shared/transport/events/settings-events.ts index 4bcea3d9e..f30e1c577 100644 --- a/src/shared/transport/events/settings-events.ts +++ b/src/shared/transport/events/settings-events.ts @@ -14,26 +14,29 @@ export const SettingsEvents = { * M7 T2 added three optional fields (`category`, `unit`, `scope`); T1 of * the Update-check toggle project widened `type`, `current`, `default`, * and `restartRequired` to also cover boolean descriptors, and made - * `min` / `max` optional (only integer descriptors carry them). All - * widenings are additive at the JSON layer, so consumers that read - * existing integer fields continue to parse the wire format. + * `min` / `max` optional (only integer descriptors carry them). M16.1 + * added the `'readonly-info'` variant: a snapshot has no `default` and + * `current` may be a structured object or `undefined` (when no info + * provider is registered). All widenings are additive at the JSON + * layer, so consumers that read existing integer fields continue to + * parse the wire format. */ export interface SettingsItemDTO { category?: 'concurrency' | 'llm' | 'task-history' | 'updates' - current: boolean | number - default: boolean | number + current: boolean | number | Readonly> | undefined + default?: boolean | number description: string key: string max?: number min?: number restartRequired: boolean scope?: 'global' | 'project' - type: 'boolean' | 'integer' + type: 'boolean' | 'integer' | 'readonly-info' unit?: 'count' | 'ms' } export interface SettingsErrorDTO { - code: 'invalid_value' | 'invalid_value_type' | 'unknown_key' + code: 'invalid_value' | 'invalid_value_type' | 'read_only' | 'unknown_key' /** Expected runtime kind, only set when `code === 'invalid_value_type'`. */ expected?: 'boolean' | 'integer' /** `typeof` of the offending value, only set when `code === 'invalid_value_type'`. */ diff --git a/src/shared/types/settings-row.ts b/src/shared/types/settings-row.ts index 23cf81983..d327b02ce 100644 --- a/src/shared/types/settings-row.ts +++ b/src/shared/types/settings-row.ts @@ -9,14 +9,18 @@ export type SettingsRowUnit = 'count' | 'ms' * Restart requirement is propagated from the descriptor verbatim (no * literal `true` constraint) so the dirty-banner filter on the page can * gate the restart warning per row. + * + * Readonly-info rows carry no `default` / `displayDefault`. The + * renderer must omit the `(default ...)` cell for them and skip + * edit / toggle / reset keybinds. */ export interface SettingsRow { readonly category: SettingsRowCategory - readonly current: boolean | number - readonly default: boolean | number + readonly current: boolean | number | Readonly> | undefined + readonly default?: boolean | number readonly description: string readonly displayCurrent: string - readonly displayDefault: string + readonly displayDefault?: string readonly displayRange: string readonly key: string readonly label: string @@ -24,7 +28,7 @@ export interface SettingsRow { readonly min?: number readonly modified: boolean readonly restartRequired: boolean - readonly type: 'boolean' | 'integer' + readonly type: 'boolean' | 'integer' | 'readonly-info' readonly unit?: SettingsRowUnit } diff --git a/src/shared/utils/format-readonly-info.ts b/src/shared/utils/format-readonly-info.ts new file mode 100644 index 000000000..4ed27ccaf --- /dev/null +++ b/src/shared/utils/format-readonly-info.ts @@ -0,0 +1,59 @@ +/** + * Per-key text formatter registry for `readonly-info` settings descriptors. + * + * Both the oclif CLI (`brv settings list` / `get`) and the TUI settings + * page read live operational snapshots through this registry. The default + * formatter renders `undefined` as `(unavailable)`, strings as-is, and + * everything else via `JSON.stringify`. Consumers (e.g. t3's + * `analytics.status` module) call `registerReadonlyInfoFormatter` at + * module load time to install a human-friendly view for their key. + * + * The registry lives in `shared/` so neither surface crosses the + * `tui/` <-> `oclif/` boundary; both import the same singleton. + * + * Type asymmetry note: `ReadonlyInfoSnapshot` (in + * `server/infra/transport/handlers/settings-handler.ts`) tightly + * constrains what an info PROVIDER may return. `ReadonlyInfoFormatter` + * deliberately accepts `unknown` so a formatter can stay robust against + * unexpected wire shapes (e.g. legacy clients, hand-edited fixtures). + * The default formatter even handles `string`, which the snapshot type + * does not allow — both layers are correct: the snapshot guards the + * write boundary, the formatter guards the read boundary. + */ + +export type ReadonlyInfoFormatter = (value: unknown) => string + +const FORMATTERS = new Map() + +/** + * Installs a per-key formatter. Idempotent on the same function reference. + * Throws when a DIFFERENT function is registered for an already-registered + * key, to surface accidental overrides that would otherwise silently + * mask the canonical formatter. + */ +export function registerReadonlyInfoFormatter(key: string, fn: ReadonlyInfoFormatter): void { + const existing = FORMATTERS.get(key) + if (existing !== undefined && existing !== fn) { + throw new Error( + `Readonly-info formatter for '${key}' is already registered. Call unregisterReadonlyInfoFormatter first, or reuse the same function reference.`, + ) + } + + FORMATTERS.set(key, fn) +} + +export function unregisterReadonlyInfoFormatter(key: string): void { + FORMATTERS.delete(key) +} + +export function formatReadonlyInfoValue(key: string, value: unknown): string { + const fn = FORMATTERS.get(key) + if (fn) return fn(value) + return defaultFormat(value) +} + +function defaultFormat(value: unknown): string { + if (value === undefined) return '(unavailable)' + if (typeof value === 'string') return value + return JSON.stringify(value) +} diff --git a/src/shared/utils/format-settings.ts b/src/shared/utils/format-settings.ts index 79d415e7e..e874a5567 100644 --- a/src/shared/utils/format-settings.ts +++ b/src/shared/utils/format-settings.ts @@ -3,10 +3,16 @@ import type {RowParseResult, SettingsRow, SettingsRowCategory, SettingsRowUnit} import {CATEGORY_ORDER} from '../types/settings-row.js' import {formatCount, formatDuration, parseDuration} from './format-duration.js' +import {formatReadonlyInfoValue} from './format-readonly-info.js' export function buildSettingsRows(items: readonly SettingsItemDTO[]): SettingsRow[] { const rows: SettingsRow[] = [] for (const item of items) { + if (item.type === 'readonly-info') { + rows.push(toReadonlyInfoRow(item)) + continue + } + if (item.type === 'boolean' && typeof item.current === 'boolean' && typeof item.default === 'boolean') { rows.push(toBooleanRow(item, item.current, item.default)) continue @@ -124,6 +130,21 @@ function toBooleanRow(item: SettingsItemDTO, current: boolean, defaultValue: boo } } +function toReadonlyInfoRow(item: SettingsItemDTO): SettingsRow { + return { + category: toRowCategory(item.category), + current: item.current, + description: item.description, + displayCurrent: formatReadonlyInfoValue(item.key, item.current), + displayRange: '', + key: item.key, + label: item.key, + modified: false, + restartRequired: item.restartRequired, + type: 'readonly-info', + } +} + function renderBoolean(value: boolean): string { return value ? '[ on ]' : '[ off ]' } diff --git a/src/tui/features/settings/components/settings-page.tsx b/src/tui/features/settings/components/settings-page.tsx index 3a7ca99f9..db00b67cd 100644 --- a/src/tui/features/settings/components/settings-page.tsx +++ b/src/tui/features/settings/components/settings-page.tsx @@ -144,6 +144,12 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea if (key.return || input === ' ') { const row = rows[cursor] + if (row.type === 'readonly-info') { + // Read-only rows refuse every mutation keybind: no toggle, no + // edit, no reset. Selection still works (Up/Down navigate). + return + } + if (row.type === 'boolean') { toggleBoolean(row).catch(() => {}) } else { @@ -154,7 +160,9 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea } if (input?.toLowerCase() === 'r') { - resetRow(rows[cursor]).catch(() => {}) + const row = rows[cursor] + if (row.type === 'readonly-info') return + resetRow(row).catch(() => {}) } }, {isActive: mode === 'browse'}, @@ -211,7 +219,7 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea const keyWidth = Math.max(40, ...rows.map((r) => r.label.length)) const currentWidth = Math.max(7, ...rows.map((r) => r.displayCurrent.length)) - const defaultWidth = Math.max(8, ...rows.map((r) => r.displayDefault.length)) + const defaultWidth = Math.max(8, ...rows.map((r) => (r.displayDefault ?? '').length)) const rangeWidth = Math.max(8, ...rows.map((r) => r.displayRange.length)) return ( @@ -242,11 +250,14 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea isSavingThis, width: currentWidth, }) + const trailingCell = row.type === 'readonly-info' + ? pad('(read-only)', defaultWidth + 10) + : `${pad(`(default ${row.displayDefault ?? ''})`, defaultWidth + 10)} ${pad(row.displayRange, rangeWidth)}` return ( {marker} - {pad(row.label, keyWidth)} {currentDisplay} {pad(`(default ${row.displayDefault})`, defaultWidth + 10)} {pad(row.displayRange, rangeWidth)} + {pad(row.label, keyWidth)} {currentDisplay} {trailingCell} {isSelected && rowError !== undefined && ( @@ -260,7 +271,7 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea ))} - {bottomHintFor(hintMode, focusedRow?.key)} + {bottomHintFor(hintMode, focusedRow?.key, focusedRow?.type)} ) diff --git a/src/tui/features/settings/utils/format-settings.ts b/src/tui/features/settings/utils/format-settings.ts index 4c171fb83..fb653752c 100644 --- a/src/tui/features/settings/utils/format-settings.ts +++ b/src/tui/features/settings/utils/format-settings.ts @@ -32,9 +32,17 @@ export function groupRowsByCategory(rows: readonly SettingsRow[]): ReadonlyArray return result } -export function bottomHintFor(mode: 'browse' | 'edit' | 'edit-error' | 'saving', focusedKey?: string): string { +export function bottomHintFor( + mode: 'browse' | 'edit' | 'edit-error' | 'saving', + focusedKey?: string, + focusedRowType?: 'boolean' | 'integer' | 'readonly-info', +): string { switch (mode) { case 'browse': { + if (focusedRowType === 'readonly-info') { + return 'Up/Down move | Esc exit | (read-only)' + } + return 'Up/Down move | Enter edit | R reset | Esc exit' } diff --git a/test/unit/core/domain/entities/settings-registry.test.ts b/test/unit/core/domain/entities/settings-registry.test.ts index 449c1e895..d56f032e1 100644 --- a/test/unit/core/domain/entities/settings-registry.test.ts +++ b/test/unit/core/domain/entities/settings-registry.test.ts @@ -1,5 +1,10 @@ import {expect} from 'chai' +import type { + ReadonlyInfoSettingDescriptor, + SettingDescriptor, +} from '../../../../../src/server/core/domain/entities/settings.js' + import { findSettingDescriptor, SETTINGS_KEYS, @@ -91,7 +96,11 @@ describe('settings registry — M7 T2 shape', () => { it('declares the descriptor as type=boolean with default=true', () => { const descriptor = findSettingDescriptor(SETTINGS_KEYS.UPDATE_CHECK_FOR_UPDATES) expect(descriptor?.type).to.equal('boolean') - expect(descriptor?.default).to.equal(true) + if (descriptor?.type === 'boolean') { + expect(descriptor.default).to.equal(true) + } else { + expect.fail('expected boolean descriptor for update.checkForUpdates') + } }) it('marks the descriptor as not requiring a daemon restart', () => { @@ -127,4 +136,54 @@ describe('settings registry — M7 T2 shape', () => { } }) }) + + describe('readonly-info variant (M16.1)', () => { + it('accepts a readonly-info literal that narrows on type without a cast', () => { + const descriptor: ReadonlyInfoSettingDescriptor = { + category: 'updates', + description: 'live operational snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + } + expect(descriptor.type).to.equal('readonly-info') + }) + + it('discriminates the SettingDescriptor union on type without an `as` assertion', () => { + const descriptor: SettingDescriptor = { + category: 'updates', + description: 'live operational snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + } + if (descriptor.type === 'readonly-info') { + const {key} = descriptor + expect(key).to.equal('_test.snapshot') + } else { + expect.fail('expected readonly-info branch') + } + }) + + it('rejects restartRequired=true on a readonly-info descriptor at the type level', () => { + // The descriptor narrows `restartRequired` to literal `false`. The + // assignment below would fail to type-check if a future refactor + // widened the field back to `boolean`, regressing the invariant. + const descriptor: ReadonlyInfoSettingDescriptor = { + description: 'snapshot', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + } + expect(descriptor.restartRequired).to.equal(false) + }) + + it('SETTINGS_REGISTRY still contains only boolean and integer descriptors in t1', () => { + // t1 (ENG-3003) adds the framework variant. The first real + // readonly-info entry (`analytics.status`) lands in t3, not here. + for (const descriptor of SETTINGS_REGISTRY) { + expect(descriptor.type, `${descriptor.key} unexpected type`).to.be.oneOf(['boolean', 'integer']) + } + }) + }) }) diff --git a/test/unit/infra/storage/file-settings-store.test.ts b/test/unit/infra/storage/file-settings-store.test.ts index 6c3a593ac..e5bc937de 100644 --- a/test/unit/infra/storage/file-settings-store.test.ts +++ b/test/unit/infra/storage/file-settings-store.test.ts @@ -1,11 +1,16 @@ import {expect} from 'chai' +import {existsSync} from 'node:fs' import {mkdir, readdir, readFile, rm, writeFile} from 'node:fs/promises' import {tmpdir} from 'node:os' import {join} from 'node:path' +import type {SettingDescriptor} from '../../../../src/server/core/domain/entities/settings.js' + import {FileSettingsStore} from '../../../../src/server/infra/storage/file-settings-store.js' import { InvalidSettingValueError, + ReadonlySettingKeyError, + SettingsValidator, UnknownSettingKeyError, } from '../../../../src/server/infra/storage/settings-validator.js' @@ -485,4 +490,138 @@ describe('FileSettingsStore', () => { expect(file.values).to.deep.equal(v1Payload.values) }) }) + + describe('readonly-info variant (M16.1)', () => { + const readonlyInfoRegistry: readonly SettingDescriptor[] = [ + { + category: 'updates', + description: 'live operational snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + }, + { + category: 'concurrency', + default: 10, + description: 'test writable', + key: '_test.writable', + max: 100, + min: 1, + restartRequired: true, + type: 'integer', + }, + ] + + let isolatedStore: FileSettingsStore + let isolatedDir: string + + beforeEach(async () => { + isolatedDir = join(tmpdir(), `brv-settings-roi-${Date.now()}-${Math.random().toString(36).slice(2)}`) + await mkdir(isolatedDir, {recursive: true}) + isolatedStore = new FileSettingsStore({ + baseDir: isolatedDir, + registry: readonlyInfoRegistry, + validator: new SettingsValidator({registry: readonlyInfoRegistry}), + }) + }) + + afterEach(async () => { + await rm(isolatedDir, {force: true, recursive: true}) + }) + + describe('set', () => { + it('throws ReadonlySettingKeyError on a readonly-info key', async () => { + try { + await isolatedStore.set('_test.snapshot', 'whatever') + expect.fail('expected throw') + } catch (error) { + expect(error).to.be.instanceOf(ReadonlySettingKeyError) + } + }) + + it('does NOT create the settings file when refusing a readonly-info write', async () => { + try { + await isolatedStore.set('_test.snapshot', 'whatever') + } catch { + // expected + } + + expect(existsSync(join(isolatedDir, SETTINGS_FILENAME))).to.equal(false) + }) + + it('does NOT mutate an existing settings file when refusing a readonly-info write', async () => { + await isolatedStore.set('_test.writable', 25) + const before = await readFile(join(isolatedDir, SETTINGS_FILENAME), 'utf8') + + try { + await isolatedStore.set('_test.snapshot', 'whatever') + } catch { + // expected + } + + const after = await readFile(join(isolatedDir, SETTINGS_FILENAME), 'utf8') + expect(after).to.equal(before) + }) + }) + + describe('reset', () => { + it('throws ReadonlySettingKeyError on a readonly-info key', async () => { + try { + await isolatedStore.reset('_test.snapshot') + expect.fail('expected throw') + } catch (error) { + expect(error).to.be.instanceOf(ReadonlySettingKeyError) + } + }) + + it('does NOT mutate the settings file when refusing a readonly-info reset', async () => { + await isolatedStore.set('_test.writable', 25) + const before = await readFile(join(isolatedDir, SETTINGS_FILENAME), 'utf8') + + try { + await isolatedStore.reset('_test.snapshot') + } catch { + // expected + } + + const after = await readFile(join(isolatedDir, SETTINGS_FILENAME), 'utf8') + expect(after).to.equal(before) + }) + }) + + describe('get', () => { + it('returns current=undefined and omits default for a readonly-info key', async () => { + const item = await isolatedStore.get('_test.snapshot') + expect(item.key).to.equal('_test.snapshot') + expect(item.current).to.equal(undefined) + expect(item.default).to.equal(undefined) + expect(item.restartRequired).to.equal(false) + }) + + it('still returns descriptor defaults for writable keys alongside readonly-info', async () => { + const item = await isolatedStore.get('_test.writable') + expect(item.key).to.equal('_test.writable') + expect(item.current).to.equal(10) + expect(item.default).to.equal(10) + }) + }) + + describe('list', () => { + it('includes the readonly-info row with current=undefined and default omitted', async () => { + const items = await isolatedStore.list() + const snapshot = items.find((i) => i.key === '_test.snapshot') + expect(snapshot, 'readonly-info row must be present').to.exist + expect(snapshot?.current).to.equal(undefined) + expect(snapshot?.default).to.equal(undefined) + expect(snapshot?.restartRequired).to.equal(false) + }) + + it('keeps writable rows unaffected by the readonly-info branch', async () => { + const items = await isolatedStore.list() + const writable = items.find((i) => i.key === '_test.writable') + expect(writable?.current).to.equal(10) + expect(writable?.default).to.equal(10) + }) + }) + }) }) diff --git a/test/unit/infra/storage/settings-validator.test.ts b/test/unit/infra/storage/settings-validator.test.ts index 0a3aa2d96..cc88a1caa 100644 --- a/test/unit/infra/storage/settings-validator.test.ts +++ b/test/unit/infra/storage/settings-validator.test.ts @@ -1,7 +1,10 @@ import {expect} from 'chai' +import type {SettingDescriptor} from '../../../../src/server/core/domain/entities/settings.js' + import { InvalidSettingValueError, + ReadonlySettingKeyError, SettingsValidator, UnknownSettingKeyError, } from '../../../../src/server/infra/storage/settings-validator.js' @@ -295,4 +298,87 @@ describe('SettingsValidator', () => { expect(result.invalid[0].value).to.equal('yes') }) }) + + describe('readonly-info variant (M16.1)', () => { + const readonlyInfoRegistry: readonly SettingDescriptor[] = [ + { + category: 'updates', + description: 'live operational snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + }, + ] + + describe('constructor accepts an injected registry override', () => { + it('validateKey resolves keys from the injected registry only', () => { + const isolated = new SettingsValidator({registry: readonlyInfoRegistry}) + expect(isolated.validateKey('_test.snapshot').type).to.equal('readonly-info') + expect(() => isolated.validateKey('agentPool.maxSize')).to.throw(UnknownSettingKeyError) + }) + }) + + describe('validate', () => { + it('throws ReadonlySettingKeyError when called on a readonly-info key', () => { + const isolated = new SettingsValidator({registry: readonlyInfoRegistry}) + expect(() => isolated.validate('_test.snapshot', {q: 1})).to.throw(ReadonlySettingKeyError) + }) + + it('names the offending key on ReadonlySettingKeyError', () => { + const isolated = new SettingsValidator({registry: readonlyInfoRegistry}) + try { + isolated.validate('_test.snapshot', 1) + expect.fail('expected throw') + } catch (error) { + expect(error).to.be.instanceOf(ReadonlySettingKeyError) + if (error instanceof ReadonlySettingKeyError) { + expect(error.key).to.equal('_test.snapshot') + expect(error.message).to.match(/read-only|readonly/i) + } + } + }) + }) + + describe('partition', () => { + it('pushes a readonly-info key found on disk into invalid with a readonly reason', () => { + const isolated = new SettingsValidator({registry: readonlyInfoRegistry}) + const result = isolated.partition({'_test.snapshot': {q: 1}}) + expect(result.valid).to.deep.equal({}) + expect(result.invalid).to.have.lengthOf(1) + expect(result.invalid[0].key).to.equal('_test.snapshot') + expect(result.invalid[0].reason.toLowerCase()).to.include('read') + }) + + it('omits readonly-info keys from the valid record when the file does NOT mention them', () => { + const isolated = new SettingsValidator({registry: readonlyInfoRegistry}) + const result = isolated.partition({}) + expect(result.valid).to.deep.equal({}) + expect(result.invalid).to.deep.equal([]) + }) + + it('still partitions writable keys correctly when mixed with readonly-info entries', () => { + const mixedRegistry: readonly SettingDescriptor[] = [ + ...readonlyInfoRegistry, + { + category: 'concurrency', + default: 10, + description: 'test writable', + key: '_test.writable', + max: 100, + min: 1, + restartRequired: true, + type: 'integer', + }, + ] + const isolated = new SettingsValidator({registry: mixedRegistry}) + const result = isolated.partition({ + '_test.snapshot': {q: 1}, + '_test.writable': 25, + }) + expect(result.valid).to.deep.equal({'_test.writable': 25}) + expect(result.invalid).to.have.lengthOf(1) + expect(result.invalid[0].key).to.equal('_test.snapshot') + }) + }) + }) }) diff --git a/test/unit/infra/transport/handlers/settings-handler-analytics.test.ts b/test/unit/infra/transport/handlers/settings-handler-analytics.test.ts index 9f6909669..c6af82141 100644 --- a/test/unit/infra/transport/handlers/settings-handler-analytics.test.ts +++ b/test/unit/infra/transport/handlers/settings-handler-analytics.test.ts @@ -1,6 +1,7 @@ import {expect} from 'chai' import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' +import type {SettingDescriptor} from '../../../../../src/server/core/domain/entities/settings.js' import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' import type {ISettingsStore} from '../../../../../src/server/core/interfaces/storage/i-settings-store.js' import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' @@ -148,4 +149,71 @@ describe('SettingsHandler analytics emits', () => { await local[SettingsEvents.SET]({key: SETTINGS_KEYS.AGENT_POOL_MAX_SIZE, value: 1}, 'c1') expect(analyticsClient.trackSpy.called).to.equal(false) }) + + describe('readonly-info variant (M16.1)', () => { + const readonlyInfoRegistry: readonly SettingDescriptor[] = [ + { + category: 'updates', + description: 'live operational snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + }, + ] + + let isolatedRequestHandlers: Record + let isolatedAnalytics: IAnalyticsClient & {trackSpy: SinonStub} + + beforeEach(() => { + isolatedRequestHandlers = {} + const isolatedTransport = { + ...transport, + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + isolatedRequestHandlers[event] = handler + }), + } as never + isolatedAnalytics = makeFakeAnalyticsClient() + new SettingsHandler({ + analyticsClient: isolatedAnalytics, + registry: readonlyInfoRegistry, + store, + transport: isolatedTransport, + }).setup() + }) + + it('emits setting_changed failure_kind=read_only with value_kind=readonly-info on SET attempt', async () => { + const handler = isolatedRequestHandlers[SettingsEvents.SET] + await handler({key: '_test.snapshot', value: 1}, 'c1') + const calls = isolatedAnalytics.trackSpy.getCalls().filter((c) => c.args[0] === AnalyticsEventNames.SETTING_CHANGED) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string; setting_key: string; value_kind: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('read_only') + expect(props.setting_key).to.equal('_test.snapshot') + expect(props.value_kind).to.equal('readonly-info') + }) + + it('emits setting_reset failure_kind=read_only with value_kind=readonly-info on RESET attempt', async () => { + const handler = isolatedRequestHandlers[SettingsEvents.RESET] + await handler({key: '_test.snapshot'}, 'c1') + const calls = isolatedAnalytics.trackSpy.getCalls().filter((c) => c.args[0] === AnalyticsEventNames.SETTING_RESET) + expect(calls.length).to.equal(1) + const props = calls[0].args[1] as {failure_kind?: string; outcome: string; setting_key: string; value_kind: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('read_only') + expect(props.value_kind).to.equal('readonly-info') + }) + + it('does NOT call store.set when the SET is gated as read_only', async () => { + const handler = isolatedRequestHandlers[SettingsEvents.SET] + await handler({key: '_test.snapshot', value: 1}, 'c1') + expect(store.set.called).to.equal(false) + }) + + it('does NOT call store.reset when the RESET is gated as read_only', async () => { + const handler = isolatedRequestHandlers[SettingsEvents.RESET] + await handler({key: '_test.snapshot'}, 'c1') + expect(store.reset.called).to.equal(false) + }) + }) }) diff --git a/test/unit/infra/transport/handlers/settings-handler.test.ts b/test/unit/infra/transport/handlers/settings-handler.test.ts index 8b4defc87..eb00c230a 100644 --- a/test/unit/infra/transport/handlers/settings-handler.test.ts +++ b/test/unit/infra/transport/handlers/settings-handler.test.ts @@ -1,6 +1,9 @@ import {expect} from 'chai' -import type {SettingItem} from '../../../../../src/server/core/domain/entities/settings.js' +import type { + SettingDescriptor, + SettingItem, +} from '../../../../../src/server/core/domain/entities/settings.js' import type { ISettingsStore, SettingsStartupSnapshot, @@ -17,9 +20,13 @@ import type { import { InvalidSettingValueError, + ReadonlySettingKeyError, UnknownSettingKeyError, } from '../../../../../src/server/infra/storage/settings-validator.js' -import {SettingsHandler} from '../../../../../src/server/infra/transport/handlers/settings-handler.js' +import { + type ReadonlyInfoProvider, + SettingsHandler, +} from '../../../../../src/server/infra/transport/handlers/settings-handler.js' import {SettingsEvents} from '../../../../../src/shared/transport/events/settings-events.js' import {createMockTransportServer} from '../../../../helpers/mock-factories.js' @@ -327,4 +334,270 @@ describe('SettingsHandler', () => { if (!handler) throw new Error('RESET handler not registered') return handler(payload, 'test-client') as Promise } + + describe('readonly-info variant (M16.1)', () => { + const readonlyInfoRegistry: readonly SettingDescriptor[] = [ + { + category: 'updates', + description: 'live operational snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + }, + ] + + let store: StubSettingsStore + let transport: ReturnType + + function setupHandler(opts: { + readonly providers?: ReadonlyMap + } = {}): void { + new SettingsHandler({ + infoProviders: opts.providers, + registry: readonlyInfoRegistry, + store, + transport, + }).setup() + } + + async function invokeList(): Promise { + const handler = transport._handlers.get(SettingsEvents.LIST) + if (!handler) throw new Error('LIST handler not registered') + return handler(undefined, 'test-client') as Promise + } + + async function invokeGet(payload: SettingsGetRequest): Promise { + const handler = transport._handlers.get(SettingsEvents.GET) + if (!handler) throw new Error('GET handler not registered') + return handler(payload, 'test-client') as Promise + } + + async function invokeSet(payload: SettingsSetRequest): Promise { + const handler = transport._handlers.get(SettingsEvents.SET) + if (!handler) throw new Error('SET handler not registered') + return handler(payload, 'test-client') as Promise + } + + async function invokeReset(payload: SettingsResetRequest): Promise { + const handler = transport._handlers.get(SettingsEvents.RESET) + if (!handler) throw new Error('RESET handler not registered') + return handler(payload, 'test-client') as Promise + } + + beforeEach(() => { + store = new StubSettingsStore() + store.listResult = [{current: undefined, key: '_test.snapshot', restartRequired: false}] + transport = createMockTransportServer() + }) + + describe('SET', () => { + it('returns code=read_only without calling store.set', async () => { + setupHandler() + const result = await invokeSet({key: '_test.snapshot', value: 1}) + + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('read_only') + expect(result.error.key).to.equal('_test.snapshot') + expect(result.error.message.toLowerCase()).to.include('read') + } + + const setCalls = store.calls.filter((c) => c.method === 'set') + expect(setCalls).to.have.lengthOf(0) + }) + + it('maps a ReadonlySettingKeyError thrown from the store to a read_only DTO error', async () => { + store.setBehavior = async (key) => { + throw new ReadonlySettingKeyError(key) + } + + setupHandler() + // Use a writable key that the registry knows about so we bypass the + // top-level guard. We simulate the store layer throwing for a key + // that escalated past pre-validation. + const writableRegistry: readonly SettingDescriptor[] = [ + { + category: 'concurrency', + default: 10, + description: 'test', + key: '_test.writable', + max: 100, + min: 1, + restartRequired: true, + type: 'integer', + }, + ] + const localTransport = createMockTransportServer() + const localStore = new StubSettingsStore() + localStore.setBehavior = async (key) => { + throw new ReadonlySettingKeyError(key) + } + + new SettingsHandler({registry: writableRegistry, store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.SET) + if (!handler) throw new Error('SET handler not registered') + const result = (await handler({key: '_test.writable', value: 1}, 'test-client')) as SettingsSetResponse + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('read_only') + expect(result.error.key).to.equal('_test.writable') + } + }) + }) + + describe('RESET', () => { + it('returns code=read_only without calling store.reset', async () => { + setupHandler() + const result = await invokeReset({key: '_test.snapshot'}) + + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('read_only') + expect(result.error.key).to.equal('_test.snapshot') + } + + const resetCalls = store.calls.filter((c) => c.method === 'reset') + expect(resetCalls).to.have.lengthOf(0) + }) + }) + + describe('LIST', () => { + it('resolves current via the registered info provider when present', async () => { + const providers = new Map([ + ['_test.snapshot', () => ({endpoint: 'test', queueDepth: 3})], + ]) + setupHandler({providers}) + + const result = await invokeList() + const snapshot = result.items.find((i) => i.key === '_test.snapshot') + expect(snapshot, 'readonly-info row must be present').to.exist + expect(snapshot?.type).to.equal('readonly-info') + expect(snapshot?.current).to.deep.equal({endpoint: 'test', queueDepth: 3}) + expect(snapshot?.default).to.equal(undefined) + expect(snapshot?.min).to.equal(undefined) + expect(snapshot?.max).to.equal(undefined) + expect(snapshot?.unit).to.equal(undefined) + }) + + it('returns current=undefined when no info provider is registered', async () => { + setupHandler() + const result = await invokeList() + const snapshot = result.items.find((i) => i.key === '_test.snapshot') + expect(snapshot?.current).to.equal(undefined) + }) + + it('awaits an async info provider before responding', async () => { + const providers = new Map([ + ['_test.snapshot', async () => ({lastFlush: 'now'})], + ]) + setupHandler({providers}) + const result = await invokeList() + const snapshot = result.items.find((i) => i.key === '_test.snapshot') + expect(snapshot?.current).to.deep.equal({lastFlush: 'now'}) + }) + + it('isolates a throwing provider so the row surfaces with current=undefined instead of crashing the whole list', async () => { + const providers = new Map([ + ['_test.snapshot', () => { + throw new Error('provider boom') + }], + ]) + setupHandler({providers}) + + const result = await invokeList() + const snapshot = result.items.find((i) => i.key === '_test.snapshot') + expect(snapshot, 'readonly-info row must still be present').to.exist + expect(snapshot?.current).to.equal(undefined) + }) + + it('isolates a single throwing provider while resolving other readonly-info rows in the same response', async () => { + const multiRegistry: readonly SettingDescriptor[] = [ + { + category: 'updates', + description: 'broken snapshot', + key: '_test.broken', + restartRequired: false, + type: 'readonly-info', + }, + { + category: 'updates', + description: 'healthy snapshot', + key: '_test.healthy', + restartRequired: false, + type: 'readonly-info', + }, + ] + + const providers = new Map([ + ['_test.broken', () => { + throw new Error('provider boom') + }], + ['_test.healthy', () => ({queueDepth: 5})], + ]) + + const localStore = new StubSettingsStore() + localStore.listResult = [] + const localTransport = createMockTransportServer() + new SettingsHandler({ + infoProviders: providers, + registry: multiRegistry, + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.LIST) + if (!handler) throw new Error('LIST handler not registered') + const result = (await handler(undefined, 'test-client')) as SettingsListResponse + + const broken = result.items.find((i) => i.key === '_test.broken') + const healthy = result.items.find((i) => i.key === '_test.healthy') + expect(broken?.current).to.equal(undefined) + expect(healthy?.current).to.deep.equal({queueDepth: 5}) + }) + }) + + describe('GET', () => { + it('resolves current via the registered info provider when present', async () => { + const providers = new Map([ + ['_test.snapshot', () => ({queueDepth: 7})], + ]) + setupHandler({providers}) + + const result = await invokeGet({key: '_test.snapshot'}) + expect(result.ok).to.be.true + if (result.ok) { + expect(result.type).to.equal('readonly-info') + expect(result.current).to.deep.equal({queueDepth: 7}) + expect(result.default).to.equal(undefined) + } + }) + + it('returns current=undefined when no info provider is registered', async () => { + setupHandler() + const result = await invokeGet({key: '_test.snapshot'}) + expect(result.ok).to.be.true + if (result.ok) { + expect(result.current).to.equal(undefined) + } + }) + + it('returns invalid_value when the info provider throws (does not crash)', async () => { + const providers = new Map([ + ['_test.snapshot', () => { + throw new Error('provider boom') + }], + ]) + setupHandler({providers}) + + const result = await invokeGet({key: '_test.snapshot'}) + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('invalid_value') + expect(result.error.key).to.equal('_test.snapshot') + expect(result.error.message.toLowerCase()).to.include('boom') + } + }) + }) + }) }) diff --git a/test/unit/shared/utils/format-settings.test.ts b/test/unit/shared/utils/format-settings.test.ts index 8c9ea961e..d0fbcfe95 100644 --- a/test/unit/shared/utils/format-settings.test.ts +++ b/test/unit/shared/utils/format-settings.test.ts @@ -3,6 +3,11 @@ import {expect} from 'chai' import type {SettingsItemDTO} from '../../../../src/shared/transport/events/settings-events.js' import type {SettingsRow} from '../../../../src/shared/types/settings-row.js' +import { + formatReadonlyInfoValue, + registerReadonlyInfoFormatter, + unregisterReadonlyInfoFormatter, +} from '../../../../src/shared/utils/format-readonly-info.js' import {buildSettingsRows, parseRowInput} from '../../../../src/shared/utils/format-settings.js' function makeItem(overrides: Partial = {}): SettingsItemDTO { @@ -32,6 +37,21 @@ function makeBooleanItem(current: boolean): SettingsItemDTO { } } +function makeReadonlyInfoItem(current: SettingsItemDTO['current']): SettingsItemDTO { + return { + category: 'updates', + current, + description: 'live analytics snapshot for tests', + key: '_test.snapshot', + restartRequired: false, + type: 'readonly-info', + } +} + +const FIRST_FORMATTER = (): string => 'first' +const SECOND_FORMATTER = (): string => 'second' +const SAME_FORMATTER = (): string => 'same' + function makeRow(overrides: Partial = {}): SettingsRow { return { category: 'concurrency', @@ -253,4 +273,123 @@ describe('format-settings (shared)', () => { expect(rows.map((r) => r.category)).to.deep.equal(['concurrency', 'task-history', 'updates']) }) }) + + describe('readonly-info rows (M16.1)', () => { + it('builds a row for readonly-info items', () => { + const rows = buildSettingsRows([makeReadonlyInfoItem({endpoint: 'host', queueDepth: 3})]) + expect(rows).to.have.lengthOf(1) + expect(rows[0].key).to.equal('_test.snapshot') + expect(rows[0].type).to.equal('readonly-info') + }) + + it('renders displayCurrent via the per-key formatter when registered', () => { + registerReadonlyInfoFormatter('_test.snapshot', (value) => { + const v = value as {queueDepth: number} + return `queue=${v.queueDepth}` + }) + try { + const row = buildSettingsRows([makeReadonlyInfoItem({queueDepth: 7})])[0] + expect(row.displayCurrent).to.equal('queue=7') + } finally { + unregisterReadonlyInfoFormatter('_test.snapshot') + } + }) + + it('falls back to JSON.stringify when no per-key formatter is registered', () => { + const row = buildSettingsRows([makeReadonlyInfoItem({queueDepth: 3})])[0] + expect(row.displayCurrent).to.equal('{"queueDepth":3}') + }) + + it('renders "(unavailable)" when current is undefined and no formatter is registered', () => { + const noCurrent: {value?: SettingsItemDTO['current']} = {} + const row = buildSettingsRows([makeReadonlyInfoItem(noCurrent.value)])[0] + expect(row.displayCurrent).to.equal('(unavailable)') + }) + + it('omits displayDefault and displayRange on readonly-info rows', () => { + const row = buildSettingsRows([makeReadonlyInfoItem({q: 1})])[0] + expect(row.displayDefault).to.equal(undefined) + expect(row.displayRange).to.equal('') + expect(row.default).to.equal(undefined) + }) + + it('marks readonly-info rows as not modified (no default to diverge from)', () => { + const row = buildSettingsRows([makeReadonlyInfoItem({q: 1})])[0] + expect(row.modified).to.equal(false) + }) + + it('propagates restartRequired=false onto the row', () => { + const row = buildSettingsRows([makeReadonlyInfoItem({q: 1})])[0] + expect(row.restartRequired).to.equal(false) + }) + + it('mixes correctly with boolean and integer rows; category order preserved', () => { + const rows = buildSettingsRows([ + makeReadonlyInfoItem({q: 1}), + makeItem({category: 'concurrency', key: 'agentPool.maxSize'}), + makeBooleanItem(true), + ]) + // Concurrency comes before updates per CATEGORY_ORDER. Within the + // 'updates' category, the stable sort preserves the input order + // (_test.snapshot at index 0, update.checkForUpdates at index 2). + expect(rows.map((r) => r.key)).to.deep.equal([ + 'agentPool.maxSize', + '_test.snapshot', + 'update.checkForUpdates', + ]) + }) + }) + + describe('formatReadonlyInfoValue (M16.1)', () => { + afterEach(() => { + unregisterReadonlyInfoFormatter('_test.fmt') + }) + + it('returns (unavailable) when value is undefined', () => { + const noValue: {value?: unknown} = {} + expect(formatReadonlyInfoValue('_test.fmt', noValue.value)).to.equal('(unavailable)') + }) + + it('returns the raw string when value is a string and no formatter is registered', () => { + // Strings are not part of the wire type but the formatter survives + // them via JSON.stringify; this guarantees no crash on hand-injected + // values from a future provider that returns a primitive. + expect(formatReadonlyInfoValue('_test.fmt', 'hello')).to.equal('hello') + }) + + it('JSON-stringifies an object payload by default', () => { + expect(formatReadonlyInfoValue('_test.fmt', {a: 1, b: 2})).to.equal('{"a":1,"b":2}') + }) + + it('uses the registered formatter when present', () => { + registerReadonlyInfoFormatter('_test.fmt', (value) => `<${JSON.stringify(value)}>`) + expect(formatReadonlyInfoValue('_test.fmt', {x: 1})).to.equal('<{"x":1}>') + }) + + it('does NOT call the registered formatter for a different key', () => { + registerReadonlyInfoFormatter('_test.fmt', () => 'should-not-fire') + expect(formatReadonlyInfoValue('_test.other', {x: 1})).to.equal('{"x":1}') + }) + + it('unregisterReadonlyInfoFormatter clears the registration', () => { + registerReadonlyInfoFormatter('_test.fmt', () => 'registered') + unregisterReadonlyInfoFormatter('_test.fmt') + expect(formatReadonlyInfoValue('_test.fmt', {x: 1})).to.equal('{"x":1}') + }) + + it('throws when a key is registered twice with a different function (prevents silent override)', () => { + registerReadonlyInfoFormatter('_test.fmt', FIRST_FORMATTER) + expect(() => registerReadonlyInfoFormatter('_test.fmt', SECOND_FORMATTER)).to.throw( + /already registered/i, + ) + // First registration is preserved. + expect(formatReadonlyInfoValue('_test.fmt', {x: 1})).to.equal('first') + }) + + it('is idempotent when the same function is registered twice', () => { + registerReadonlyInfoFormatter('_test.fmt', SAME_FORMATTER) + expect(() => registerReadonlyInfoFormatter('_test.fmt', SAME_FORMATTER)).to.not.throw() + expect(formatReadonlyInfoValue('_test.fmt', {x: 1})).to.equal('same') + }) + }) }) diff --git a/test/unit/tui/features/settings/format-settings.test.ts b/test/unit/tui/features/settings/format-settings.test.ts index 36dc8d01b..7587325a5 100644 --- a/test/unit/tui/features/settings/format-settings.test.ts +++ b/test/unit/tui/features/settings/format-settings.test.ts @@ -102,6 +102,22 @@ describe('format-settings (tui)', () => { expect(hint).to.include('Saving') expect(hint).to.include('background') }) + + it('returns a read-only hint in browse mode when the focused row is readonly-info (M16.1)', () => { + const hint = bottomHintFor('browse', '_test.snapshot', 'readonly-info') + expect(hint).to.include('read-only') + expect(hint).to.not.include('Enter edit') + expect(hint).to.not.include('R reset') + }) + + it('keeps the writable browse hint for boolean/integer rows even when focusedRowType is passed', () => { + expect(bottomHintFor('browse', 'agentPool.maxSize', 'integer')).to.equal( + 'Up/Down move | Enter edit | R reset | Esc exit', + ) + expect(bottomHintFor('browse', 'update.checkForUpdates', 'boolean')).to.equal( + 'Up/Down move | Enter edit | R reset | Esc exit', + ) + }) }) describe('preFillBufferFor', () => { From d53dd9b502fcb728925855901cf7fdb798db1163 Mon Sep 17 00:00:00 2001 From: bao-byterover Date: Thu, 28 May 2026 17:30:49 +0700 Subject: [PATCH 64/87] feat: [ENG-3008] add migrate_run analytics event for brv migrate (#727) --- src/server/infra/process/feature-handlers.ts | 2 +- .../transport/handlers/analytics-handler.ts | 8 + .../transport/handlers/migrate-handler.ts | 78 ++++++- src/shared/analytics/event-names.ts | 1 + src/shared/analytics/events/index.ts | 3 + src/shared/analytics/events/migrate-run.ts | 62 +++++ .../migrate-handler-analytics.test.ts | 221 ++++++++++++++++++ .../handlers/analytics-handler.test.ts | 10 +- .../unit/shared/analytics/event-names.test.ts | 3 +- .../analytics/events/migrate-run.test.ts | 195 ++++++++++++++++ .../shared/analytics/privacy-fixture.test.ts | 47 +++- 11 files changed, 618 insertions(+), 12 deletions(-) create mode 100644 src/shared/analytics/events/migrate-run.ts create mode 100644 test/unit/infra/transport/handlers/migrate-handler-analytics.test.ts create mode 100644 test/unit/shared/analytics/events/migrate-run.test.ts diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 518a99302..42dc59e7e 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -444,7 +444,7 @@ export async function setupFeatureHandlers({ transport, }).setup() - new MigrateHandler({resolveProjectPath, transport}).setup() + new MigrateHandler({analyticsClient, resolveProjectPath, transport}).setup() new ResetHandler({ analyticsClient, diff --git a/src/server/infra/transport/handlers/analytics-handler.ts b/src/server/infra/transport/handlers/analytics-handler.ts index 03f532c5c..69370805b 100644 --- a/src/server/infra/transport/handlers/analytics-handler.ts +++ b/src/server/infra/transport/handlers/analytics-handler.ts @@ -21,6 +21,7 @@ import {isAnalyticsEventName} from '../../../../shared/analytics/events/index.js import {McpSessionEndedSchema} from '../../../../shared/analytics/events/mcp-session-ended.js' import {McpSessionStartSchema} from '../../../../shared/analytics/events/mcp-session-start.js' import {McpToolCalledSchema} from '../../../../shared/analytics/events/mcp-tool-called.js' +import {MigrateRunSchema} from '../../../../shared/analytics/events/migrate-run.js' import {OnboardingAutoSetupStartedSchema} from '../../../../shared/analytics/events/onboarding-auto-setup-started.js' import {OnboardingCompletedSchema} from '../../../../shared/analytics/events/onboarding-completed.js' import {QueryCompletedSchema} from '../../../../shared/analytics/events/query-completed.js' @@ -234,6 +235,13 @@ export class AnalyticsHandler { break } + case AnalyticsEventNames.MIGRATE_RUN: { + const props = MigrateRunSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.MIGRATE_RUN, props.data) + break + } + case AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED: { const props = OnboardingAutoSetupStartedSchema.safeParse(rawProperties ?? {}) if (!props.success) return diff --git a/src/server/infra/transport/handlers/migrate-handler.ts b/src/server/infra/transport/handlers/migrate-handler.ts index 0cef08c0c..65101885b 100644 --- a/src/server/infra/transport/handlers/migrate-handler.ts +++ b/src/server/infra/transport/handlers/migrate-handler.ts @@ -12,28 +12,36 @@ * VcHandler, PushHandler, etc. */ +/* eslint-disable camelcase */ import type { MigrateRollbackRequest, MigrateRollbackResponse, MigrateRunRequest, MigrateRunResponse, } from '../../../../shared/transport/events/migrate-events.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' +import {type MigrateRunProps} from '../../../../shared/analytics/events/migrate-run.js' import {MigrateEvents} from '../../../../shared/transport/events/migrate-events.js' +import {processLog} from '../../../utils/process-logger.js' import {rollback, runMigration} from '../../migrate/orchestrator.js' import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' export interface MigrateHandlerDeps { + readonly analyticsClient?: IAnalyticsClient resolveProjectPath: ProjectPathResolver transport: ITransportServer } export class MigrateHandler { + private readonly analyticsClient: IAnalyticsClient | undefined private readonly resolveProjectPath: ProjectPathResolver private readonly transport: ITransportServer constructor(deps: MigrateHandlerDeps) { + this.analyticsClient = deps.analyticsClient this.resolveProjectPath = deps.resolveProjectPath this.transport = deps.transport } @@ -49,12 +57,47 @@ export class MigrateHandler { ) } + /** + * Analytics emit helper. Mirrors the try/processLog pattern from + * SettingsHandler so analytics failures never affect command outcomes. + */ + private emitMigrateRun(properties: MigrateRunProps): void { + const client = this.analyticsClient + if (!client) return + try { + client.track(AnalyticsEventNames.MIGRATE_RUN, properties) + } catch (error) { + processLog( + `[Migrate] analytics track migrate_run failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + private async handleRollback( data: MigrateRollbackRequest, clientId: string, ): Promise { const projectRoot = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - return rollback({dryRun: data.dryRun, projectRoot}) + try { + const report = rollback({dryRun: data.dryRun, projectRoot}) + this.emitMigrateRun({ + deleted_html: report.deletedHtml.length, + dry_run: report.dryRun, + mode: 'rollback', + outcome: 'success', + preserved_html: report.preservedHtml.length, + restored: report.restored, + }) + return report + } catch (error) { + this.emitMigrateRun({ + dry_run: data.dryRun, + failure_kind: classifyMigrateFailure(error), + mode: 'rollback', + outcome: 'failure', + }) + throw error + } } private async handleRun( @@ -62,7 +105,36 @@ export class MigrateHandler { clientId: string, ): Promise { const projectRoot = resolveRequiredProjectPath(this.resolveProjectPath, clientId) - const report = runMigration({dryRun: data.dryRun, projectRoot}) - return {report} + try { + const report = runMigration({dryRun: data.dryRun, projectRoot}) + this.emitMigrateRun({ + archived: report.summary.archived, + dry_run: report.dryRun, + failed: report.summary.failed, + migrated: report.summary.migrated, + mode: 'forward', + outcome: 'success', + skipped: report.summary.skipped, + }) + return {report} + } catch (error) { + this.emitMigrateRun({ + dry_run: data.dryRun, + failure_kind: classifyMigrateFailure(error), + mode: 'forward', + outcome: 'failure', + }) + throw error + } + } +} + +function classifyMigrateFailure(error: unknown): string { + if (error instanceof Error) { + const msg = error.message + if (msg.startsWith('Migration already ran today')) return 'archive_exists' + if (msg.startsWith('No archive to roll back')) return 'no_archive' } + + return 'unknown' } diff --git a/src/shared/analytics/event-names.ts b/src/shared/analytics/event-names.ts index 3a88b2f78..2e2495b21 100644 --- a/src/shared/analytics/event-names.ts +++ b/src/shared/analytics/event-names.ts @@ -31,6 +31,7 @@ export const AnalyticsEventNames = { MCP_SESSION_ENDED: 'mcp_session_ended', MCP_SESSION_START: 'mcp_session_start', MCP_TOOL_CALLED: 'mcp_tool_called', + MIGRATE_RUN: 'migrate_run', ONBOARDING_AUTO_SETUP_STARTED: 'onboarding_auto_setup_started', ONBOARDING_COMPLETED: 'onboarding_completed', QUERY_COMPLETED: 'query_completed', diff --git a/src/shared/analytics/events/index.ts b/src/shared/analytics/events/index.ts index c2169933b..d63e6843c 100644 --- a/src/shared/analytics/events/index.ts +++ b/src/shared/analytics/events/index.ts @@ -18,6 +18,7 @@ import {type HubRegistryRemovedProps, HubRegistryRemovedSchema} from './hub-regi import {type McpSessionEndedProps, McpSessionEndedSchema} from './mcp-session-ended.js' import {type McpSessionStartProps, McpSessionStartSchema} from './mcp-session-start.js' import {type McpToolCalledProps, McpToolCalledSchema} from './mcp-tool-called.js' +import {type MigrateRunProps, MigrateRunSchema} from './migrate-run.js' import {type OnboardingAutoSetupStartedProps, OnboardingAutoSetupStartedSchema} from './onboarding-auto-setup-started.js' import {type OnboardingCompletedProps, OnboardingCompletedSchema} from './onboarding-completed.js' import {type QueryCompletedProps, QueryCompletedSchema} from './query-completed.js' @@ -82,6 +83,7 @@ export const ALL_EVENT_SCHEMAS = { [AnalyticsEventNames.MCP_SESSION_ENDED]: McpSessionEndedSchema, [AnalyticsEventNames.MCP_SESSION_START]: McpSessionStartSchema, [AnalyticsEventNames.MCP_TOOL_CALLED]: McpToolCalledSchema, + [AnalyticsEventNames.MIGRATE_RUN]: MigrateRunSchema, [AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED]: OnboardingAutoSetupStartedSchema, [AnalyticsEventNames.ONBOARDING_COMPLETED]: OnboardingCompletedSchema, [AnalyticsEventNames.QUERY_COMPLETED]: QueryCompletedSchema, @@ -137,6 +139,7 @@ export type AnyAnalyticsEvent = | {name: typeof AnalyticsEventNames.MCP_SESSION_ENDED; properties: McpSessionEndedProps} | {name: typeof AnalyticsEventNames.MCP_SESSION_START; properties: McpSessionStartProps} | {name: typeof AnalyticsEventNames.MCP_TOOL_CALLED; properties: McpToolCalledProps} + | {name: typeof AnalyticsEventNames.MIGRATE_RUN; properties: MigrateRunProps} | {name: typeof AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED; properties: OnboardingAutoSetupStartedProps} | {name: typeof AnalyticsEventNames.ONBOARDING_COMPLETED; properties: OnboardingCompletedProps} | {name: typeof AnalyticsEventNames.QUERY_COMPLETED; properties: QueryCompletedProps} diff --git a/src/shared/analytics/events/migrate-run.ts b/src/shared/analytics/events/migrate-run.ts new file mode 100644 index 000000000..da0d10827 --- /dev/null +++ b/src/shared/analytics/events/migrate-run.ts @@ -0,0 +1,62 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `migrate_run`. + * + * Emitted by the daemon's `MigrateHandler` once per `brv migrate` invocation + * (forward or rollback, success or failure). Counts mirror the orchestrator's + * `MigrationReport` and `RollbackReport` shapes: + * + * forward: migrated / archived / skipped / failed come from `summary.*` + * rollback: restored / deleted_html / preserved_html come from `restored`, + * `deletedHtml.length`, `preservedHtml.length` + * + * The wire schema is a discriminated union on `mode` so a `rollback` payload + * structurally cannot carry forward-only counters (`migrated`, `archived`, + * `skipped`, `failed`) and vice versa. Downstream warehouse queries can rely + * on the schema to enforce per-mode counter separation rather than filtering + * by `mode` after the fact. + * + * Per-mode counters stay optional because failure paths surface before counts + * can be computed. + * + * `failure_kind` is populated only when `outcome === 'failure'`. Free-form + * short string (caller-classified, e.g. `archive_exists`, `no_archive`, + * `unknown`) so the producer can taxonomize without a schema migration. + */ + +const failureKindSchema = z.string().min(1).max(64).optional() +const countSchema = z.number().int().nonnegative().optional() + +const MigrateRunForwardSchema = z + .object({ + archived: countSchema, + dry_run: z.boolean(), + failed: countSchema, + failure_kind: failureKindSchema, + migrated: countSchema, + mode: z.literal('forward'), + outcome: z.enum(['success', 'failure']), + skipped: countSchema, + }) + .strict() + +const MigrateRunRollbackSchema = z + .object({ + deleted_html: countSchema, + dry_run: z.boolean(), + failure_kind: failureKindSchema, + mode: z.literal('rollback'), + outcome: z.enum(['success', 'failure']), + preserved_html: countSchema, + restored: countSchema, + }) + .strict() + +export const MigrateRunSchema = z.discriminatedUnion('mode', [ + MigrateRunForwardSchema, + MigrateRunRollbackSchema, +]) + +export type MigrateRunProps = z.infer diff --git a/test/unit/infra/transport/handlers/migrate-handler-analytics.test.ts b/test/unit/infra/transport/handlers/migrate-handler-analytics.test.ts new file mode 100644 index 000000000..4e2569525 --- /dev/null +++ b/test/unit/infra/transport/handlers/migrate-handler-analytics.test.ts @@ -0,0 +1,221 @@ +import {expect} from 'chai' +import {mkdirSync, mkdtempSync, rmSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {AnalyticsEventName} from '../../../../../src/shared/analytics/event-names.js' +import type {PropsArg} from '../../../../../src/shared/analytics/events/index.js' +import type {MigrateRunProps} from '../../../../../src/shared/analytics/events/migrate-run.js' +import type {MigrateRollbackResponse, MigrateRunResponse} from '../../../../../src/shared/transport/events/migrate-events.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import { + ARCHIVE_FOLDER_PREFIX, + BRV_DIR, + CONTEXT_TREE_DIR, + MIGRATIONS_DIR, +} from '../../../../../src/server/infra/migrate/constants.js' +import {MigrateHandler} from '../../../../../src/server/infra/transport/handlers/migrate-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {MigrateEvents} from '../../../../../src/shared/transport/events/migrate-events.js' +import {createMockTransportServer, type MockTransportServer} from '../../../../helpers/mock-factories.js' + +type TrackCall = {event: AnalyticsEventName; properties: unknown} + +type MockAnalyticsClient = IAnalyticsClient & { + readonly trackCalls: readonly TrackCall[] + trackThrows?: Error +} + +/** + * Hand-rolled mock preserving `track(event, ...rest: PropsArg)` generics. + * Mirrors the pattern from `analytics-handler.test.ts` so sinon's collapsed + * SinonStub overload doesn't fight the IAnalyticsClient contract. + */ +function makeMockAnalyticsClient(): MockAnalyticsClient { + const trackCalls: TrackCall[] = [] + const mock: MockAnalyticsClient = { + abort(): void { + /* not exercised */ + }, + flush: () => Promise.resolve(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: () => Promise.resolve(), + track(event: E, ...rest: PropsArg): void { + if (mock.trackThrows) throw mock.trackThrows + const [properties] = rest + trackCalls.push({event, properties}) + }, + trackCalls, + } + return mock +} + +function todayUtc(): string { + return new Date().toISOString().slice(0, 10) +} + +function isMigrateRunProps(value: unknown): value is MigrateRunProps { + return typeof value === 'object' && value !== null && 'mode' in value && 'outcome' in value +} + +function findMigrateRunEmits(client: MockAnalyticsClient): MigrateRunProps[] { + const out: MigrateRunProps[] = [] + for (const call of client.trackCalls) { + if (call.event !== AnalyticsEventNames.MIGRATE_RUN) continue + if (!isMigrateRunProps(call.properties)) continue + out.push(call.properties) + } + + return out +} + +describe('MigrateHandler analytics emits', () => { + let transport: MockTransportServer + let analyticsClient: MockAnalyticsClient + let projectRoot: string + + beforeEach(() => { + transport = createMockTransportServer() + analyticsClient = makeMockAnalyticsClient() + projectRoot = mkdtempSync(join(tmpdir(), 'brv-migrate-handler-analytics-')) + new MigrateHandler({ + analyticsClient, + resolveProjectPath: () => projectRoot, + transport, + }).setup() + }) + + afterEach(() => { + rmSync(projectRoot, {force: true, recursive: true}) + }) + + describe('forward path (migrate:run)', () => { + it('emits migrate_run outcome=success with forward counters on a clean project', async () => { + const handler = transport._handlers.get(MigrateEvents.RUN) + if (handler === undefined) throw new Error('migrate:run handler not registered') + + await handler({dryRun: true}, 'client-1') + + const emits = findMigrateRunEmits(analyticsClient) + expect(emits.length).to.equal(1) + const [props] = emits + if (props.mode !== 'forward') throw new Error(`expected forward, got ${props.mode}`) + expect(props.outcome).to.equal('success') + expect(props.dry_run).to.equal(true) + expect(props.migrated).to.equal(0) + expect(props.archived).to.equal(0) + expect(props.skipped).to.equal(0) + expect(props.failed).to.equal(0) + }) + + it('emits migrate_run outcome=failure with failure_kind when orchestrator throws (archive already exists)', async () => { + mkdirSync(join(projectRoot, BRV_DIR, CONTEXT_TREE_DIR), {recursive: true}) + mkdirSync( + join(projectRoot, BRV_DIR, MIGRATIONS_DIR, `${ARCHIVE_FOLDER_PREFIX}${todayUtc()}`), + {recursive: true}, + ) + + const handler = transport._handlers.get(MigrateEvents.RUN) + if (handler === undefined) throw new Error('migrate:run handler not registered') + + let caught: unknown + try { + await handler({dryRun: false}, 'client-1') + } catch (error) { + caught = error + } + + expect(caught, 'orchestrator throw must propagate').to.be.instanceOf(Error) + + const emits = findMigrateRunEmits(analyticsClient) + expect(emits.length).to.equal(1) + const [props] = emits + if (props.mode !== 'forward') throw new Error(`expected forward, got ${props.mode}`) + expect(props.outcome).to.equal('failure') + expect(props.dry_run).to.equal(false) + expect(props.failure_kind).to.be.a('string').and.not.empty + }) + }) + + describe('rollback path (migrate:rollback)', () => { + it('emits migrate_run outcome=success with rollback counters when an archive exists', async () => { + mkdirSync( + join(projectRoot, BRV_DIR, MIGRATIONS_DIR, `${ARCHIVE_FOLDER_PREFIX}${todayUtc()}`), + {recursive: true}, + ) + + const handler = transport._handlers.get(MigrateEvents.ROLLBACK) + if (handler === undefined) throw new Error('migrate:rollback handler not registered') + + await handler({dryRun: true}, 'client-1') + + const emits = findMigrateRunEmits(analyticsClient) + expect(emits.length).to.equal(1) + const [props] = emits + if (props.mode !== 'rollback') throw new Error(`expected rollback, got ${props.mode}`) + expect(props.outcome).to.equal('success') + expect(props.dry_run).to.equal(true) + expect(props.restored).to.equal(0) + expect(props.deleted_html).to.equal(0) + expect(props.preserved_html).to.equal(0) + }) + + it('emits migrate_run outcome=failure with failure_kind when no archive exists', async () => { + const handler = transport._handlers.get(MigrateEvents.ROLLBACK) + if (handler === undefined) throw new Error('migrate:rollback handler not registered') + + let caught: unknown + try { + await handler({dryRun: true}, 'client-1') + } catch (error) { + caught = error + } + + expect(caught, 'orchestrator throw must propagate').to.be.instanceOf(Error) + + const emits = findMigrateRunEmits(analyticsClient) + expect(emits.length).to.equal(1) + const [props] = emits + if (props.mode !== 'rollback') throw new Error(`expected rollback, got ${props.mode}`) + expect(props.outcome).to.equal('failure') + expect(props.dry_run).to.equal(true) + expect(props.failure_kind).to.be.a('string').and.not.empty + }) + }) + + describe('no-op when analyticsClient is not injected', () => { + it('does not throw and does not call track on either path', async () => { + const localTransport = createMockTransportServer() + const localAnalyticsClient = makeMockAnalyticsClient() + new MigrateHandler({ + resolveProjectPath: () => projectRoot, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(MigrateEvents.RUN) + if (handler === undefined) throw new Error('migrate:run handler not registered') + + await handler({dryRun: true}, 'client-1') + expect(localAnalyticsClient.trackCalls).to.have.lengthOf(0) + }) + }) + + describe('analytics throw never propagates to caller', () => { + it('forward path returns a normal report even if track() throws', async () => { + analyticsClient.trackThrows = new Error('analytics down') + + const handler = transport._handlers.get(MigrateEvents.RUN) + if (handler === undefined) throw new Error('migrate:run handler not registered') + + const response: MigrateRollbackResponse | MigrateRunResponse = await handler( + {dryRun: true}, + 'client-1', + ) + + if (!('report' in response)) throw new Error('expected a forward report on success') + expect(response.report.summary).to.exist + }) + }) +}) diff --git a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts index ca2461497..a31471dfd 100644 --- a/test/unit/server/infra/transport/handlers/analytics-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-handler.test.ts @@ -173,7 +173,7 @@ describe('AnalyticsHandler', () => { describe('per-event dispatch coverage — every new event name reaches track()', () => { const validHashHex = 'a'.repeat(64) // Per-event minimal payloads that satisfy each schema. Lifecycle events - // (33 of 36) carry `outcome: 'success'`; 3 observation events stay + // (34 of 37) carry `outcome: 'success'`; 3 observation events stay // outcome-less. Payloads are intentionally narrow — broader fixture // coverage lives in privacy-fixture.test.ts. const cases: Array<{event: AnalyticsEventName; properties?: Record}> = [ @@ -206,6 +206,10 @@ describe('AnalyticsHandler', () => { properties: {is_default: true, outcome: 'success', registry_kind: 'byterover'}, }, {event: AnalyticsEventNames.HUB_REGISTRY_REMOVED, properties: {outcome: 'success', registry_kind: 'byterover'}}, + { + event: AnalyticsEventNames.MIGRATE_RUN, + properties: {dry_run: false, mode: 'forward', outcome: 'success'}, + }, {event: AnalyticsEventNames.ONBOARDING_AUTO_SETUP_STARTED, properties: {mode: 'auto', outcome: 'success'}}, {event: AnalyticsEventNames.ONBOARDING_COMPLETED, properties: {outcome: 'success'}}, { @@ -320,8 +324,8 @@ describe('AnalyticsHandler', () => { }) } - it('coverage matches schema count (36 new events covered)', () => { - expect(cases.length, 'must enumerate all 36 new event names').to.equal(36) + it('coverage matches schema count (37 new events covered)', () => { + expect(cases.length, 'must enumerate all 37 new event names').to.equal(37) }) }) }) diff --git a/test/unit/shared/analytics/event-names.test.ts b/test/unit/shared/analytics/event-names.test.ts index e00c6ef29..f207d218f 100644 --- a/test/unit/shared/analytics/event-names.test.ts +++ b/test/unit/shared/analytics/event-names.test.ts @@ -3,7 +3,7 @@ import {expect} from 'chai' import {type AnalyticsEventName, AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' describe('AnalyticsEventNames', () => { - it('should expose exactly the forty-six shipped event names', () => { + it('should expose exactly the forty-seven shipped event names', () => { expect(Object.keys(AnalyticsEventNames).sort()).to.deep.equal([ 'ANALYTICS_DISABLED', 'AUTH_LOGIN', @@ -22,6 +22,7 @@ describe('AnalyticsEventNames', () => { 'MCP_SESSION_ENDED', 'MCP_SESSION_START', 'MCP_TOOL_CALLED', + 'MIGRATE_RUN', 'ONBOARDING_AUTO_SETUP_STARTED', 'ONBOARDING_COMPLETED', 'QUERY_COMPLETED', diff --git a/test/unit/shared/analytics/events/migrate-run.test.ts b/test/unit/shared/analytics/events/migrate-run.test.ts new file mode 100644 index 000000000..54f759e43 --- /dev/null +++ b/test/unit/shared/analytics/events/migrate-run.test.ts @@ -0,0 +1,195 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import {MigrateRunSchema} from '../../../../../src/shared/analytics/events/migrate-run.js' + +const baseForwardSuccess = { + archived: 1, + dry_run: true, + failed: 0, + migrated: 2, + mode: 'forward' as const, + outcome: 'success' as const, + skipped: 3, +} + +const baseRollbackSuccess = { + deleted_html: 2, + dry_run: false, + mode: 'rollback' as const, + outcome: 'success' as const, + preserved_html: 1, + restored: 5, +} + +describe('MigrateRunSchema', () => { + describe('valid payloads', () => { + it('accepts a forward success payload with all counts', () => { + expect(MigrateRunSchema.safeParse(baseForwardSuccess).success).to.equal(true) + }) + + it('accepts a rollback success payload with all counts', () => { + expect(MigrateRunSchema.safeParse(baseRollbackSuccess).success).to.equal(true) + }) + + it('accepts a forward failure payload with failure_kind and no counts', () => { + const result = MigrateRunSchema.safeParse({ + dry_run: false, + failure_kind: 'archive_exists', + mode: 'forward', + outcome: 'failure', + }) + expect(result.success).to.equal(true) + }) + + it('accepts a rollback failure payload with failure_kind', () => { + const result = MigrateRunSchema.safeParse({ + dry_run: true, + failure_kind: 'no_archive', + mode: 'rollback', + outcome: 'failure', + }) + expect(result.success).to.equal(true) + }) + + it('accepts a forward payload with all counts zeroed', () => { + const result = MigrateRunSchema.safeParse({ + archived: 0, + dry_run: false, + failed: 0, + migrated: 0, + mode: 'forward', + outcome: 'success', + skipped: 0, + }) + expect(result.success).to.equal(true) + }) + + it('accepts a rollback payload with all counts zeroed', () => { + const result = MigrateRunSchema.safeParse({ + deleted_html: 0, + dry_run: false, + mode: 'rollback', + outcome: 'success', + preserved_html: 0, + restored: 0, + }) + expect(result.success).to.equal(true) + }) + + it('accepts a forward payload with only required fields', () => { + const result = MigrateRunSchema.safeParse({ + dry_run: false, + mode: 'forward', + outcome: 'success', + }) + expect(result.success).to.equal(true) + }) + + it('accepts a rollback payload with only required fields', () => { + const result = MigrateRunSchema.safeParse({ + dry_run: false, + mode: 'rollback', + outcome: 'success', + }) + expect(result.success).to.equal(true) + }) + }) + + describe('invalid payloads', () => { + it('rejects missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {mode: _mode, ...withoutMode} = baseForwardSuccess + expect(MigrateRunSchema.safeParse(withoutMode).success).to.equal(false) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {outcome: _outcome, ...withoutOutcome} = baseForwardSuccess + expect(MigrateRunSchema.safeParse(withoutOutcome).success).to.equal(false) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {dry_run: _dryRun, ...withoutDryRun} = baseForwardSuccess + expect(MigrateRunSchema.safeParse(withoutDryRun).success).to.equal(false) + }) + + it('rejects out-of-enum mode', () => { + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, mode: 'sideways'}).success, + ).to.equal(false) + }) + + it('rejects out-of-enum outcome', () => { + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, outcome: 'unknown'}).success, + ).to.equal(false) + }) + + it('rejects non-boolean dry_run', () => { + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, dry_run: 'yes'}).success, + ).to.equal(false) + }) + + it('rejects negative counts', () => { + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, migrated: -1}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseRollbackSuccess, restored: -1}).success, + ).to.equal(false) + }) + + it('rejects non-integer counts', () => { + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, archived: 1.5}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseRollbackSuccess, deleted_html: 0.5}).success, + ).to.equal(false) + }) + + it('rejects empty failure_kind', () => { + expect( + MigrateRunSchema.safeParse({ + dry_run: false, + failure_kind: '', + mode: 'forward', + outcome: 'failure', + }).success, + ).to.equal(false) + }) + + it('rejects unknown extra fields (strict)', () => { + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, mystery_field: 'oops'}).success, + ).to.equal(false) + }) + + it('rejects forward payload carrying rollback-only counters', () => { + // Discriminated-union guarantee: per-mode counter fields stay segregated. + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, deleted_html: 1}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, preserved_html: 1}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseForwardSuccess, restored: 1}).success, + ).to.equal(false) + }) + + it('rejects rollback payload carrying forward-only counters', () => { + expect( + MigrateRunSchema.safeParse({...baseRollbackSuccess, migrated: 1}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseRollbackSuccess, archived: 1}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseRollbackSuccess, skipped: 1}).success, + ).to.equal(false) + expect( + MigrateRunSchema.safeParse({...baseRollbackSuccess, failed: 1}).success, + ).to.equal(false) + }) + }) +}) diff --git a/test/unit/shared/analytics/privacy-fixture.test.ts b/test/unit/shared/analytics/privacy-fixture.test.ts index d33efefc7..bca77c5f7 100644 --- a/test/unit/shared/analytics/privacy-fixture.test.ts +++ b/test/unit/shared/analytics/privacy-fixture.test.ts @@ -59,10 +59,15 @@ const FIXTURE_SENTINEL_NAMES: ReadonlySet = new Set([ /** * Recursively collect every field name reachable from a Zod schema, including - * fields inside nested ZodObject, ZodOptional / ZodNullable wrappers, and - * ZodArray element schemas. The privacy fixture must audit nested shapes - * because adding `{error: {message, code}}` should surface `message` as a - * forbidden name even though the top level only declares `error`. + * fields inside nested ZodObject, ZodOptional / ZodNullable wrappers, + * ZodArray element schemas, and ZodUnion / ZodDiscriminatedUnion members. + * The privacy fixture must audit nested shapes because adding + * `{error: {message, code}}` should surface `message` as a forbidden name + * even though the top level only declares `error`. + * + * Discriminated unions (used by `migrate_run` to enforce per-mode counter + * separation) must be walked across every member or a forbidden field + * declared only on the rollback variant would slip past audit. */ function getShapeFieldNames(schema: z.ZodTypeAny, seen: Set = new Set()): string[] { if (seen.has(schema)) return [] @@ -85,6 +90,15 @@ function getShapeFieldNames(schema: z.ZodTypeAny, seen: Set = new return out } + if (schema instanceof z.ZodDiscriminatedUnion || schema instanceof z.ZodUnion) { + const out: string[] = [] + for (const option of schema.options as z.ZodTypeAny[]) { + out.push(...getShapeFieldNames(option, seen)) + } + + return out + } + return [] } @@ -133,6 +147,7 @@ describe('analytics privacy fixture (smoke)', () => { 'mcp_session_ended', 'mcp_session_start', 'mcp_tool_called', + 'migrate_run', 'onboarding_auto_setup_started', 'onboarding_completed', 'query_completed', @@ -197,5 +212,29 @@ describe('analytics privacy fixture (smoke)', () => { expect(getShapeFieldNames(optionalBad)).to.include('token') expect(getShapeFieldNames(nullableBad)).to.include('api_key') }) + + it('should walk every member of a ZodDiscriminatedUnion', () => { + // Forbidden names live on different variants — the walker MUST visit + // both, or migrate_run-style discriminated schemas would let a PII + // field declared only on one variant slip past privacy audit. + const unionBad = z.discriminatedUnion('kind', [ + z.object({email: z.string(), kind: z.literal('a')}), + // eslint-disable-next-line camelcase + z.object({api_key: z.string(), kind: z.literal('b')}), + ]) + const fields = getShapeFieldNames(unionBad) + expect(fields).to.include('email') + expect(fields).to.include('api_key') + }) + + it('should walk every option of a plain ZodUnion', () => { + const unionBad = z.union([ + z.object({password: z.string()}), + z.object({token: z.string()}), + ]) + const fields = getShapeFieldNames(unionBad) + expect(fields).to.include('password') + expect(fields).to.include('token') + }) }) }) From 2420a5bc136ec0206ac7353f55630e4157416c9a Mon Sep 17 00:00:00 2001 From: cuongdo-byterover Date: Thu, 28 May 2026 20:06:47 +0700 Subject: [PATCH 65/87] Fix/eng 3011 (#728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: [ENG-3011] M17.1 add synthetic LLM-event emitters for tool-mode dispatch Tool-mode dispatch (curate-tool-mode, query-tool-mode) was emitting zero inputs to AnalyticsHook because it bypassed the llmservice:* event channel the legacy LLM-driven path used. The downstream M12 producers ran with all-zero counters in prod: - `curate_operation_applied` never landed at all - `curate_run_completed.operations_*` always 0 - `query_completed.read_paths_with_metadata` always absent - `query_completed.{matched,read}_doc_count` always 0 - `query_completed.{read_tool,search}_call_count` always 0 Confirmed on the local telemetry stack: 5 curate-tool-mode runs and 4 query-tool-mode runs on the user's deviceId all show the bad shape. Fix: dispatch synthetic llmservice:toolResult (curate) and llmservice:toolCall (query) events from agent-process.ts immediately after the deterministic write / retrieval succeeds. Wire shape matches what the legacy LLM path produced, so: - AnalyticsHook's onToolResult chain fires unchanged → per-op emits - TaskRouter.accumulateLlmEvent populates task.toolCalls → terminal query_completed enrichment runs against the same data the LLM path would have produced - CurateLogHandler's parallel onToolResult listener also picks up the curate ops for free (closes the same gap in curate-log.jsonl) New module `src/server/infra/process/synthetic-tool-result-emit.ts` encapsulates the two emit shapes + privacy guard (raw query string intentionally NOT included in args). Unit tests pin: - curate envelope round-trips through extractCurateOperations - failed ops preserved so operations_failed bumps correctly - transport errors swallowed (analytics MUST NOT block dispatch) - query emits 1 search_knowledge + 1 read_file per matched doc - absolute paths constructed under /.brv/context-tree - no `query` field in any args (privacy) - empty matchedDocs case still emits search_knowledge 9734 unit + integration tests pass. DB e2e validation deferred to a follow-up that needs a seeded fixture project. * fix: [ENG-3011] M17.2 mark synthetic LLM events to skip per-client broadcast Manual validation of the M16 synthetic-emit fix surfaced a leak: the synthetic `llmservice:toolResult` / `llmservice:toolCall` events I forge in agent-process.ts to feed AnalyticsHook were going through `TaskRouter.routeLlmEvent`'s normal path — which broadcasts every LLM event to the originating client (`transport.sendTo`) AND the project room (`broadcastToProjectRoom`). Result: each `brv query --format json` printed N extra JSON lines (synthetic search_knowledge + read_file envelopes) BEFORE the actual query envelope. Same for TUI live view, any subscribed MCP client, and the webui. Fix: tag the synthetic events with `metadata: {_synthetic: true}` at the emit site, then teach `routeLlmEvent` to skip the per-client send + project-room broadcast when the marker is present. The accumulator (populates task.toolCalls) and the onToolResult hook chain (fires AnalyticsHook / CurateLogHandler) still run — they're gated above the broadcast and don't depend on the client-side send. Manual verification against the live daemon (deviceId df3de624-…): - `brv query --format json` output: 1 line (was 5 before this patch) - `curate_operation_applied` lands in raw_events with operation_type ADD + needs_review true + relative_path correctly under `.brv/context-tree/` - `curate_run_completed.operations_added` = 1 (was always 0 before the broader M16 fix) - `query_completed.read_paths_with_metadata` populated as a 2-entry array, each with `relative_path`, plus `read_tool_call_count=2`, `search_call_count=1`, `read_doc_count=2` Unit tests updated to pin the marker on every synthetic emit. * fix: [ENG-3011] M17.3 read keywords/tags/related from HTML attrs Manual validation surfaced that `curate_operation_applied.{keywords,tags}` and `query_completed.read_paths_with_metadata[].{keywords,tags, related_paths}` were landing as empty arrays for every curate-tool-mode write. The schema fields were emitted but their values stayed empty. Cause: `AnalyticsHook.readFrontmatterFields` only knows how to parse YAML frontmatter (`parseFrontmatter` from `markdown-writer.ts`). Curate-tool-mode writes HTML topic files whose `tags` / `keywords` / `related` are comma-separated attributes on ``, not YAML. The reader hit an HTML body, parseFrontmatter returned null, and the helper returned an empty `FrontmatterFields`. Fix: branch on `.html` extension and use `readHtmlTopicSync` (the same parser the search/index pipeline already uses for HTML topics) to lift `topicAttributes`. New `splitTopicAttrList()` helper splits the comma-separated attr value the HTML writer emits into a string array that goes through the existing `capStringArray` length-cap logic. Manual verification against the live daemon (deviceId df3de624-…): `curate_operation_applied` for an HTML write now carries: tags: ['analytics', 'm16', 'tool-mode', 'html-frontmatter'] keywords: ['readHtmlTopicSync', 'topicAttributes', 'splitTopicAttrList', 'curate_operation_applied', 'frontmatter'] `query_completed.read_paths_with_metadata[]` for an HTML hit now carries the same tags / keywords arrays per entry. `related_paths` is also populated from the `related="@a.html, @b.html"` attribute, lifted into the structured `[{relative_path, keywords:[], tags:[]}]` shape that the schema documents. Symmetric coverage: legacy YAML markdown topics still go through the parseFrontmatter branch unchanged. The branch is a pure addition; no existing test breaks (analytics-hook suite: 76 → 77 passing, +1). * fix: [ENG-3011] M17.4 stamp project_path_hash on task/curate/query funnel emits 23 project-scoped analytics events already carry project_path_hash as their canonical join key (vc-*, review-*, source-*, worktree-*, brv-init, context-tree-file-edited, webui-session-*). The six funnel events — task_created, task_completed, task_failed, curate_run_completed, query_completed, curate_operation_applied — never declared the field and never plumbed the value, so dashboards filtering "all curate ops for project X" couldn't join the funnel against the rest of the surface. Fix: - Add `project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional()` to all six `.strict()` schemas. Optional because TaskInfo.projectPath is `?: string`; webui-session-started uses the same optional pattern for the same reason. - New `projectPathHashOptional(projectPath)` helper in analytics-hook.ts returns `{project_path_hash}` or `{}` so each emit can spread it uniformly. Backed by the existing `hashProjectPath()` utility. - Plumb through every emit site: onTaskCreate, onTaskCompleted, emitTaskFailed, buildCurateRunPayload, buildQueryCompletedPayload, processToolResult. Manual verification against the live daemon: - All 7 funnel rows for the validation curate + query carry the same hash prefix `d361f08675359132…`. - Hash matches `sha256('/Users/cuong/workspaces/vibing-zone')` exactly, so PMs can join against any vc-* / review-* row on the same project using a simple `properties->>'project_path_hash'` filter. Unit tests pin: - Every funnel emit carries `project_path_hash` matching the sha256 hex regex when projectPath is set. - All emits for one task share the same hash. - The value equals `hashProjectPath(projectPath)` byte-for-byte. - The field is OMITTED (not empty / null) when projectPath is undefined. Full suite: 9738 passing (+3 new tests on top of the previous 9735). * chore: [ENG-3011] consolidate M16→M17 naming in source (M16 reserved for analytics wrap-up) Linear now has an official "M16 - Wrap-up CLI Analytics" milestone (ENG-3003 through ENG-3010) covering settings unification, status snapshot, `migrate_run` event, ISO 8601 wire timestamps, and the `brv analytics` command-tree deletion. My ad-hoc "M16" tag for the tool-mode emit-gap fix collides directly. Rename the local label to M17 across all source comments, test docs, event-schema rationales, and the plan tree in `plans/07-tool-mode-analytics-emit-gap/`. No behavior change — every identifier, function name, and runtime path is unchanged. Only docblock and comment prose moves from M16 → M17. Test fixture in analytics-hook-toolmode-inspection.test.ts also moves from a sham `analytics/m16` topic path to `analytics/m17` so the test data is consistent with the rename. The webui SVG `path d="M16…"` attributes are pure SVG drawing commands — unrelated, left untouched. 88 hook + emit unit tests still pass. * fix: [ENG-3011] PR #728 review — wire-shape + persistence + privacy cleanups Six findings from the PR review, batched as one logical commit because each fix is small and they all tighten the M17 emit contract: - splitTopicAttrList canonicalizes the `@` prefix off HTML `related` refs so `curate_operation_applied.related` / `query_completed.read_paths_with_metadata[].related_paths[].relative_path` carry the same shape across HTML and YAML sources. PMs can aggregate by `related` without a per-source switch. (analytics-hook.ts:702) - CurateLogHandler skips the curate-tool-mode create path. Before M17.1 it saved a sibling entry with `operations: []` because onToolResult never fired for tool-mode — harmless duplicate. After M17.1 the synthetic toolResult populates ops here too, turning the sibling into a near-duplicate of the agent-process-built entry. agent-process owns the curate-tool-mode log entry; handler stays the source of truth for legacy `curate` / `curate-folder`. (curate-log-handler.ts:230) - Synthetic dispatch is now async-safe. New `safeDispatch()` helper catches BOTH sync throws from `transport.request()` and rejections from the returned Promise. Without the Promise catch, an async rejection bubbles up as an unhandled rejection (Node 16+ warning, crash under strict modes). The existing stub tests only exercised sync throws — confirmed gap, new test covers async. (synthetic-tool-result-emit.ts) - Synthetic query toolCalls are paired with toolResults so the accumulator's TOOL_RESULT branch flips `status: 'running'` to `'completed'`. Without the pair, the per-doc synthetic read_file entries would persist as running indefinitely on the in-memory TaskInfo. Pair shares the same callId so the matching logic links them. (synthetic-tool-result-emit.ts) - `LlmToolCallEventSchema` gains `metadata: z.record(z.unknown()).optional()` for parity with `LlmToolResultEventSchema`. Future `.strict()` migrations would silently drop the `_synthetic` marker on toolCall envelopes; the schema is the contract. (schemas.ts:733) - analytics-hook-m14.test.ts removes the misleading `expectedHash` negative-sentinel; the next test already gives positive byte-for-byte verification via `hashProjectPath()`. All 9739 tests pass. New tests added: - async-rejection swallowing on `emitSyntheticCurateToolResult` - paired toolCall+toolResult shape on `emitSyntheticQueryToolCalls` - callId pairing across call+result - privacy guard widened to also assert no raw query in toolResult payloads * fix: [ENG-3011] PR #728 review — hide synthetic toolCalls from task history Reviewer's medium-severity finding: even with the broadcast-skip in place, `TaskRouter.accumulateLlmEvent` still mutates `task.toolCalls` so the synthetic search_knowledge / read_file envelopes get persisted into `TaskHistoryEntry.toolCalls` via `TaskHistoryHook.persist()`. The WebUI task-detail panel then displays them forever as if they were real LLM tool calls — defeats the "internal plumbing not user-visible" goal of the M17 marker. We can't drop the accumulation outright — AnalyticsHook's `buildQueryCompletedPayload` reads `task.toolCalls` to seed `read_paths_with_metadata[]` and the per-tool counters. Instead, mark the synthetic accumulator entries and filter at the history seam. Change: - `ToolCallEvent` gains an optional `_synthetic?: true` field. - `TaskRouter.accumulateLlmEvent` TOOL_CALL branch carries the marker forward from the inbound envelope's `metadata._synthetic`. - `task-history-entry-builder.ts` filters out `_synthetic` entries when projecting `task.toolCalls` into the persisted `TaskHistoryEntry`. AnalyticsHook keeps reading `task.toolCalls` and seeing the synthetic entries (necessary for the counter math). The history file and the WebUI now see ONLY the real LLM-driven calls. Two distinct concerns cleanly separated by the marker. --- src/server/core/domain/transport/schemas.ts | 6 + src/server/infra/daemon/agent-process.ts | 72 +++-- src/server/infra/process/analytics-hook.ts | 63 +++- .../infra/process/curate-log-handler.ts | 10 + .../process/synthetic-tool-result-emit.ts | 218 ++++++++++++++ .../process/task-history-entry-builder.ts | 10 +- src/server/infra/process/task-router.ts | 44 ++- .../events/curate-operation-applied.ts | 2 + .../analytics/events/curate-run-completed.ts | 2 + .../analytics/events/query-completed.ts | 2 + src/shared/analytics/events/task-completed.ts | 2 + src/shared/analytics/events/task-created.ts | 8 + src/shared/analytics/events/task-failed.ts | 2 + src/shared/transport/events/task-events.ts | 9 + .../infra/process/analytics-hook-m14.test.ts | 48 +++ ...analytics-hook-toolmode-inspection.test.ts | 56 ++++ .../synthetic-tool-result-emit.test.ts | 273 ++++++++++++++++++ 17 files changed, 796 insertions(+), 31 deletions(-) create mode 100644 src/server/infra/process/synthetic-tool-result-emit.ts create mode 100644 test/unit/server/infra/process/synthetic-tool-result-emit.test.ts diff --git a/src/server/core/domain/transport/schemas.ts b/src/server/core/domain/transport/schemas.ts index 2f9e3b928..8af12c076 100644 --- a/src/server/core/domain/transport/schemas.ts +++ b/src/server/core/domain/transport/schemas.ts @@ -733,6 +733,12 @@ export const LlmResponseEventSchema = z.object({ export const LlmToolCallEventSchema = z.object({ args: z.record(z.unknown()), callId: z.string().optional(), + // PR #728 review nit: parity with `LlmToolResultEventSchema` so M17 + // synthetic emits can stamp `metadata._synthetic = true` on toolCalls + // too — same marker the broadcast-skip guard in `TaskRouter.routeLlmEvent` + // reads. Without this, a future `.strict()` migration would silently drop + // the marker on toolCall envelopes. + metadata: z.record(z.unknown()).optional(), sessionId: z.string(), taskId: z.string(), toolName: z.string(), diff --git a/src/server/infra/daemon/agent-process.ts b/src/server/infra/daemon/agent-process.ts index 09078e229..72c72ddab 100644 --- a/src/server/infra/daemon/agent-process.ts +++ b/src/server/infra/daemon/agent-process.ts @@ -69,6 +69,10 @@ import {FolderPackExecutor} from '../executor/folder-pack-executor.js' import {QueryExecutor} from '../executor/query-executor.js' import {SearchExecutor} from '../executor/search-executor.js' import {backupContextTreeFile, buildCurateHtmlLogEntry} from '../process/curate-html-log.js' +import { + emitSyntheticCurateToolResult, + emitSyntheticQueryToolCalls, +} from '../process/synthetic-tool-result-emit.js' import {validateHtmlTopic, writeHtmlTopic} from '../render/writer/html-writer.js' import {FileCurateLogStore} from '../storage/file-curate-log-store.js' import {FileReviewBackupStore} from '../storage/file-review-backup-store.js' @@ -759,26 +763,27 @@ async function executeTask( topicPathResolved = preValidation.topicPath } + const curateLogStore = new FileCurateLogStore({baseDir: storagePath}) + const entryId = await curateLogStore.getNextId() + const logEntry = buildCurateHtmlLogEntry({ + completedAt, + confirmOverwrite: Boolean(confirmOverwrite), + existedBefore, + // Absolute path — the review-handler treats `op.filePath` as + // absolute and calls `relative(contextTreeDir, ...)` to derive + // a display key. Storing a relative path here makes the entry + // unmatchable in `brv review approve`. + filePath: writeResult.ok ? writeResult.filePath : undefined, + id: entryId, + meta, + reviewDisabled: reviewDisabled ?? false, + startedAt, + taskId, + topicPath: topicPathResolved, + writeResult, + }) + try { - const curateLogStore = new FileCurateLogStore({baseDir: storagePath}) - const entryId = await curateLogStore.getNextId() - const logEntry = buildCurateHtmlLogEntry({ - completedAt, - confirmOverwrite: Boolean(confirmOverwrite), - existedBefore, - // Absolute path — the review-handler treats `op.filePath` as - // absolute and calls `relative(contextTreeDir, ...)` to derive - // a display key. Storing a relative path here makes the entry - // unmatchable in `brv review approve`. - filePath: writeResult.ok ? writeResult.filePath : undefined, - id: entryId, - meta, - reviewDisabled: reviewDisabled ?? false, - startedAt, - taskId, - topicPath: topicPathResolved, - writeResult, - }) await curateLogStore.save(logEntry) logId = entryId } catch (error) { @@ -790,6 +795,19 @@ async function executeTask( ) } + // M17: synthetic llmservice:toolResult so AnalyticsHook + + // CurateLogHandler fire `curate_operation_applied` and bump + // `curate_run_completed.operations_*` (the legacy LLM-driven + // path emitted this event; tool-mode has to forge it). + if (transport) { + emitSyntheticCurateToolResult({ + log: agentLog, + operations: logEntry.operations, + taskId, + transport, + }) + } + // Regenerate the context-tree index so the new topic appears in // index.html. Deferred to postWorkRegistry (drained below): it // runs after task:completed — off the user-facing latency path — @@ -1016,6 +1034,22 @@ async function executeTask( }) result = JSON.stringify(toolModeResult) + // M17: synthetic llmservice:toolCall events so AnalyticsHook + // populates query_completed counters + read_paths_with_metadata + // (the legacy LLM path emitted these via real read_file / + // search_knowledge tool calls; tool-mode runs deterministic + // BM25 server-side and emits zero LLM events on its own). + if (transport) { + emitSyntheticQueryToolCalls({ + log: agentLog, + matchedDocs: toolModeResult.matchedDocs, + metadata: toolModeResult.metadata, + projectPath, + taskId, + transport, + }) + } + break } diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index 9464af587..c3c4b8b8d 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -18,7 +18,9 @@ import {AnalyticsEventNames} from '../../../shared/analytics/event-names.js' import {TaskTypes} from '../../../shared/analytics/task-types.js' import {parseFrontmatter} from '../../core/domain/knowledge/markdown-writer.js' import {extractCurateOperations} from '../../utils/curate-result-parser.js' +import {hashProjectPath} from '../../utils/hash-path.js' import {processLog} from '../../utils/process-logger.js' +import {readHtmlTopicSync} from '../render/reader/html-reader.js' import {CURATE_TASK_TYPES} from './curate-log-handler.js' import {QUERY_TASK_TYPES} from './query-log-handler.js' @@ -136,6 +138,19 @@ type FrontmatterFields = { tags?: string[] } +/** + * M17 follow-up: project-scoped join key for the task / curate / query + * funnel events. Mirrors the convention every other handler-emitted + * event uses (vc-*, review-*, source-*, worktree-*, brv-init, + * context-tree-file-edited, webui-session-*). Returns `{}` when the + * project path is unset so the spread omits the field — schemas declare + * `project_path_hash` as optional for that reason. + */ +function projectPathHashOptional(projectPath: string | undefined): {project_path_hash?: string} { + if (typeof projectPath !== 'string' || projectPath.length === 0) return {} + return {project_path_hash: hashProjectPath(projectPath)} +} + /** * Clip a frontmatter array to schema caps: array length <= 50, per-entry * string length <= 256. Returns `undefined` when the input is not an array @@ -279,6 +294,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { // per-flavor M12 emit (terminal-event-last convention). this.emit(AnalyticsEventNames.TASK_COMPLETED, { duration_ms: this.durationMs(task), + ...projectPathHashOptional(task.projectPath), task_id: taskId, task_type: toAnalyticsTaskType(task.type), }) @@ -295,6 +311,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { this.emit(AnalyticsEventNames.TASK_CREATED, { has_files: (task.files?.length ?? 0) > 0, has_folder: typeof task.folderPath === 'string' && task.folderPath.length > 0, + ...projectPathHashOptional(task.projectPath), task_id: task.taskId, task_type: toAnalyticsTaskType(task.type), }) @@ -380,6 +397,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { operations_updated: state.counters.updated, outcome, pending_review_count: state.counters.pendingReview, + ...projectPathHashOptional(task.projectPath ?? state.projectPath), task_id: taskId, task_type: toAnalyticsTaskType(state.taskType), } @@ -465,6 +483,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { duration_ms: this.durationMs(task), matched_doc_count: matchedDocCount, outcome, + ...projectPathHashOptional(task.projectPath), read_doc_count: readPaths.size, // M12.1 schema marks read_paths_with_metadata as optional outer array. // Mirror that: omit the field when the command had no read paths @@ -548,6 +567,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { this.emit(AnalyticsEventNames.TASK_FAILED, { duration_ms: this.durationMs(task), failure_kind: failureKind, + ...projectPathHashOptional(task.projectPath), task_id: taskId, task_type: toAnalyticsTaskType(task.type), }) @@ -617,6 +637,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { knowledge_path: op.path, needs_review: op.needsReview ?? false, operation_type: op.type, + ...projectPathHashOptional(state.projectPath), ...(frontmatter.related ? {related: frontmatter.related} : {}), relative_path: toRelativePath(op.filePath, state.projectPath), tags: frontmatter.tags ?? [], @@ -643,6 +664,19 @@ export class AnalyticsHook implements ITaskLifecycleHook { if (!this.isEnabled()) return {} try { const content = await this.readFile(filePath, 'utf8') + // M17 follow-up: HTML topic files (curate-tool-mode writes) carry the + // frontmatter as attributes on ``, NOT as YAML. parseFrontmatter + // returns null for them. Branch on extension so both formats produce + // the same FrontmatterFields shape downstream. + if (filePath.toLowerCase().endsWith('.html')) { + const htmlAttrs = readHtmlTopicSync(content).topicAttributes + return { + keywords: capStringArray(splitTopicAttrList(htmlAttrs.keywords)), + related: capStringArray(splitTopicAttrList(htmlAttrs.related)), + tags: capStringArray(splitTopicAttrList(htmlAttrs.tags)), + } + } + const parsed = parseFrontmatter(content) if (parsed === null) return {} return { @@ -651,9 +685,34 @@ export class AnalyticsHook implements ITaskLifecycleHook { tags: capStringArray(parsed.frontmatter.tags), } } catch { - // ENOENT, EACCES, permission, malformed YAML — all silently treated - // as "no frontmatter". No retry, no log noise. + // ENOENT, EACCES, permission, malformed YAML / HTML — all silently + // treated as "no frontmatter". No retry, no log noise. return {} } } } + +/** + * Split a `` attribute value into a string array. The HTML writer + * emits these as comma-separated lists (e.g. `tags="analytics, m17, tool-mode"`) + * to mirror the YAML array semantics. Whitespace around each entry is + * trimmed; empty entries are dropped so a trailing comma never produces + * a zero-length tag. + * + * PR #728 review fix: HTML `related` refs carry a leading `@` marker (e.g. + * `related="@analytics/x.html, @analytics/y.html"`) per the renderer + * convention. The legacy YAML path stores them stripped — see + * `related-ref-warner.ts:33`. Canonicalize here so the same wire field + * (`curate_operation_applied.related` / + * `query_completed.read_paths_with_metadata[].related_paths[].relative_path`) + * doesn't carry two shapes across HTML and YAML sources. + */ +function splitTopicAttrList(value: string | undefined): string[] | undefined { + if (typeof value !== 'string' || value.trim().length === 0) return undefined + const parts = value + .split(',') + .map((part) => part.trim()) + .map((part) => (part.startsWith('@') ? part.slice(1) : part)) + .filter((part) => part.length > 0) + return parts.length > 0 ? parts : undefined +} diff --git a/src/server/infra/process/curate-log-handler.ts b/src/server/infra/process/curate-log-handler.ts index aaf36ba59..c3b919fa5 100644 --- a/src/server/infra/process/curate-log-handler.ts +++ b/src/server/infra/process/curate-log-handler.ts @@ -229,6 +229,16 @@ export class CurateLogHandler implements ITaskLifecycleHook { async onTaskCreate(task: TaskInfo): Promise { if (!CURATE_TASK_TYPES.includes(task.type as (typeof CURATE_TASK_TYPES)[number])) return if (!task.projectPath) return + // PR #728 review fix: `curate-tool-mode` writes are persisted directly + // by `agent-process.ts` via `buildCurateHtmlLogEntry` + `FileCurateLogStore.save`. + // Before M17, `CurateLogHandler.onTaskCompleted` also saved a sibling entry but + // with `operations: []` (because `onToolResult` never fired for tool-mode) + // — tolerably useless, ignored by `brv curate view`. After M17.1 the + // synthetic `llmservice:toolResult` makes `onToolResult` accumulate ops + // here too, so the sibling entry becomes a near-duplicate of the + // agent-process one. Skip the create path for tool-mode entirely; the + // handler stays the source of truth for legacy `curate` / `curate-folder`. + if (task.type === 'curate-tool-mode') return const store = this.getOrCreateStore(task.projectPath) const logId = await store.getNextId().catch(() => {}) diff --git a/src/server/infra/process/synthetic-tool-result-emit.ts b/src/server/infra/process/synthetic-tool-result-emit.ts new file mode 100644 index 000000000..36a6962b6 --- /dev/null +++ b/src/server/infra/process/synthetic-tool-result-emit.ts @@ -0,0 +1,218 @@ +import type {ITransportClient} from '@campfirein/brv-transport-client' + +import {randomUUID} from 'node:crypto' +import {join} from 'node:path' + +import type {CurateLogOperation} from '../../core/domain/entities/curate-log-entry.js' +import type { + QueryToolModeMatchedDoc, + QueryToolModeMetadata, +} from '../../core/interfaces/executor/i-query-executor.js' + +import {BRV_DIR, CONTEXT_TREE_DIR} from '../../constants.js' +import {LlmEventNames} from '../../core/domain/transport/schemas.js' + +/** + * Tool-mode synthetic LLM-event emitters. + * + * Tool-mode dispatch (`curate-tool-mode`, `query-tool-mode`) bypasses the + * `llmservice:toolResult` / `llmservice:toolCall` channel that the legacy + * LLM-driven path used. AnalyticsHook, CurateLogHandler, and QueryLogHandler + * all listen on that channel — when it is silent, every downstream M12 emit + * fires with zero inputs (e.g. `curate_run_completed{operations_added:0}`, + * `query_completed{matched_doc_count:0, read_paths_with_metadata: absent}`). + * + * These helpers shape the same wire envelopes the legacy LLM path produced + * and ship them through the existing `ITransportClient`, so the daemon's + * `TaskRouter.routeLlmEvent` chain runs unchanged. No producer code needs + * to learn about tool-mode. + * + * Errors are swallowed — analytics MUST NOT block the user-facing + * curate/query response. + */ + +/** Synthetic events have no LLM session; use empty-string for the field. */ +const SYNTHETIC_SESSION_ID = '' + +/** + * Fire-and-forget emit that swallows BOTH synchronous throws and async + * rejections (PR #728 review fix). `ITransportClient.request` can return + * a Promise that rejects after the synchronous call returns; without this + * wrapper that rejection becomes an unhandled-rejection warning in Node 16+ + * and a crash under strict modes. The unit tests only exercise sync throws, + * so this guards the prod path against the async case that test stubs miss. + */ +function safeDispatch( + transport: ITransportClient, + event: string, + payload: Record, + log: ((msg: string) => void) | undefined, + context: string, +): void { + try { + const result = transport.request(event, payload) as unknown + if (result && typeof (result as PromiseLike).then === 'function') { + ;(result as Promise).catch((error: unknown) => { + log?.(`${context}: async rejection — ${error instanceof Error ? error.message : String(error)}`) + }) + } + } catch (error) { + log?.(`${context}: sync throw — ${error instanceof Error ? error.message : String(error)}`) + } +} + +/** + * Marker stamped on every synthetic event's `metadata`. `TaskRouter.routeLlmEvent` + * inspects this and SKIPS the per-client `sendTo()` + `broadcastToProjectRoom()` + * for synthetic events. Without the skip, the synthetic tool-call envelopes + * leak into the CLI's streamed JSON output, the TUI live view, every MCP + * client subscribed to the project, and the webui — surfacing internal + * analytics plumbing as user-facing progress events. + * + * The internal accumulator + `onToolResult` hook chain still run (they're + * gated separately in `routeLlmEvent`), so AnalyticsHook / CurateLogHandler / + * QueryLogHandler get their inputs unchanged. + */ +export const SYNTHETIC_EVENT_METADATA = {_synthetic: true} as const + +/** + * Fire a synthetic `llmservice:toolResult` mirroring the legacy curate-tool + * envelope (`{applied: CurateLogOperation[]}`). + * + * Consumed by: + * - `AnalyticsHook.processToolResult` → `extractCurateOperations` → + * `curate_operation_applied` per op + bumps `curate_run_completed.operations_*` + * - `CurateLogHandler.onToolResult` → persistence to `curate-log.jsonl` + * (parallel coverage — the same gap exists there) + */ +export function emitSyntheticCurateToolResult(opts: { + log?: (msg: string) => void + operations: readonly CurateLogOperation[] + taskId: string + transport: ITransportClient +}): void { + const {log, operations, taskId, transport} = opts + if (operations.length === 0) return + safeDispatch( + transport, + LlmEventNames.TOOL_RESULT, + { + metadata: SYNTHETIC_EVENT_METADATA, + result: JSON.stringify({applied: operations}), + sessionId: SYNTHETIC_SESSION_ID, + success: true, + taskId, + toolName: 'curate', + }, + log, + `synthetic curate toolResult emit failed for ${taskId}`, + ) +} + +/** + * Fire synthetic `llmservice:toolCall` events for the deterministic BM25 + * retrieval + per-doc render that `brv query` runs server-side. + * + * Consumed by: + * - `AnalyticsHook.buildQueryCompletedPayload` reads `task.toolCalls`: + * - `search_knowledge` calls bump `search_call_count` + * - `read_file` calls bump `read_tool_call_count` AND seed + * `read_paths_with_metadata[]` (enriched from each file's frontmatter) + * + * `matchedDocs[i].path` is a context-tree-relative path (e.g. + * `development/guidelines/x.md`). The enrichment reader needs an absolute + * path to find the file on disk; we join against `/.brv/context-tree/` + * before passing it through `args.filePath`. `AnalyticsHook.toRelativePath` + * then translates the absolute path back to project-relative for the wire + * `relative_path` field. + * + * PRIVACY: the raw user query string is NOT included in `args`. Only + * structured retrieval metadata (tier, count, score, cacheHit) flows. + */ +export function emitSyntheticQueryToolCalls(opts: { + log?: (msg: string) => void + matchedDocs: readonly QueryToolModeMatchedDoc[] + metadata: QueryToolModeMetadata + projectPath: string + taskId: string + transport: ITransportClient +}): void { + const {log, matchedDocs, metadata, projectPath, taskId, transport} = opts + const contextTreeRoot = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR) + + // PR #728 review fix: emit each toolCall + a paired toolResult. The + // accumulator's `TOOL_RESULT` branch (`task-router.ts` TOOL_RESULT case) + // matches on `callId` and flips the running call to `completed`. Without + // the pair, the accumulator persists `status: 'running'` forever and + // task-history snapshots show the synthetic call stuck mid-flight. + // Sharing the callId between the call and its result is what links them. + const searchCallId = randomUUID() + safeDispatch( + transport, + LlmEventNames.TOOL_CALL, + { + args: { + cacheHit: metadata.cacheHit ?? null, + matchedCount: matchedDocs.length, + tier: metadata.tier, + topScore: metadata.topScore, + totalFound: metadata.totalFound, + }, + callId: searchCallId, + metadata: SYNTHETIC_EVENT_METADATA, + sessionId: SYNTHETIC_SESSION_ID, + taskId, + toolName: 'search_knowledge', + }, + log, + `synthetic query search_knowledge toolCall emit failed for ${taskId}`, + ) + safeDispatch( + transport, + LlmEventNames.TOOL_RESULT, + { + callId: searchCallId, + metadata: SYNTHETIC_EVENT_METADATA, + result: JSON.stringify({matched: matchedDocs.length, tier: metadata.tier}), + sessionId: SYNTHETIC_SESSION_ID, + success: true, + taskId, + toolName: 'search_knowledge', + }, + log, + `synthetic query search_knowledge toolResult emit failed for ${taskId}`, + ) + + for (const doc of matchedDocs) { + const readCallId = randomUUID() + safeDispatch( + transport, + LlmEventNames.TOOL_CALL, + { + args: {filePath: join(contextTreeRoot, doc.path)}, + callId: readCallId, + metadata: SYNTHETIC_EVENT_METADATA, + sessionId: SYNTHETIC_SESSION_ID, + taskId, + toolName: 'read_file', + }, + log, + `synthetic query read_file toolCall emit failed for ${taskId}`, + ) + safeDispatch( + transport, + LlmEventNames.TOOL_RESULT, + { + callId: readCallId, + metadata: SYNTHETIC_EVENT_METADATA, + result: JSON.stringify({path: doc.path}), + sessionId: SYNTHETIC_SESSION_ID, + success: true, + taskId, + toolName: 'read_file', + }, + log, + `synthetic query read_file toolResult emit failed for ${taskId}`, + ) + } +} diff --git a/src/server/infra/process/task-history-entry-builder.ts b/src/server/infra/process/task-history-entry-builder.ts index 6cb10b1e9..f95548dad 100644 --- a/src/server/infra/process/task-history-entry-builder.ts +++ b/src/server/infra/process/task-history-entry-builder.ts @@ -44,7 +44,15 @@ function baseFromTaskInfo(task: TaskInfo): Record { ...(task.reasoningContents === undefined ? {} : {reasoningContents: task.reasoningContents}), ...(task.responseContent === undefined ? {} : {responseContent: task.responseContent}), ...(task.sessionId === undefined ? {} : {sessionId: task.sessionId}), - ...(task.toolCalls === undefined ? {} : {toolCalls: task.toolCalls}), + // PR #728 review fix (M17): tool-mode dispatch forges synthetic + // `llmservice:toolCall` envelopes so AnalyticsHook can read them off + // task.toolCalls — but those entries MUST NOT surface in persisted + // history or the WebUI task-detail panel as if they were real LLM tool + // calls. The accumulator stamps them with `_synthetic: true`; strip + // here so history sees only the LLM-driven calls. + ...(task.toolCalls === undefined + ? {} + : {toolCalls: task.toolCalls.filter((c) => c._synthetic !== true)}), ...(task.worktreeRoot === undefined ? {} : {worktreeRoot: task.worktreeRoot}), } } diff --git a/src/server/infra/process/task-router.ts b/src/server/infra/process/task-router.ts index 655ff82a8..2d649744d 100644 --- a/src/server/infra/process/task-router.ts +++ b/src/server/infra/process/task-router.ts @@ -168,6 +168,18 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } +/** + * M17: tool-mode curate / query emit synthetic `llmservice:toolResult` / + * `llmservice:toolCall` events so the lifecycle-hook chain (AnalyticsHook, + * CurateLogHandler, QueryLogHandler) has inputs. Those events MUST NOT + * broadcast to clients (CLI, TUI, MCP, webui) — they're internal plumbing, + * not user-visible progress. The marker lives under `metadata._synthetic` + * (declared in synthetic-tool-result-emit.ts:SYNTHETIC_EVENT_METADATA). + */ +function isSyntheticLlmEvent(data: {[key: string]: unknown}): boolean { + return isRecord(data.metadata) && data.metadata._synthetic === true +} + /** * Bounded-concurrency map for async I/O (M2.16 pass-2 lazy crack of data files). * Keeps file-descriptor usage well under macOS default soft limit (256). @@ -558,6 +570,11 @@ export class TaskRouter { const callId = typeof data.callId === 'string' ? data.callId : undefined const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '' const toolName = typeof data.toolName === 'string' ? data.toolName : '' + // PR #728 review fix: forward the M17 `_synthetic` marker from the + // wire envelope's `metadata` onto the persisted ToolCallEvent so + // downstream consumers (TaskHistoryHook, WebUI task-detail panel) + // can hide synthetic accumulator entries as internal plumbing. + const isSynthetic = isSyntheticLlmEvent(data) const newCall: ToolCallEvent = { args, ...(callId === undefined ? {} : {callId}), @@ -565,6 +582,7 @@ export class TaskRouter { status: 'running', timestamp: Date.now(), toolName, + ...(isSynthetic ? {_synthetic: true as const} : {}), } this.tasks.set(taskId, { ...task, @@ -1882,15 +1900,23 @@ export class TaskRouter { } } - this.transport.sendTo(task.clientId, eventName, {taskId, ...rest}) - broadcastToProjectRoom( - this.projectRegistry, - this.projectRouter, - task.projectPath, - eventName, - {taskId, ...rest}, - task.clientId, - ) + // M17: synthetic LLM events (emitted by tool-mode curate / query so the + // lifecycle-hook chain has inputs) MUST NOT surface in the CLI's + // streamed output, TUI live view, MCP client, or webui — they're + // internal analytics plumbing. Skip the per-client send + broadcast + // when the marker is present; the accumulator and onToolResult hook + // chain above already ran. + if (!isSyntheticLlmEvent(data)) { + this.transport.sendTo(task.clientId, eventName, {taskId, ...rest}) + broadcastToProjectRoom( + this.projectRegistry, + this.projectRouter, + task.projectPath, + eventName, + {taskId, ...rest}, + task.clientId, + ) + } // Reset the heartbeat timer — every forwarded LLM event counts as // activity so a noisy task never triggers a redundant `task:heartbeat`. diff --git a/src/shared/analytics/events/curate-operation-applied.ts b/src/shared/analytics/events/curate-operation-applied.ts index 8ed21b180..dc8fc39de 100644 --- a/src/shared/analytics/events/curate-operation-applied.ts +++ b/src/shared/analytics/events/curate-operation-applied.ts @@ -24,6 +24,8 @@ export const CurateOperationAppliedSchema = z knowledge_path: z.string().min(1), needs_review: z.boolean(), operation_type: z.enum(['ADD', 'UPDATE', 'DELETE', 'MERGE', 'UPSERT']), + /** M17 follow-up: see task-created.ts for the rationale. */ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), // TODO(M15.x): harmonise with the sibling `query_completed.read_paths_ // _with_metadata[].related_paths` structured shape — current asymmetry // forces consumers to special-case parsing `related` between the two diff --git a/src/shared/analytics/events/curate-run-completed.ts b/src/shared/analytics/events/curate-run-completed.ts index 57f223eb9..f4e5cba6e 100644 --- a/src/shared/analytics/events/curate-run-completed.ts +++ b/src/shared/analytics/events/curate-run-completed.ts @@ -26,6 +26,8 @@ export const CurateRunCompletedSchema = z operations_updated: z.number().int().nonnegative(), outcome: z.enum(['completed', 'partial', 'cancelled', 'error']), pending_review_count: z.number().int().nonnegative(), + /** M17 follow-up: see task-created.ts for the rationale. */ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), task_id: z.string().min(1), task_type: z.enum(TASK_TYPE_VALUES), }) diff --git a/src/shared/analytics/events/query-completed.ts b/src/shared/analytics/events/query-completed.ts index d7bc2865e..b77007748 100644 --- a/src/shared/analytics/events/query-completed.ts +++ b/src/shared/analytics/events/query-completed.ts @@ -61,6 +61,8 @@ export const QueryCompletedSchema = z duration_ms: z.number().int().nonnegative(), matched_doc_count: z.number().int().nonnegative(), outcome: z.enum(['completed', 'cancelled', 'error']), + /** M17 follow-up: see task-created.ts for the rationale. */ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), read_doc_count: z.number().int().nonnegative(), read_paths_with_metadata: z.array(ReadPathWithMetadataSchema).max(10).optional(), read_tool_call_count: z.number().int().nonnegative(), diff --git a/src/shared/analytics/events/task-completed.ts b/src/shared/analytics/events/task-completed.ts index 9aa1ac3e5..d84b33adb 100644 --- a/src/shared/analytics/events/task-completed.ts +++ b/src/shared/analytics/events/task-completed.ts @@ -13,6 +13,8 @@ import {TASK_TYPE_VALUES} from '../task-types.js' export const TaskCompletedSchema = z .object({ duration_ms: z.number().int().nonnegative(), + /** M17 follow-up: see task-created.ts for the rationale. */ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), task_id: z.string().min(1), task_type: z.enum(TASK_TYPE_VALUES), }) diff --git a/src/shared/analytics/events/task-created.ts b/src/shared/analytics/events/task-created.ts index 43c622ddf..b291186bf 100644 --- a/src/shared/analytics/events/task-created.ts +++ b/src/shared/analytics/events/task-created.ts @@ -17,6 +17,14 @@ export const TaskCreatedSchema = z .object({ has_files: z.boolean(), has_folder: z.boolean(), + /** + * M17 follow-up: project-scoped join key, matching the convention every + * other handler-emitted event uses (vc-*, review-*, source-*, worktree-*, + * brv-init, context-tree-file-edited, webui-session-*). Optional because + * TaskInfo.projectPath is `?: string` — when the router does not resolve + * a project context the emit omits the field rather than fabricating one. + */ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), task_id: z.string().min(1), task_type: z.enum(TASK_TYPE_VALUES), }) diff --git a/src/shared/analytics/events/task-failed.ts b/src/shared/analytics/events/task-failed.ts index d4cf0cd3f..6015761a5 100644 --- a/src/shared/analytics/events/task-failed.ts +++ b/src/shared/analytics/events/task-failed.ts @@ -34,6 +34,8 @@ export const TaskFailedSchema = z .object({ duration_ms: z.number().int().nonnegative(), failure_kind: z.enum(FailureKindValues), + /** M17 follow-up: see task-created.ts for the rationale. */ + project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), task_id: z.string().min(1), task_type: z.enum(TASK_TYPE_VALUES), }) diff --git a/src/shared/transport/events/task-events.ts b/src/shared/transport/events/task-events.ts index 640040de4..b98831d8c 100644 --- a/src/shared/transport/events/task-events.ts +++ b/src/shared/transport/events/task-events.ts @@ -99,6 +99,15 @@ export type ReasoningContentItem = { * stored in `TaskHistoryEntry.toolCalls`. */ export type ToolCallEvent = { + /** + * PR #728 review fix (M17): true when this entry was produced by the + * tool-mode synthetic-emit path (`synthetic-tool-result-emit.ts`) + * rather than a real LLM-driven tool call. The accumulator carries the + * flag forward from the inbound event's `metadata._synthetic` marker so + * downstream consumers (history persistence, WebUI task-detail panel) + * can filter or hide them as internal plumbing. + */ + _synthetic?: true args: Record callId?: string error?: string diff --git a/test/unit/server/infra/process/analytics-hook-m14.test.ts b/test/unit/server/infra/process/analytics-hook-m14.test.ts index 85b782293..e26f36397 100644 --- a/test/unit/server/infra/process/analytics-hook-m14.test.ts +++ b/test/unit/server/infra/process/analytics-hook-m14.test.ts @@ -314,4 +314,52 @@ describe('AnalyticsHook M14.3 generic task_* emit simulation', () => { expect((op?.args[1] as Record).relative_path).to.equal('/secret.md') }) }) + + describe('project_path_hash (M17 follow-up): join-key parity with other handler-emitted events', () => { + it('stamps the sha256(projectPath) on every emit when task.projectPath is set', async () => { + const task = buildTask('curate', {projectPath: '/Users/dev/example-project', taskId: 'task-pph-1'}) + await hook.onTaskCreate(task) + await hook.onToolResult('task-pph-1', buildCurateOpToolResult()) + await hook.onTaskCompleted('task-pph-1', '', task) + + const events = trackStub.getCalls().map((c) => ({ + name: c.args[0] as string, + props: c.args[1] as Record, + })) + + // Every payload carries project_path_hash matching the sha256 hex regex. + for (const {name, props} of events) { + expect(props.project_path_hash, `${name} should carry project_path_hash`).to.be.a('string').and.match(/^[0-9a-f]{64}$/) + } + + // All payloads share the same hash (same projectPath). Positive byte-for-byte + // verification against `hashProjectPath()` lives in the next `it`. + const hashes = new Set(events.map((e) => e.props.project_path_hash)) + expect(hashes.size, 'all emits for one task share the same project_path_hash').to.equal(1) + }) + + it('omits the field when task.projectPath is undefined', async () => { + const task = buildTask('search', {projectPath: undefined, taskId: 'task-pph-noproj'}) + await hook.onTaskCreate(task) + await hook.onTaskCompleted('task-pph-noproj', '', task) + + const events = trackStub.getCalls().map((c) => c.args[1] as Record) + for (const props of events) { + expect(props).to.not.have.property('project_path_hash') + } + }) + + it('matches hashProjectPath(projectPath) — verifiable from the public utility', async () => { + const {hashProjectPath} = await import('../../../../../src/server/utils/hash-path.js') + const projectPath = '/Users/dev/some/other/proj' + const expected = hashProjectPath(projectPath) + + const task = buildTask('curate', {projectPath, taskId: 'task-pph-match'}) + await hook.onTaskCreate(task) + + const created = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.TASK_CREATED) + const props = created?.args[1] as Record + expect(props.project_path_hash).to.equal(expected) + }) + }) }) diff --git a/test/unit/server/infra/process/analytics-hook-toolmode-inspection.test.ts b/test/unit/server/infra/process/analytics-hook-toolmode-inspection.test.ts index 723f7ac21..c3d0ecf88 100644 --- a/test/unit/server/infra/process/analytics-hook-toolmode-inspection.test.ts +++ b/test/unit/server/infra/process/analytics-hook-toolmode-inspection.test.ts @@ -58,6 +58,12 @@ async function fakeReadFileForInspection(filePath: string): Promise { return '---\nkeywords: ["jwt", "session"]\nrelated: ["auth/middleware", "users"]\ntags: ["security"]\n---\nbody\n' } + if (filePath === '/Users/dev/example-project/.brv/context-tree/analytics/m17.html') { + // M17 follow-up: curate-tool-mode writes HTML topic files whose + // tags/keywords/related live as comma-separated attrs on ``. + return 'noop' + } + return '---\n---\nempty\n' } @@ -271,4 +277,54 @@ describe('analytics-hook tool-mode event inspection (M14)', () => { dumpEvents('query-tool-mode — error path', trackStub) }) + + it('HTML topic: read_paths_with_metadata extracts keywords/tags/related from `` attrs (M17 follow-up)', async () => { + const trackStub = sinon.stub() + const client: IAnalyticsClient = { + abort() { + /* noop */ + }, + flush: sinon.stub().resolves(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: sinon.stub().resolves(), + track: trackStub, + } + const hook = new AnalyticsHook({readFile: fakeReadFileForInspection}) + hook.setAnalyticsClient(client) + + const task = buildToolModeQueryTask({ + taskId: 'task-query-html-1', + toolCalls: [ + { + args: {filePath: '/Users/dev/example-project/.brv/context-tree/analytics/m17.html'}, + sessionId: 's1', + status: 'completed', + timestamp: NOW, + toolName: 'read_file', + }, + ], + type: 'query', + } as Partial) + + await hook.onTaskCreate(task) + await hook.onTaskCompleted(task.taskId, '', task) + + const queryCompleted = trackStub.getCalls().find((c) => c.args[0] === 'query_completed') + const props = queryCompleted?.args[1] as Record + const paths = props.read_paths_with_metadata as Array> + expect(paths).to.have.lengthOf(1) + const entry = paths[0] + expect(entry.relative_path).to.equal('.brv/context-tree/analytics/m17.html') + // M17: comma-separated `tags`/`keywords`/`related` HTML attrs become arrays. + expect(entry.keywords).to.deep.equal(['synthetic', 'broadcast-skip']) + expect(entry.tags).to.deep.equal(['analytics', 'm17', 'tool-mode']) + // `related` lifts to the structured `related_paths[]` shape: each entry's + // own keywords/tags arrays default to empty (only top-level reads enrich). + expect(entry.related_paths).to.deep.equal([ + // PR #728 review: `@` prefix is canonicalized off so HTML and YAML + // produce the same wire shape for `related_paths[].relative_path`. + {keywords: [], relative_path: 'analytics/related.html', tags: []}, + {keywords: [], relative_path: 'analytics/another.html', tags: []}, + ]) + }) }) diff --git a/test/unit/server/infra/process/synthetic-tool-result-emit.test.ts b/test/unit/server/infra/process/synthetic-tool-result-emit.test.ts new file mode 100644 index 000000000..278cb367c --- /dev/null +++ b/test/unit/server/infra/process/synthetic-tool-result-emit.test.ts @@ -0,0 +1,273 @@ +/* eslint-disable camelcase */ +import type {ITransportClient} from '@campfirein/brv-transport-client' + +import {expect} from 'chai' +import sinon from 'sinon' + +import type {CurateLogOperation} from '../../../../../src/server/core/domain/entities/curate-log-entry.js' +import type { + QueryToolModeMatchedDoc, + QueryToolModeMetadata, +} from '../../../../../src/server/core/interfaces/executor/i-query-executor.js' + +import {LlmEventNames} from '../../../../../src/server/core/domain/transport/schemas.js' +import { + emitSyntheticCurateToolResult, + emitSyntheticQueryToolCalls, +} from '../../../../../src/server/infra/process/synthetic-tool-result-emit.js' +import {extractCurateOperations} from '../../../../../src/server/utils/curate-result-parser.js' + +const buildTransport = (): {requestStub: sinon.SinonStub; transport: ITransportClient} => { + const requestStub = sinon.stub().resolves() + const transport = {request: requestStub} as unknown as ITransportClient + return {requestStub, transport} +} + +const buildMatchedDoc = (overrides: Partial = {}): QueryToolModeMatchedDoc => ({ + format: 'markdown', + path: 'topics/intro.md', + rendered_md: '## stub', + score: 0.5, + title: 'Intro', + ...overrides, +}) + +const buildMetadata = (overrides: Partial = {}): QueryToolModeMetadata => ({ + cacheHit: null, + durationMs: 12, + skippedSharedCount: 0, + tier: 2, + topScore: 0.72, + totalFound: 1, + ...overrides, +}) + +describe('synthetic-tool-result-emit (M17 tool-mode gap fix)', () => { + describe('emitSyntheticCurateToolResult', () => { + it('dispatches an llmservice:toolResult that round-trips through extractCurateOperations', () => { + const {requestStub, transport} = buildTransport() + const operations: CurateLogOperation[] = [ + { + confidence: 'high', + filePath: '/proj/.brv/context-tree/topic.html', + impact: 'high', + needsReview: true, + path: 'analytics/topic', + status: 'success', + type: 'ADD', + }, + ] + + emitSyntheticCurateToolResult({operations, taskId: 'task-1', transport}) + + expect(requestStub.calledOnce).to.equal(true) + const [eventName, payload] = requestStub.firstCall.args as [string, Record] + expect(eventName).to.equal(LlmEventNames.TOOL_RESULT) + expect(payload.toolName).to.equal('curate') + expect(payload.success).to.equal(true) + expect(payload.taskId).to.equal('task-1') + // M17: marker tells TaskRouter to skip the per-client broadcast so + // synthetic envelopes never surface in CLI / TUI / MCP / webui. + expect(payload.metadata).to.deep.equal({_synthetic: true}) + + // The result must round-trip through the parser AnalyticsHook uses, + // otherwise the synthetic envelope is dead-on-arrival downstream. + const parsed = extractCurateOperations({ + result: payload.result as string, + toolName: 'curate', + }) + expect(parsed).to.have.length(1) + expect(parsed[0]).to.deep.include({ + filePath: '/proj/.brv/context-tree/topic.html', + path: 'analytics/topic', + status: 'success', + type: 'ADD', + }) + }) + + it('skips emit when operations array is empty', () => { + const {requestStub, transport} = buildTransport() + emitSyntheticCurateToolResult({operations: [], taskId: 'task-1', transport}) + expect(requestStub.called).to.equal(false) + }) + + it('preserves a failed op so curate_run_completed.operations_failed bumps correctly', () => { + const {requestStub, transport} = buildTransport() + const operations: CurateLogOperation[] = [ + {path: 'analytics/topic', status: 'failed', type: 'ADD'}, + ] + + emitSyntheticCurateToolResult({operations, taskId: 'task-1', transport}) + + expect(requestStub.calledOnce).to.equal(true) + const parsed = extractCurateOperations({ + result: requestStub.firstCall.args[1].result, + toolName: 'curate', + }) + expect(parsed[0].status).to.equal('failed') + }) + + it('swallows synchronous transport throws and logs', () => { + const requestStub = sinon.stub().throws(new Error('boom')) + const transport = {request: requestStub} as unknown as ITransportClient + const logStub = sinon.stub() + + expect(() => + emitSyntheticCurateToolResult({ + log: logStub, + operations: [{path: 'x', status: 'success', type: 'ADD'}], + taskId: 'task-1', + transport, + }), + ).to.not.throw() + expect(logStub.calledOnce).to.equal(true) + expect(logStub.firstCall.args[0]).to.include('synthetic curate toolResult emit failed') + expect(logStub.firstCall.args[0]).to.include('sync throw') + }) + + it('swallows async transport rejections and logs (PR #728 review fix)', async () => { + const requestStub = sinon.stub().rejects(new Error('socket dead')) + const transport = {request: requestStub} as unknown as ITransportClient + const logStub = sinon.stub() + + emitSyntheticCurateToolResult({ + log: logStub, + operations: [{path: 'x', status: 'success', type: 'ADD'}], + taskId: 'task-1', + transport, + }) + + // Async rejection runs on the microtask queue — yield once so the + // catch handler fires before we assert. Without this, the log + // assertion races the rejection. + await new Promise((res) => { + setImmediate(res) + }) + + expect(logStub.calledOnce).to.equal(true) + expect(logStub.firstCall.args[0]).to.include('synthetic curate toolResult emit failed') + expect(logStub.firstCall.args[0]).to.include('async rejection') + }) + }) + + describe('emitSyntheticQueryToolCalls', () => { + it('fires paired toolCall+toolResult for search_knowledge + one pair per matched doc (PR #728 review fix)', () => { + const {requestStub, transport} = buildTransport() + + emitSyntheticQueryToolCalls({ + matchedDocs: [ + buildMatchedDoc({path: 'a.md'}), + buildMatchedDoc({path: 'b.md'}), + ], + metadata: buildMetadata({totalFound: 2}), + projectPath: '/proj', + taskId: 'task-q', + transport, + }) + + // 1 search_knowledge toolCall + 1 toolResult, then 2 docs × (call+result) = 6. + // The pair is what flips the accumulator's `status: 'running'` to + // `'completed'` in `TaskRouter.accumulateLlmEvent`; without it the + // synthetic call would be stuck running for the task's lifetime. + expect(requestStub.callCount).to.equal(6) + + // Every emission carries the synthetic marker so TaskRouter skips the + // per-client broadcast (M17 — see SYNTHETIC_EVENT_METADATA docblock). + for (const call of requestStub.getCalls()) { + expect(call.args[1].metadata).to.deep.equal({_synthetic: true}) + } + + // Call/result pairs share a callId so the accumulator matches them. + const calls = requestStub.getCalls() + const search = {call: calls[0], result: calls[1]} + expect(search.call.args[0]).to.equal(LlmEventNames.TOOL_CALL) + expect(search.call.args[1].toolName).to.equal('search_knowledge') + expect(search.result.args[0]).to.equal(LlmEventNames.TOOL_RESULT) + expect(search.result.args[1].toolName).to.equal('search_knowledge') + expect(search.result.args[1].callId).to.equal(search.call.args[1].callId) + expect(search.result.args[1].success).to.equal(true) + + // Per-doc pairs: one call + one result per matched doc, callIds aligned. + const readPairs = [ + {call: calls[2], result: calls[3]}, + {call: calls[4], result: calls[5]}, + ] + for (const [i, pair] of readPairs.entries()) { + expect(pair.call.args[0]).to.equal(LlmEventNames.TOOL_CALL) + expect(pair.call.args[1].toolName).to.equal('read_file') + expect(pair.call.args[1].args.filePath).to.equal( + ['/proj/.brv/context-tree/a.md', '/proj/.brv/context-tree/b.md'][i], + ) + expect(pair.result.args[0]).to.equal(LlmEventNames.TOOL_RESULT) + expect(pair.result.args[1].toolName).to.equal('read_file') + expect(pair.result.args[1].callId).to.equal(pair.call.args[1].callId) + expect(pair.result.args[1].success).to.equal(true) + } + }) + + it('emits only the search_knowledge call+result pair when no docs matched', () => { + const {requestStub, transport} = buildTransport() + + emitSyntheticQueryToolCalls({ + matchedDocs: [], + metadata: buildMetadata({totalFound: 0}), + projectPath: '/proj', + taskId: 'task-q', + transport, + }) + + expect(requestStub.callCount).to.equal(2) + expect(requestStub.getCall(0).args[0]).to.equal(LlmEventNames.TOOL_CALL) + expect(requestStub.getCall(0).args[1].toolName).to.equal('search_knowledge') + expect(requestStub.getCall(0).args[1].args.matchedCount).to.equal(0) + expect(requestStub.getCall(1).args[0]).to.equal(LlmEventNames.TOOL_RESULT) + expect(requestStub.getCall(1).args[1].callId).to.equal(requestStub.getCall(0).args[1].callId) + }) + + it('does NOT leak the raw query string into args (privacy guard)', () => { + const {requestStub, transport} = buildTransport() + + emitSyntheticQueryToolCalls({ + matchedDocs: [buildMatchedDoc()], + metadata: buildMetadata(), + projectPath: '/proj', + taskId: 'task-q', + transport, + }) + + for (const call of requestStub.getCalls()) { + // toolCall envelopes carry `args`; toolResult envelopes carry `result`. + // Either way, the raw query string MUST NOT appear anywhere in the payload. + const payload = call.args[1] as Record + const args = payload.args as Record | undefined + if (args) expect(args).to.not.have.property('query') + // The result string also MUST NOT carry it. + if (typeof payload.result === 'string') { + expect(payload.result.toLowerCase()).to.not.include('query') + } + } + }) + + it('swallows synchronous transport throws and logs (per emit site)', () => { + const requestStub = sinon.stub().throws(new Error('socket dead')) + const transport = {request: requestStub} as unknown as ITransportClient + const logStub = sinon.stub() + + expect(() => + emitSyntheticQueryToolCalls({ + log: logStub, + matchedDocs: [], + metadata: buildMetadata(), + projectPath: '/proj', + taskId: 'task-q', + transport, + }), + ).to.not.throw() + // No-docs case fires 2 emits (search call + search result); both + // throw → both get logged independently via safeDispatch. + expect(logStub.callCount).to.equal(2) + expect(logStub.firstCall.args[0]).to.include('search_knowledge') + expect(logStub.firstCall.args[0]).to.include('sync throw') + }) + }) +}) From f60e219c6a737ccd3761c6733060c41611e386cd Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Thu, 28 May 2026 21:16:21 +0700 Subject: [PATCH 66/87] feat: [ENG-3005] expose analytics operational snapshot as brv settings analytics.status Folds `brv analytics status` into the unified `brv settings` surface via the readonly-info descriptor variant from M16.1. SettingsHandler routes GET for `analytics.status` to a shared `buildAnalyticsStatusSnapshot` builder extracted from `AnalyticsStatusHandler.compose()`, so the legacy transport event and the new settings handler share one implementation. SET / RESET return the existing `read_only` error path without touching the store. The per-key text and JSON formatters live in `src/shared/utils/format-analytics-status.ts` and self-register with the readonly-info formatter registry on import, giving `brv settings get analytics.status` character-for-character text parity (and snake_case JSON envelope parity) with the deleted command. The legacy `brv analytics status` command is preserved until M16.4 deletes it. --- src/oclif/commands/settings/get.ts | 21 ++- src/oclif/commands/settings/index.ts | 19 ++- src/server/core/domain/entities/settings.ts | 10 +- .../infra/analytics/build-status-snapshot.ts | 80 +++++++++++ src/server/infra/process/feature-handlers.ts | 20 ++- .../handlers/analytics-status-handler.ts | 80 ++--------- .../transport/events/settings-events.ts | 2 +- src/shared/types/settings-row.ts | 3 +- src/shared/utils/format-analytics-status.ts | 114 +++++++++++++++ src/shared/utils/format-settings.ts | 12 +- .../settings/utils/format-settings.ts | 1 + .../domain/entities/settings-registry.test.ts | 66 ++++++++- .../infra/storage/file-settings-store.test.ts | 10 +- .../handlers/settings-handler.test.ts | 95 ++++++++++++ .../analytics/build-status-snapshot.test.ts | 112 +++++++++++++++ .../utils/format-analytics-status.test.ts | 135 ++++++++++++++++++ 16 files changed, 692 insertions(+), 88 deletions(-) create mode 100644 src/server/infra/analytics/build-status-snapshot.ts create mode 100644 src/shared/utils/format-analytics-status.ts create mode 100644 test/unit/server/infra/analytics/build-status-snapshot.test.ts create mode 100644 test/unit/shared/utils/format-analytics-status.test.ts diff --git a/src/oclif/commands/settings/get.ts b/src/oclif/commands/settings/get.ts index f73c97218..54fd0d6c8 100644 --- a/src/oclif/commands/settings/get.ts +++ b/src/oclif/commands/settings/get.ts @@ -1,11 +1,16 @@ import {Args, Command, Flags} from '@oclif/core' +import {SETTINGS_KEYS} from '../../../server/core/domain/entities/settings.js' import { SettingsEvents, type SettingsGetRequest, type SettingsGetResponse, type SettingsItemDTO, } from '../../../shared/transport/events/settings-events.js' +// Side-effect import: registers the analytics.status formatter so +// `formatReadonlyInfoValue('analytics.status', ...)` in `printTextBlock` +// returns the legacy text shape for the direct `brv settings get` path. +import {formatAnalyticsStatusJson} from '../../../shared/utils/format-analytics-status.js' import {formatCount, formatDuration} from '../../../shared/utils/format-duration.js' import {formatReadonlyInfoValue} from '../../../shared/utils/format-readonly-info.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' @@ -75,13 +80,16 @@ export default class SettingsGet extends Command { } private printTextBlock(item: SettingsItemDTO): void { - this.log(item.key) if (item.type === 'readonly-info') { - this.log(` current: ${formatReadonlyInfoValue(item.key, item.current)}`) - this.log(` scope: ${item.scope ?? 'global'}`) + // Print the snapshot text verbatim so `brv settings get analytics.status` + // matches the deleted `brv analytics status` output character-for-character. + // No `` header / `current:` prefix / `scope:` footer — the chrome + // is reserved for writable variants where it carries meaningful labels. + this.log(formatReadonlyInfoValue(item.key, item.current)) return } + this.log(item.key) this.log(` current: ${renderWritableValue(item, item.current)}`) if (item.default !== undefined) { this.log(` default: ${renderWritableValue(item, item.default)}`) @@ -96,6 +104,13 @@ export default class SettingsGet extends Command { } private toJsonPayload(item: SettingsItemDTO): Record { + // M16.3: `analytics.status` keeps the legacy snake_case envelope of + // the deleted `brv analytics status --format json` so callers that + // already script against that wire shape are not broken. + if (item.key === SETTINGS_KEYS.ANALYTICS_STATUS) { + return {...formatAnalyticsStatusJson(item.current)} + } + const payload: Record = { current: item.current, description: item.description, diff --git a/src/oclif/commands/settings/index.ts b/src/oclif/commands/settings/index.ts index b91e96a43..9a87311a5 100644 --- a/src/oclif/commands/settings/index.ts +++ b/src/oclif/commands/settings/index.ts @@ -5,16 +5,22 @@ import { type SettingsItemDTO, type SettingsListResponse, } from '../../../shared/transport/events/settings-events.js' +// Side-effect import: registers the `analytics.status` readonly-info text +// formatter into the shared registry. Without this, a cold-start `brv settings` +// invocation that does not transitively load any other module containing the +// side-effect would render `analytics.status` rows as a raw JSON dump. +import '../../../shared/utils/format-analytics-status.js' import {formatCount, formatDuration} from '../../../shared/utils/format-duration.js' import {formatReadonlyInfoValue} from '../../../shared/utils/format-readonly-info.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' import {writeJsonResponse} from '../../lib/json-response.js' -type CategoryName = 'concurrency' | 'llm' | 'task-history' | 'updates' +type CategoryName = 'analytics' | 'concurrency' | 'llm' | 'task-history' | 'updates' -const CATEGORY_ORDER: readonly CategoryName[] = ['concurrency', 'llm', 'task-history', 'updates'] +const CATEGORY_ORDER: readonly CategoryName[] = ['concurrency', 'llm', 'task-history', 'updates', 'analytics'] const CATEGORY_HEADERS: Readonly> = { + analytics: 'ANALYTICS', concurrency: 'CONCURRENCY', llm: 'LLM', 'task-history': 'TASK HISTORY', @@ -110,8 +116,13 @@ function groupByCategory(items: readonly SettingsItemDTO[]): Map`. + const fullText = formatReadonlyInfoValue(item.key, item.current) + const headline = fullText.split('\n')[0] + return ` ${pad(item.key, 40)} ${headline}` } const current = renderWritableValue(item, item.current) diff --git a/src/server/core/domain/entities/settings.ts b/src/server/core/domain/entities/settings.ts index ade8ec897..1c9221220 100644 --- a/src/server/core/domain/entities/settings.ts +++ b/src/server/core/domain/entities/settings.ts @@ -12,7 +12,7 @@ import { * and TUI render output (uppercased). Web docs / WebUI consume this * field to render the same groupings independently of key naming. */ -export type SettingCategory = 'concurrency' | 'llm' | 'task-history' | 'updates' +export type SettingCategory = 'analytics' | 'concurrency' | 'llm' | 'task-history' | 'updates' /** * Value-kind for dispatch between the duration formatter / parser @@ -100,6 +100,7 @@ export type SettingItem = { export const SETTINGS_KEYS = { AGENT_POOL_MAX_CONCURRENT_TASKS: 'agentPool.maxConcurrentTasksPerProject', AGENT_POOL_MAX_SIZE: 'agentPool.maxSize', + ANALYTICS_STATUS: 'analytics.status', LLM_ITERATION_BUDGET_MS: 'llm.iterationBudgetMs', LLM_REQUEST_TIMEOUT_MS: 'llm.requestTimeoutMs', TASK_HISTORY_MAX_ENTRIES: 'taskHistory.maxEntries', @@ -167,6 +168,13 @@ export const SETTINGS_REGISTRY: readonly SettingDescriptor[] = [ restartRequired: false, type: 'boolean', }, + { + category: 'analytics', + description: 'Live analytics shipping snapshot (queue, last flush, backoff, endpoint)', + key: SETTINGS_KEYS.ANALYTICS_STATUS, + restartRequired: false, + type: 'readonly-info', + }, ] export function findSettingDescriptor(key: string): SettingDescriptor | undefined { diff --git a/src/server/infra/analytics/build-status-snapshot.ts b/src/server/infra/analytics/build-status-snapshot.ts new file mode 100644 index 000000000..f47a75c06 --- /dev/null +++ b/src/server/infra/analytics/build-status-snapshot.ts @@ -0,0 +1,80 @@ +import type {AnalyticsStatusResponse} from '../../../shared/transport/events/analytics-events.js' +import type {IAnalyticsBackoffPolicy} from '../../core/interfaces/analytics/i-analytics-backoff-policy.js' +import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics-client.js' + +/** + * User-facing reachability label derived from the M4.5 backoff policy's + * `consecutiveFailures()` counter. Boundaries fixed by the M4.6 ticket: + * - 0 failures -> healthy + * - 1 or 2 failures -> degraded + * - 3+ failures -> unreachable + * + * The mapper is pure (presentation layer) so the policy stays free of + * UX concerns and so non-status consumers of `consecutiveFailures()` + * can apply different labels if needed. + * + * Defensive on invalid input: negative or NaN inputs return 'healthy' + * (the most optimistic label) rather than throw, so a malformed counter + * never breaks the status command's hot path. + */ +export type ReachabilityState = 'degraded' | 'healthy' | 'unreachable' + +export function consecutiveFailuresToReachabilityState(consecutiveFailures: number): ReachabilityState { + if (!Number.isFinite(consecutiveFailures) || consecutiveFailures < 1) return 'healthy' + if (consecutiveFailures < 3) return 'degraded' + return 'unreachable' +} + +const NOT_CONFIGURED_ENDPOINT = '(not configured)' + +export interface BuildAnalyticsStatusSnapshotDeps { + readonly analyticsClient: IAnalyticsClient + readonly backoffPolicy: IAnalyticsBackoffPolicy + /** + * Resolved `BRV_ANALYTICS_BASE_URL`. Empty string when the env var + * isn't set; the builder substitutes the `(not configured)` placeholder + * AND forces `backoff.state = 'unreachable'` to reflect that no real + * health signal is possible. + */ + readonly endpoint: string + readonly isAnalyticsEnabled: () => boolean +} + +/** + * Composes the analytics-status wire response from runtime state, backoff + * state, endpoint, and the enabled flag. + * + * Shared between the legacy `analytics:status` transport event (M4.6 + * `AnalyticsStatusHandler`) and the new `settings:get` / `settings:list` + * routing for the `analytics.status` readonly-info descriptor (M16.3). + * + * Pure async function — no transport, no side effects. Throwing is fatal + * to the caller; the M16.1 `SettingsHandler` LIST path isolates per-row + * provider errors via `Promise.allSettled`-style catching, so a transient + * failure here surfaces as `current: undefined` on the row rather than + * blanking the whole settings response. + */ +export async function buildAnalyticsStatusSnapshot( + deps: BuildAnalyticsStatusSnapshotDeps, +): Promise { + const runtime = await deps.analyticsClient.getRuntimeState() + const consecutiveFailures = deps.backoffPolicy.consecutiveFailures() + const nextDelayMs = deps.backoffPolicy.nextDelayMs() + const endpointConfigured = deps.endpoint !== '' + const endpoint = endpointConfigured ? deps.endpoint : NOT_CONFIGURED_ENDPOINT + // M4.6 override: when no endpoint is configured the daemon has + // nothing to be "healthy" against — surface unreachable so the user + // doesn't see a misleading "healthy" label paired with "(not configured)". + const state: ReachabilityState = endpointConfigured + ? consecutiveFailuresToReachabilityState(consecutiveFailures) + : 'unreachable' + + return { + backoff: {consecutiveFailures, nextDelayMs, state}, + droppedCount: runtime.droppedCount, + enabled: deps.isAnalyticsEnabled(), + endpoint, + ...(runtime.lastSuccessfulFlushAt === undefined ? {} : {lastFlushAt: runtime.lastSuccessfulFlushAt}), + queueDepth: runtime.queueDepth, + } +} diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 518a99302..32496fa84 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -33,6 +33,7 @@ import {readCliVersion} from '../../utils/read-cli-version.js' import {AnalyticsBackoffPolicy} from '../analytics/analytics-backoff-policy.js' import {AnalyticsClient} from '../analytics/analytics-client.js' import {BoundedQueue} from '../analytics/bounded-queue.js' +import {buildAnalyticsStatusSnapshot} from '../analytics/build-status-snapshot.js' import {IdentityResolver} from '../analytics/identity-resolver.js' import {JsonlAnalyticsStore} from '../analytics/jsonl-analytics-store.js' import {SuperPropertiesResolver} from '../analytics/super-properties-resolver.js' @@ -289,8 +290,23 @@ export async function setupFeatureHandlers({ // Global SettingsHandler (no project context). Deferred from line 180 so // analyticsClient is in scope for M15.4 `setting_changed` / `setting_reset` - // emits. - new SettingsHandler({analyticsClient, store: settingsStore, transport}).setup() + // emits. M16.3 wires the `analytics.status` readonly-info provider so + // `brv settings get analytics.status` returns the same operational + // snapshot as the legacy `brv analytics status` command. + const analyticsStatusSnapshotDeps = { + analyticsClient, + backoffPolicy: analyticsBackoffPolicy, + endpoint: envConfig.analyticsBaseUrl ?? '', + isAnalyticsEnabled: () => globalConfigHandler.getCachedAnalytics(), + } + new SettingsHandler({ + analyticsClient, + infoProviders: new Map([ + ['analytics.status', async () => buildAnalyticsStatusSnapshot(analyticsStatusSnapshotDeps)], + ]), + store: settingsStore, + transport, + }).setup() // M11.2: webui-facing read API. Shares the same JsonlAnalyticsStore instance // as the AnalyticsClient so reads see exactly what trackAsync persisted. diff --git a/src/server/infra/transport/handlers/analytics-status-handler.ts b/src/server/infra/transport/handlers/analytics-status-handler.ts index 3771cce4e..d0b814a79 100644 --- a/src/server/infra/transport/handlers/analytics-status-handler.ts +++ b/src/server/infra/transport/handlers/analytics-status-handler.ts @@ -1,64 +1,27 @@ -import type {IAnalyticsBackoffPolicy} from '../../../core/interfaces/analytics/i-analytics-backoff-policy.js' -import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' import { AnalyticsEvents, type AnalyticsStatusResponse, } from '../../../../shared/transport/events/analytics-events.js' +import { + buildAnalyticsStatusSnapshot, + type BuildAnalyticsStatusSnapshotDeps, +} from '../../analytics/build-status-snapshot.js' -/** - * User-facing reachability label derived from the M4.5 backoff policy's - * `consecutiveFailures()` counter. Boundaries fixed by the M4.6 ticket: - * - 0 failures → healthy - * - 1 or 2 failures → degraded - * - 3+ failures → unreachable - * - * The mapper is pure and lives here (presentation layer) rather than in - * the policy itself, so the policy stays free of UX concerns and so - * non-status consumers of `consecutiveFailures()` can apply different - * labels if needed. - * - * Defensive on invalid input: negative or NaN inputs return 'healthy' - * (the most optimistic label) rather than throw, so a malformed counter - * never breaks the status command's hot path. - */ -export type ReachabilityState = 'degraded' | 'healthy' | 'unreachable' - -export function consecutiveFailuresToReachabilityState(consecutiveFailures: number): ReachabilityState { - if (!Number.isFinite(consecutiveFailures) || consecutiveFailures < 1) return 'healthy' - if (consecutiveFailures < 3) return 'degraded' - return 'unreachable' -} - -const NOT_CONFIGURED_ENDPOINT = '(not configured)' +// Re-export the reachability mapper for back-compat with existing tests +// and any external consumer that depended on its prior location. +export {consecutiveFailuresToReachabilityState, type ReachabilityState} from '../../analytics/build-status-snapshot.js' -export interface AnalyticsStatusHandlerDeps { - readonly analyticsClient: IAnalyticsClient - readonly backoffPolicy: IAnalyticsBackoffPolicy - /** - * Resolved `BRV_ANALYTICS_BASE_URL`. Empty string when the env var - * isn't set; the handler substitutes the `(not configured)` placeholder - * AND forces `backoff.state = 'unreachable'` to reflect that no real - * health signal is possible. - */ - readonly endpoint: string - readonly isAnalyticsEnabled: () => boolean +export interface AnalyticsStatusHandlerDeps extends BuildAnalyticsStatusSnapshotDeps { readonly transport: ITransportServer } /** * Composes the `analytics:status` wire response for `brv analytics - * status` (M4.6). Pulls together: - * - enabled flag (GlobalConfigHandler's cached value) - * - client runtime state (last-flush ts, queue depth, dropped count) - * - backoff state (M4.5 policy + derived reachability label) - * - endpoint URL (or placeholder when unset) - * - * Kept as a focused handler rather than extending the existing - * `AnalyticsHandler` so the track/list domain stays clean and the - * status surface can evolve (M4.6 may grow more fields) without - * accreting unrelated dependencies on the track handler's class. + * status` (M4.6). Delegates the actual snapshot composition to the + * shared `buildAnalyticsStatusSnapshot` builder so the legacy transport + * event and the new M16.3 settings handler share the same implementation. */ export class AnalyticsStatusHandler { private readonly deps: AnalyticsStatusHandlerDeps @@ -76,25 +39,6 @@ export class AnalyticsStatusHandler { * the transport handler registered in `setup()`. */ private async compose(): Promise { - const runtime = await this.deps.analyticsClient.getRuntimeState() - const consecutiveFailures = this.deps.backoffPolicy.consecutiveFailures() - const nextDelayMs = this.deps.backoffPolicy.nextDelayMs() - const endpointConfigured = this.deps.endpoint !== '' - const endpoint = endpointConfigured ? this.deps.endpoint : NOT_CONFIGURED_ENDPOINT - // M4.6 override: when no endpoint is configured the daemon has - // nothing to be "healthy" against — surface unreachable so the user - // doesn't see a misleading "healthy" label paired with "(not configured)". - const state: ReachabilityState = endpointConfigured - ? consecutiveFailuresToReachabilityState(consecutiveFailures) - : 'unreachable' - - return { - backoff: {consecutiveFailures, nextDelayMs, state}, - droppedCount: runtime.droppedCount, - enabled: this.deps.isAnalyticsEnabled(), - endpoint, - ...(runtime.lastSuccessfulFlushAt === undefined ? {} : {lastFlushAt: runtime.lastSuccessfulFlushAt}), - queueDepth: runtime.queueDepth, - } + return buildAnalyticsStatusSnapshot(this.deps) } } diff --git a/src/shared/transport/events/settings-events.ts b/src/shared/transport/events/settings-events.ts index f30e1c577..9d3d4e07b 100644 --- a/src/shared/transport/events/settings-events.ts +++ b/src/shared/transport/events/settings-events.ts @@ -22,7 +22,7 @@ export const SettingsEvents = { * parse the wire format. */ export interface SettingsItemDTO { - category?: 'concurrency' | 'llm' | 'task-history' | 'updates' + category?: 'analytics' | 'concurrency' | 'llm' | 'task-history' | 'updates' current: boolean | number | Readonly> | undefined default?: boolean | number description: string diff --git a/src/shared/types/settings-row.ts b/src/shared/types/settings-row.ts index d327b02ce..422e81f53 100644 --- a/src/shared/types/settings-row.ts +++ b/src/shared/types/settings-row.ts @@ -1,4 +1,4 @@ -export type SettingsRowCategory = 'concurrency' | 'llm' | 'other' | 'task-history' | 'updates' +export type SettingsRowCategory = 'analytics' | 'concurrency' | 'llm' | 'other' | 'task-history' | 'updates' export type SettingsRowUnit = 'count' | 'ms' /** @@ -41,5 +41,6 @@ export const CATEGORY_ORDER: readonly SettingsRowCategory[] = [ 'llm', 'task-history', 'updates', + 'analytics', 'other', ] diff --git a/src/shared/utils/format-analytics-status.ts b/src/shared/utils/format-analytics-status.ts new file mode 100644 index 000000000..096a2e26d --- /dev/null +++ b/src/shared/utils/format-analytics-status.ts @@ -0,0 +1,114 @@ +/* eslint-disable camelcase -- legacy `brv analytics status --format json` envelope is snake_case. */ +import type {AnalyticsStatusResponse} from '../transport/events/analytics-events.js' + +import {AnalyticsStatusResponseSchema} from '../transport/events/analytics-events.js' +import {registerReadonlyInfoFormatter} from './format-readonly-info.js' + +const MS_PER_MIN = 60_000 +const MS_PER_HOUR = 60 * MS_PER_MIN +const MS_PER_DAY = 24 * MS_PER_HOUR + +const UNAVAILABLE_TEXT = '(unavailable)' + +/** + * Humanise a millisecond delta to a short relative-time label, matching + * the M4.6 ticket example: `(5m ago)`. Cut points: + * - < 1 minute -> "just now" + * - < 1 hour -> "{n}m ago" + * - < 1 day -> "{n}h ago" + * - >= 1 day -> "{n}d ago" + */ +export function formatRelativeAgo(deltaMs: number): string { + if (!Number.isFinite(deltaMs) || deltaMs < MS_PER_MIN) return 'just now' + if (deltaMs < MS_PER_HOUR) return `${Math.floor(deltaMs / MS_PER_MIN)}m ago` + if (deltaMs < MS_PER_DAY) return `${Math.floor(deltaMs / MS_PER_HOUR)}h ago` + return `${Math.floor(deltaMs / MS_PER_DAY)}d ago` +} + +/** + * Humanise a forward-looking delay in milliseconds. Cut points mirror the + * M4.5 backoff schedule (30s, 60s, 2m, 5m). + */ +export function formatDelayMs(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return '0ms' + if (ms < 1000) return `${ms}ms` + if (ms < MS_PER_MIN) return `${Math.floor(ms / 1000)}s` + if (ms < MS_PER_HOUR) return `${Math.floor(ms / MS_PER_MIN)}m` + return `${Math.floor(ms / MS_PER_HOUR)}h` +} + +/** + * Renders the analytics-status snapshot as the multi-line text block the + * legacy `brv analytics status` command printed. The output is consumed by + * both `brv settings get analytics.status` and `brv settings list` + * (per-key readonly-info formatter) — and by the TUI settings page via + * the same shared registry. + * + * Accepts `unknown` because the formatter registry surface is wider than + * any single key's snapshot shape. Falls back to `(unavailable)` when + * the value does not match `AnalyticsStatusResponseSchema`. + */ +export function formatAnalyticsStatusText(value: unknown, now: () => number = Date.now): string { + const parsed = AnalyticsStatusResponseSchema.safeParse(value) + if (!parsed.success) return UNAVAILABLE_TEXT + + const response = parsed.data + if (!response.enabled) return 'Analytics: disabled' + + return [ + 'Analytics: enabled', + `Last successful flush: ${formatLastFlush(response.lastFlushAt, now)}`, + `Queue depth: ${response.queueDepth} events`, + `Dropped events (this session): ${response.droppedCount}`, + `Backoff state: ${response.backoff.state} (${formatBackoffSummary(response.backoff)})`, + `Endpoint: ${response.endpoint}`, + ].join('\n') +} + +/** + * JSON wire shape matching the legacy `brv analytics status --format json` + * envelope (snake_case fields). Consumed by + * `brv settings get analytics.status --format json` so callers depending + * on the legacy programmatic shape do not break when the legacy command + * is deleted in M16.4. + */ +export function formatAnalyticsStatusJson(value: unknown): Readonly> { + const parsed = AnalyticsStatusResponseSchema.safeParse(value) + if (!parsed.success) return {unavailable: true} + + const response = parsed.data + return { + backoff: { + consecutive_failures: response.backoff.consecutiveFailures, + next_delay_ms: response.backoff.nextDelayMs, + state: response.backoff.state, + }, + dropped_events: response.droppedCount, + enabled: response.enabled, + endpoint: response.endpoint, + last_flush: response.lastFlushAt === undefined ? null : new Date(response.lastFlushAt).toISOString(), + queue_depth: response.queueDepth, + } +} + +function formatLastFlush(lastFlushAt: number | undefined, now: () => number): string { + if (lastFlushAt === undefined) return 'never' + const iso = new Date(lastFlushAt).toISOString() + const ago = formatRelativeAgo(now() - lastFlushAt) + return `${iso} (${ago})` +} + +function formatBackoffSummary(backoff: AnalyticsStatusResponse['backoff']): string { + const failurePart = + backoff.consecutiveFailures === 1 + ? '1 consecutive failure' + : `${backoff.consecutiveFailures} consecutive failures` + return `${failurePart}, next attempt in ${formatDelayMs(backoff.nextDelayMs)}` +} + +// Self-register the analytics.status formatter so any consumer of +// `formatReadonlyInfoValue('analytics.status', ...)` (CLI list/get, +// TUI settings page, future WebUI cleanup) gets the legacy text shape +// without an explicit boot-time registration step. M16.1's +// double-register guard makes accidental re-imports a no-op. +registerReadonlyInfoFormatter('analytics.status', formatAnalyticsStatusText) diff --git a/src/shared/utils/format-settings.ts b/src/shared/utils/format-settings.ts index e874a5567..9e7e520b2 100644 --- a/src/shared/utils/format-settings.ts +++ b/src/shared/utils/format-settings.ts @@ -2,6 +2,11 @@ import type {SettingsItemDTO} from '../transport/events/settings-events.js' import type {RowParseResult, SettingsRow, SettingsRowCategory, SettingsRowUnit} from '../types/settings-row.js' import {CATEGORY_ORDER} from '../types/settings-row.js' +// Side-effect import: registers the analytics.status readonly-info text +// formatter so `formatReadonlyInfoValue('analytics.status', ...)` returns +// the legacy text shape regardless of which surface (CLI / TUI / WebUI) +// triggers the first read. +import './format-analytics-status.js' import {formatCount, formatDuration, parseDuration} from './format-duration.js' import {formatReadonlyInfoValue} from './format-readonly-info.js' @@ -131,11 +136,16 @@ function toBooleanRow(item: SettingsItemDTO, current: boolean, defaultValue: boo } function toReadonlyInfoRow(item: SettingsItemDTO): SettingsRow { + // Row views (CLI list, TUI page) are single-line per row. If the per-key + // formatter returns a multi-line snapshot (e.g. `analytics.status`), + // surface only the headline so the table stays aligned; users see the + // full block via `brv settings get `. + const fullText = formatReadonlyInfoValue(item.key, item.current) return { category: toRowCategory(item.category), current: item.current, description: item.description, - displayCurrent: formatReadonlyInfoValue(item.key, item.current), + displayCurrent: fullText.split('\n')[0], displayRange: '', key: item.key, label: item.key, diff --git a/src/tui/features/settings/utils/format-settings.ts b/src/tui/features/settings/utils/format-settings.ts index fb653752c..29224d75e 100644 --- a/src/tui/features/settings/utils/format-settings.ts +++ b/src/tui/features/settings/utils/format-settings.ts @@ -2,6 +2,7 @@ import {CATEGORY_ORDER, type SettingsRow, type SettingsRowCategory} from '../../ import {formatDuration} from '../../../../shared/utils/format-duration.js' const CATEGORY_HEADERS: Readonly> = { + analytics: 'ANALYTICS', concurrency: 'CONCURRENCY', llm: 'LLM', other: 'OTHER', diff --git a/test/unit/core/domain/entities/settings-registry.test.ts b/test/unit/core/domain/entities/settings-registry.test.ts index d56f032e1..35d2d42e6 100644 --- a/test/unit/core/domain/entities/settings-registry.test.ts +++ b/test/unit/core/domain/entities/settings-registry.test.ts @@ -26,6 +26,7 @@ describe('settings registry — M7 T2 shape', () => { it('declares category on every descriptor', () => { for (const descriptor of SETTINGS_REGISTRY) { expect(descriptor.category, `key ${descriptor.key} missing category`).to.be.oneOf([ + 'analytics', 'concurrency', 'llm', 'task-history', @@ -137,6 +138,31 @@ describe('settings registry — M7 T2 shape', () => { }) }) + describe('analytics category (M16.3)', () => { + it('accepts category=analytics on a readonly-info descriptor', () => { + const descriptor: ReadonlyInfoSettingDescriptor = { + category: 'analytics', + description: 'live analytics shipping snapshot', + key: '_test.analytics', + restartRequired: false, + type: 'readonly-info', + } + expect(descriptor.category).to.equal('analytics') + }) + + it('accepts category=analytics on a boolean descriptor (M16.2 will use this)', () => { + const descriptor: SettingDescriptor = { + category: 'analytics', + default: false, + description: 'analytics opt-in', + key: '_test.analytics.enabled', + restartRequired: false, + type: 'boolean', + } + expect(descriptor.category).to.equal('analytics') + }) + }) + describe('readonly-info variant (M16.1)', () => { it('accepts a readonly-info literal that narrows on type without a cast', () => { const descriptor: ReadonlyInfoSettingDescriptor = { @@ -178,12 +204,40 @@ describe('settings registry — M7 T2 shape', () => { expect(descriptor.restartRequired).to.equal(false) }) - it('SETTINGS_REGISTRY still contains only boolean and integer descriptors in t1', () => { - // t1 (ENG-3003) adds the framework variant. The first real - // readonly-info entry (`analytics.status`) lands in t3, not here. - for (const descriptor of SETTINGS_REGISTRY) { - expect(descriptor.type, `${descriptor.key} unexpected type`).to.be.oneOf(['boolean', 'integer']) - } + it('SETTINGS_REGISTRY now includes analytics.status as the first readonly-info entry (M16.3)', () => { + // M16.3 lands the first real readonly-info descriptor in the + // production registry: `analytics.status` (the live shipping + // snapshot consumed by the legacy `brv analytics status`). + const readonlyInfoEntries = SETTINGS_REGISTRY.filter((d) => d.type === 'readonly-info') + expect(readonlyInfoEntries).to.have.lengthOf(1) + expect(readonlyInfoEntries[0].key).to.equal('analytics.status') + }) + }) + + describe('analytics.status descriptor (M16.3)', () => { + it('exposes ANALYTICS_STATUS on SETTINGS_KEYS', () => { + expect(SETTINGS_KEYS.ANALYTICS_STATUS).to.equal('analytics.status') + }) + + it('registers a descriptor for analytics.status', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_STATUS) + expect(descriptor, 'descriptor must exist in SETTINGS_REGISTRY').to.exist + }) + + it('declares the descriptor as type=readonly-info under category=analytics', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_STATUS) + expect(descriptor?.type).to.equal('readonly-info') + expect(descriptor?.category).to.equal('analytics') + }) + + it('marks the descriptor as not requiring a daemon restart', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_STATUS) + expect(descriptor?.restartRequired).to.equal(false) + }) + + it('description fits the 80-char tooltip budget', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_STATUS) + expect(descriptor?.description.length).to.be.at.most(80) }) }) }) diff --git a/test/unit/infra/storage/file-settings-store.test.ts b/test/unit/infra/storage/file-settings-store.test.ts index e5bc937de..4f3e636fc 100644 --- a/test/unit/infra/storage/file-settings-store.test.ts +++ b/test/unit/infra/storage/file-settings-store.test.ts @@ -54,13 +54,21 @@ describe('FileSettingsStore', () => { expect(keys).to.deep.equal([ 'agentPool.maxConcurrentTasksPerProject', 'agentPool.maxSize', + 'analytics.status', 'llm.iterationBudgetMs', 'llm.requestTimeoutMs', 'taskHistory.maxEntries', 'update.checkForUpdates', ]) for (const item of items) { - expect(item.current).to.equal(item.default) + // readonly-info rows carry current/default both undefined; writable + // rows have current === default when no override is present. + if (item.key === 'analytics.status') { + expect(item.current).to.equal(undefined) + expect(item.default).to.equal(undefined) + } else { + expect(item.current).to.equal(item.default) + } } }) diff --git a/test/unit/infra/transport/handlers/settings-handler.test.ts b/test/unit/infra/transport/handlers/settings-handler.test.ts index eb00c230a..9181af9e5 100644 --- a/test/unit/infra/transport/handlers/settings-handler.test.ts +++ b/test/unit/infra/transport/handlers/settings-handler.test.ts @@ -93,6 +93,7 @@ describe('SettingsHandler', () => { expect(result.items.map((i) => i.key).sort()).to.deep.equal([ 'agentPool.maxConcurrentTasksPerProject', 'agentPool.maxSize', + 'analytics.status', 'llm.iterationBudgetMs', 'llm.requestTimeoutMs', 'taskHistory.maxEntries', @@ -600,4 +601,98 @@ describe('SettingsHandler', () => { }) }) }) + + describe('analytics.status routing (M16.3 — production registry)', () => { + it('GET resolves analytics.status via the registered provider against the production registry', async () => { + const localStore = new StubSettingsStore() + // Real FileSettingsStore returns `{current: undefined, key, restartRequired: false}` + // for readonly-info keys. Stub mirrors that so the handler's GET path + // reaches the provider resolution step. + localStore.listResult = [{current: undefined, key: 'analytics.status', restartRequired: false}] + const localTransport = createMockTransportServer() + const snapshot = { + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy' as const}, + droppedCount: 0, + enabled: true, + endpoint: 'https://telemetry-dev.byterover.dev', + lastFlushAt: 1_700_000_000_000, + queueDepth: 4, + } + const providers = new Map([ + ['analytics.status', () => snapshot], + ]) + new SettingsHandler({infoProviders: providers, store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.GET) + if (!handler) throw new Error('GET handler not registered') + const result = (await handler({key: 'analytics.status'}, 'test-client')) as SettingsGetResponse + + expect(result.ok).to.be.true + if (result.ok) { + expect(result.type).to.equal('readonly-info') + expect(result.current).to.deep.equal(snapshot) + expect(result.category).to.equal('analytics') + expect(result.default).to.equal(undefined) + } + }) + + it('SET on analytics.status returns code=read_only against the production registry', async () => { + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.SET) + if (!handler) throw new Error('SET handler not registered') + const result = (await handler({key: 'analytics.status', value: 1}, 'test-client')) as SettingsSetResponse + + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('read_only') + expect(result.error.key).to.equal('analytics.status') + } + }) + + it('RESET on analytics.status returns code=read_only against the production registry', async () => { + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.RESET) + if (!handler) throw new Error('RESET handler not registered') + const result = (await handler({key: 'analytics.status'}, 'test-client')) as SettingsResetResponse + + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('read_only') + expect(result.error.key).to.equal('analytics.status') + } + }) + + it('LIST includes analytics.status as a readonly-info row with current resolved by the provider', async () => { + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + const snapshot = { + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy' as const}, + droppedCount: 0, + enabled: false, + endpoint: 'https://telemetry-dev.byterover.dev', + queueDepth: 0, + } + const providers = new Map([ + ['analytics.status', () => snapshot], + ]) + new SettingsHandler({infoProviders: providers, store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.LIST) + if (!handler) throw new Error('LIST handler not registered') + const result = (await handler(undefined, 'test-client')) as SettingsListResponse + + const row = result.items.find((i) => i.key === 'analytics.status') + expect(row, 'analytics.status row present in LIST').to.exist + expect(row?.type).to.equal('readonly-info') + expect(row?.category).to.equal('analytics') + expect(row?.current).to.deep.equal(snapshot) + expect(row?.default).to.equal(undefined) + }) + }) }) diff --git a/test/unit/server/infra/analytics/build-status-snapshot.test.ts b/test/unit/server/infra/analytics/build-status-snapshot.test.ts new file mode 100644 index 000000000..c0bb0c154 --- /dev/null +++ b/test/unit/server/infra/analytics/build-status-snapshot.test.ts @@ -0,0 +1,112 @@ +import {expect} from 'chai' + +import type {IAnalyticsBackoffPolicy} from '../../../../../src/server/core/interfaces/analytics/i-analytics-backoff-policy.js' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {buildAnalyticsStatusSnapshot} from '../../../../../src/server/infra/analytics/build-status-snapshot.js' + +type RuntimeStateSnapshot = { + droppedCount: number + lastSuccessfulFlushAt: number | undefined + queueDepth: number +} + +function makeClientStub(state: RuntimeStateSnapshot): IAnalyticsClient { + return { + abort() {}, + flush: async () => AnalyticsBatch.create([]), + getRuntimeState: async () => state, + async onAuthTransition() {}, + track() {}, + } +} + +function makePolicyStub(consecutiveFailures: number, nextDelayMs: number): IAnalyticsBackoffPolicy { + return { + consecutiveFailures: () => consecutiveFailures, + nextDelayMs: () => nextDelayMs, + onFailure() {}, + onSuccess() {}, + } +} + +describe('buildAnalyticsStatusSnapshot (M16.3)', () => { + it('composes the wire response with all fields populated', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: 1_700_000_000_000, queueDepth: 4}), + backoffPolicy: makePolicyStub(0, 30_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + }) + + expect(snapshot).to.deep.equal({ + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy'}, + droppedCount: 0, + enabled: true, + endpoint: 'https://telemetry-dev.byterover.dev', + lastFlushAt: 1_700_000_000_000, + queueDepth: 4, + }) + }) + + it('substitutes the (not configured) placeholder and forces unreachable when endpoint is empty', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + backoffPolicy: makePolicyStub(0, 30_000), + endpoint: '', + isAnalyticsEnabled: () => true, + }) + + expect(snapshot.endpoint).to.equal('(not configured)') + expect(snapshot.backoff.state).to.equal('unreachable') + }) + + it('omits lastFlushAt when the daemon has never shipped', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 2}), + backoffPolicy: makePolicyStub(0, 30_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + }) + + expect(snapshot.lastFlushAt).to.equal(undefined) + }) + + it('preserves the disabled flag without dropping operational fields', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 7, lastSuccessfulFlushAt: 1_700_000_000_000, queueDepth: 3}), + backoffPolicy: makePolicyStub(1, 60_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => false, + }) + + expect(snapshot.enabled).to.equal(false) + expect(snapshot.queueDepth).to.equal(3) + expect(snapshot.droppedCount).to.equal(7) + expect(snapshot.lastFlushAt).to.equal(1_700_000_000_000) + expect(snapshot.backoff).to.deep.equal({consecutiveFailures: 1, nextDelayMs: 60_000, state: 'degraded'}) + }) + + it('maps 1-2 consecutive failures to degraded', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + backoffPolicy: makePolicyStub(2, 120_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + }) + + expect(snapshot.backoff.state).to.equal('degraded') + }) + + it('maps 3+ consecutive failures to unreachable', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 5}), + backoffPolicy: makePolicyStub(5, 300_000), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + }) + + expect(snapshot.backoff.state).to.equal('unreachable') + }) +}) diff --git a/test/unit/shared/utils/format-analytics-status.test.ts b/test/unit/shared/utils/format-analytics-status.test.ts new file mode 100644 index 000000000..4eaba8a08 --- /dev/null +++ b/test/unit/shared/utils/format-analytics-status.test.ts @@ -0,0 +1,135 @@ +/* eslint-disable camelcase -- legacy `brv analytics status --format json` envelope is snake_case. */ +import {expect} from 'chai' + +import type {AnalyticsStatusResponse} from '../../../../src/shared/transport/events/analytics-events.js' + +import { + formatAnalyticsStatusJson, + formatAnalyticsStatusText, +} from '../../../../src/shared/utils/format-analytics-status.js' + +const PINNED_NOW = 1_700_000_000_000 + +const HEALTHY: AnalyticsStatusResponse = { + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy'}, + droppedCount: 0, + enabled: true, + endpoint: 'https://telemetry-dev.byterover.dev', + lastFlushAt: PINNED_NOW - 5 * 60_000, + queueDepth: 4, +} + +function formatAt(response: AnalyticsStatusResponse): string { + return formatAnalyticsStatusText(response, () => PINNED_NOW) +} + +describe('format-analytics-status (M16.3)', () => { + describe('formatAnalyticsStatusText', () => { + const format = formatAt + + it('disabled state: only shows "Analytics: disabled" (other fields suppressed)', () => { + const text = format({...HEALTHY, enabled: false}) + expect(text).to.equal('Analytics: disabled') + }) + + it('enabled, never flushed: "Last successful flush: never"', () => { + const text = format({...HEALTHY, lastFlushAt: undefined}) + expect(text).to.include('Analytics: enabled') + expect(text).to.include('Last successful flush: never') + }) + + it('enabled, flushed 5 minutes ago: ISO timestamp with relative time', () => { + const text = format(HEALTHY) + expect(text).to.include('Last successful flush:') + expect(text).to.include('2023-11-14T22:08:20') + expect(text).to.include('(5m ago)') + }) + + it('"just now" for sub-minute deltas', () => { + const text = format({...HEALTHY, lastFlushAt: PINNED_NOW - 30_000}) + expect(text).to.include('(just now)') + }) + + it('hours-then-days relative formatting', () => { + expect(format({...HEALTHY, lastFlushAt: PINNED_NOW - 3 * 60 * 60_000})).to.include('(3h ago)') + expect(format({...HEALTHY, lastFlushAt: PINNED_NOW - 2 * 24 * 60 * 60_000})).to.include('(2d ago)') + }) + + it('backoff state "degraded": label + consecutive failures + humanized delay', () => { + const text = format({ + ...HEALTHY, + backoff: {consecutiveFailures: 2, nextDelayMs: 120_000, state: 'degraded'}, + }) + expect(text).to.include('Backoff state: degraded') + expect(text).to.include('2 consecutive failures') + expect(text).to.include('next attempt in 2m') + }) + + it('singularises "1 consecutive failure" on a single-failure backoff', () => { + const text = format({ + ...HEALTHY, + backoff: {consecutiveFailures: 1, nextDelayMs: 60_000, state: 'degraded'}, + }) + expect(text).to.include('1 consecutive failure') + expect(text).to.not.include('1 consecutive failures') + expect(text).to.include('next attempt in 1m') + }) + + it('endpoint not configured: shows literal placeholder + unreachable backoff', () => { + const text = format({ + ...HEALTHY, + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'unreachable'}, + endpoint: '(not configured)', + }) + expect(text).to.include('Endpoint: (not configured)') + expect(text).to.include('Backoff state: unreachable') + }) + + it('shows queue depth and dropped events on enabled state', () => { + const text = format({...HEALTHY, droppedCount: 7, queueDepth: 12}) + expect(text).to.include('Queue depth: 12 events') + expect(text).to.include('Dropped events (this session): 7') + }) + + it('returns the unavailable placeholder when value is not a valid snapshot shape', () => { + const noValue: {value?: unknown} = {} + expect(formatAnalyticsStatusText(noValue.value)).to.equal('(unavailable)') + expect(formatAnalyticsStatusText({garbage: true})).to.equal('(unavailable)') + expect(formatAnalyticsStatusText(null)).to.equal('(unavailable)') + }) + }) + + describe('formatAnalyticsStatusJson', () => { + it('emits the legacy snake_case envelope on enabled state', () => { + const flushAt = PINNED_NOW - 5 * 60_000 + const json = formatAnalyticsStatusJson({...HEALTHY, lastFlushAt: flushAt}) + expect(json).to.deep.equal({ + backoff: { + consecutive_failures: 0, + next_delay_ms: 30_000, + state: 'healthy', + }, + dropped_events: 0, + enabled: true, + endpoint: 'https://telemetry-dev.byterover.dev', + last_flush: new Date(flushAt).toISOString(), + queue_depth: 4, + }) + }) + + it('last_flush is null when undefined', () => { + const json = formatAnalyticsStatusJson({...HEALTHY, lastFlushAt: undefined}) + if ('unavailable' in json) { + expect.fail('expected a valid JSON shape') + return + } + + expect(json.last_flush).to.equal(null) + }) + + it('returns the unavailable shape when value is not a valid snapshot', () => { + const json = formatAnalyticsStatusJson({garbage: true}) + expect(json).to.deep.equal({unavailable: true}) + }) + }) +}) From 3120a17ba423bbda3933c01319ca1d385ec1ffb1 Mon Sep 17 00:00:00 2001 From: ncnthien Date: Thu, 28 May 2026 21:50:41 +0700 Subject: [PATCH 67/87] feat: [ENG-2621] move AnalyticsPanel from General to a new Privacy tab The Configuration page was reorganised into a sidebar + sub-routes layout (General / Connectors / Version control). The AnalyticsPanel mount was lost in that rewrite and rescued into General as a stopgap (cfa89dad). Give it a proper home: add a Privacy section under the same layout, mount the panel there, and drop the stopgap mount from General. --- src/webui/pages/configuration/general.tsx | 2 -- src/webui/pages/configuration/layout.tsx | 1 + src/webui/pages/configuration/privacy.tsx | 5 +++++ src/webui/router.tsx | 2 ++ 4 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 src/webui/pages/configuration/privacy.tsx diff --git a/src/webui/pages/configuration/general.tsx b/src/webui/pages/configuration/general.tsx index 14e1a6089..a5e5c1237 100644 --- a/src/webui/pages/configuration/general.tsx +++ b/src/webui/pages/configuration/general.tsx @@ -1,4 +1,3 @@ -import { AnalyticsPanel } from '../../features/analytics/components/analytics-panel' import {ConcurrencyPanel} from '../../features/settings/components/concurrency-panel' import {LlmPanel} from '../../features/settings/components/llm-panel' import {TaskHistoryPanel} from '../../features/settings/components/task-history-panel' @@ -11,7 +10,6 @@ export function GeneralSection() { - ) } diff --git a/src/webui/pages/configuration/layout.tsx b/src/webui/pages/configuration/layout.tsx index 2fbf57a3d..4d27621a7 100644 --- a/src/webui/pages/configuration/layout.tsx +++ b/src/webui/pages/configuration/layout.tsx @@ -13,6 +13,7 @@ const SECTIONS: readonly SectionDef[] = [ {end: true, label: 'General', path: '.'}, {label: 'Connectors', path: 'connectors'}, {label: 'Version control', path: 'version-control'}, + {label: 'Privacy', path: 'privacy'}, ] export function ConfigurationLayout() { diff --git a/src/webui/pages/configuration/privacy.tsx b/src/webui/pages/configuration/privacy.tsx new file mode 100644 index 000000000..074f6ad39 --- /dev/null +++ b/src/webui/pages/configuration/privacy.tsx @@ -0,0 +1,5 @@ +import {AnalyticsPanel} from '../../features/analytics/components/analytics-panel' + +export function PrivacySection() { + return +} diff --git a/src/webui/router.tsx b/src/webui/router.tsx index 858aca90b..39f353787 100644 --- a/src/webui/router.tsx +++ b/src/webui/router.tsx @@ -7,6 +7,7 @@ import {ChangesPage} from './pages/changes-page' import {ConnectorsSection} from './pages/configuration/connectors' import {GeneralSection} from './pages/configuration/general' import {ConfigurationLayout} from './pages/configuration/layout' +import {PrivacySection} from './pages/configuration/privacy' import {VersionControlSection} from './pages/configuration/version-control' import {ContextsPage} from './pages/contexts-page' import {HomePage} from './pages/home-page' @@ -36,6 +37,7 @@ export const router = createBrowserRouter([ {element: , index: true}, {element: , path: 'connectors'}, {element: , path: 'version-control'}, + {element: , path: 'privacy'}, ], element: , path: 'configuration', From 68b528ec1a96294785f5eb3e89bb682cde89ff68 Mon Sep 17 00:00:00 2001 From: Cuong Date: Thu, 28 May 2026 21:44:49 +0700 Subject: [PATCH 68/87] feat: [ENG-3012] M16.8 add content_migrated analytics event schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema-only registration for the admin-op content-migration event, mirroring the ENG-2770 (curate_* family) precedent. No daemon-handler emit site exists in this codebase yet; the producer will land alongside the admin op when its handler is built. Distinct from `migrate_run` (ENG-3008 / migrate-handler.ts) which covers the file-format `brv migrate` MD→HTML one-shot. `content_migrated` is for scope-aware data movement (e.g. between local / space / shared scopes); `source_kind` and `target_kind` are short producer-taxonomized strings so future scopes plug in without a schema migration. Shape: - outcome ('success' | 'failure'), failure_kind? (≤64 chars) - source_kind, target_kind (string min 1, max 64) - dry_run? (boolean) - migrated? / skipped? / failed? (non-negative int) - duration_ms? (non-negative int) Per the convention checklist in plans/m16-swarm-and-content-migrated-events.md: - [x] event-names.ts constant - [x] per-event Zod schema (strict) - [x] ALL_EVENT_SCHEMAS + AnyAnalyticsEvent union - [x] analytics-handler.ts dispatch branch - [x] privacy-fixture coverage list updated - [ ] daemon handler emit — deferred (no surface yet) - [ ] analyticsClient DI wiring — deferred (no surface yet) - [x] no forbidden field names (privacy walk green) - [x] no schema_version bump --- .../transport/handlers/analytics-handler.ts | 8 ++++ src/shared/analytics/event-names.ts | 1 + .../analytics/events/content-migrated.ts | 48 +++++++++++++++++++ src/shared/analytics/events/index.ts | 3 ++ .../shared/analytics/privacy-fixture.test.ts | 1 + 5 files changed, 61 insertions(+) create mode 100644 src/shared/analytics/events/content-migrated.ts diff --git a/src/server/infra/transport/handlers/analytics-handler.ts b/src/server/infra/transport/handlers/analytics-handler.ts index 69370805b..ede49b5bc 100644 --- a/src/server/infra/transport/handlers/analytics-handler.ts +++ b/src/server/infra/transport/handlers/analytics-handler.ts @@ -9,6 +9,7 @@ import {AuthLogoutSchema} from '../../../../shared/analytics/events/auth-logout. import {BrvInitSchema} from '../../../../shared/analytics/events/brv-init.js' import {CliInvocationSchema} from '../../../../shared/analytics/events/cli-invocation.js' import {ConnectorInstalledSchema} from '../../../../shared/analytics/events/connector-installed.js' +import {ContentMigratedSchema} from '../../../../shared/analytics/events/content-migrated.js' import {ContextTreeFileEditedSchema} from '../../../../shared/analytics/events/context-tree-file-edited.js' import {CurateOperationAppliedSchema} from '../../../../shared/analytics/events/curate-operation-applied.js' import {CurateRunCompletedSchema} from '../../../../shared/analytics/events/curate-run-completed.js' @@ -158,6 +159,13 @@ export class AnalyticsHandler { break } + case AnalyticsEventNames.CONTENT_MIGRATED: { + const props = ContentMigratedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.CONTENT_MIGRATED, props.data) + break + } + case AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED: { const props = ContextTreeFileEditedSchema.safeParse(rawProperties ?? {}) if (!props.success) return diff --git a/src/shared/analytics/event-names.ts b/src/shared/analytics/event-names.ts index 2e2495b21..99d53d8c2 100644 --- a/src/shared/analytics/event-names.ts +++ b/src/shared/analytics/event-names.ts @@ -20,6 +20,7 @@ export const AnalyticsEventNames = { BRV_INIT: 'brv_init', CLI_INVOCATION: 'cli_invocation', CONNECTOR_INSTALLED: 'connector_installed', + CONTENT_MIGRATED: 'content_migrated', CONTEXT_TREE_FILE_EDITED: 'context_tree_file_edited', CURATE_OPERATION_APPLIED: 'curate_operation_applied', CURATE_RUN_COMPLETED: 'curate_run_completed', diff --git a/src/shared/analytics/events/content-migrated.ts b/src/shared/analytics/events/content-migrated.ts new file mode 100644 index 000000000..bddaa27a0 --- /dev/null +++ b/src/shared/analytics/events/content-migrated.ts @@ -0,0 +1,48 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `content_migrated`. + * + * Admin-op content migration between scopes (e.g. moving curated knowledge + * between sources, spaces, or projects). Distinct from `migrate_run` + * (ENG-3008 / `migrate-run.ts`) which covers the `brv migrate` MD→HTML + * one-shot — that operation lives in `MigrateHandler` and its semantics + * are file-format-conversion, not scope-aware data movement. + * + * `source_kind` / `target_kind` are short enum strings naming the + * abstract scope on each side (e.g. `'local'`, `'space'`, `'shared'`). + * Kept as `z.string().min(1).max(64)` rather than a closed enum so future + * scopes can plug in without a schema migration; the producer is + * responsible for taxonomizing. + * + * Per the M15.1 outcome taxonomy: `outcome: 'success' | 'failure'`, + * `failure_kind` populated only on failure. Counts are optional so a + * failure path that surfaces before counts are known still emits a + * well-formed event. + * + * SCHEMA-ONLY REGISTRATION TODAY: no daemon-handler emit site exists in + * this codebase yet. The producer will land alongside the admin op when + * its handler is built. See ENG-2770 for the precedent. + */ +const failureKindSchema = z.string().min(1).max(64).optional() +const countSchema = z.number().int().nonnegative().optional() + +export const ContentMigratedSchema = z + .object({ + /** True when the run was a no-write dry run. */ + dry_run: z.boolean().optional(), + /** Counts — optional because failure can surface before they're computed. */ + duration_ms: z.number().int().nonnegative().optional(), + failed: countSchema, + failure_kind: failureKindSchema, + migrated: countSchema, + outcome: z.enum(['success', 'failure']), + skipped: countSchema, + /** Abstract scope identifiers (e.g. 'local', 'space', 'shared'). Producer-taxonomized. */ + source_kind: z.string().min(1).max(64), + target_kind: z.string().min(1).max(64), + }) + .strict() + +export type ContentMigratedProps = z.infer diff --git a/src/shared/analytics/events/index.ts b/src/shared/analytics/events/index.ts index d63e6843c..d675a272c 100644 --- a/src/shared/analytics/events/index.ts +++ b/src/shared/analytics/events/index.ts @@ -7,6 +7,7 @@ import {type AuthLogoutProps, AuthLogoutSchema} from './auth-logout.js' import {type BrvInitProps, BrvInitSchema} from './brv-init.js' import {type CliInvocationProps, CliInvocationSchema} from './cli-invocation.js' import {type ConnectorInstalledProps, ConnectorInstalledSchema} from './connector-installed.js' +import {type ContentMigratedProps, ContentMigratedSchema} from './content-migrated.js' import {type ContextTreeFileEditedProps, ContextTreeFileEditedSchema} from './context-tree-file-edited.js' import {type CurateOperationAppliedProps, CurateOperationAppliedSchema} from './curate-operation-applied.js' import {type CurateRunCompletedProps, CurateRunCompletedSchema} from './curate-run-completed.js' @@ -72,6 +73,7 @@ export const ALL_EVENT_SCHEMAS = { [AnalyticsEventNames.BRV_INIT]: BrvInitSchema, [AnalyticsEventNames.CLI_INVOCATION]: CliInvocationSchema, [AnalyticsEventNames.CONNECTOR_INSTALLED]: ConnectorInstalledSchema, + [AnalyticsEventNames.CONTENT_MIGRATED]: ContentMigratedSchema, [AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED]: ContextTreeFileEditedSchema, [AnalyticsEventNames.CURATE_OPERATION_APPLIED]: CurateOperationAppliedSchema, [AnalyticsEventNames.CURATE_RUN_COMPLETED]: CurateRunCompletedSchema, @@ -128,6 +130,7 @@ export type AnyAnalyticsEvent = | {name: typeof AnalyticsEventNames.BRV_INIT; properties: BrvInitProps} | {name: typeof AnalyticsEventNames.CLI_INVOCATION; properties: CliInvocationProps} | {name: typeof AnalyticsEventNames.CONNECTOR_INSTALLED; properties: ConnectorInstalledProps} + | {name: typeof AnalyticsEventNames.CONTENT_MIGRATED; properties: ContentMigratedProps} | {name: typeof AnalyticsEventNames.CONTEXT_TREE_FILE_EDITED; properties: ContextTreeFileEditedProps} | {name: typeof AnalyticsEventNames.CURATE_OPERATION_APPLIED; properties: CurateOperationAppliedProps} | {name: typeof AnalyticsEventNames.CURATE_RUN_COMPLETED; properties: CurateRunCompletedProps} diff --git a/test/unit/shared/analytics/privacy-fixture.test.ts b/test/unit/shared/analytics/privacy-fixture.test.ts index bca77c5f7..89071f2d7 100644 --- a/test/unit/shared/analytics/privacy-fixture.test.ts +++ b/test/unit/shared/analytics/privacy-fixture.test.ts @@ -136,6 +136,7 @@ describe('analytics privacy fixture (smoke)', () => { 'brv_init', 'cli_invocation', 'connector_installed', + 'content_migrated', 'context_tree_file_edited', 'curate_operation_applied', 'curate_run_completed', From 4c323189aa947393e3d7b7c7a20c42f1db811163 Mon Sep 17 00:00:00 2001 From: Cuong Date: Thu, 28 May 2026 21:46:28 +0700 Subject: [PATCH 69/87] feat: [ENG-3015] M16.11 add swarm_onboarded analytics event schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema-only registration for the swarm activation entry point. Fires when `brv swarm onboard` completes (success) or aborts (failure) — swarm counterpart to M15.2's brv-init / onboarding-completed events. Shape: - outcome ('success' | 'failure'), failure_kind? (≤64 chars) - swarm_kind? ('new' | 'joined' | ... ; producer-taxonomized string) - member_count? (resulting swarm's active-provider count) - duration_ms? Producer wiring deferred. The swarm onboard surface lives in the agent process at src/agent/infra/swarm/wizard/swarm-wizard.ts, not in a daemon transport handler. Emit wiring requires either a new daemon handler the CLI calls into, or a synthetic-emit pattern (cf. M17). Plan flag #2 in plans/m16-swarm-and-content-migrated-events.md. Per the convention checklist: - [x] event-names.ts constant - [x] per-event Zod schema (strict) - [x] ALL_EVENT_SCHEMAS + AnyAnalyticsEvent union - [x] analytics-handler.ts dispatch branch - [x] privacy-fixture coverage list updated - [ ] daemon handler emit — deferred (swarm lives in agent process) - [ ] analyticsClient DI wiring — deferred - [x] no forbidden field names (privacy walk green) - [x] no schema_version bump --- .../transport/handlers/analytics-handler.ts | 8 ++++ src/shared/analytics/event-names.ts | 1 + src/shared/analytics/events/index.ts | 3 ++ .../analytics/events/swarm-onboarded.ts | 41 +++++++++++++++++++ .../shared/analytics/privacy-fixture.test.ts | 1 + 5 files changed, 54 insertions(+) create mode 100644 src/shared/analytics/events/swarm-onboarded.ts diff --git a/src/server/infra/transport/handlers/analytics-handler.ts b/src/server/infra/transport/handlers/analytics-handler.ts index ede49b5bc..e7f1cfe7e 100644 --- a/src/server/infra/transport/handlers/analytics-handler.ts +++ b/src/server/infra/transport/handlers/analytics-handler.ts @@ -34,6 +34,7 @@ import {SettingResetSchema} from '../../../../shared/analytics/events/setting-re import {SourceAddedSchema} from '../../../../shared/analytics/events/source-added.js' import {SourceRemovedSchema} from '../../../../shared/analytics/events/source-removed.js' import {SpaceSwitchedSchema} from '../../../../shared/analytics/events/space-switched.js' +import {SwarmOnboardedSchema} from '../../../../shared/analytics/events/swarm-onboarded.js' import {TaskCompletedSchema} from '../../../../shared/analytics/events/task-completed.js' import {TaskCreatedSchema} from '../../../../shared/analytics/events/task-created.js' import {TaskFailedSchema} from '../../../../shared/analytics/events/task-failed.js' @@ -327,6 +328,13 @@ export class AnalyticsHandler { break } + case AnalyticsEventNames.SWARM_ONBOARDED: { + const props = SwarmOnboardedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.SWARM_ONBOARDED, props.data) + break + } + case AnalyticsEventNames.TASK_COMPLETED: { const props = TaskCompletedSchema.safeParse(rawProperties ?? {}) if (!props.success) return diff --git a/src/shared/analytics/event-names.ts b/src/shared/analytics/event-names.ts index 99d53d8c2..1e5f9cfd7 100644 --- a/src/shared/analytics/event-names.ts +++ b/src/shared/analytics/event-names.ts @@ -44,6 +44,7 @@ export const AnalyticsEventNames = { SOURCE_ADDED: 'source_added', SOURCE_REMOVED: 'source_removed', SPACE_SWITCHED: 'space_switched', + SWARM_ONBOARDED: 'swarm_onboarded', TASK_COMPLETED: 'task_completed', TASK_CREATED: 'task_created', TASK_FAILED: 'task_failed', diff --git a/src/shared/analytics/events/index.ts b/src/shared/analytics/events/index.ts index d675a272c..e388f7c81 100644 --- a/src/shared/analytics/events/index.ts +++ b/src/shared/analytics/events/index.ts @@ -31,6 +31,7 @@ import {type SettingResetProps, SettingResetSchema} from './setting-reset.js' import {type SourceAddedProps, SourceAddedSchema} from './source-added.js' import {type SourceRemovedProps, SourceRemovedSchema} from './source-removed.js' import {type SpaceSwitchedProps, SpaceSwitchedSchema} from './space-switched.js' +import {type SwarmOnboardedProps, SwarmOnboardedSchema} from './swarm-onboarded.js' import {type TaskCompletedProps, TaskCompletedSchema} from './task-completed.js' import {type TaskCreatedProps, TaskCreatedSchema} from './task-created.js' import {type TaskFailedProps, TaskFailedSchema} from './task-failed.js' @@ -97,6 +98,7 @@ export const ALL_EVENT_SCHEMAS = { [AnalyticsEventNames.SOURCE_ADDED]: SourceAddedSchema, [AnalyticsEventNames.SOURCE_REMOVED]: SourceRemovedSchema, [AnalyticsEventNames.SPACE_SWITCHED]: SpaceSwitchedSchema, + [AnalyticsEventNames.SWARM_ONBOARDED]: SwarmOnboardedSchema, [AnalyticsEventNames.TASK_COMPLETED]: TaskCompletedSchema, [AnalyticsEventNames.TASK_CREATED]: TaskCreatedSchema, [AnalyticsEventNames.TASK_FAILED]: TaskFailedSchema, @@ -154,6 +156,7 @@ export type AnyAnalyticsEvent = | {name: typeof AnalyticsEventNames.SOURCE_ADDED; properties: SourceAddedProps} | {name: typeof AnalyticsEventNames.SOURCE_REMOVED; properties: SourceRemovedProps} | {name: typeof AnalyticsEventNames.SPACE_SWITCHED; properties: SpaceSwitchedProps} + | {name: typeof AnalyticsEventNames.SWARM_ONBOARDED; properties: SwarmOnboardedProps} | {name: typeof AnalyticsEventNames.TASK_COMPLETED; properties: TaskCompletedProps} | {name: typeof AnalyticsEventNames.TASK_CREATED; properties: TaskCreatedProps} | {name: typeof AnalyticsEventNames.TASK_FAILED; properties: TaskFailedProps} diff --git a/src/shared/analytics/events/swarm-onboarded.ts b/src/shared/analytics/events/swarm-onboarded.ts new file mode 100644 index 000000000..a573148d5 --- /dev/null +++ b/src/shared/analytics/events/swarm-onboarded.ts @@ -0,0 +1,41 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `swarm_onboarded`. + * + * Activation entry point for `brv swarm onboard` — fires when the wizard + * completes (success path) or aborts (failure path). Swarm counterpart + * to M15.2's brv-init / onboarding-completed activation events. + * + * `swarm_kind` is a short producer-taxonomized string (e.g. `'new'` when + * the user scaffolded a fresh config, `'joined'` when they pointed at an + * existing swarm). Kept as `z.string().min(1).max(64)` so future flows + * plug in without a schema migration. + * + * `member_count` captures the active-provider count from the resulting + * swarm config (e.g. `byterover`, `obsidian`, `gbrain`). Optional — + * failure paths may surface before the count is computed. + * + * SCHEMA-ONLY REGISTRATION TODAY: the swarm onboard surface lives in the + * agent process (`src/agent/infra/swarm/wizard/swarm-wizard.ts`), not in + * a daemon transport handler. The producer requires either a new daemon + * handler that the CLI command calls, or a synthetic-emit pattern (cf. + * M17). That wiring is deferred to a follow-up. See ENG-2770 for the + * schema-only precedent. + */ +const failureKindSchema = z.string().min(1).max(64).optional() + +export const SwarmOnboardedSchema = z + .object({ + duration_ms: z.number().int().nonnegative().optional(), + failure_kind: failureKindSchema, + /** Number of active providers in the resulting swarm config. */ + member_count: z.number().int().nonnegative().optional(), + outcome: z.enum(['success', 'failure']), + /** Onboarding flow taxonomy (e.g. 'new', 'joined'). Producer-taxonomized. */ + swarm_kind: z.string().min(1).max(64).optional(), + }) + .strict() + +export type SwarmOnboardedProps = z.infer diff --git a/test/unit/shared/analytics/privacy-fixture.test.ts b/test/unit/shared/analytics/privacy-fixture.test.ts index 89071f2d7..439c0b4ef 100644 --- a/test/unit/shared/analytics/privacy-fixture.test.ts +++ b/test/unit/shared/analytics/privacy-fixture.test.ts @@ -160,6 +160,7 @@ describe('analytics privacy fixture (smoke)', () => { 'source_added', 'source_removed', 'space_switched', + 'swarm_onboarded', 'task_completed', 'task_created', 'task_failed', From cd33c75e33ee349b2755a8f622fdfd8cdd232842 Mon Sep 17 00:00:00 2001 From: Cuong Date: Thu, 28 May 2026 21:47:46 +0700 Subject: [PATCH 70/87] feat: [ENG-3013] M16.9 add swarm_query_completed analytics event schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema-only registration for the swarm-side query funnel. Fires once per `brv swarm query` or per `swarm_query` LLM tool call — swarm counterpart to `query_completed` (ENG-2770 / M12). M15 explicitly excluded swarm; this closes that gap on the read side. Shape: - outcome ('success' | 'failure'), failure_kind? (≤64 chars) - duration_ms (required — coordinator always knows it at terminal) - result_count? (fused result count returned to caller) - swarm_scope? ('local' | 'remote' | 'mixed' | … producer-taxonomized) - tags? / keywords? / related? (M12.3 frontmatter-harvest parity for fused Memory-Wiki adapter results) Producer wiring deferred. The swarm query surface lives in the agent process at src/agent/infra/swarm/swarm-coordinator.ts, not in a daemon transport handler. Same architectural choice as ENG-3015. Plan flag #2 in plans/m16-swarm-and-content-migrated-events.md. Per the convention checklist: - [x] event-names.ts constant - [x] per-event Zod schema (strict) - [x] ALL_EVENT_SCHEMAS + AnyAnalyticsEvent union - [x] analytics-handler.ts dispatch branch - [x] privacy-fixture coverage list updated - [ ] producer wiring (swarm-coordinator emit) — deferred - [x] no forbidden field names - [x] no schema_version bump --- .../transport/handlers/analytics-handler.ts | 8 +++ src/shared/analytics/event-names.ts | 1 + src/shared/analytics/events/index.ts | 3 ++ .../analytics/events/swarm-query-completed.ts | 50 +++++++++++++++++++ .../shared/analytics/privacy-fixture.test.ts | 1 + 5 files changed, 63 insertions(+) create mode 100644 src/shared/analytics/events/swarm-query-completed.ts diff --git a/src/server/infra/transport/handlers/analytics-handler.ts b/src/server/infra/transport/handlers/analytics-handler.ts index e7f1cfe7e..85c174aa3 100644 --- a/src/server/infra/transport/handlers/analytics-handler.ts +++ b/src/server/infra/transport/handlers/analytics-handler.ts @@ -35,6 +35,7 @@ import {SourceAddedSchema} from '../../../../shared/analytics/events/source-adde import {SourceRemovedSchema} from '../../../../shared/analytics/events/source-removed.js' import {SpaceSwitchedSchema} from '../../../../shared/analytics/events/space-switched.js' import {SwarmOnboardedSchema} from '../../../../shared/analytics/events/swarm-onboarded.js' +import {SwarmQueryCompletedSchema} from '../../../../shared/analytics/events/swarm-query-completed.js' import {TaskCompletedSchema} from '../../../../shared/analytics/events/task-completed.js' import {TaskCreatedSchema} from '../../../../shared/analytics/events/task-created.js' import {TaskFailedSchema} from '../../../../shared/analytics/events/task-failed.js' @@ -335,6 +336,13 @@ export class AnalyticsHandler { break } + case AnalyticsEventNames.SWARM_QUERY_COMPLETED: { + const props = SwarmQueryCompletedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.SWARM_QUERY_COMPLETED, props.data) + break + } + case AnalyticsEventNames.TASK_COMPLETED: { const props = TaskCompletedSchema.safeParse(rawProperties ?? {}) if (!props.success) return diff --git a/src/shared/analytics/event-names.ts b/src/shared/analytics/event-names.ts index 1e5f9cfd7..4bd12799d 100644 --- a/src/shared/analytics/event-names.ts +++ b/src/shared/analytics/event-names.ts @@ -45,6 +45,7 @@ export const AnalyticsEventNames = { SOURCE_REMOVED: 'source_removed', SPACE_SWITCHED: 'space_switched', SWARM_ONBOARDED: 'swarm_onboarded', + SWARM_QUERY_COMPLETED: 'swarm_query_completed', TASK_COMPLETED: 'task_completed', TASK_CREATED: 'task_created', TASK_FAILED: 'task_failed', diff --git a/src/shared/analytics/events/index.ts b/src/shared/analytics/events/index.ts index e388f7c81..5daec8fc1 100644 --- a/src/shared/analytics/events/index.ts +++ b/src/shared/analytics/events/index.ts @@ -32,6 +32,7 @@ import {type SourceAddedProps, SourceAddedSchema} from './source-added.js' import {type SourceRemovedProps, SourceRemovedSchema} from './source-removed.js' import {type SpaceSwitchedProps, SpaceSwitchedSchema} from './space-switched.js' import {type SwarmOnboardedProps, SwarmOnboardedSchema} from './swarm-onboarded.js' +import {type SwarmQueryCompletedProps, SwarmQueryCompletedSchema} from './swarm-query-completed.js' import {type TaskCompletedProps, TaskCompletedSchema} from './task-completed.js' import {type TaskCreatedProps, TaskCreatedSchema} from './task-created.js' import {type TaskFailedProps, TaskFailedSchema} from './task-failed.js' @@ -99,6 +100,7 @@ export const ALL_EVENT_SCHEMAS = { [AnalyticsEventNames.SOURCE_REMOVED]: SourceRemovedSchema, [AnalyticsEventNames.SPACE_SWITCHED]: SpaceSwitchedSchema, [AnalyticsEventNames.SWARM_ONBOARDED]: SwarmOnboardedSchema, + [AnalyticsEventNames.SWARM_QUERY_COMPLETED]: SwarmQueryCompletedSchema, [AnalyticsEventNames.TASK_COMPLETED]: TaskCompletedSchema, [AnalyticsEventNames.TASK_CREATED]: TaskCreatedSchema, [AnalyticsEventNames.TASK_FAILED]: TaskFailedSchema, @@ -157,6 +159,7 @@ export type AnyAnalyticsEvent = | {name: typeof AnalyticsEventNames.SOURCE_REMOVED; properties: SourceRemovedProps} | {name: typeof AnalyticsEventNames.SPACE_SWITCHED; properties: SpaceSwitchedProps} | {name: typeof AnalyticsEventNames.SWARM_ONBOARDED; properties: SwarmOnboardedProps} + | {name: typeof AnalyticsEventNames.SWARM_QUERY_COMPLETED; properties: SwarmQueryCompletedProps} | {name: typeof AnalyticsEventNames.TASK_COMPLETED; properties: TaskCompletedProps} | {name: typeof AnalyticsEventNames.TASK_CREATED; properties: TaskCreatedProps} | {name: typeof AnalyticsEventNames.TASK_FAILED; properties: TaskFailedProps} diff --git a/src/shared/analytics/events/swarm-query-completed.ts b/src/shared/analytics/events/swarm-query-completed.ts new file mode 100644 index 000000000..5d78880c3 --- /dev/null +++ b/src/shared/analytics/events/swarm-query-completed.ts @@ -0,0 +1,50 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `swarm_query_completed`. + * + * Swarm counterpart to `query_completed` (ENG-2770 / M12) — fires once + * per `brv swarm query` invocation OR per `swarm_query` LLM tool call, + * covering the read loop across federated memory providers (byterover, + * obsidian, gbrain, …) coordinated by `swarm-coordinator.ts`. + * + * `swarm_scope` is a short producer-taxonomized string describing which + * provider set the query spanned: `'local'` (current project only), + * `'remote'` (external providers only), or `'mixed'` (both). Kept as + * `z.string().min(1).max(64)` so future scope kinds plug in without a + * schema migration; the producer is responsible for the taxonomy. + * + * `tags` / `keywords` / `related` mirror the M12.3 frontmatter-harvest + * precedent — when the query fuses results from a Memory-Wiki adapter + * that carries those fields, surface them so the funnel stays comparable + * to the in-project `query_completed` events. + * + * Per the M15.1 outcome taxonomy: `outcome: 'success' | 'failure'`, + * `failure_kind` populated only on failure. `duration_ms` is required + * because the coordinator always knows it by terminal time. + * + * SCHEMA-ONLY REGISTRATION TODAY: the swarm query surface lives in the + * agent process (`src/agent/infra/swarm/swarm-coordinator.ts`), not in + * a daemon transport handler. Emit wiring deferred per plan flag #2. + */ +const failureKindSchema = z.string().min(1).max(64).optional() +const stringArraySchema = z.array(z.string().max(256)).max(50).optional() + +export const SwarmQueryCompletedSchema = z + .object({ + duration_ms: z.number().int().nonnegative(), + failure_kind: failureKindSchema, + /** Optional frontmatter harvest (M12.3 parity) for the top-N fused results. */ + keywords: stringArraySchema, + outcome: z.enum(['success', 'failure']), + related: stringArraySchema, + /** Number of fused results returned to the caller. */ + result_count: z.number().int().nonnegative().optional(), + /** Provider-set kind ('local' | 'remote' | 'mixed' | …). Producer-taxonomized. */ + swarm_scope: z.string().min(1).max(64).optional(), + tags: stringArraySchema, + }) + .strict() + +export type SwarmQueryCompletedProps = z.infer diff --git a/test/unit/shared/analytics/privacy-fixture.test.ts b/test/unit/shared/analytics/privacy-fixture.test.ts index 439c0b4ef..a96a52e09 100644 --- a/test/unit/shared/analytics/privacy-fixture.test.ts +++ b/test/unit/shared/analytics/privacy-fixture.test.ts @@ -161,6 +161,7 @@ describe('analytics privacy fixture (smoke)', () => { 'source_removed', 'space_switched', 'swarm_onboarded', + 'swarm_query_completed', 'task_completed', 'task_created', 'task_failed', From d2259b878ff34be60c7b61a2809abdc9973445f8 Mon Sep 17 00:00:00 2001 From: Cuong Date: Thu, 28 May 2026 21:54:26 +0700 Subject: [PATCH 71/87] feat: [ENG-3014] M16.10 add swarm_store_completed analytics event schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema-only registration for the swarm-side write funnel. Fires once per `brv swarm curate` or per `swarm_store` LLM tool call — swarm counterpart to `curate_operation_applied` / `curate_run_completed` (ENG-2770 / M12). Closes the write half of the swarm coverage gap opened by M15's explicit exclusion. Shape: - outcome ('success' | 'failure'), failure_kind? (≤64 chars) - duration_ms (required — coordinator always knows it at terminal) - operation ('add' | 'update' | 'merge' | … producer-taxonomized) - stored? / updated? / skipped? (per-provider outcome counters, mirroring the M12 curate-aggregation idiom) - tags? / keywords? / related? (M12.3 frontmatter-harvest parity) Producer wiring deferred — same as ENG-3013/3015. Surface lives in swarm-coordinator.ts + memory-wiki-adapter.ts (agent process), not in a daemon handler. Plan flag #2. Closes the M16 event-schema batch (ENG-3012/3013/3014/3015). All four follow the ENG-2770 "schema-only registration template" precedent the plan calls out. Also updates `event-names.test.ts` from "forty-seven shipped event names" to "fifty-one" to reflect the +4 net-new additions. Per the convention checklist: - [x] event-names.ts constant - [x] per-event Zod schema (strict) - [x] ALL_EVENT_SCHEMAS + AnyAnalyticsEvent union - [x] analytics-handler.ts dispatch branch - [x] privacy-fixture + event-names tests updated - [ ] producer wiring (swarm-coordinator emit) — deferred - [x] no forbidden field names - [x] no schema_version bump Full suite: 9766 passing. --- .../transport/handlers/analytics-handler.ts | 8 +++ src/shared/analytics/event-names.ts | 1 + src/shared/analytics/events/index.ts | 3 ++ .../analytics/events/swarm-store-completed.ts | 53 +++++++++++++++++++ .../unit/shared/analytics/event-names.test.ts | 6 ++- .../shared/analytics/privacy-fixture.test.ts | 1 + 6 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/shared/analytics/events/swarm-store-completed.ts diff --git a/src/server/infra/transport/handlers/analytics-handler.ts b/src/server/infra/transport/handlers/analytics-handler.ts index 85c174aa3..8af9c2fd3 100644 --- a/src/server/infra/transport/handlers/analytics-handler.ts +++ b/src/server/infra/transport/handlers/analytics-handler.ts @@ -36,6 +36,7 @@ import {SourceRemovedSchema} from '../../../../shared/analytics/events/source-re import {SpaceSwitchedSchema} from '../../../../shared/analytics/events/space-switched.js' import {SwarmOnboardedSchema} from '../../../../shared/analytics/events/swarm-onboarded.js' import {SwarmQueryCompletedSchema} from '../../../../shared/analytics/events/swarm-query-completed.js' +import {SwarmStoreCompletedSchema} from '../../../../shared/analytics/events/swarm-store-completed.js' import {TaskCompletedSchema} from '../../../../shared/analytics/events/task-completed.js' import {TaskCreatedSchema} from '../../../../shared/analytics/events/task-created.js' import {TaskFailedSchema} from '../../../../shared/analytics/events/task-failed.js' @@ -343,6 +344,13 @@ export class AnalyticsHandler { break } + case AnalyticsEventNames.SWARM_STORE_COMPLETED: { + const props = SwarmStoreCompletedSchema.safeParse(rawProperties ?? {}) + if (!props.success) return + this.analyticsClient.track(AnalyticsEventNames.SWARM_STORE_COMPLETED, props.data) + break + } + case AnalyticsEventNames.TASK_COMPLETED: { const props = TaskCompletedSchema.safeParse(rawProperties ?? {}) if (!props.success) return diff --git a/src/shared/analytics/event-names.ts b/src/shared/analytics/event-names.ts index 4bd12799d..b822295af 100644 --- a/src/shared/analytics/event-names.ts +++ b/src/shared/analytics/event-names.ts @@ -46,6 +46,7 @@ export const AnalyticsEventNames = { SPACE_SWITCHED: 'space_switched', SWARM_ONBOARDED: 'swarm_onboarded', SWARM_QUERY_COMPLETED: 'swarm_query_completed', + SWARM_STORE_COMPLETED: 'swarm_store_completed', TASK_COMPLETED: 'task_completed', TASK_CREATED: 'task_created', TASK_FAILED: 'task_failed', diff --git a/src/shared/analytics/events/index.ts b/src/shared/analytics/events/index.ts index 5daec8fc1..1507cfab6 100644 --- a/src/shared/analytics/events/index.ts +++ b/src/shared/analytics/events/index.ts @@ -33,6 +33,7 @@ import {type SourceRemovedProps, SourceRemovedSchema} from './source-removed.js' import {type SpaceSwitchedProps, SpaceSwitchedSchema} from './space-switched.js' import {type SwarmOnboardedProps, SwarmOnboardedSchema} from './swarm-onboarded.js' import {type SwarmQueryCompletedProps, SwarmQueryCompletedSchema} from './swarm-query-completed.js' +import {type SwarmStoreCompletedProps, SwarmStoreCompletedSchema} from './swarm-store-completed.js' import {type TaskCompletedProps, TaskCompletedSchema} from './task-completed.js' import {type TaskCreatedProps, TaskCreatedSchema} from './task-created.js' import {type TaskFailedProps, TaskFailedSchema} from './task-failed.js' @@ -101,6 +102,7 @@ export const ALL_EVENT_SCHEMAS = { [AnalyticsEventNames.SPACE_SWITCHED]: SpaceSwitchedSchema, [AnalyticsEventNames.SWARM_ONBOARDED]: SwarmOnboardedSchema, [AnalyticsEventNames.SWARM_QUERY_COMPLETED]: SwarmQueryCompletedSchema, + [AnalyticsEventNames.SWARM_STORE_COMPLETED]: SwarmStoreCompletedSchema, [AnalyticsEventNames.TASK_COMPLETED]: TaskCompletedSchema, [AnalyticsEventNames.TASK_CREATED]: TaskCreatedSchema, [AnalyticsEventNames.TASK_FAILED]: TaskFailedSchema, @@ -160,6 +162,7 @@ export type AnyAnalyticsEvent = | {name: typeof AnalyticsEventNames.SPACE_SWITCHED; properties: SpaceSwitchedProps} | {name: typeof AnalyticsEventNames.SWARM_ONBOARDED; properties: SwarmOnboardedProps} | {name: typeof AnalyticsEventNames.SWARM_QUERY_COMPLETED; properties: SwarmQueryCompletedProps} + | {name: typeof AnalyticsEventNames.SWARM_STORE_COMPLETED; properties: SwarmStoreCompletedProps} | {name: typeof AnalyticsEventNames.TASK_COMPLETED; properties: TaskCompletedProps} | {name: typeof AnalyticsEventNames.TASK_CREATED; properties: TaskCreatedProps} | {name: typeof AnalyticsEventNames.TASK_FAILED; properties: TaskFailedProps} diff --git a/src/shared/analytics/events/swarm-store-completed.ts b/src/shared/analytics/events/swarm-store-completed.ts new file mode 100644 index 000000000..5c0ce4b5c --- /dev/null +++ b/src/shared/analytics/events/swarm-store-completed.ts @@ -0,0 +1,53 @@ +/* eslint-disable camelcase */ +import {z} from 'zod' + +/** + * Per-event schema for `swarm_store_completed`. + * + * Swarm counterpart to `curate_operation_applied` / `curate_run_completed` + * (ENG-2770 / M12). Fires once per `brv swarm curate` invocation OR per + * `swarm_store` LLM tool call — covering the write loop that fans curated + * knowledge out to federated memory providers via `swarm-coordinator.store()`. + * + * `operation` is a short producer-taxonomized string naming the write + * shape (`'add'`, `'update'`, `'merge'`, …). Kept as `z.string().min(1).max(64)` + * so future operation kinds plug in without a schema migration. + * + * Counters mirror the M12 curate-aggregation idiom: + * - `stored` — providers that accepted a new write + * - `updated` — providers that updated an existing entry + * - `skipped` — providers that no-op'd (already up to date, declined, etc.) + * + * `tags` / `keywords` / `related` mirror the M12.3 frontmatter-harvest + * precedent for parity with the in-project curate events. + * + * Per the M15.1 outcome taxonomy: `outcome: 'success' | 'failure'`, + * `failure_kind` populated only on failure. `duration_ms` is required. + * + * SCHEMA-ONLY REGISTRATION TODAY: the swarm store surface lives in the + * agent process (`src/agent/infra/swarm/swarm-coordinator.ts` + + * `src/agent/infra/swarm/adapters/memory-wiki-adapter.ts`), not in a + * daemon transport handler. Emit wiring deferred per plan flag #2. + */ +const failureKindSchema = z.string().min(1).max(64).optional() +const countSchema = z.number().int().nonnegative().optional() +const stringArraySchema = z.array(z.string().max(256)).max(50).optional() + +export const SwarmStoreCompletedSchema = z + .object({ + duration_ms: z.number().int().nonnegative(), + failure_kind: failureKindSchema, + keywords: stringArraySchema, + /** Write-operation kind ('add' | 'update' | 'merge' | …). Producer-taxonomized. */ + operation: z.string().min(1).max(64), + outcome: z.enum(['success', 'failure']), + related: stringArraySchema, + /** Per-outcome provider counts; optional because failure can surface before they're computed. */ + skipped: countSchema, + stored: countSchema, + tags: stringArraySchema, + updated: countSchema, + }) + .strict() + +export type SwarmStoreCompletedProps = z.infer diff --git a/test/unit/shared/analytics/event-names.test.ts b/test/unit/shared/analytics/event-names.test.ts index f207d218f..29232dbef 100644 --- a/test/unit/shared/analytics/event-names.test.ts +++ b/test/unit/shared/analytics/event-names.test.ts @@ -3,7 +3,7 @@ import {expect} from 'chai' import {type AnalyticsEventName, AnalyticsEventNames} from '../../../../src/shared/analytics/event-names.js' describe('AnalyticsEventNames', () => { - it('should expose exactly the forty-seven shipped event names', () => { + it('should expose exactly the fifty-one shipped event names', () => { expect(Object.keys(AnalyticsEventNames).sort()).to.deep.equal([ 'ANALYTICS_DISABLED', 'AUTH_LOGIN', @@ -11,6 +11,7 @@ describe('AnalyticsEventNames', () => { 'BRV_INIT', 'CLI_INVOCATION', 'CONNECTOR_INSTALLED', + 'CONTENT_MIGRATED', 'CONTEXT_TREE_FILE_EDITED', 'CURATE_OPERATION_APPLIED', 'CURATE_RUN_COMPLETED', @@ -34,6 +35,9 @@ describe('AnalyticsEventNames', () => { 'SOURCE_ADDED', 'SOURCE_REMOVED', 'SPACE_SWITCHED', + 'SWARM_ONBOARDED', + 'SWARM_QUERY_COMPLETED', + 'SWARM_STORE_COMPLETED', 'TASK_COMPLETED', 'TASK_CREATED', 'TASK_FAILED', diff --git a/test/unit/shared/analytics/privacy-fixture.test.ts b/test/unit/shared/analytics/privacy-fixture.test.ts index a96a52e09..6c6973e5a 100644 --- a/test/unit/shared/analytics/privacy-fixture.test.ts +++ b/test/unit/shared/analytics/privacy-fixture.test.ts @@ -162,6 +162,7 @@ describe('analytics privacy fixture (smoke)', () => { 'space_switched', 'swarm_onboarded', 'swarm_query_completed', + 'swarm_store_completed', 'task_completed', 'task_created', 'task_failed', From 88ad6e6e3bad5956e483ff098201bd5da35bbbbd Mon Sep 17 00:00:00 2001 From: Cuong Date: Thu, 28 May 2026 22:04:52 +0700 Subject: [PATCH 72/87] feat: [ENG-3013/3014/3015] add SwarmHandler daemon transport for swarm analytics emits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the producer-wiring gap for the three swarm analytics events landed in 7265c6899 / 3de49fcd5 / da651873f as schema-only registrations. The swarm-coordinator itself still lives in the agent process — moving it into the daemon is a substantially bigger refactor than appropriate for this ticket bundle — so the handler is a thin emit surface that the swarm CLI commands and the `swarm_*` LLM tools dispatch into when their operations terminate. Three transport events, one per analytics event: - swarm:trackQueryCompleted → SWARM_QUERY_COMPLETED (ENG-3013) - swarm:trackStoreCompleted → SWARM_STORE_COMPLETED (ENG-3014) - swarm:trackOnboarded → SWARM_ONBOARDED (ENG-3015) Each request payload is the analytics props verbatim (no re-shaping at the transport boundary). The handler validates against the per-event Zod schema before forwarding to `analyticsClient.track()`, so a future re-version of the CLI sending an outdated wire shape gets a clean {tracked: false, reason: 'schema-rejection'} rather than a malformed row in raw_events. Graceful-degradation contract — never throws: - analyticsClient unwired → {tracked: false, reason: 'analytics-unavailable'} - schema validation fails → {tracked: false, reason: 'schema-rejection'} - track() throws → {tracked: false, reason: 'analytics-throw'} - happy path → {tracked: true} Why a dedicated transport namespace (instead of using `analytics:track`): - Typed wire surface — CLI gets compile-time type safety from the SwarmTrack*Request shapes. - Stable seam — when the swarm coordinator is moved into the daemon later, the same three event names will carry the operation request payloads; only the handler internals change. CLI / LLM-tool callers don't move. Mirrors `SettingsHandler` / `MigrateHandler` / `ConnectorsHandler` patterns for: dep injection, try/processLog analytics-emit safety, schema validation at the transport boundary, no schema_version bump. Unit test exercises all three events: happy path, schema-rejection, unwired-analytics, and track-throws. 8 tests pass. Full suite 9774 passing. --- src/server/infra/process/feature-handlers.ts | 8 + src/server/infra/transport/handlers/index.ts | 2 + .../infra/transport/handlers/swarm-handler.ts | 121 +++++++++++ src/shared/transport/events/swarm-events.ts | 54 +++++ .../transport/handlers/swarm-handler.test.ts | 201 ++++++++++++++++++ 5 files changed, 386 insertions(+) create mode 100644 src/server/infra/transport/handlers/swarm-handler.ts create mode 100644 src/shared/transport/events/swarm-events.ts create mode 100644 test/unit/infra/transport/handlers/swarm-handler.test.ts diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 42dc59e7e..221aa23f2 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -89,6 +89,7 @@ import { SourceHandler, SpaceHandler, StatusHandler, + SwarmHandler, TeamHandler, VcHandler, WorktreeHandler, @@ -551,6 +552,13 @@ export async function setupFeatureHandlers({ new WorktreeHandler({analyticsClient, resolveProjectPath, transport}).setup() new SourceHandler({analyticsClient, resolveProjectPath, transport}).setup() + // Swarm handler — thin emit surface for federated memory-provider events + // (M16.9 / M16.10 / M16.11). The CLI swarm commands and LLM swarm_* tools + // run their coordinator client-side and dispatch terminal-state events + // through this handler. See `swarm-handler.ts` docblock for the forward + // direction (moving the coordinator into the daemon process). + new SwarmHandler({analyticsClient, transport}).setup() + log('Feature handlers registered') // M12.3: expose the cached-analytics check so daemon-side consumers diff --git a/src/server/infra/transport/handlers/index.ts b/src/server/infra/transport/handlers/index.ts index 386b21b01..c842b5192 100644 --- a/src/server/infra/transport/handlers/index.ts +++ b/src/server/infra/transport/handlers/index.ts @@ -46,6 +46,8 @@ export {SpaceHandler} from './space-handler.js' export type {SpaceHandlerDeps} from './space-handler.js' export {StatusHandler} from './status-handler.js' export type {StatusHandlerDeps} from './status-handler.js' +export {SwarmHandler} from './swarm-handler.js' +export type {SwarmHandlerDeps} from './swarm-handler.js' export {TeamHandler} from './team-handler.js' export type {TeamHandlerDeps} from './team-handler.js' export {VcHandler} from './vc-handler.js' diff --git a/src/server/infra/transport/handlers/swarm-handler.ts b/src/server/infra/transport/handlers/swarm-handler.ts new file mode 100644 index 000000000..d03d01d36 --- /dev/null +++ b/src/server/infra/transport/handlers/swarm-handler.ts @@ -0,0 +1,121 @@ +/** + * Handler for `swarm:*` transport events. + * + * Thin emit surface for the federated-memory-provider operations + * (`brv swarm query`, `brv swarm curate`, `brv swarm onboard`). The + * coordinator itself still lives in the agent process at + * `src/agent/infra/swarm/swarm-coordinator.ts` — the daemon does NOT + * proxy the operations today. The CLI commands run swarm-coordinator + * client-side and dispatch one of these three transport events to + * the daemon when the operation terminates. The handler validates + * the payload against the per-event Zod schema and forwards to + * `analyticsClient.track()`. + * + * Mirrors the try/processLog pattern from `SettingsHandler` and + * `MigrateHandler` so analytics failures never affect command + * outcomes — the CLI gets `tracked: false` plus a reason; nothing + * throws. + * + * Forward direction (out of scope for this commit): if the swarm + * coordinator is moved into the daemon process, the SAME three event + * names extend to carry the operation request payloads. Only the + * handler internals change; CLI / LLM-tool callers stay unchanged. + */ + +import type {SwarmOnboardedProps} from '../../../../shared/analytics/events/swarm-onboarded.js' +import type {SwarmQueryCompletedProps} from '../../../../shared/analytics/events/swarm-query-completed.js' +import type {SwarmStoreCompletedProps} from '../../../../shared/analytics/events/swarm-store-completed.js' +import type { + SwarmTrackOnboardedRequest, + SwarmTrackQueryCompletedRequest, + SwarmTrackResponse, + SwarmTrackStoreCompletedRequest, +} from '../../../../shared/transport/events/swarm-events.js' +import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import {AnalyticsEventNames} from '../../../../shared/analytics/event-names.js' +import {SwarmOnboardedSchema} from '../../../../shared/analytics/events/swarm-onboarded.js' +import {SwarmQueryCompletedSchema} from '../../../../shared/analytics/events/swarm-query-completed.js' +import {SwarmStoreCompletedSchema} from '../../../../shared/analytics/events/swarm-store-completed.js' +import {SwarmEvents} from '../../../../shared/transport/events/swarm-events.js' +import {processLog} from '../../../utils/process-logger.js' + +export interface SwarmHandlerDeps { + /** + * Optional — when undefined the handler still registers the transport + * events but returns `{tracked: false, reason: 'analytics-unavailable'}` + * for every call. Lets the wiring exist before analytics is plumbed + * in test harnesses. + */ + readonly analyticsClient?: IAnalyticsClient + transport: ITransportServer +} + +export class SwarmHandler { + private readonly analyticsClient: IAnalyticsClient | undefined + private readonly transport: ITransportServer + + constructor(deps: SwarmHandlerDeps) { + this.analyticsClient = deps.analyticsClient + this.transport = deps.transport + } + + setup(): void { + this.transport.onRequest( + SwarmEvents.TRACK_QUERY_COMPLETED, + (data) => this.handleTrackQueryCompleted(data), + ) + this.transport.onRequest( + SwarmEvents.TRACK_STORE_COMPLETED, + (data) => this.handleTrackStoreCompleted(data), + ) + this.transport.onRequest( + SwarmEvents.TRACK_ONBOARDED, + (data) => this.handleTrackOnboarded(data), + ) + } + + private emit

( + eventName: typeof AnalyticsEventNames[keyof typeof AnalyticsEventNames], + properties: P, + ): SwarmTrackResponse { + const client = this.analyticsClient + if (!client) return {reason: 'analytics-unavailable', tracked: false} + try { + // The `track` method is typed against AnalyticsEventNames; the + // dispatch above ensures each branch passes the matching props + // type. A direct call here keeps the type-narrowing without an + // `as` cast — the schema parse already validated the shape. + ;(client.track as (event: string, props: P) => void)(eventName, properties) + return {tracked: true} + } catch (error) { + processLog( + `[Swarm] analytics track ${eventName} failed: ${error instanceof Error ? error.message : String(error)}`, + ) + return {reason: 'analytics-throw', tracked: false} + } + } + + private handleTrackOnboarded(data: SwarmTrackOnboardedRequest): SwarmTrackResponse { + const parsed = SwarmOnboardedSchema.safeParse(data) + if (!parsed.success) return {reason: 'schema-rejection', tracked: false} + return this.emit(AnalyticsEventNames.SWARM_ONBOARDED, parsed.data) + } + + private handleTrackQueryCompleted(data: SwarmTrackQueryCompletedRequest): SwarmTrackResponse { + // Validate at the transport boundary — the CLI is an external trust + // boundary even though we ship it ourselves. A future re-version of + // the CLI sending an outdated wire shape gets a clean rejection here + // rather than a malformed row in raw_events. + const parsed = SwarmQueryCompletedSchema.safeParse(data) + if (!parsed.success) return {reason: 'schema-rejection', tracked: false} + return this.emit(AnalyticsEventNames.SWARM_QUERY_COMPLETED, parsed.data) + } + + private handleTrackStoreCompleted(data: SwarmTrackStoreCompletedRequest): SwarmTrackResponse { + const parsed = SwarmStoreCompletedSchema.safeParse(data) + if (!parsed.success) return {reason: 'schema-rejection', tracked: false} + return this.emit(AnalyticsEventNames.SWARM_STORE_COMPLETED, parsed.data) + } +} diff --git a/src/shared/transport/events/swarm-events.ts b/src/shared/transport/events/swarm-events.ts new file mode 100644 index 000000000..18e5bb0a0 --- /dev/null +++ b/src/shared/transport/events/swarm-events.ts @@ -0,0 +1,54 @@ +/** + * Events for `brv swarm` — federated memory-provider operations. + * + * Three emit-only events the swarm CLI commands and the LLM `swarm_*` + * tools dispatch to the daemon AFTER doing their client-side work + * (`swarm-coordinator` lives in the agent process, not the daemon). + * The handler validates the payload against the matching per-event + * Zod schema in `src/shared/analytics/events/swarm-*.ts` and forwards + * to `analyticsClient.track()`. + * + * Why a dedicated transport namespace vs `analytics:track`: + * - Typed wire surface — request shapes mirror the analytics + * schemas so the CLI gets compile-time validation. + * - Stable seam — when (if) the swarm coordinator is moved into the + * daemon, this same transport channel will carry the operation + * request itself. The emit event names stay the same; only the + * handler internals change. + */ + +import type {SwarmOnboardedProps} from '../../analytics/events/swarm-onboarded.js' +import type {SwarmQueryCompletedProps} from '../../analytics/events/swarm-query-completed.js' +import type {SwarmStoreCompletedProps} from '../../analytics/events/swarm-store-completed.js' + +export const SwarmEvents = { + TRACK_ONBOARDED: 'swarm:trackOnboarded', + TRACK_QUERY_COMPLETED: 'swarm:trackQueryCompleted', + TRACK_STORE_COMPLETED: 'swarm:trackStoreCompleted', +} as const + +/** + * Wire shape mirrors `SwarmQueryCompletedProps` exactly. Re-exported here + * so CLI callers can import a transport-flavored type even though the + * shape is structurally identical to the analytics props. + */ +export type SwarmTrackQueryCompletedRequest = SwarmQueryCompletedProps + +export type SwarmTrackStoreCompletedRequest = SwarmStoreCompletedProps + +export type SwarmTrackOnboardedRequest = SwarmOnboardedProps + +/** + * The handler returns a small ack so the CLI can confirm the emit was + * accepted (or learn it was schema-rejected). Analytics-handler.ts pattern. + */ +export interface SwarmTrackResponse { + /** Set when the daemon dropped the emit; populated for schema-rejection or analytics-disabled. */ + reason?: string + /** + * True when the daemon accepted the payload and forwarded to the + * analytics client. False when validation failed or the analytics + * client was unavailable. + */ + tracked: boolean +} diff --git a/test/unit/infra/transport/handlers/swarm-handler.test.ts b/test/unit/infra/transport/handlers/swarm-handler.test.ts new file mode 100644 index 000000000..bb08664f2 --- /dev/null +++ b/test/unit/infra/transport/handlers/swarm-handler.test.ts @@ -0,0 +1,201 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' +import type {AnalyticsEventName} from '../../../../../src/shared/analytics/event-names.js' +import type {PropsArg} from '../../../../../src/shared/analytics/events/index.js' +import type {SwarmTrackResponse} from '../../../../../src/shared/transport/events/swarm-events.js' + +import {AnalyticsBatch} from '../../../../../src/server/core/domain/analytics/batch.js' +import {SwarmHandler} from '../../../../../src/server/infra/transport/handlers/swarm-handler.js' +import {AnalyticsEventNames} from '../../../../../src/shared/analytics/event-names.js' +import {SwarmEvents} from '../../../../../src/shared/transport/events/swarm-events.js' +import {createMockTransportServer, type MockTransportServer} from '../../../../helpers/mock-factories.js' + +type TrackCall = {event: AnalyticsEventName; properties: unknown} + +type MockAnalyticsClient = IAnalyticsClient & { + readonly trackCalls: readonly TrackCall[] + trackThrows?: Error +} + +/** + * Hand-rolled mock preserving `track(event, ...rest: PropsArg)` generics. + * Mirrors the pattern from `migrate-handler-analytics.test.ts`. + */ +function makeMockAnalyticsClient(): MockAnalyticsClient { + const trackCalls: TrackCall[] = [] + const mock: MockAnalyticsClient = { + abort(): void { + /* not exercised */ + }, + flush: () => Promise.resolve(AnalyticsBatch.create([])), + getRuntimeState: () => Promise.resolve({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 0}), + onAuthTransition: () => Promise.resolve(), + track(event: E, ...rest: PropsArg): void { + if (mock.trackThrows) throw mock.trackThrows + const [properties] = rest + trackCalls.push({event, properties}) + }, + trackCalls, + } + return mock +} + +describe('SwarmHandler', () => { + let transport: MockTransportServer + let analyticsClient: MockAnalyticsClient + + beforeEach(() => { + transport = createMockTransportServer() + analyticsClient = makeMockAnalyticsClient() + new SwarmHandler({analyticsClient, transport}).setup() + }) + + describe('swarm:trackQueryCompleted', () => { + it('forwards a valid SwarmQueryCompletedProps payload to analyticsClient.track', async () => { + const handler = transport._handlers.get(SwarmEvents.TRACK_QUERY_COMPLETED) + if (handler === undefined) throw new Error('handler not registered') + + const response = (await handler( + { + duration_ms: 142, + outcome: 'success', + result_count: 7, + swarm_scope: 'mixed', + tags: ['k1', 'k2'], + }, + 'client-1', + )) as SwarmTrackResponse + + expect(response).to.deep.equal({tracked: true}) + expect(analyticsClient.trackCalls).to.have.length(1) + const [call] = analyticsClient.trackCalls + expect(call.event).to.equal(AnalyticsEventNames.SWARM_QUERY_COMPLETED) + const props = call.properties as Record + expect(props.duration_ms).to.equal(142) + expect(props.outcome).to.equal('success') + expect(props.result_count).to.equal(7) + expect(props.swarm_scope).to.equal('mixed') + }) + + it('returns {tracked: false, reason: schema-rejection} for a payload missing required outcome', async () => { + const handler = transport._handlers.get(SwarmEvents.TRACK_QUERY_COMPLETED) + if (handler === undefined) throw new Error('handler not registered') + + const response = (await handler({duration_ms: 5}, 'client-1')) as SwarmTrackResponse + + expect(response.tracked).to.equal(false) + expect(response.reason).to.equal('schema-rejection') + expect(analyticsClient.trackCalls).to.have.length(0) + }) + + it('emits failure_kind when the producer indicated a failure', async () => { + const handler = transport._handlers.get(SwarmEvents.TRACK_QUERY_COMPLETED) + if (handler === undefined) throw new Error('handler not registered') + + await handler( + { + duration_ms: 88, + failure_kind: 'provider_timeout', + outcome: 'failure', + }, + 'client-1', + ) + + const props = analyticsClient.trackCalls[0].properties as Record + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('provider_timeout') + }) + }) + + describe('swarm:trackStoreCompleted', () => { + it('forwards a valid SwarmStoreCompletedProps payload', async () => { + const handler = transport._handlers.get(SwarmEvents.TRACK_STORE_COMPLETED) + if (handler === undefined) throw new Error('handler not registered') + + const response = (await handler( + { + duration_ms: 234, + operation: 'update', + outcome: 'success', + skipped: 1, + stored: 2, + updated: 1, + }, + 'client-1', + )) as SwarmTrackResponse + + expect(response).to.deep.equal({tracked: true}) + const [call] = analyticsClient.trackCalls + expect(call.event).to.equal(AnalyticsEventNames.SWARM_STORE_COMPLETED) + const props = call.properties as Record + expect(props.operation).to.equal('update') + expect(props.stored).to.equal(2) + }) + + it('rejects when `operation` field is missing (required by schema)', async () => { + const handler = transport._handlers.get(SwarmEvents.TRACK_STORE_COMPLETED) + if (handler === undefined) throw new Error('handler not registered') + + const response = (await handler({duration_ms: 5, outcome: 'success'}, 'client-1')) as SwarmTrackResponse + expect(response.tracked).to.equal(false) + expect(response.reason).to.equal('schema-rejection') + }) + }) + + describe('swarm:trackOnboarded', () => { + it('forwards a valid SwarmOnboardedProps payload', async () => { + const handler = transport._handlers.get(SwarmEvents.TRACK_ONBOARDED) + if (handler === undefined) throw new Error('handler not registered') + + const response = (await handler( + { + duration_ms: 1024, + member_count: 3, + outcome: 'success', + swarm_kind: 'new', + }, + 'client-1', + )) as SwarmTrackResponse + + expect(response).to.deep.equal({tracked: true}) + const [call] = analyticsClient.trackCalls + expect(call.event).to.equal(AnalyticsEventNames.SWARM_ONBOARDED) + const props = call.properties as Record + expect(props.swarm_kind).to.equal('new') + expect(props.member_count).to.equal(3) + }) + }) + + describe('graceful degradation', () => { + it('returns {tracked: false, reason: analytics-unavailable} when no analyticsClient is wired', async () => { + const standaloneTransport = createMockTransportServer() + new SwarmHandler({transport: standaloneTransport}).setup() + const handler = standaloneTransport._handlers.get(SwarmEvents.TRACK_QUERY_COMPLETED) + if (handler === undefined) throw new Error('handler not registered') + + const response = (await handler( + {duration_ms: 1, outcome: 'success'}, + 'client-1', + )) as SwarmTrackResponse + + expect(response.tracked).to.equal(false) + expect(response.reason).to.equal('analytics-unavailable') + }) + + it('returns {tracked: false, reason: analytics-throw} when track() throws — never propagates to the caller', async () => { + const handler = transport._handlers.get(SwarmEvents.TRACK_QUERY_COMPLETED) + if (handler === undefined) throw new Error('handler not registered') + analyticsClient.trackThrows = new Error('queue full') + + const response = (await handler( + {duration_ms: 5, outcome: 'success'}, + 'client-1', + )) as SwarmTrackResponse + + expect(response.tracked).to.equal(false) + expect(response.reason).to.equal('analytics-throw') + }) + }) +}) From 39b6ee233ce7f073e8eacd5cc2cad29ca66d5124 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Thu, 28 May 2026 22:55:29 +0700 Subject: [PATCH 73/87] feat: [ENG-3006] no-fallback resolution for BRV_ANALYTICS_BASE_URL Disable outbound analytics shipping when BRV_ANALYTICS_BASE_URL is unset, empty, or malformed. Production builds inject the env at build time; a code-side fallback to a shared upstream endpoint would silently route misconfigured builds' events into the wrong backend. Resolver returns undefined for unset/empty/whitespace/malformed input; only malformed emits a single processLog warning. wireAnalyticsHttpSender short-circuits to a new NoopAnalyticsSender that marks every record as succeeded so the JSONL queue drains on each flush. Local tracking via AnalyticsClient.track() and the user-controlled enabled flag are unchanged. Status surface keeps the existing "(not configured)" placeholder; no new wire field. --- src/server/config/environment.ts | 68 ++++++++-- .../infra/analytics/noop-analytics-sender.ts | 40 ++++++ .../process/wire-analytics-http-sender.ts | 17 ++- .../wire-analytics-http-sender.test.ts | 19 +++ test/unit/config/environment.test.ts | 128 ++++++++++++++++++ .../analytics/noop-analytics-sender.test.ts | 49 +++++++ 6 files changed, 306 insertions(+), 15 deletions(-) create mode 100644 src/server/infra/analytics/noop-analytics-sender.ts create mode 100644 test/unit/server/infra/analytics/noop-analytics-sender.test.ts diff --git a/src/server/config/environment.ts b/src/server/config/environment.ts index 26d9bbb79..96b2aba83 100644 --- a/src/server/config/environment.ts +++ b/src/server/config/environment.ts @@ -1,4 +1,5 @@ import {API_V1_PATH} from '../constants.js' +import {processLog} from '../utils/process-logger.js' /** * Environment types supported by the CLI. @@ -26,7 +27,16 @@ export const ENVIRONMENT: Environment = isEnvironment(envValue) ? envValue : 'de * that does not follow the general "API version at point of use" pattern. */ type EnvironmentConfig = { - analyticsBaseUrl: string + /** + * Resolved `BRV_ANALYTICS_BASE_URL`. `undefined` means "no outbound + * shipping" — the env var is absent, empty, whitespace-only, or + * malformed. There is NO code-side fallback to a shared default; see + * `resolveAnalyticsBaseUrl` below for the rationale. Consumers + * downstream MUST handle the `undefined` case + * (`wireAnalyticsHttpSender` swaps in `NoopAnalyticsSender`; the + * status snapshot coalesces to `''` and surfaces `(not configured)`). + */ + analyticsBaseUrl: string | undefined authorizationUrl: string billingBaseUrl: string clientId: string @@ -44,15 +54,10 @@ type EnvironmentConfig = { /** * Non-infrastructure config that stays in source (same across envs or not sensitive). * - * `analyticsBaseUrl` defaults to the dev-beta telemetry endpoint so the - * daemon ships events out of the box; setting `BRV_ANALYTICS_BASE_URL` - * overrides it (M4.2). Unlike the IAM / Cogit base URLs this is NOT - * `readRequiredEnv` because analytics is opt-in: a missing env var must - * not block daemon startup, and the default keeps M4.7's smoke test - * pointing at the right backend without per-developer setup. + * `BRV_ANALYTICS_BASE_URL` is intentionally NOT in this table; see + * `resolveAnalyticsBaseUrl` for the env-only resolution rule. */ const DEFAULTS = { - analyticsBaseUrl: 'https://telemetry-dev.byterover.dev', clientId: 'byterover-cli-client', hubRegistryUrl: 'https://hub.byterover.dev/r/registry.json', scopes: { @@ -63,6 +68,46 @@ const DEFAULTS = { const normalizeUrl = (url: string): string => url.replace(/\/+$/, '') +/** + * Resolve `BRV_ANALYTICS_BASE_URL` with no code-side fallback. + * + * - unset (env var missing) -> `undefined` (silent) + * - empty string or whitespace only -> `undefined` (silent) + * - `URL.canParse(value)` rejects -> `undefined` + one warning + * - valid URL -> normalize trailing slash and return + * + * Production builds inject `BRV_ANALYTICS_BASE_URL` at build time. A + * missing env means the build is misconfigured (a fork stripped the + * vars, a CI image dropped them). A code-side fallback to a shared + * upstream endpoint would silently route that build's events to the + * wrong backend — privacy leak and telemetry pollution. Unset and empty + * are silent because they are legitimate states for forks, CI, and + * air-gapped installs; only malformed input (a user-error signal) emits + * a warning. + * + * The second parameter is a test-only seam so unit tests can assert the + * warning surface without touching the `processLog` session-file cache; + * production callers MUST NOT override it. + * + * @internal + */ +export const resolveAnalyticsBaseUrl = ( + raw: string | undefined, + log: (message: string) => void = processLog, +): string | undefined => { + const trimmed = raw?.trim() + if (trimmed === undefined || trimmed === '') return undefined + + if (!URL.canParse(trimmed)) { + log( + `[Environment] BRV_ANALYTICS_BASE_URL is malformed (${JSON.stringify(trimmed)}); remote analytics shipping disabled. Local JSONL tracking continues.`, + ) + return undefined + } + + return normalizeUrl(trimmed) +} + const assertRootDomain = (name: string, url: string): void => { if (new URL(url).pathname !== '/') { throw new Error( @@ -97,12 +142,7 @@ export const getCurrentConfig = (): EnvironmentConfig => { const oidcBase = `${iamBaseUrl}${API_V1_PATH}/oidc` - // M4.2: BRV_ANALYTICS_BASE_URL overrides the default dev-beta endpoint - // for analytics POSTs. Trailing slashes normalised so axios's baseURL - // composes cleanly with the `/v1/events` request path. - const analyticsBaseUrl = normalizeUrl( - process.env.BRV_ANALYTICS_BASE_URL?.trim() ?? DEFAULTS.analyticsBaseUrl, - ) + const analyticsBaseUrl = resolveAnalyticsBaseUrl(process.env.BRV_ANALYTICS_BASE_URL) return { analyticsBaseUrl, diff --git a/src/server/infra/analytics/noop-analytics-sender.ts b/src/server/infra/analytics/noop-analytics-sender.ts new file mode 100644 index 000000000..9eaca9af6 --- /dev/null +++ b/src/server/infra/analytics/noop-analytics-sender.ts @@ -0,0 +1,40 @@ +import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' +import type { + AnalyticsSenderOptions, + IAnalyticsSender, + SendResult, +} from '../../core/interfaces/analytics/i-analytics-sender.js' + +/** + * Graceful-degradation sender. `wireAnalyticsHttpSender` swaps this in + * when `BRV_ANALYTICS_BASE_URL` resolves to `undefined` (absent, empty + * after trim, or malformed). No HTTP client is constructed; the axios + * layer is never touched, so a misconfigured build never burns retries + * or leaks events into the upstream backend. + * + * Every input record is reported as `succeeded` so the flush wiring + * transitions the matching JSONL rows to `status='sent'` and the + * pending count stays at 0. This drains the queue and optimizes for the + * "never ship" case (open-source forks, CI, air-gapped installs). + * + * Contrast with the test-seam `NoOpAnalyticsSender` at + * `no-op-analytics-sender.ts`: + * - `NoOpAnalyticsSender` - both arrays empty, JSONL stays pending. + * Used by tests to assert the + * "leave-JSONL-untouched" invariant; NOT + * wired in production. + * - `NoopAnalyticsSender` - this class. Marks all-succeeded, JSONL + * drains. Wired in production whenever + * the env var is absent or unusable. + */ +export class NoopAnalyticsSender implements IAnalyticsSender { + public async send( + records: readonly StoredAnalyticsRecord[], + _options?: AnalyticsSenderOptions, + ): Promise { + // `_options.signal` intentionally ignored: there is no transport to + // cancel. Accepting the parameter keeps structural assignability to + // `IAnalyticsSender` clean. + return {failed: [], succeeded: records.map((record) => record.id)} + } +} diff --git a/src/server/infra/process/wire-analytics-http-sender.ts b/src/server/infra/process/wire-analytics-http-sender.ts index 647cac220..1191faf97 100644 --- a/src/server/infra/process/wire-analytics-http-sender.ts +++ b/src/server/infra/process/wire-analytics-http-sender.ts @@ -4,9 +4,16 @@ import type {IGlobalConfigStore} from '../../core/interfaces/storage/i-global-co import {AxiosAnalyticsHttpClient} from '../analytics/axios-analytics-http-client.js' import {HttpAnalyticsSender} from '../analytics/http-analytics-sender.js' +import {NoopAnalyticsSender} from '../analytics/noop-analytics-sender.js' export type AnalyticsHttpSenderWiring = { - analyticsBaseUrl: string + /** + * Resolved `BRV_ANALYTICS_BASE_URL`. `undefined` signals "no working + * remote endpoint" (env unset, empty, or malformed — see + * `resolveAnalyticsBaseUrl`). The factory then returns a + * `NoopAnalyticsSender` and the axios client is never constructed. + */ + analyticsBaseUrl: string | undefined authStateReader: IAuthStateReader globalConfigStore: IGlobalConfigStore /** CLI semver string (e.g. `3.12.0`). Wrapped into the user-agent header. */ @@ -30,10 +37,18 @@ export type AnalyticsHttpSenderWiring = { * a future swap (e.g. swapping axios for undici, or wrapping the sender * for M4.5 backoff) lands at one obvious seam. * + * When `wiring.analyticsBaseUrl === undefined` (env unset, empty, or + * malformed) the factory short-circuits to `NoopAnalyticsSender` so the + * axios client is never constructed and no outbound HTTP fires. Local + * JSONL tracking via `AnalyticsClient.track()` keeps working unchanged; + * the noop drains the pending queue on each flush. + * * The returned value is the `IAnalyticsSender` consumed by * `AnalyticsClient.flush()`. */ export function wireAnalyticsHttpSender(wiring: AnalyticsHttpSenderWiring): IAnalyticsSender { + if (wiring.analyticsBaseUrl === undefined) return new NoopAnalyticsSender() + const httpClient = new AxiosAnalyticsHttpClient({baseUrl: wiring.analyticsBaseUrl}) return new HttpAnalyticsSender({ authStateReader: wiring.authStateReader, diff --git a/test/integration/server/infra/process/wire-analytics-http-sender.test.ts b/test/integration/server/infra/process/wire-analytics-http-sender.test.ts index f0a69910b..d8ef5be8c 100644 --- a/test/integration/server/infra/process/wire-analytics-http-sender.test.ts +++ b/test/integration/server/infra/process/wire-analytics-http-sender.test.ts @@ -10,6 +10,7 @@ import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' +import {NoopAnalyticsSender} from '../../../../../src/server/infra/analytics/noop-analytics-sender.js' import {wireAnalyticsHttpSender} from '../../../../../src/server/infra/process/wire-analytics-http-sender.js' /** @@ -175,6 +176,24 @@ describe('M4.2 wireAnalyticsHttpSender (integration)', () => { expect(result).to.deep.equal({failed: ['r1'], succeeded: []}) }) + it('returns a NoopAnalyticsSender when analyticsBaseUrl is undefined (no HTTP traffic, all ids drained)', async () => { + // Strict: no nock scope registered. With `disableNetConnect`, any + // axios construction that actually issues a request would throw. + // We additionally assert the sender's class identity to lock-in + // the wiring swap at the composition root. + const sender = wireAnalyticsHttpSender({ + analyticsBaseUrl: undefined, + authStateReader: makeAuthReader(), + globalConfigStore: makeConfigStore(), + version: '3.12.0', + }) + + expect(sender).to.be.instanceOf(NoopAnalyticsSender) + + const result = await sender.send([makeRecord({id: 'r1'}), makeRecord({id: 'r2'})]) + expect(result).to.deep.equal({failed: [], succeeded: ['r1', 'r2']}) + }) + it('normalises a trailing slash on the base URL (axios baseURL hygiene)', async () => { // Without normalisation, axios's baseURL='http://x.com/' + path='/v1/events' // emits a POST to '//v1/events' on some axios versions. The helper diff --git a/test/unit/config/environment.test.ts b/test/unit/config/environment.test.ts index 1766d7ff6..12db5ba70 100644 --- a/test/unit/config/environment.test.ts +++ b/test/unit/config/environment.test.ts @@ -197,4 +197,132 @@ describe('Environment Configuration', () => { expect(() => getCurrentConfig()).to.throw('Missing required environment variable: BRV_IAM_BASE_URL') }) }) + + describe('BRV_ANALYTICS_BASE_URL resolution (no-fallback)', () => { + let savedAnalyticsBaseUrl: string | undefined + + before(() => { + savedAnalyticsBaseUrl = process.env.BRV_ANALYTICS_BASE_URL + }) + + after(() => { + if (savedAnalyticsBaseUrl === undefined) { + delete process.env.BRV_ANALYTICS_BASE_URL + } else { + process.env.BRV_ANALYTICS_BASE_URL = savedAnalyticsBaseUrl + } + }) + + afterEach(() => { + delete process.env.BRV_ANALYTICS_BASE_URL + }) + + it('resolves to undefined when BRV_ANALYTICS_BASE_URL is unset (no code-side fallback)', async () => { + delete process.env.BRV_ANALYTICS_BASE_URL + + const {getCurrentConfig} = await import(`../../../src/server/config/environment.js?t=${Date.now()}`) + const config = getCurrentConfig() + + expect(config.analyticsBaseUrl).to.equal(undefined) + }) + + it('resolves to undefined when BRV_ANALYTICS_BASE_URL is an empty string', async () => { + process.env.BRV_ANALYTICS_BASE_URL = '' + + const {getCurrentConfig} = await import(`../../../src/server/config/environment.js?t=${Date.now()}`) + const config = getCurrentConfig() + + expect(config.analyticsBaseUrl).to.equal(undefined) + }) + + it('resolves to undefined when BRV_ANALYTICS_BASE_URL is whitespace only', async () => { + process.env.BRV_ANALYTICS_BASE_URL = ' ' + + const {getCurrentConfig} = await import(`../../../src/server/config/environment.js?t=${Date.now()}`) + const config = getCurrentConfig() + + expect(config.analyticsBaseUrl).to.equal(undefined) + }) + + it('resolves to undefined and does not throw when BRV_ANALYTICS_BASE_URL is malformed', async () => { + process.env.BRV_ANALYTICS_BASE_URL = 'not-a-url' + + const {getCurrentConfig} = await import(`../../../src/server/config/environment.js?t=${Date.now()}`) + + expect(() => getCurrentConfig()).to.not.throw() + const config = getCurrentConfig() + expect(config.analyticsBaseUrl).to.equal(undefined) + }) + + it('preserves a valid URL and strips trailing slash', async () => { + process.env.BRV_ANALYTICS_BASE_URL = 'https://telemetry-test.example/' + + const {getCurrentConfig} = await import(`../../../src/server/config/environment.js?t=${Date.now()}`) + const config = getCurrentConfig() + + expect(config.analyticsBaseUrl).to.equal('https://telemetry-test.example') + }) + + describe('resolveAnalyticsBaseUrl (pure helper)', () => { + it('returns undefined and does not invoke the logger when input is undefined', async () => { + const {resolveAnalyticsBaseUrl} = await import( + `../../../src/server/config/environment.js?t=${Date.now()}` + ) + const messages: string[] = [] + const log = (message: string): void => { + messages.push(message) + } + + const result = resolveAnalyticsBaseUrl(undefined, log) + + expect(result).to.equal(undefined) + expect(messages).to.deep.equal([]) + }) + + it('returns undefined and does not invoke the logger when input is empty / whitespace', async () => { + const {resolveAnalyticsBaseUrl} = await import( + `../../../src/server/config/environment.js?t=${Date.now()}` + ) + const messages: string[] = [] + const log = (message: string): void => { + messages.push(message) + } + + expect(resolveAnalyticsBaseUrl('', log)).to.equal(undefined) + expect(resolveAnalyticsBaseUrl(' ', log)).to.equal(undefined) + expect(messages).to.deep.equal([]) + }) + + it('returns undefined AND emits one warning naming the env var and the bad value on malformed input', async () => { + const {resolveAnalyticsBaseUrl} = await import( + `../../../src/server/config/environment.js?t=${Date.now()}` + ) + const messages: string[] = [] + const log = (message: string): void => { + messages.push(message) + } + + const result = resolveAnalyticsBaseUrl('not-a-url', log) + + expect(result).to.equal(undefined) + expect(messages).to.have.lengthOf(1) + expect(messages[0]).to.include('BRV_ANALYTICS_BASE_URL') + expect(messages[0]).to.include('not-a-url') + }) + + it('returns the normalized URL (trailing slash stripped) on valid input without logging', async () => { + const {resolveAnalyticsBaseUrl} = await import( + `../../../src/server/config/environment.js?t=${Date.now()}` + ) + const messages: string[] = [] + const log = (message: string): void => { + messages.push(message) + } + + expect(resolveAnalyticsBaseUrl('https://valid.example/', log)).to.equal('https://valid.example') + expect(resolveAnalyticsBaseUrl('https://valid.example', log)).to.equal('https://valid.example') + expect(messages).to.deep.equal([]) + }) + }) + }) }) diff --git a/test/unit/server/infra/analytics/noop-analytics-sender.test.ts b/test/unit/server/infra/analytics/noop-analytics-sender.test.ts new file mode 100644 index 000000000..6a0468040 --- /dev/null +++ b/test/unit/server/infra/analytics/noop-analytics-sender.test.ts @@ -0,0 +1,49 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' + +import {NoopAnalyticsSender} from '../../../../../src/server/infra/analytics/noop-analytics-sender.js' + +function makeRecord(id: string): StoredAnalyticsRecord { + return { + attempts: 0, + id, + identity: {device_id: '550e8400-e29b-41d4-a716-446655440000', user_id: 'user-123'}, + name: 'daemon_start', + properties: {cli_version: '3.12.0'}, + status: 'pending', + timestamp: 1_700_000_000_000, + } satisfies StoredAnalyticsRecord +} + +describe('NoopAnalyticsSender (graceful-degradation sender)', () => { + it('marks every input id as succeeded so JSONL drains', async () => { + const sender = new NoopAnalyticsSender() + const result = await sender.send([makeRecord('a'), makeRecord('b'), makeRecord('c')]) + expect(result).to.deep.equal({failed: [], succeeded: ['a', 'b', 'c']}) + }) + + it('returns empty arrays for an empty batch', async () => { + const sender = new NoopAnalyticsSender() + const result = await sender.send([]) + expect(result).to.deep.equal({failed: [], succeeded: []}) + }) + + it('ignores the AbortSignal option and never throws', async () => { + const sender = new NoopAnalyticsSender() + const controller = new AbortController() + controller.abort() + const result = await sender.send([makeRecord('a')], {signal: controller.signal}) + expect(result.succeeded).to.deep.equal(['a']) + expect(result.failed).to.deep.equal([]) + }) + + it('does not invoke any collaborator (no deps to inject means none can be touched)', async () => { + // Structural assertion: NoopAnalyticsSender has a zero-arg constructor. + // If a future refactor introduces deps, this no-arg construction line + // would fail to type-check, surfacing the regression at compile time. + const sender = new NoopAnalyticsSender() + expect(sender).to.be.an.instanceOf(NoopAnalyticsSender) + }) +}) From ecff891be0a43ad24e7f8f3117a1368a86e74e82 Mon Sep 17 00:00:00 2001 From: Cuong Date: Thu, 28 May 2026 22:58:08 +0700 Subject: [PATCH 74/87] fix: [ENG-3013/3014/3015] address PR #730 review on SwarmHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings #1, #2, #3, and #5 from the Claude bot review. #1 — drop the `as` widening cast in `SwarmHandler.emit()`. Replaced the generic emit

() helper with a `runEmit(label, thunk)` wrapper that owns the shared try/catch and the analytics-unavailable short circuit. Each per-event handler now calls `client.track(NAME, props)` inside the thunk where TS narrows the literal name to the matching `PropsArg` — no cast, no widening. #2 — type SwarmTrackResponse.reason as a closed enum `SwarmTrackReason` (`'analytics-throw' | 'analytics-unavailable' | 'schema-rejection'`). A producer typo or stray ad-hoc value now fails the compile rather than landing on a downstream switch. #3 — drop the three `swarm_*` dispatch branches (and their unused schema imports) from `AnalyticsHandler`. `SwarmHandler` is now the single canonical surface for the swarm trio; the two-path footgun is gone. `content_migrated` still ships via `analytics:track` because no dedicated handler exists for it — the reviewer flagged that as cosmetic until a migrate-into-daemon refactor lands. #5 — loop the graceful-degradation specs over all three swarm events via `for (const eventName of Object.values(SwarmEvents))`. A future divergence between handlers fails loudly. 8 tests → 12 tests. Finding #4 (privacy nit on `keywords` / `tags`) is deferred to the producer follow-up ticket as the reviewer suggested — schema-side this PR is already clean and the field NAMES are privacy-safe. Full suite: 9778 passing. --- .../transport/handlers/analytics-handler.ts | 24 -------- .../infra/transport/handlers/swarm-handler.ts | 59 +++++++++--------- src/shared/transport/events/swarm-events.ts | 8 ++- .../transport/handlers/swarm-handler.test.ts | 60 ++++++++++--------- 4 files changed, 68 insertions(+), 83 deletions(-) diff --git a/src/server/infra/transport/handlers/analytics-handler.ts b/src/server/infra/transport/handlers/analytics-handler.ts index 8af9c2fd3..ede49b5bc 100644 --- a/src/server/infra/transport/handlers/analytics-handler.ts +++ b/src/server/infra/transport/handlers/analytics-handler.ts @@ -34,9 +34,6 @@ import {SettingResetSchema} from '../../../../shared/analytics/events/setting-re import {SourceAddedSchema} from '../../../../shared/analytics/events/source-added.js' import {SourceRemovedSchema} from '../../../../shared/analytics/events/source-removed.js' import {SpaceSwitchedSchema} from '../../../../shared/analytics/events/space-switched.js' -import {SwarmOnboardedSchema} from '../../../../shared/analytics/events/swarm-onboarded.js' -import {SwarmQueryCompletedSchema} from '../../../../shared/analytics/events/swarm-query-completed.js' -import {SwarmStoreCompletedSchema} from '../../../../shared/analytics/events/swarm-store-completed.js' import {TaskCompletedSchema} from '../../../../shared/analytics/events/task-completed.js' import {TaskCreatedSchema} from '../../../../shared/analytics/events/task-created.js' import {TaskFailedSchema} from '../../../../shared/analytics/events/task-failed.js' @@ -330,27 +327,6 @@ export class AnalyticsHandler { break } - case AnalyticsEventNames.SWARM_ONBOARDED: { - const props = SwarmOnboardedSchema.safeParse(rawProperties ?? {}) - if (!props.success) return - this.analyticsClient.track(AnalyticsEventNames.SWARM_ONBOARDED, props.data) - break - } - - case AnalyticsEventNames.SWARM_QUERY_COMPLETED: { - const props = SwarmQueryCompletedSchema.safeParse(rawProperties ?? {}) - if (!props.success) return - this.analyticsClient.track(AnalyticsEventNames.SWARM_QUERY_COMPLETED, props.data) - break - } - - case AnalyticsEventNames.SWARM_STORE_COMPLETED: { - const props = SwarmStoreCompletedSchema.safeParse(rawProperties ?? {}) - if (!props.success) return - this.analyticsClient.track(AnalyticsEventNames.SWARM_STORE_COMPLETED, props.data) - break - } - case AnalyticsEventNames.TASK_COMPLETED: { const props = TaskCompletedSchema.safeParse(rawProperties ?? {}) if (!props.success) return diff --git a/src/server/infra/transport/handlers/swarm-handler.ts b/src/server/infra/transport/handlers/swarm-handler.ts index d03d01d36..044df542a 100644 --- a/src/server/infra/transport/handlers/swarm-handler.ts +++ b/src/server/infra/transport/handlers/swarm-handler.ts @@ -22,9 +22,6 @@ * handler internals change; CLI / LLM-tool callers stay unchanged. */ -import type {SwarmOnboardedProps} from '../../../../shared/analytics/events/swarm-onboarded.js' -import type {SwarmQueryCompletedProps} from '../../../../shared/analytics/events/swarm-query-completed.js' -import type {SwarmStoreCompletedProps} from '../../../../shared/analytics/events/swarm-store-completed.js' import type { SwarmTrackOnboardedRequest, SwarmTrackQueryCompletedRequest, @@ -76,46 +73,48 @@ export class SwarmHandler { ) } - private emit

( - eventName: typeof AnalyticsEventNames[keyof typeof AnalyticsEventNames], - properties: P, - ): SwarmTrackResponse { - const client = this.analyticsClient - if (!client) return {reason: 'analytics-unavailable', tracked: false} - try { - // The `track` method is typed against AnalyticsEventNames; the - // dispatch above ensures each branch passes the matching props - // type. A direct call here keeps the type-narrowing without an - // `as` cast — the schema parse already validated the shape. - ;(client.track as (event: string, props: P) => void)(eventName, properties) - return {tracked: true} - } catch (error) { - processLog( - `[Swarm] analytics track ${eventName} failed: ${error instanceof Error ? error.message : String(error)}`, - ) - return {reason: 'analytics-throw', tracked: false} - } - } - private handleTrackOnboarded(data: SwarmTrackOnboardedRequest): SwarmTrackResponse { const parsed = SwarmOnboardedSchema.safeParse(data) if (!parsed.success) return {reason: 'schema-rejection', tracked: false} - return this.emit(AnalyticsEventNames.SWARM_ONBOARDED, parsed.data) + return this.runEmit(AnalyticsEventNames.SWARM_ONBOARDED, (client) => + client.track(AnalyticsEventNames.SWARM_ONBOARDED, parsed.data), + ) } private handleTrackQueryCompleted(data: SwarmTrackQueryCompletedRequest): SwarmTrackResponse { // Validate at the transport boundary — the CLI is an external trust - // boundary even though we ship it ourselves. A future re-version of - // the CLI sending an outdated wire shape gets a clean rejection here - // rather than a malformed row in raw_events. + // boundary even though we ship it ourselves. const parsed = SwarmQueryCompletedSchema.safeParse(data) if (!parsed.success) return {reason: 'schema-rejection', tracked: false} - return this.emit(AnalyticsEventNames.SWARM_QUERY_COMPLETED, parsed.data) + return this.runEmit(AnalyticsEventNames.SWARM_QUERY_COMPLETED, (client) => + client.track(AnalyticsEventNames.SWARM_QUERY_COMPLETED, parsed.data), + ) } private handleTrackStoreCompleted(data: SwarmTrackStoreCompletedRequest): SwarmTrackResponse { const parsed = SwarmStoreCompletedSchema.safeParse(data) if (!parsed.success) return {reason: 'schema-rejection', tracked: false} - return this.emit(AnalyticsEventNames.SWARM_STORE_COMPLETED, parsed.data) + return this.runEmit(AnalyticsEventNames.SWARM_STORE_COMPLETED, (client) => + client.track(AnalyticsEventNames.SWARM_STORE_COMPLETED, parsed.data), + ) + } + + /** + * Shared try/catch wrapper. The thunk does the literal-narrowed + * `track(NAME, props)` call so TS infers `PropsArg` per event — no + * generic widening, no `as` cast. + */ + private runEmit(eventLabel: string, fn: (client: IAnalyticsClient) => void): SwarmTrackResponse { + const client = this.analyticsClient + if (!client) return {reason: 'analytics-unavailable', tracked: false} + try { + fn(client) + return {tracked: true} + } catch (error) { + processLog( + `[Swarm] analytics track ${eventLabel} failed: ${error instanceof Error ? error.message : String(error)}`, + ) + return {reason: 'analytics-throw', tracked: false} + } } } diff --git a/src/shared/transport/events/swarm-events.ts b/src/shared/transport/events/swarm-events.ts index 18e5bb0a0..1c24cf681 100644 --- a/src/shared/transport/events/swarm-events.ts +++ b/src/shared/transport/events/swarm-events.ts @@ -38,13 +38,19 @@ export type SwarmTrackStoreCompletedRequest = SwarmStoreCompletedProps export type SwarmTrackOnboardedRequest = SwarmOnboardedProps +/** + * Closed enum so a typo or stray ad-hoc reason becomes a compile error + * rather than a silent miss on the consumer side. + */ +export type SwarmTrackReason = 'analytics-throw' | 'analytics-unavailable' | 'schema-rejection' + /** * The handler returns a small ack so the CLI can confirm the emit was * accepted (or learn it was schema-rejected). Analytics-handler.ts pattern. */ export interface SwarmTrackResponse { /** Set when the daemon dropped the emit; populated for schema-rejection or analytics-disabled. */ - reason?: string + reason?: SwarmTrackReason /** * True when the daemon accepted the payload and forwarded to the * analytics client. False when validation failed or the analytics diff --git a/test/unit/infra/transport/handlers/swarm-handler.test.ts b/test/unit/infra/transport/handlers/swarm-handler.test.ts index bb08664f2..0ad2c3736 100644 --- a/test/unit/infra/transport/handlers/swarm-handler.test.ts +++ b/test/unit/infra/transport/handlers/swarm-handler.test.ts @@ -169,33 +169,37 @@ describe('SwarmHandler', () => { }) describe('graceful degradation', () => { - it('returns {tracked: false, reason: analytics-unavailable} when no analyticsClient is wired', async () => { - const standaloneTransport = createMockTransportServer() - new SwarmHandler({transport: standaloneTransport}).setup() - const handler = standaloneTransport._handlers.get(SwarmEvents.TRACK_QUERY_COMPLETED) - if (handler === undefined) throw new Error('handler not registered') - - const response = (await handler( - {duration_ms: 1, outcome: 'success'}, - 'client-1', - )) as SwarmTrackResponse - - expect(response.tracked).to.equal(false) - expect(response.reason).to.equal('analytics-unavailable') - }) - - it('returns {tracked: false, reason: analytics-throw} when track() throws — never propagates to the caller', async () => { - const handler = transport._handlers.get(SwarmEvents.TRACK_QUERY_COMPLETED) - if (handler === undefined) throw new Error('handler not registered') - analyticsClient.trackThrows = new Error('queue full') - - const response = (await handler( - {duration_ms: 5, outcome: 'success'}, - 'client-1', - )) as SwarmTrackResponse - - expect(response.tracked).to.equal(false) - expect(response.reason).to.equal('analytics-throw') - }) + // Run the degradation checks across every event so a future divergence + // (e.g. one handler refactored, others not) fails loudly. + const VALID_PAYLOAD_BY_EVENT: Record> = { + [SwarmEvents.TRACK_ONBOARDED]: {duration_ms: 1, member_count: 1, outcome: 'success', swarm_kind: 'new'}, + [SwarmEvents.TRACK_QUERY_COMPLETED]: {duration_ms: 1, outcome: 'success'}, + [SwarmEvents.TRACK_STORE_COMPLETED]: {duration_ms: 1, operation: 'create', outcome: 'success'}, + } + + for (const eventName of Object.values(SwarmEvents)) { + it(`returns {tracked: false, reason: analytics-unavailable} for ${eventName} when no analyticsClient is wired`, async () => { + const standaloneTransport = createMockTransportServer() + new SwarmHandler({transport: standaloneTransport}).setup() + const handler = standaloneTransport._handlers.get(eventName) + if (handler === undefined) throw new Error('handler not registered') + + const response = (await handler(VALID_PAYLOAD_BY_EVENT[eventName], 'client-1')) as SwarmTrackResponse + + expect(response.tracked).to.equal(false) + expect(response.reason).to.equal('analytics-unavailable') + }) + + it(`returns {tracked: false, reason: analytics-throw} for ${eventName} when track() throws`, async () => { + const handler = transport._handlers.get(eventName) + if (handler === undefined) throw new Error('handler not registered') + analyticsClient.trackThrows = new Error('queue full') + + const response = (await handler(VALID_PAYLOAD_BY_EVENT[eventName], 'client-1')) as SwarmTrackResponse + + expect(response.tracked).to.equal(false) + expect(response.reason).to.equal('analytics-throw') + }) + } }) }) From 88120b80bfc6fd63ef3aeb1626da0cb494573963 Mon Sep 17 00:00:00 2001 From: wzlng Date: Fri, 29 May 2026 01:03:04 +0700 Subject: [PATCH 75/87] feat(ENG-2605): add post-tour analytics sharing opt-in --- src/server/templates/skill/onboarding.md | 28 +++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/server/templates/skill/onboarding.md b/src/server/templates/skill/onboarding.md index a145d89f2..5067ddc62 100644 --- a/src/server/templates/skill/onboarding.md +++ b/src/server/templates/skill/onboarding.md @@ -281,6 +281,32 @@ The persona you saved becomes seed knowledge for every future session. From here If the user invokes the tour again later, run it again — there is no state tracking, no "you've already seen this." A second tour is a re-orientation, not an error. The new persona save replaces (or augments) the previous one through normal curate behavior. +## Share Analytics (Opt-In) + +After the tour closes, ask **once** whether the user wants to share anonymous usage analytics with ByteRover. Sharing is opt-in: it defaults to off, and we do not flip it without an explicit yes. Do not volunteer details about local-only collection — that's an implementation detail, and surfacing it unprompted invites questions the tour shouldn't have to answer. If the user asks what's collected locally, answer plainly; otherwise stay scoped to the sharing decision. + +Place the ask _after_ the "Either way, you're set" close, as a single follow-up message — not bundled into Message 3: + +> "One optional ask before you go: if you'd like to help us improve ByteRover, you can opt in to share anonymous usage telemetry — things like which commands ran and how long they took. No query content, file contents, or memory is ever sent. You can change your mind anytime with `brv analytics disable`. +> +> Want to opt in? Either answer is fine." + +Handling the response: + +- **Yes** → run `brv analytics enable --yes` (or instruct the user to run it if you cannot), then confirm in one line: "Done — thanks. `brv analytics disable` reverses it anytime." +- **No / silence / "maybe later"** → one-line acknowledgement ("No problem — `brv analytics enable` is there whenever.") and stop. Do not re-ask in future sessions. + +Why this beat exists: + +- **Trust separation.** Local collection and shared telemetry are two different promises. Conflating them ("analytics is on") would undo the trust the tour just built. +- **One ask, never a nag.** One sentence, one question, one line of follow-up. If declined or skipped, the agent never raises it again — the user has the command if they change their mind. +- **Tour-adjacent, not tour-blocking.** The tour itself still ends at Message 3. A user who's done can disengage at the close without ever seeing this ask. + +Skip the ask entirely if: + +- Sharing is already enabled (the `brv analytics status` flag is true). +- The user signaled disengagement at the close ("ok", "got it", "thanks", no further input). Don't pull a yes/no out of someone who's already left. + ## What NOT To Do - Do NOT extend past 3 messages. @@ -291,6 +317,6 @@ If the user invokes the tour again later, run it again — there is no state tra - Do NOT prompt for an LLM provider, login, or any configuration. The tour runs with zero setup. - Do NOT skip the persona-shaped tailoring in Message 2 in favor of a generic "here's how retrieve works" explanation. The tailored example IS the value demo. - Do NOT tailor with hollow phrases like "As a Rust developer, you'll love…" or "Since you work on a CLI, you might want to…" — these read as templated personalization and erode trust faster than no tailoring at all. The tailored example must reference something **specific** the user said, paired with a **specific** action the agent will take. -- Do NOT turn the visible artifact in Message 1 into a confirmation step. No "Does this look right?" prompts. The artifact is shown so the user *feels* what was captured, not so they validate it. +- Do NOT turn the visible artifact in Message 1 into a confirmation step. No "Does this look right?" prompts. The artifact is shown so the user _feels_ what was captured, not so they validate it. - Do NOT manufacture a pain if the user didn't share one. Skip the pain-naming paragraph and the pain-ending demonstration in that case. A thinner tour is better than a fake one. - Do NOT overpromise on pains outside the context-memory family. If the user names a pain ByteRover doesn't solve (hallucinations, model speed, bad code generation), acknowledge briefly and redirect to the in-scope pain. Do NOT claim ByteRover fixes things it doesn't. From 2e37fecd38fac1534b2cd943438663c67fc6de38 Mon Sep 17 00:00:00 2001 From: bao-byterover Date: Fri, 29 May 2026 09:54:50 +0700 Subject: [PATCH 76/87] feat: [ENG-3010] replace wire timestamp with created_at (ISO 8601) (#735) * feat: [ENG-3010] replace wire timestamp with created_at (ISO 8601) * fix: [ENG-3010] drop stray line + doc the wire-format caveats --- CLAUDE.md | 2 +- src/server/core/domain/analytics/batch.ts | 18 +++-- src/server/core/domain/analytics/event.ts | 16 ++-- .../infra/analytics/analytics-client.ts | 23 ++++-- src/shared/analytics/stored-record.ts | 48 ++++++++--- test/e2e/analytics/dev-beta.e2e.ts | 29 ++++--- .../analytics/daemon-tracking.test.ts | 8 +- .../core/domain/analytics/batch.test.ts | 69 +++++++++++++--- .../infra/analytics/analytics-client.test.ts | 36 ++++++--- .../axios-analytics-http-client.test.ts | 2 +- .../analytics/http-analytics-sender.test.ts | 1 + .../shared/analytics/stored-record.test.ts | 79 ++++++++++++++++++- 12 files changed, 265 insertions(+), 66 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa71a2668..66f0f7516 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,7 +197,7 @@ Scenarios covered (`describe` names): - `npm install` (so `bin/run.js` and `node_modules/.bin` are present). `npm link` is NOT required: the test spawns `node /bin/run.js` directly so it always exercises THIS checkout. - `npm run build` is chained in front of mocha inside the npm script; you only need to run it manually if you invoke `mocha` directly without going through `npm run test:e2e:analytics`. - Network access to `dev-beta-iam.byterover.dev` and `telemetry-dev.byterover.dev` (the test defaults `BRV_ANALYTICS_BASE_URL` + `BRV_IAM_BASE_URL` to those; override via env to point at a different backend). -- **Backend on the M4.x wire format**: this CLI sends `timestamp` as epoch milliseconds (per M4.1). The top-level `before()` runs one known-good POST and `this.skip()`s the entire suite with a clear reason if the backend rejects it. Last verified green against `https://telemetry-dev.byterover.dev` on 2026-05-25. If a future deployment regresses to the older ISO-8601 schema, every scenario would FAIL with retry-cap exhaustion - coordinate with the telemetry team before running. +- **Backend on the M4.x wire format**: this CLI sends each event's `created_at` as an ISO 8601 string with a timezone designator (e.g. `2026-05-28T21:32:11+07:00` or `...Z`), per the byterover-telemetry backend contract. The top-level `before()` runs one known-good POST and `this.skip()`s the entire suite with a clear reason if the backend rejects it. Last verified green against `https://telemetry-dev.byterover.dev` on 2026-05-29. If a future deployment regresses to the older numeric `timestamp` (epoch milliseconds) schema, every scenario would FAIL with retry-cap exhaustion - coordinate with the telemetry team before running. - For scenario 4: a browser to complete the OAuth login flow. **Test isolation**: each scenario builds a per-scenario `env` object with a temp `BRV_DATA_DIR` and a temp `HOME` and passes it to every `spawnSync(node, [bin/run.js, ...])`. That isolates the analytics JSONL queue, daemon log, auth token store, and the platform-derived global config path (`~/Library/Application Support/brv/config.json` on macOS) away from the developer's real profile. Teardown uses `brv restart` (with the scenario env) instead of `bin/kill-daemon.js` — `restart` properly cleans the SCENARIO's daemon + state files; calling `kill-daemon.js` without scoped env would read the user's real global `daemon.json` and leak the scenario daemon to the process table. The emit helper temporarily mutates `process.env.BRV_DATA_DIR` / `HOME` because `connectToDaemon` reads them for instance discovery, and restores in `finally` - safe because mocha runs scenarios sequentially. Do NOT pass `--parallel`. diff --git a/src/server/core/domain/analytics/batch.ts b/src/server/core/domain/analytics/batch.ts index 4fea83b89..5ca0fb8e5 100644 --- a/src/server/core/domain/analytics/batch.ts +++ b/src/server/core/domain/analytics/batch.ts @@ -34,12 +34,18 @@ const IdentityWireSchema = z.object({ user_id: z.string().optional(), }) -const AnalyticsEventWithIdentityWireSchema = z.object({ - identity: IdentityWireSchema, - name: z.string(), - properties: z.record(z.string(), z.unknown()), - timestamp: z.number(), -}) +// `.strict()` mirrors the backend's `forbidNonWhitelisted` validator +// (byterover-telemetry PR #21): any residual field from a pre-upgrade +// producer (notably the legacy numeric `timestamp`) must be rejected at the +// wire boundary, not silently stripped. +const AnalyticsEventWithIdentityWireSchema = z + .object({ + created_at: z.string().datetime({offset: true}), + identity: IdentityWireSchema, + name: z.string(), + properties: z.record(z.string(), z.unknown()), + }) + .strict() const AnalyticsBatchJsonSchema = z.object({ events: z.array(AnalyticsEventWithIdentityWireSchema), diff --git a/src/server/core/domain/analytics/event.ts b/src/server/core/domain/analytics/event.ts index def148485..7d6745841 100644 --- a/src/server/core/domain/analytics/event.ts +++ b/src/server/core/domain/analytics/event.ts @@ -1,12 +1,16 @@ /** - * Internal analytics event shape, before identity stamping. CamelCase - * member names follow internal TS conventions; serializers at the wire - * boundary convert (or, for analytics, the wire shape happens to coincide - * with these field names — `name`, `properties`, `timestamp` are not - * snake_cased on the wire). + * Internal analytics event shape, before identity stamping. This is the wire- + * bound event type: `AnalyticsBatch.events` carries `AnalyticsEventWithIdentity` + * values, which extend this shape with `identity`. + * + * `created_at` is the wire timestamp: a strict ISO 8601 string with a + * timezone designator (e.g. `2026-05-28T21:32:11+07:00` or `...Z`). The + * local-only numeric sort key (`timestamp` on `StoredAnalyticsRecord`) + * lives only on disk and never crosses the wire — see + * `src/shared/analytics/stored-record.ts`. */ export type AnalyticsEvent = Readonly<{ + created_at: string name: string properties: Record - timestamp: number }> diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index 564d16867..eebe0561f 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -1,3 +1,4 @@ +import {formatISO} from 'date-fns' import {randomUUID} from 'node:crypto' import type {AnalyticsEventName} from '../../../shared/analytics/event-names.js' @@ -236,9 +237,15 @@ export class AnalyticsClient implements IAnalyticsClient { // user action happened, not when the async resolver chain settled. Under // burst load (many tracks queued before the first resolver completes) this // preserves the inter-event durations downstream consumers care about. - const timestamp = Date.now() + // + // A single `new Date()` read drives both fields so the local numeric + // sort key (`timestamp`, epoch ms) and the wire-bound ISO 8601 string + // (`created_at`) always describe the same instant. + const now = new Date() + const timestamp = now.getTime() + const createdAt = formatISO(now) const [properties] = rest - const pending = this.trackAsync(event, properties, timestamp) + const pending = this.trackAsync({createdAt, event, properties, timestamp}) this.pendingTracks.add(pending) // Remove from the in-flight set once the track settles either way. // `void` keeps `track()` synchronous per the IAnalyticsClient contract. @@ -346,10 +353,14 @@ export class AnalyticsClient implements IAnalyticsClient { } private async trackAsync( - event: E, - properties: PropsForEvent | undefined, - timestamp: number, + input: Readonly<{ + createdAt: string + event: E + properties: PropsForEvent | undefined + timestamp: number + }>, ): Promise { + const {createdAt, event, properties, timestamp} = input try { const [identity, superProps] = await Promise.all([ this.deps.identityResolver.resolve(), @@ -361,6 +372,8 @@ export class AnalyticsClient implements IAnalyticsClient { // fast in-memory mirror for status display / future webui hot path. const record: StoredAnalyticsRecord = { attempts: 0, + // eslint-disable-next-line camelcase + created_at: createdAt, id: randomUUID(), identity, name: event, diff --git a/src/shared/analytics/stored-record.ts b/src/shared/analytics/stored-record.ts index aad43fcc2..a9cd69eb1 100644 --- a/src/shared/analytics/stored-record.ts +++ b/src/shared/analytics/stored-record.ts @@ -1,4 +1,5 @@ /* eslint-disable camelcase */ +import {formatISO} from 'date-fns' import {z} from 'zod' /** @@ -50,11 +51,18 @@ const IdentityWireSchema = z.object({ */ export const StoredAnalyticsRecordSchema = z.object({ attempts: z.number().int().min(0), + // Wire-bound ISO 8601 string with timezone designator. Optional so pre- + // upgrade rows on disk (which carry only the numeric `timestamp`) continue + // to parse. `toWireEvent` derives a value from `timestamp` when this is + // absent so the wire payload is always complete. + created_at: z.string().datetime({offset: true}).optional(), id: z.string().min(1), identity: IdentityWireSchema, name: z.string(), properties: z.record(z.string(), z.unknown()), status: StoredStatusSchema, + // Local-only sort key (epoch ms). Required: see `JsonlAnalyticsStore.list` + // which sorts on numeric subtraction. NEVER emitted on the wire. timestamp: z.number(), }) @@ -67,25 +75,47 @@ export const StoredAnalyticsRecordSchema = z.object({ export type StoredAnalyticsRecord = Readonly> /** - * The wire-shape view of a stored record (no `id` / `status` / `attempts`). - * Structurally identical to the daemon-side `AnalyticsEventWithIdentity` - * type; declared here as a `Pick` so this module has no dependency on - * server-side domain code and can be imported by `shared/`. + * The wire-shape view of a stored record (no `id` / `status` / `attempts` / + * `timestamp`). Structurally identical to the daemon-side + * `AnalyticsEventWithIdentity` type; declared here as a `Pick` so this + * module has no dependency on server-side domain code and can be imported + * by `shared/`. + * + * `created_at` is required on the wire even though it is optional on + * `StoredAnalyticsRecord` (pre-upgrade rows derive it from `timestamp` at + * send time via `toWireEvent`). */ -export type WireAnalyticsEvent = Pick +export type WireAnalyticsEvent = Pick & { + created_at: string +} /** - * Strips local-only fields (`id`, `status`, `attempts`) from a stored - * record and returns the wire-format event shape that can be shipped to - * the backend. M4's HTTP sender uses this on the way out; M9.3 + * Strips local-only fields (`id`, `status`, `attempts`, `timestamp`) from a + * stored record and returns the wire-format event shape that can be shipped + * to the backend. M4's HTTP sender uses this on the way out; M9.3 * (in-process) and M11.2 (over transport) both keep the local fields for * their own purposes. + * + * Emits `created_at` (ISO 8601 with offset). For pre-upgrade rows that lack + * a stored `created_at`, derives one from the numeric `timestamp` so the + * wire payload is always complete. The UTC instant is exact, but the offset + * in the derived string reflects the daemon's local timezone at send time, + * not the user's timezone at original capture. + * + * Note: `formatISO` (date-fns) emits second precision and drops the + * millisecond component, so a derived `created_at` reparses to an instant + * up to 999ms earlier than the stored `timestamp`. The backend's UTC + * normalization tolerates this; the local `timestamp` remains the + * authoritative sub-second sort key on disk. + * + * The backend stores the normalized UTC instant, so both the offset drift + * and the second-precision truncation are informational only. */ export function toWireEvent(record: StoredAnalyticsRecord): WireAnalyticsEvent { return { + created_at: record.created_at ?? formatISO(new Date(record.timestamp)), identity: record.identity, name: record.name, properties: record.properties, - timestamp: record.timestamp, } } diff --git a/test/e2e/analytics/dev-beta.e2e.ts b/test/e2e/analytics/dev-beta.e2e.ts index 04ab899d8..75816ac93 100644 --- a/test/e2e/analytics/dev-beta.e2e.ts +++ b/test/e2e/analytics/dev-beta.e2e.ts @@ -25,6 +25,7 @@ */ import {expect} from 'chai' +import {formatISO} from 'date-fns' import {spawnSync} from 'node:child_process' import {randomUUID} from 'node:crypto' import {existsSync, mkdtempSync, readFileSync} from 'node:fs' @@ -146,11 +147,7 @@ async function waitFor(predicate: () => boolean, timeoutMs: number, intervalMs = return predicate() } -async function waitForStatus( - path: string, - target: 'failed' | 'pending' | 'sent', - timeoutMs: number, -): Promise { +async function waitForStatus(path: string, target: 'failed' | 'pending' | 'sent', timeoutMs: number): Promise { return waitFor(() => { const rows = readRows(path) const last = rows.at(-1) @@ -289,14 +286,16 @@ function readBackoffFailures(env: NodeJS.ProcessEnv): {failures: number; state: } async function preflightBackend(url: string): Promise<{ok: boolean; reason?: string}> { - // Mirrors scripts/e2e-analytics.sh:170-195 — send one known-good M4.x - // wire-format batch and check that the backend accepts it. If the - // deployment is still on the old ISO timestamp schema, all scenarios - // would FAIL with retry-cap exhaustion; better to skip the suite - // up-front with a clear reason. + // Wire format: per-event `created_at` (ISO 8601 with offset), + // `schema_version: 1`, no numeric `timestamp` field. If the backend + // still validates against a legacy `{timestamp: number}` shape or + // rejects this shape via `forbidNonWhitelisted`, every scenario would + // FAIL with retry-cap exhaustion; better to skip the suite up-front + // with a clear reason. const body = JSON.stringify({ events: [ { + created_at: formatISO(new Date()), identity: {device_id: 'e2e-preflight'}, name: 'daemon_start', properties: { @@ -305,7 +304,6 @@ async function preflightBackend(url: string): Promise<{ok: boolean; reason?: str node_version: process.version, os: process.platform, }, - timestamp: Date.now(), }, ], schema_version: 1, @@ -325,7 +323,7 @@ async function preflightBackend(url: string): Promise<{ok: boolean; reason?: str if (res.status === 400) { return { ok: false, - reason: `backend at ${url} returned 400 to the M4.x wire format - likely still on the older ISO-8601 timestamp schema`, + reason: `backend at ${url} returned 400 to the created_at wire format - likely not yet deployed`, } } @@ -457,9 +455,10 @@ describe('M4.7 analytics e2e (real CLI, real daemon, real backend)', function () // Wait for that to settle before emitting the authed event. await sleep(10_000) expect((await emitEvents(1, scenario.env)).failed).to.equal(0) - expect(await waitForStatus(jsonlPath(scenario.dataDir), 'sent', 45_000), 'post-login event did not ship').to.equal( - true, - ) + expect( + await waitForStatus(jsonlPath(scenario.dataDir), 'sent', 45_000), + 'post-login event did not ship', + ).to.equal(true) const rows = readRows(jsonlPath(scenario.dataDir)) const last = rows.at(-1) const userId = last?.identity?.user_id ?? '' diff --git a/test/integration/analytics/daemon-tracking.test.ts b/test/integration/analytics/daemon-tracking.test.ts index b4ce585ba..b6656753b 100644 --- a/test/integration/analytics/daemon-tracking.test.ts +++ b/test/integration/analytics/daemon-tracking.test.ts @@ -97,8 +97,12 @@ describe('daemon analytics tracking integration (ticket scenario 6)', () => { const [event] = batch.events expect(event.name).to.equal('daemon_start') - expect(event.timestamp).to.be.at.least(before) - expect(event.timestamp).to.be.at.most(after) + // The wire event carries `created_at` (ISO 8601 string); the numeric + // sort key `timestamp` lives only on the stored record. `formatISO` + // drops millis, so compare against floor-to-second bounds. + expect(event.created_at).to.be.a('string') + expect(Date.parse(event.created_at)).to.be.at.least(Math.floor((before - 1000) / 1000) * 1000) + expect(Date.parse(event.created_at)).to.be.at.most(Math.floor(after / 1000) * 1000) // Anonymous identity: device_id only (no token in the stub reader) expect(event.identity).to.deep.equal({device_id: validDeviceId}) diff --git a/test/unit/server/core/domain/analytics/batch.test.ts b/test/unit/server/core/domain/analytics/batch.test.ts index a2a2297e0..1cfc33858 100644 --- a/test/unit/server/core/domain/analytics/batch.test.ts +++ b/test/unit/server/core/domain/analytics/batch.test.ts @@ -8,17 +8,17 @@ const validIdentity = { } const eventA = { + created_at: '2023-11-14T22:13:20+00:00', identity: validIdentity, name: 'event_a', properties: {x: 1}, - timestamp: 1_700_000_000_000, } const eventB = { + created_at: '2023-11-14T22:13:20.001+00:00', identity: validIdentity, name: 'event_b', properties: {y: 'hello'}, - timestamp: 1_700_000_000_001, } describe('AnalyticsBatch', () => { @@ -110,7 +110,7 @@ describe('AnalyticsBatch', () => { it('should return undefined when an event is missing name', () => { const json = { - events: [{identity: validIdentity, properties: {}, timestamp: 1}], + events: [{created_at: '2023-11-14T22:13:20+00:00', identity: validIdentity, properties: {}}], schema_version: 1, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined @@ -118,7 +118,7 @@ describe('AnalyticsBatch', () => { it('should return undefined when an event has non-string name', () => { const json = { - events: [{identity: validIdentity, name: 123, properties: {}, timestamp: 1}], + events: [{created_at: '2023-11-14T22:13:20+00:00', identity: validIdentity, name: 123, properties: {}}], schema_version: 1, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined @@ -126,7 +126,7 @@ describe('AnalyticsBatch', () => { it('should return undefined when an event is missing identity', () => { const json = { - events: [{name: 'x', properties: {}, timestamp: 1}], + events: [{created_at: '2023-11-14T22:13:20+00:00', name: 'x', properties: {}}], schema_version: 1, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined @@ -134,7 +134,7 @@ describe('AnalyticsBatch', () => { it('should return undefined when identity is missing device_id', () => { const json = { - events: [{identity: {}, name: 'x', properties: {}, timestamp: 1}], + events: [{created_at: '2023-11-14T22:13:20+00:00', identity: {}, name: 'x', properties: {}}], schema_version: 1, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined @@ -142,15 +142,62 @@ describe('AnalyticsBatch', () => { it('should return undefined when identity has empty device_id', () => { const json = { - events: [{identity: {device_id: ''}, name: 'x', properties: {}, timestamp: 1}], + events: [ + {created_at: '2023-11-14T22:13:20+00:00', identity: {device_id: ''}, name: 'x', properties: {}}, + ], schema_version: 1, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined }) - it('should return undefined when an event has non-number timestamp', () => { + it('should return undefined when an event is missing created_at', () => { const json = { - events: [{identity: validIdentity, name: 'x', properties: {}, timestamp: 'now'}], + events: [{identity: validIdentity, name: 'x', properties: {}}], + schema_version: 1, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when an event has a non-string created_at', () => { + const json = { + events: [{created_at: 1_700_000_000_000, identity: validIdentity, name: 'x', properties: {}}], + schema_version: 1, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should return undefined when created_at is missing a timezone designator', () => { + const json = { + events: [{created_at: '2023-11-14T22:13:20', identity: validIdentity, name: 'x', properties: {}}], + schema_version: 1, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + + it('should accept created_at with Z suffix or numeric offset', () => { + for (const ts of ['2023-11-14T22:13:20Z', '2023-11-14T22:13:20+07:00', '2023-11-14T22:13:20.123-05:30']) { + const json = { + events: [{created_at: ts, identity: validIdentity, name: 'x', properties: {}}], + schema_version: 1, + } + expect(AnalyticsBatch.fromJson(json), `created_at=${ts} should parse`).to.not.be.undefined + } + }) + + it('should return undefined when an event carries a stray legacy timestamp field', () => { + // Wire schema is strict: events must be exactly {created_at, identity, name, properties}. + // A residual `timestamp` from a pre-upgrade producer must be rejected, matching the backend's + // `forbidNonWhitelisted` semantics in byterover-telemetry PR #21. + const json = { + events: [ + { + created_at: '2023-11-14T22:13:20+00:00', + identity: validIdentity, + name: 'x', + properties: {}, + timestamp: 1_700_000_000_000, + }, + ], schema_version: 1, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined @@ -158,7 +205,9 @@ describe('AnalyticsBatch', () => { it('should return undefined when an event has non-object properties', () => { const json = { - events: [{identity: validIdentity, name: 'x', properties: 'foo', timestamp: 1}], + events: [ + {created_at: '2023-11-14T22:13:20+00:00', identity: validIdentity, name: 'x', properties: 'foo'}, + ], schema_version: 1, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index 906614ec0..91f04ddba 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -224,11 +224,12 @@ describe('AnalyticsClient', () => { const queue = new BoundedQueue() const identity = makeRegisteredIdentity() const superProps = makeSuperProps() + const jsonlStore = makeFakeJsonlStore() const client = new AnalyticsClient({ identityResolver: makeStubIdentityResolver(identity), isEnabled: () => true, - jsonlStore: makeFakeJsonlStore(), + jsonlStore, queue, sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(superProps), @@ -246,8 +247,16 @@ describe('AnalyticsClient', () => { const [event] = batch.events expect(event.name).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) expect(event.identity).to.deep.equal(identity) - expect(event.timestamp).to.be.at.least(before) - expect(event.timestamp).to.be.at.most(after) + // Local numeric `timestamp` is captured at call-site at millisecond precision. + expect(jsonlStore.records[0].timestamp).to.be.at.least(before) + expect(jsonlStore.records[0].timestamp).to.be.at.most(after) + // Wire `created_at` is the ISO 8601 string derived from the same `new Date()` + // read. `formatISO` drops millis, so it describes the same instant floored + // to the second. + expect(event.created_at).to.be.a('string') + expect(Date.parse(event.created_at)).to.equal( + Math.floor(jsonlStore.records[0].timestamp / 1000) * 1000, + ) // user properties merged through expect(event.properties.relative_path).to.equal('tmp/merge-fixture.md') @@ -424,10 +433,11 @@ describe('AnalyticsClient', () => { }), } + const jsonlStore = makeFakeJsonlStore() const client = new AnalyticsClient({ identityResolver: slowIdentityResolver, isEnabled: () => true, - jsonlStore: makeFakeJsonlStore(), + jsonlStore, queue, sender: makeFakeSender(), superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), @@ -449,11 +459,18 @@ describe('AnalyticsClient', () => { const batch = await client.flush() expect(batch.events).to.have.lengthOf(1) - // Captured-at-call: timestamp falls within the call-site window… - expect(batch.events[0].timestamp).to.be.at.least(before) - expect(batch.events[0].timestamp).to.be.at.most(after) + // Captured-at-call: local numeric `timestamp` (millisecond precision) + // falls within the call-site window… + const stored = jsonlStore.records[0] + expect(stored.timestamp).to.be.at.least(before) + expect(stored.timestamp).to.be.at.most(after) // …and is BEFORE the resolver settled (proving capture-at-call, not capture-at-settle). - expect(batch.events[0].timestamp).to.be.lessThan(settleStart) + expect(stored.timestamp).to.be.lessThan(settleStart) + // The wire-bound `created_at` is derived from the same `new Date()` read, + // floored to the second by formatISO. + expect(Date.parse(batch.events[0].created_at)).to.equal( + Math.floor(stored.timestamp / 1000) * 1000, + ) }) }) @@ -847,13 +864,14 @@ describe('AnalyticsClient', () => { expect(batch.events).to.have.lengthOf(1) const [event] = batch.events expect(event).to.have.property('name', AnalyticsEventNames.DAEMON_START) - expect(event).to.have.property('timestamp') + expect(event).to.have.property('created_at') expect(event).to.have.property('properties') expect(event).to.have.property('identity') // Local-only fields stripped on the wire. expect(event).to.not.have.property('id') expect(event).to.not.have.property('attempts') expect(event).to.not.have.property('status') + expect(event).to.not.have.property('timestamp') }) }) diff --git a/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts b/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts index 83a456b4f..08bfa9d2b 100644 --- a/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts +++ b/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts @@ -11,10 +11,10 @@ const baseUrl = 'https://telemetry-test.byterover.dev' function makeEvent(name = 'daemon_start') { return { + created_at: '2023-11-14T22:13:20+00:00', identity: {device_id: validDeviceId, user_id: 'user-123'}, name, properties: {cli_version: '3.12.0'}, - timestamp: 1_700_000_000_000, } } diff --git a/test/unit/server/infra/analytics/http-analytics-sender.test.ts b/test/unit/server/infra/analytics/http-analytics-sender.test.ts index 717ad63d2..f7ecb12cb 100644 --- a/test/unit/server/infra/analytics/http-analytics-sender.test.ts +++ b/test/unit/server/infra/analytics/http-analytics-sender.test.ts @@ -22,6 +22,7 @@ const validDeviceId = '550e8400-e29b-41d4-a716-446655440000' function makeRecord(overrides: Partial = {}): StoredAnalyticsRecord { return { attempts: 0, + created_at: '2023-11-14T22:13:20+00:00', id: overrides.id ?? '11111111-1111-1111-1111-111111111111', identity: {device_id: validDeviceId, user_id: 'user-123'}, name: 'daemon_start', diff --git a/test/unit/shared/analytics/stored-record.test.ts b/test/unit/shared/analytics/stored-record.test.ts index 273c027e0..64757f4e1 100644 --- a/test/unit/shared/analytics/stored-record.test.ts +++ b/test/unit/shared/analytics/stored-record.test.ts @@ -7,8 +7,12 @@ const validIdentity = { device_id: '550e8400-e29b-41d4-a716-446655440000', } +// Both fields describe the same instant. `created_at` is the wire-bound +// ISO 8601 string with offset; `timestamp` is the local-only sort key. +// `1_700_000_000_000` epoch ms = 2023-11-14T22:13:20Z. const validRecord = { attempts: 0, + created_at: '2023-11-14T22:13:20+00:00', id: '11111111-2222-3333-4444-555555555555', identity: validIdentity, name: 'cli_invocation', @@ -115,6 +119,7 @@ describe('StoredAnalyticsRecord', () => { it('should reject a record missing timestamp', () => { const parsed = StoredAnalyticsRecordSchema.safeParse({ attempts: validRecord.attempts, + created_at: validRecord.created_at, id: validRecord.id, identity: validRecord.identity, name: validRecord.name, @@ -125,6 +130,53 @@ describe('StoredAnalyticsRecord', () => { expect(parsed.success).to.equal(false) }) + it('should accept a post-upgrade row carrying both timestamp and created_at', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse(validRecord) + + expect(parsed.success).to.equal(true) + if (parsed.success) { + expect(parsed.data.timestamp).to.equal(1_700_000_000_000) + expect(parsed.data.created_at).to.equal('2023-11-14T22:13:20+00:00') + } + }) + + it('should accept a pre-upgrade row missing created_at (optional)', () => { + const preUpgrade = { + attempts: validRecord.attempts, + id: validRecord.id, + identity: validRecord.identity, + name: validRecord.name, + properties: validRecord.properties, + status: validRecord.status, + timestamp: validRecord.timestamp, + } + const parsed = StoredAnalyticsRecordSchema.safeParse(preUpgrade) + + expect(parsed.success).to.equal(true) + if (parsed.success) { + expect(parsed.data.created_at).to.equal(undefined) + expect(parsed.data.timestamp).to.equal(1_700_000_000_000) + } + }) + + it('should reject a record where created_at is not ISO 8601 with offset', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + ...validRecord, + created_at: '2023-11-14', + }) + + expect(parsed.success).to.equal(false) + }) + + it('should accept created_at with Z suffix', () => { + const parsed = StoredAnalyticsRecordSchema.safeParse({ + ...validRecord, + created_at: '2023-11-14T22:13:20.000Z', + }) + + expect(parsed.success).to.equal(true) + }) + it('should reject a record missing properties', () => { const parsed = StoredAnalyticsRecordSchema.safeParse({ attempts: validRecord.attempts, @@ -196,15 +248,21 @@ describe('StoredAnalyticsRecord', () => { }) describe('toWireEvent()', () => { - it('should strip id, status, and attempts from the record', () => { + it('should produce exactly {identity, name, properties, created_at} for a post-upgrade record', () => { const wire = toWireEvent(validRecord) expect(wire).to.deep.equal({ + created_at: '2023-11-14T22:13:20+00:00', identity: validIdentity, name: 'cli_invocation', properties: {x: 1}, - timestamp: 1_700_000_000_000, }) + expect(Object.keys(wire).sort()).to.deep.equal(['created_at', 'identity', 'name', 'properties']) + }) + + it('should never emit a numeric timestamp field on the wire', () => { + const wire = toWireEvent(validRecord) + expect(wire).to.not.have.property('timestamp') }) it('should not retain id field', () => { @@ -237,6 +295,22 @@ describe('StoredAnalyticsRecord', () => { expect(wire.identity).to.deep.equal(recordWithFullIdentity.identity) }) + it('should pass through created_at verbatim when the stored record carries it', () => { + const wire = toWireEvent({...validRecord, created_at: '2025-02-01T03:04:05+07:00'}) + expect(wire.created_at).to.equal('2025-02-01T03:04:05+07:00') + }) + + it('should derive created_at from the numeric timestamp for pre-upgrade rows', () => { + // Pre-upgrade row: numeric timestamp only, no created_at on disk. + const preUpgrade = {...validRecord, created_at: undefined} + const wire = toWireEvent(preUpgrade) + + // The derived value must describe the same instant as `timestamp`, + // floored to the second (formatISO drops millis). 1_700_000_000_000 = 2023-11-14T22:13:20Z. + expect(wire.created_at).to.be.a('string') + expect(Date.parse(wire.created_at)).to.equal(1_700_000_000_000) + }) + it('should strip local fields when chained after Zod parse (sent record with attempts > 0)', () => { const recordWithStatusSent = {...validRecord, attempts: 2, status: 'sent' as const} const parsed = StoredAnalyticsRecordSchema.safeParse(recordWithStatusSent) @@ -247,6 +321,7 @@ describe('StoredAnalyticsRecord', () => { expect(wire.name).to.equal('cli_invocation') expect(wire).to.not.have.property('attempts') expect(wire).to.not.have.property('status') + expect(wire).to.not.have.property('timestamp') } }) }) From b94829c84a873bca16bb2cc3389437c2a275b1cb Mon Sep 17 00:00:00 2001 From: Cuong Date: Fri, 29 May 2026 10:44:37 +0700 Subject: [PATCH 77/87] feat: [ENG-3020] stamp space_id on curate_run_completed + query_completed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enriches the curate/query funnel events with the active Context Hub space ID so PMs can slice memory operations by space (e.g. % of curates landing in a connected space vs. standalone projects). Schemas: optional `space_id: z.string().min(1).max(64).optional()` on both `CurateRunCompletedSchema` and `QueryCompletedSchema`. Optional because the project may be standalone (no spaceId in config) and the hook must never block an emit on space metadata. AnalyticsHook: new `getSpaceId: (projectPath) => Promise` dep. Resolved at emit time inside the curate / query terminal paths in onTaskCompleted and dispatchTerminal — including the failure-path emits (onTaskError / onTaskCancelled). A defensive `resolveSpaceId` wrapper swallows producer throws and normalizes empty strings to undefined so a stale config or a projectStateLoader race never poisons the terminal emit. Daemon: wires `getSpaceId` through `projectStateLoader.getProjectConfig` keyed by the task's projectPath — the same source the `GET_PROJECT_CONFIG` transport endpoint reads (`config?.spaceId`). The accessor remains optional in the hook signature so unit tests default to a no-op. Privacy: `space_id` is an opaque backend-issued identifier (same shape as `from_space_id` / `to_space_id` in `space_switched`, which already ships verbatim to telemetry). Not on FORBIDDEN_FIELD_NAMES; not hashed. Tests: - Schema: accepts populated / omitted space_id; rejects empty + >64-char - Hook: stamps on curate + query happy paths; omits on undefined / empty / throw; stamps on failure-path (onTaskError) emits - 9897 passing, no regressions. --- src/server/infra/daemon/brv-server.ts | 5 + src/server/infra/process/analytics-hook.ts | 47 +++++++++- .../analytics/events/curate-run-completed.ts | 7 ++ .../analytics/events/query-completed.ts | 7 ++ .../infra/process/analytics-hook.test.ts | 93 +++++++++++++++++++ .../events/curate-run-completed.test.ts | 13 +++ .../analytics/events/query-completed.test.ts | 13 +++ 7 files changed, 181 insertions(+), 4 deletions(-) diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index cd8f7bc89..67281b69b 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -392,6 +392,11 @@ async function main(): Promise { // lifecycleHooks[] but still observe the live config. let isAnalyticsEnabledRef: () => boolean = () => true const analyticsHook = new AnalyticsHook({ + async getSpaceId(projectPath) { + if (!projectPath) return + const config = await projectStateLoader.getProjectConfig(projectPath) + return config?.spaceId + }, isEnabled: () => isAnalyticsEnabledRef(), }) diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index c3c4b8b8d..a11986c5f 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -213,6 +213,15 @@ const isCurateLiteral = (value: string): value is CurateTaskTypeLiteral => * payloads via a daemon-side post-op file read. */ type AnalyticsHookDeps = { + /** + * Look up the active Context Hub space ID for `projectPath` at emit + * time. Returns `undefined` when the project is unconnected, the + * lookup fails, or the daemon couldn't resolve a project path — + * a missing space_id NEVER blocks an emit. Production wires through + * `projectStateLoader.getProjectConfig` in `brv-server.ts`; tests + * default to a no-op that always returns `undefined`. + */ + getSpaceId?: (projectPath: string | undefined) => Promise /** * Returns the daemon's cached analytics-enabled flag. Used by M12.3 to * short-circuit frontmatter file reads when analytics is disabled (avoids @@ -232,6 +241,7 @@ type AnalyticsHookDeps = { export class AnalyticsHook implements ITaskLifecycleHook { /** Lazy-injected by the daemon after `setupFeatureHandlers` constructs the client. */ private analyticsClient?: IAnalyticsClient + private readonly getSpaceId: (projectPath: string | undefined) => Promise private readonly isEnabled: () => boolean /** * Per-task FIFO of in-flight `onToolResult` processing. Without this the @@ -248,6 +258,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { private readonly tasks = new Map() constructor(deps: AnalyticsHookDeps = {}) { + this.getSpaceId = deps.getSpaceId ?? (async () => {}) this.isEnabled = deps.isEnabled ?? (() => true) this.readFile = deps.readFile ?? readFileAsync } @@ -277,14 +288,16 @@ export class AnalyticsHook implements ITaskLifecycleHook { if (state.flavor === 'curate') { const outcome = state.counters.failed > 0 ? 'partial' : 'completed' + const spaceId = await this.resolveSpaceId(task.projectPath ?? state.projectPath) this.emit( AnalyticsEventNames.CURATE_RUN_COMPLETED, - this.buildCurateRunPayload({outcome, state, task, taskId}), + this.buildCurateRunPayload({outcome, spaceId, state, task, taskId}), ) } else { + const spaceId = await this.resolveSpaceId(task.projectPath) this.emit( AnalyticsEventNames.QUERY_COMPLETED, - await this.buildQueryCompletedPayload({outcome: 'completed', state, task, taskId}), + await this.buildQueryCompletedPayload({outcome: 'completed', spaceId, state, task, taskId}), ) } } @@ -379,11 +392,13 @@ export class AnalyticsHook implements ITaskLifecycleHook { private buildCurateRunPayload({ outcome, + spaceId, state, task, taskId, }: { outcome: 'cancelled' | 'completed' | 'error' | 'partial' + spaceId: string | undefined state: CurateTaskAnalyticsState task: TaskInfo taskId: string @@ -398,6 +413,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { outcome, pending_review_count: state.counters.pendingReview, ...projectPathHashOptional(task.projectPath ?? state.projectPath), + ...(spaceId === undefined ? {} : {space_id: spaceId}), task_id: taskId, task_type: toAnalyticsTaskType(state.taskType), } @@ -405,11 +421,13 @@ export class AnalyticsHook implements ITaskLifecycleHook { private async buildQueryCompletedPayload({ outcome, + spaceId, state, task, taskId, }: { outcome: 'cancelled' | 'completed' | 'error' + spaceId: string | undefined state: QueryTaskAnalyticsState task: TaskInfo taskId: string @@ -491,6 +509,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { ...(readPathsWithMetadata.length > 0 ? {read_paths_with_metadata: readPathsWithMetadata} : {}), read_tool_call_count: readToolCallCount, search_call_count: searchCallCount, + ...(spaceId === undefined ? {} : {space_id: spaceId}), task_id: taskId, task_type: toAnalyticsTaskType(task.type), ...(tier === undefined ? {} : {tier}), @@ -506,14 +525,16 @@ export class AnalyticsHook implements ITaskLifecycleHook { await this.pendingByTask.get(taskId) if (state.flavor === 'curate') { + const spaceId = await this.resolveSpaceId(task.projectPath ?? state.projectPath) this.emit( AnalyticsEventNames.CURATE_RUN_COMPLETED, - this.buildCurateRunPayload({outcome, state, task, taskId}), + this.buildCurateRunPayload({outcome, spaceId, state, task, taskId}), ) } else { + const spaceId = await this.resolveSpaceId(task.projectPath) this.emit( AnalyticsEventNames.QUERY_COMPLETED, - await this.buildQueryCompletedPayload({outcome, state, task, taskId}), + await this.buildQueryCompletedPayload({outcome, spaceId, state, task, taskId}), ) } } @@ -690,6 +711,24 @@ export class AnalyticsHook implements ITaskLifecycleHook { return {} } } + + /** + * Resolve the active space_id without ever throwing — a getSpaceId + * rejection (config-load failure, projectStateLoader race, etc.) MUST + * NOT take down the terminal emit. Anything other than a non-empty + * string normalizes to `undefined` so the payload spread omits the field. + */ + private async resolveSpaceId(projectPath: string | undefined): Promise { + try { + const value = await this.getSpaceId(projectPath) + return typeof value === 'string' && value.length > 0 ? value : undefined + } catch (error) { + processLog( + `AnalyticsHook: getSpaceId failed: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined + } + } } /** diff --git a/src/shared/analytics/events/curate-run-completed.ts b/src/shared/analytics/events/curate-run-completed.ts index f4e5cba6e..8b7cff628 100644 --- a/src/shared/analytics/events/curate-run-completed.ts +++ b/src/shared/analytics/events/curate-run-completed.ts @@ -28,6 +28,13 @@ export const CurateRunCompletedSchema = z pending_review_count: z.number().int().nonnegative(), /** M17 follow-up: see task-created.ts for the rationale. */ project_path_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(), + /** + * Active Context Hub space ID for the project, when connected. Sourced + * from `.brv/config.json#spaceId` at emit time. Omitted (not empty + * string) when the project is standalone or the lookup fails — never + * blocks an emit on space metadata. + */ + space_id: z.string().min(1).max(64).optional(), task_id: z.string().min(1), task_type: z.enum(TASK_TYPE_VALUES), }) diff --git a/src/shared/analytics/events/query-completed.ts b/src/shared/analytics/events/query-completed.ts index b77007748..4212c1d94 100644 --- a/src/shared/analytics/events/query-completed.ts +++ b/src/shared/analytics/events/query-completed.ts @@ -67,6 +67,13 @@ export const QueryCompletedSchema = z read_paths_with_metadata: z.array(ReadPathWithMetadataSchema).max(10).optional(), read_tool_call_count: z.number().int().nonnegative(), search_call_count: z.number().int().nonnegative(), + /** + * Active Context Hub space ID for the project, when connected. Sourced + * from `.brv/config.json#spaceId` at emit time. Omitted (not empty + * string) when the project is standalone or the lookup fails — never + * blocks an emit on space metadata. + */ + space_id: z.string().min(1).max(64).optional(), task_id: z.string().min(1), task_type: z.enum(TASK_TYPE_VALUES), tier: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(), diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts index 6e3ee8051..2640c556d 100644 --- a/test/unit/server/infra/process/analytics-hook.test.ts +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -81,6 +81,12 @@ const defer = (): Deferred => { const buildFrontmatterDoc = (tag: string): string => `---\ntags: ["${tag}"]\n---\nbody\n` +const findEmit = (stub: sinon.SinonStub, event: string): Record => { + const call = stub.getCalls().find((c) => c.args[0] === event) + if (!call) throw new Error(`expected ${event} emit not found`) + return call.args[1] as Record +} + const stubReadFileFromQueue = (...queue: Array>): ((p: string) => Promise) => () => { @@ -812,4 +818,91 @@ describe('AnalyticsHook', () => { expect(filterM12(bundle.trackStub), 'no replay after cleanup').to.have.lengthOf(2) }) }) + + describe('space_id stamping', () => { + it('stamps space_id on curate_run_completed when getSpaceId returns a value', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getSpaceId: async () => 'space-abc'}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(curateProps.space_id).to.equal('space-abc') + }) + + it('stamps space_id on query_completed when getSpaceId returns a value', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getSpaceId: async () => 'space-xyz'}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildQueryTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const queryProps = findEmit(bundle.trackStub, AnalyticsEventNames.QUERY_COMPLETED) + expect(queryProps.space_id).to.equal('space-xyz') + }) + + it('omits space_id when getSpaceId returns undefined (standalone project)', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({async getSpaceId() {}}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(curateProps).to.not.have.property('space_id') + }) + + it('omits space_id when getSpaceId returns an empty string', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getSpaceId: async () => ''}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildQueryTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const queryProps = findEmit(bundle.trackStub, AnalyticsEventNames.QUERY_COMPLETED) + expect(queryProps).to.not.have.property('space_id') + }) + + it('omits space_id and still emits when getSpaceId throws', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({ + async getSpaceId() { + throw new Error('config disk unreadable') + }, + }) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(curateProps).to.not.have.property('space_id') + // Funnel emit still lands — getSpaceId failure must not block the run-completion emit. + expect(curateProps.task_type).to.equal('curate') + }) + + it('also stamps space_id on the failure-path emits (onTaskError / onTaskCancelled)', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getSpaceId: async () => 'space-fail'}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskError(task.taskId, 'boom', task) + + const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(curateProps.outcome).to.equal('error') + expect(curateProps.space_id).to.equal('space-fail') + }) + }) }) diff --git a/test/unit/shared/analytics/events/curate-run-completed.test.ts b/test/unit/shared/analytics/events/curate-run-completed.test.ts index 5ec7cec2d..144b0ba5f 100644 --- a/test/unit/shared/analytics/events/curate-run-completed.test.ts +++ b/test/unit/shared/analytics/events/curate-run-completed.test.ts @@ -47,6 +47,14 @@ describe('CurateRunCompletedSchema', () => { } expect(CurateRunCompletedSchema.safeParse(zeroed).success).to.equal(true) }) + + it('accepts a populated space_id', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, space_id: 'space-uuid-abc'}).success).to.equal(true) + }) + + it('accepts payloads with no space_id field (standalone project)', () => { + expect(CurateRunCompletedSchema.safeParse(baseValid).success).to.equal(true) + }) }) describe('invalid payloads', () => { @@ -90,5 +98,10 @@ describe('CurateRunCompletedSchema', () => { it('rejects unknown extra fields (strict)', () => { expect(CurateRunCompletedSchema.safeParse({...baseValid, mystery_field: 'oops'}).success).to.equal(false) }) + + it('rejects empty / over-cap space_id', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, space_id: ''}).success).to.equal(false) + expect(CurateRunCompletedSchema.safeParse({...baseValid, space_id: 'x'.repeat(65)}).success).to.equal(false) + }) }) }) diff --git a/test/unit/shared/analytics/events/query-completed.test.ts b/test/unit/shared/analytics/events/query-completed.test.ts index 197df52ef..4ffc41866 100644 --- a/test/unit/shared/analytics/events/query-completed.test.ts +++ b/test/unit/shared/analytics/events/query-completed.test.ts @@ -94,6 +94,14 @@ describe('QueryCompletedSchema', () => { expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(true) }) + it('accepts a populated space_id', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, space_id: 'space-uuid-abc'}).success).to.equal(true) + }) + + it('accepts payloads omitting space_id (standalone project)', () => { + expect(QueryCompletedSchema.safeParse(baseValid).success).to.equal(true) + }) + it('accepts related_paths with up to 50 structured entries', () => { const fifty = Array.from({length: 50}, (_, i) => ({ keywords: [], @@ -182,6 +190,11 @@ describe('QueryCompletedSchema', () => { expect(QueryCompletedSchema.safeParse({...baseValid, mystery_field: 'oops'}).success).to.equal(false) }) + it('rejects empty / over-cap space_id', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, space_id: ''}).success).to.equal(false) + expect(QueryCompletedSchema.safeParse({...baseValid, space_id: 'x'.repeat(65)}).success).to.equal(false) + }) + it('rejects unknown extra fields inside an entry (strict)', () => { const entries = [{...baseEntry, mystery: 'oops'}] expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) From 42290522c941a801782861139e09b4d28d1d8062 Mon Sep 17 00:00:00 2001 From: Cuong Date: Fri, 29 May 2026 11:00:50 +0700 Subject: [PATCH 78/87] fix: [ENG-3020] keep explicit return type on AnalyticsHook getSpaceId default The previous commit's lint-staged auto-fix stripped the explicit `Promise` annotation from the no-op default, collapsing the body to `async () => {}`. That widens the inferred return type to `Promise`, which then fails the constructor field assignment with TS2322 (`Promise` not assignable to `Promise`). Pin the explicit annotation so the no-op default keeps the same contract the dep type advertises. --- src/server/infra/process/analytics-hook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index a11986c5f..b8a713876 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -258,7 +258,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { private readonly tasks = new Map() constructor(deps: AnalyticsHookDeps = {}) { - this.getSpaceId = deps.getSpaceId ?? (async () => {}) + this.getSpaceId = deps.getSpaceId ?? (async (): Promise => undefined) this.isEnabled = deps.isEnabled ?? (() => true) this.readFile = deps.readFile ?? readFileAsync } From 3568d4b49a4b822189799c5b699c2bba33eb4bf3 Mon Sep 17 00:00:00 2001 From: bao-byterover Date: Fri, 29 May 2026 11:51:23 +0700 Subject: [PATCH 79/87] Feat/eng 3010 (#737) * feat: [ENG-3010] replace wire timestamp with created_at (ISO 8601) * feat: [ENG-3010] bump wire schema_version to 2 Completes the load-bearing bump missed in 2bdfcac4a (which renamed the per-event field timestamp to created_at but kept schema_version: 1). The backend dispatches on schema_version to route v2 (created_at) vs v1 (numeric timestamp); a v2-shape body labeled v1 always 400s. * fix: [ENG-3010] address PR #737 bot review - event.ts: drop stray leading whitespace before the JSDoc opener - daemon-tracking.test.ts: restore the (before - 1000) 1s safety margin to keep this PR scoped to the schema_version bump - batch.ts: extend the JSDoc with the deployment-ordering constraint (backend v2 handler must be live before a v2-emitting CLI ships) --- src/server/core/domain/analytics/batch.ts | 17 ++++--- test/e2e/analytics/dev-beta.e2e.ts | 6 +-- test/e2e/analytics/lifecycle-db.e2e.ts | 2 +- .../analytics/daemon-tracking.test.ts | 2 +- .../core/domain/analytics/batch.test.ts | 50 +++++++++---------- .../axios-analytics-http-client.test.ts | 2 +- .../analytics/no-op-analytics-client.test.ts | 4 +- 7 files changed, 42 insertions(+), 41 deletions(-) diff --git a/src/server/core/domain/analytics/batch.ts b/src/server/core/domain/analytics/batch.ts index 5ca0fb8e5..edcac956a 100644 --- a/src/server/core/domain/analytics/batch.ts +++ b/src/server/core/domain/analytics/batch.ts @@ -11,12 +11,17 @@ import type {Identity} from './identity.js' export type AnalyticsEventWithIdentity = AnalyticsEvent & Readonly<{identity: Identity}> /** - * Wire shape for a batch of analytics events. `schema_version: 1` is the - * only currently-supported value. + * Wire shape for a batch of analytics events. `schema_version: 2` is the + * only currently-supported value; the backend dispatches on this field to + * route v2 batches (per-event `created_at`) away from the legacy v1 + * (per-event numeric `timestamp`) handler. Coordinate any bump with the + * byterover-telemetry deployment - the v2 handler must be live before a + * CLI that emits v2 ships, or every flush will fail validation and queue + * up to the retry cap. */ export type AnalyticsBatchJson = Readonly<{ events: ReadonlyArray - schema_version: 1 + schema_version: 2 }> /** @@ -49,7 +54,7 @@ const AnalyticsEventWithIdentityWireSchema = z const AnalyticsBatchJsonSchema = z.object({ events: z.array(AnalyticsEventWithIdentityWireSchema), - schema_version: z.literal(1), + schema_version: z.literal(2), }) /** @@ -59,11 +64,11 @@ const AnalyticsBatchJsonSchema = z.object({ */ export class AnalyticsBatch { public readonly events: ReadonlyArray - public readonly schema_version: 1 + public readonly schema_version: 2 private constructor(events: ReadonlyArray) { this.events = events - this.schema_version = 1 + this.schema_version = 2 } /** diff --git a/test/e2e/analytics/dev-beta.e2e.ts b/test/e2e/analytics/dev-beta.e2e.ts index 75816ac93..c4058a5cd 100644 --- a/test/e2e/analytics/dev-beta.e2e.ts +++ b/test/e2e/analytics/dev-beta.e2e.ts @@ -287,8 +287,8 @@ function readBackoffFailures(env: NodeJS.ProcessEnv): {failures: number; state: async function preflightBackend(url: string): Promise<{ok: boolean; reason?: string}> { // Wire format: per-event `created_at` (ISO 8601 with offset), - // `schema_version: 1`, no numeric `timestamp` field. If the backend - // still validates against a legacy `{timestamp: number}` shape or + // `schema_version: 2`, no numeric `timestamp` field. If the backend + // still validates against the legacy v1 `{timestamp: number}` shape or // rejects this shape via `forbidNonWhitelisted`, every scenario would // FAIL with retry-cap exhaustion; better to skip the suite up-front // with a clear reason. @@ -306,7 +306,7 @@ async function preflightBackend(url: string): Promise<{ok: boolean; reason?: str }, }, ], - schema_version: 1, + schema_version: 2, }) try { const ctrl = new AbortController() diff --git a/test/e2e/analytics/lifecycle-db.e2e.ts b/test/e2e/analytics/lifecycle-db.e2e.ts index 5e2c38276..b33fe17f0 100644 --- a/test/e2e/analytics/lifecycle-db.e2e.ts +++ b/test/e2e/analytics/lifecycle-db.e2e.ts @@ -278,7 +278,7 @@ async function fireCreateAndCancel( * point, not a per-test re-check. */ function assertRowShape(row: RawEventRow): void { - expect(row.schema_version, `${row.event_name}.schema_version`).to.equal(1) + expect(row.schema_version, `${row.event_name}.schema_version`).to.equal(2) expect(row.cli_version, `${row.event_name}.cli_version`).to.match(/^\d+\.\d+\.\d+/) expect(row.os, `${row.event_name}.os`).to.be.oneOf(['darwin', 'linux', 'win32']) expect(row.node_version, `${row.event_name}.node_version`).to.match(/^v\d+\./) diff --git a/test/integration/analytics/daemon-tracking.test.ts b/test/integration/analytics/daemon-tracking.test.ts index b6656753b..ce7783b66 100644 --- a/test/integration/analytics/daemon-tracking.test.ts +++ b/test/integration/analytics/daemon-tracking.test.ts @@ -142,7 +142,7 @@ describe('daemon analytics tracking integration (ticket scenario 6)', () => { const restored = AnalyticsBatch.fromJson(batch.toJson()) expect(restored).to.not.be.undefined - expect(restored?.schema_version).to.equal(1) + expect(restored?.schema_version).to.equal(2) expect(restored?.events).to.have.lengthOf(1) expect(restored?.events[0].name).to.equal('daemon_start') expect(restored?.events[0].identity.device_id).to.equal(validDeviceId) diff --git a/test/unit/server/core/domain/analytics/batch.test.ts b/test/unit/server/core/domain/analytics/batch.test.ts index 1cfc33858..1e9155705 100644 --- a/test/unit/server/core/domain/analytics/batch.test.ts +++ b/test/unit/server/core/domain/analytics/batch.test.ts @@ -26,7 +26,7 @@ describe('AnalyticsBatch', () => { it('should create an empty batch', () => { const batch = AnalyticsBatch.create([]) - expect(batch.schema_version).to.equal(1) + expect(batch.schema_version).to.equal(2) expect(batch.events).to.deep.equal([]) }) @@ -43,14 +43,14 @@ describe('AnalyticsBatch', () => { it('should serialize an empty batch', () => { const batch = AnalyticsBatch.create([]) - expect(batch.toJson()).to.deep.equal({events: [], schema_version: 1}) + expect(batch.toJson()).to.deep.equal({events: [], schema_version: 2}) }) it('should serialize a populated batch with all event fields', () => { const batch = AnalyticsBatch.create([eventA]) const json = batch.toJson() - expect(json.schema_version).to.equal(1) + expect(json.schema_version).to.equal(2) expect(json.events).to.have.lengthOf(1) expect(json.events[0]).to.deep.equal(eventA) }) @@ -62,7 +62,7 @@ describe('AnalyticsBatch', () => { const restored = AnalyticsBatch.fromJson(original.toJson()) expect(restored).to.not.be.undefined - expect(restored?.schema_version).to.equal(1) + expect(restored?.schema_version).to.equal(2) expect(restored?.events).to.deep.equal([]) }) @@ -96,22 +96,22 @@ describe('AnalyticsBatch', () => { expect(AnalyticsBatch.fromJson({events: []})).to.be.undefined }) - it('should return undefined when schema_version is not 1', () => { - expect(AnalyticsBatch.fromJson({events: [], schema_version: 2})).to.be.undefined + it('should return undefined when schema_version is not 2', () => { + expect(AnalyticsBatch.fromJson({events: [], schema_version: 1})).to.be.undefined expect(AnalyticsBatch.fromJson({events: [], schema_version: 0})).to.be.undefined - expect(AnalyticsBatch.fromJson({events: [], schema_version: '1'})).to.be.undefined + expect(AnalyticsBatch.fromJson({events: [], schema_version: '2'})).to.be.undefined }) it('should return undefined when events is not an array', () => { - expect(AnalyticsBatch.fromJson({events: {}, schema_version: 1})).to.be.undefined - expect(AnalyticsBatch.fromJson({events: 'foo', schema_version: 1})).to.be.undefined - expect(AnalyticsBatch.fromJson({schema_version: 1})).to.be.undefined + expect(AnalyticsBatch.fromJson({events: {}, schema_version: 2})).to.be.undefined + expect(AnalyticsBatch.fromJson({events: 'foo', schema_version: 2})).to.be.undefined + expect(AnalyticsBatch.fromJson({schema_version: 2})).to.be.undefined }) it('should return undefined when an event is missing name', () => { const json = { events: [{created_at: '2023-11-14T22:13:20+00:00', identity: validIdentity, properties: {}}], - schema_version: 1, + schema_version: 2, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined }) @@ -119,7 +119,7 @@ describe('AnalyticsBatch', () => { it('should return undefined when an event has non-string name', () => { const json = { events: [{created_at: '2023-11-14T22:13:20+00:00', identity: validIdentity, name: 123, properties: {}}], - schema_version: 1, + schema_version: 2, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined }) @@ -127,7 +127,7 @@ describe('AnalyticsBatch', () => { it('should return undefined when an event is missing identity', () => { const json = { events: [{created_at: '2023-11-14T22:13:20+00:00', name: 'x', properties: {}}], - schema_version: 1, + schema_version: 2, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined }) @@ -135,17 +135,15 @@ describe('AnalyticsBatch', () => { it('should return undefined when identity is missing device_id', () => { const json = { events: [{created_at: '2023-11-14T22:13:20+00:00', identity: {}, name: 'x', properties: {}}], - schema_version: 1, + schema_version: 2, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined }) it('should return undefined when identity has empty device_id', () => { const json = { - events: [ - {created_at: '2023-11-14T22:13:20+00:00', identity: {device_id: ''}, name: 'x', properties: {}}, - ], - schema_version: 1, + events: [{created_at: '2023-11-14T22:13:20+00:00', identity: {device_id: ''}, name: 'x', properties: {}}], + schema_version: 2, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined }) @@ -153,7 +151,7 @@ describe('AnalyticsBatch', () => { it('should return undefined when an event is missing created_at', () => { const json = { events: [{identity: validIdentity, name: 'x', properties: {}}], - schema_version: 1, + schema_version: 2, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined }) @@ -161,7 +159,7 @@ describe('AnalyticsBatch', () => { it('should return undefined when an event has a non-string created_at', () => { const json = { events: [{created_at: 1_700_000_000_000, identity: validIdentity, name: 'x', properties: {}}], - schema_version: 1, + schema_version: 2, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined }) @@ -169,7 +167,7 @@ describe('AnalyticsBatch', () => { it('should return undefined when created_at is missing a timezone designator', () => { const json = { events: [{created_at: '2023-11-14T22:13:20', identity: validIdentity, name: 'x', properties: {}}], - schema_version: 1, + schema_version: 2, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined }) @@ -178,7 +176,7 @@ describe('AnalyticsBatch', () => { for (const ts of ['2023-11-14T22:13:20Z', '2023-11-14T22:13:20+07:00', '2023-11-14T22:13:20.123-05:30']) { const json = { events: [{created_at: ts, identity: validIdentity, name: 'x', properties: {}}], - schema_version: 1, + schema_version: 2, } expect(AnalyticsBatch.fromJson(json), `created_at=${ts} should parse`).to.not.be.undefined } @@ -198,17 +196,15 @@ describe('AnalyticsBatch', () => { timestamp: 1_700_000_000_000, }, ], - schema_version: 1, + schema_version: 2, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined }) it('should return undefined when an event has non-object properties', () => { const json = { - events: [ - {created_at: '2023-11-14T22:13:20+00:00', identity: validIdentity, name: 'x', properties: 'foo'}, - ], - schema_version: 1, + events: [{created_at: '2023-11-14T22:13:20+00:00', identity: validIdentity, name: 'x', properties: 'foo'}], + schema_version: 2, } expect(AnalyticsBatch.fromJson(json)).to.be.undefined }) diff --git a/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts b/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts index 08bfa9d2b..72702f1cc 100644 --- a/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts +++ b/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts @@ -55,7 +55,7 @@ describe('AxiosAnalyticsHttpClient', () => { expect(result).to.deep.equal({ok: true}) expect(scope.isDone()).to.equal(true) // Body matches the AnalyticsBatch.toJson() wire shape. - expect(receivedBody).to.have.property('schema_version', 1) + expect(receivedBody).to.have.property('schema_version', 2) expect(receivedBody).to.have.nested.property('events.0.name', 'event_0') }) diff --git a/test/unit/server/infra/analytics/no-op-analytics-client.test.ts b/test/unit/server/infra/analytics/no-op-analytics-client.test.ts index f178efa2b..1844314d8 100644 --- a/test/unit/server/infra/analytics/no-op-analytics-client.test.ts +++ b/test/unit/server/infra/analytics/no-op-analytics-client.test.ts @@ -51,12 +51,12 @@ describe('NoOpAnalyticsClient', () => { }) describe('flush()', () => { - it('should resolve to an empty batch with schema_version: 1', async () => { + it('should resolve to an empty batch with schema_version: 2', async () => { const client = new NoOpAnalyticsClient() const batch = await client.flush() - expect(batch.schema_version).to.equal(1) + expect(batch.schema_version).to.equal(2) expect(batch.events).to.deep.equal([]) }) From 525ff579919486e5e640ae651683622a007ca8ba Mon Sep 17 00:00:00 2001 From: Cuong Date: Fri, 29 May 2026 12:02:46 +0700 Subject: [PATCH 80/87] feat: [ENG-3020] also stamp team_id on curate_run_completed + query_completed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles team_id alongside space_id (added in b94829c84) so PMs can slice the curate/query funnel by team as well as space. Schemas: optional `team_id: z.string().min(1).max(64).optional()` on both CurateRunCompletedSchema and QueryCompletedSchema. Same opaque-ID shape, same independent-optional semantics — a project may have a team without a space (mid-onboarding) and either / both / neither field can be present. AnalyticsHook: replaced the `getSpaceId` accessor with `getIdentity: (projectPath) => Promise<{spaceId?, teamId?}>` so a single config read serves both stamps at terminal-emit time. The defensive `resolveIdentity` wrapper normalizes empty strings to undefined per-field and swallows producer throws — a missing / broken identity NEVER blocks a terminal emit. Daemon: wires getIdentity through `projectStateLoader.getProjectConfig`, returning {spaceId: config?.spaceId, teamId: config?.teamId}. Tests: - Schema: accepts populated team_id, both together, neither; rejects empty + over-64-char (per-field) - Hook: stamps team_id alone (no space), space alone (no team), both, neither; normalizes empty strings; survives throw; stamps on failure-path emits Validated end-to-end via real curate + query in vibing-zone (a connected project with both spaceId + teamId): curate_run_completed | space_id=019e4505-... | team_id=019b8213-... query_completed | space_id=019e4505-... | team_id=019b8213-... rows landed in telemetry postgres with both fields populated. --- src/server/infra/daemon/brv-server.ts | 6 +- src/server/infra/process/analytics-hook.ts | 82 +++++++++++-------- .../analytics/events/curate-run-completed.ts | 6 ++ .../analytics/events/query-completed.ts | 6 ++ .../infra/process/analytics-hook.test.ts | 62 ++++++++++---- .../events/curate-run-completed.test.ts | 17 +++- .../analytics/events/query-completed.test.ts | 17 +++- 7 files changed, 145 insertions(+), 51 deletions(-) diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index 67281b69b..4a061b29a 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -392,10 +392,10 @@ async function main(): Promise { // lifecycleHooks[] but still observe the live config. let isAnalyticsEnabledRef: () => boolean = () => true const analyticsHook = new AnalyticsHook({ - async getSpaceId(projectPath) { - if (!projectPath) return + async getIdentity(projectPath) { + if (!projectPath) return {} const config = await projectStateLoader.getProjectConfig(projectPath) - return config?.spaceId + return {spaceId: config?.spaceId, teamId: config?.teamId} }, isEnabled: () => isAnalyticsEnabledRef(), }) diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index b8a713876..8abdb79c7 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -212,16 +212,29 @@ const isCurateLiteral = (value: string): value is CurateTaskTypeLiteral => * `tags` / `keywords` / `related` arrays onto the curate-op and per-read-path * payloads via a daemon-side post-op file read. */ +/** + * Bundle of project-scoped identity fields stamped on terminal emits. + * Each field is independently optional — a project may have a teamId + * without a spaceId (mid-onboarding) or neither (standalone). + */ +type ProjectIdentity = { + spaceId?: string + teamId?: string +} + type AnalyticsHookDeps = { /** - * Look up the active Context Hub space ID for `projectPath` at emit - * time. Returns `undefined` when the project is unconnected, the - * lookup fails, or the daemon couldn't resolve a project path — - * a missing space_id NEVER blocks an emit. Production wires through - * `projectStateLoader.getProjectConfig` in `brv-server.ts`; tests - * default to a no-op that always returns `undefined`. + * Look up the Context Hub identity (space_id + team_id) for `projectPath` + * at emit time. Returns `{}` when the project is unconnected, the lookup + * fails, or the daemon couldn't resolve a project path — missing identity + * fields NEVER block an emit. Production wires through + * `projectStateLoader.getProjectConfig` in `brv-server.ts`; tests default + * to a no-op that always returns `{}`. + * + * Bundled (instead of one accessor per field) so a single config read + * serves both stamps at terminal time. */ - getSpaceId?: (projectPath: string | undefined) => Promise + getIdentity?: (projectPath: string | undefined) => Promise /** * Returns the daemon's cached analytics-enabled flag. Used by M12.3 to * short-circuit frontmatter file reads when analytics is disabled (avoids @@ -241,7 +254,7 @@ type AnalyticsHookDeps = { export class AnalyticsHook implements ITaskLifecycleHook { /** Lazy-injected by the daemon after `setupFeatureHandlers` constructs the client. */ private analyticsClient?: IAnalyticsClient - private readonly getSpaceId: (projectPath: string | undefined) => Promise + private readonly getIdentity: (projectPath: string | undefined) => Promise private readonly isEnabled: () => boolean /** * Per-task FIFO of in-flight `onToolResult` processing. Without this the @@ -258,7 +271,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { private readonly tasks = new Map() constructor(deps: AnalyticsHookDeps = {}) { - this.getSpaceId = deps.getSpaceId ?? (async (): Promise => undefined) + this.getIdentity = deps.getIdentity ?? (async (): Promise => ({})) this.isEnabled = deps.isEnabled ?? (() => true) this.readFile = deps.readFile ?? readFileAsync } @@ -288,16 +301,16 @@ export class AnalyticsHook implements ITaskLifecycleHook { if (state.flavor === 'curate') { const outcome = state.counters.failed > 0 ? 'partial' : 'completed' - const spaceId = await this.resolveSpaceId(task.projectPath ?? state.projectPath) + const identity = await this.resolveIdentity(task.projectPath ?? state.projectPath) this.emit( AnalyticsEventNames.CURATE_RUN_COMPLETED, - this.buildCurateRunPayload({outcome, spaceId, state, task, taskId}), + this.buildCurateRunPayload({identity, outcome, state, task, taskId}), ) } else { - const spaceId = await this.resolveSpaceId(task.projectPath) + const identity = await this.resolveIdentity(task.projectPath) this.emit( AnalyticsEventNames.QUERY_COMPLETED, - await this.buildQueryCompletedPayload({outcome: 'completed', spaceId, state, task, taskId}), + await this.buildQueryCompletedPayload({identity, outcome: 'completed', state, task, taskId}), ) } } @@ -391,14 +404,14 @@ export class AnalyticsHook implements ITaskLifecycleHook { } private buildCurateRunPayload({ + identity, outcome, - spaceId, state, task, taskId, }: { + identity: ProjectIdentity outcome: 'cancelled' | 'completed' | 'error' | 'partial' - spaceId: string | undefined state: CurateTaskAnalyticsState task: TaskInfo taskId: string @@ -413,21 +426,22 @@ export class AnalyticsHook implements ITaskLifecycleHook { outcome, pending_review_count: state.counters.pendingReview, ...projectPathHashOptional(task.projectPath ?? state.projectPath), - ...(spaceId === undefined ? {} : {space_id: spaceId}), + ...(identity.spaceId === undefined ? {} : {space_id: identity.spaceId}), task_id: taskId, task_type: toAnalyticsTaskType(state.taskType), + ...(identity.teamId === undefined ? {} : {team_id: identity.teamId}), } } private async buildQueryCompletedPayload({ + identity, outcome, - spaceId, state, task, taskId, }: { + identity: ProjectIdentity outcome: 'cancelled' | 'completed' | 'error' - spaceId: string | undefined state: QueryTaskAnalyticsState task: TaskInfo taskId: string @@ -509,9 +523,10 @@ export class AnalyticsHook implements ITaskLifecycleHook { ...(readPathsWithMetadata.length > 0 ? {read_paths_with_metadata: readPathsWithMetadata} : {}), read_tool_call_count: readToolCallCount, search_call_count: searchCallCount, - ...(spaceId === undefined ? {} : {space_id: spaceId}), + ...(identity.spaceId === undefined ? {} : {space_id: identity.spaceId}), task_id: taskId, task_type: toAnalyticsTaskType(task.type), + ...(identity.teamId === undefined ? {} : {team_id: identity.teamId}), ...(tier === undefined ? {} : {tier}), } } @@ -525,16 +540,16 @@ export class AnalyticsHook implements ITaskLifecycleHook { await this.pendingByTask.get(taskId) if (state.flavor === 'curate') { - const spaceId = await this.resolveSpaceId(task.projectPath ?? state.projectPath) + const identity = await this.resolveIdentity(task.projectPath ?? state.projectPath) this.emit( AnalyticsEventNames.CURATE_RUN_COMPLETED, - this.buildCurateRunPayload({outcome, spaceId, state, task, taskId}), + this.buildCurateRunPayload({identity, outcome, state, task, taskId}), ) } else { - const spaceId = await this.resolveSpaceId(task.projectPath) + const identity = await this.resolveIdentity(task.projectPath) this.emit( AnalyticsEventNames.QUERY_COMPLETED, - await this.buildQueryCompletedPayload({outcome, spaceId, state, task, taskId}), + await this.buildQueryCompletedPayload({identity, outcome, state, task, taskId}), ) } } @@ -713,20 +728,23 @@ export class AnalyticsHook implements ITaskLifecycleHook { } /** - * Resolve the active space_id without ever throwing — a getSpaceId - * rejection (config-load failure, projectStateLoader race, etc.) MUST - * NOT take down the terminal emit. Anything other than a non-empty - * string normalizes to `undefined` so the payload spread omits the field. + * Resolve the project identity (spaceId + teamId) without ever throwing — + * a getIdentity rejection (config-load failure, projectStateLoader race, + * etc.) MUST NOT take down the terminal emit. Empty strings normalize to + * `undefined` per-field so the payload spread omits each independently. */ - private async resolveSpaceId(projectPath: string | undefined): Promise { + private async resolveIdentity(projectPath: string | undefined): Promise { try { - const value = await this.getSpaceId(projectPath) - return typeof value === 'string' && value.length > 0 ? value : undefined + const raw = await this.getIdentity(projectPath) + return { + spaceId: typeof raw.spaceId === 'string' && raw.spaceId.length > 0 ? raw.spaceId : undefined, + teamId: typeof raw.teamId === 'string' && raw.teamId.length > 0 ? raw.teamId : undefined, + } } catch (error) { processLog( - `AnalyticsHook: getSpaceId failed: ${error instanceof Error ? error.message : String(error)}`, + `AnalyticsHook: getIdentity failed: ${error instanceof Error ? error.message : String(error)}`, ) - return undefined + return {} } } } diff --git a/src/shared/analytics/events/curate-run-completed.ts b/src/shared/analytics/events/curate-run-completed.ts index 8b7cff628..2f12309e2 100644 --- a/src/shared/analytics/events/curate-run-completed.ts +++ b/src/shared/analytics/events/curate-run-completed.ts @@ -37,6 +37,12 @@ export const CurateRunCompletedSchema = z space_id: z.string().min(1).max(64).optional(), task_id: z.string().min(1), task_type: z.enum(TASK_TYPE_VALUES), + /** + * Active team ID for the project, when connected. Independent of + * `space_id` — a project can have a team without a space (intermediate + * onboarding state). Same opaque-ID shape and emit semantics. + */ + team_id: z.string().min(1).max(64).optional(), }) .strict() diff --git a/src/shared/analytics/events/query-completed.ts b/src/shared/analytics/events/query-completed.ts index 4212c1d94..2ab860981 100644 --- a/src/shared/analytics/events/query-completed.ts +++ b/src/shared/analytics/events/query-completed.ts @@ -76,6 +76,12 @@ export const QueryCompletedSchema = z space_id: z.string().min(1).max(64).optional(), task_id: z.string().min(1), task_type: z.enum(TASK_TYPE_VALUES), + /** + * Active team ID for the project, when connected. Independent of + * `space_id` — a project can have a team without a space (intermediate + * onboarding state). Same opaque-ID shape and emit semantics. + */ + team_id: z.string().min(1).max(64).optional(), tier: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(), }) .strict() diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts index 2640c556d..6a9dce0bc 100644 --- a/test/unit/server/infra/process/analytics-hook.test.ts +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -819,10 +819,10 @@ describe('AnalyticsHook', () => { }) }) - describe('space_id stamping', () => { - it('stamps space_id on curate_run_completed when getSpaceId returns a value', async () => { + describe('identity stamping (space_id + team_id)', () => { + it('stamps both space_id and team_id on curate_run_completed when getIdentity returns them', async () => { const bundle = buildAnalyticsClient() - const spacedHook = new AnalyticsHook({getSpaceId: async () => 'space-abc'}) + const spacedHook = new AnalyticsHook({getIdentity: async () => ({spaceId: 'space-abc', teamId: 'team-abc'})}) spacedHook.setAnalyticsClient(bundle.client) const task = buildCurateTask() @@ -831,11 +831,12 @@ describe('AnalyticsHook', () => { const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) expect(curateProps.space_id).to.equal('space-abc') + expect(curateProps.team_id).to.equal('team-abc') }) - it('stamps space_id on query_completed when getSpaceId returns a value', async () => { + it('stamps both space_id and team_id on query_completed when getIdentity returns them', async () => { const bundle = buildAnalyticsClient() - const spacedHook = new AnalyticsHook({getSpaceId: async () => 'space-xyz'}) + const spacedHook = new AnalyticsHook({getIdentity: async () => ({spaceId: 'space-xyz', teamId: 'team-xyz'})}) spacedHook.setAnalyticsClient(bundle.client) const task = buildQueryTask() @@ -844,11 +845,12 @@ describe('AnalyticsHook', () => { const queryProps = findEmit(bundle.trackStub, AnalyticsEventNames.QUERY_COMPLETED) expect(queryProps.space_id).to.equal('space-xyz') + expect(queryProps.team_id).to.equal('team-xyz') }) - it('omits space_id when getSpaceId returns undefined (standalone project)', async () => { + it('stamps team_id alone when spaceId is absent (mid-onboarding state)', async () => { const bundle = buildAnalyticsClient() - const spacedHook = new AnalyticsHook({async getSpaceId() {}}) + const spacedHook = new AnalyticsHook({getIdentity: async () => ({teamId: 'team-only'})}) spacedHook.setAnalyticsClient(bundle.client) const task = buildCurateTask() @@ -856,12 +858,41 @@ describe('AnalyticsHook', () => { await spacedHook.onTaskCompleted(task.taskId, '', task) const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(curateProps.team_id).to.equal('team-only') expect(curateProps).to.not.have.property('space_id') }) - it('omits space_id when getSpaceId returns an empty string', async () => { + it('stamps space_id alone when teamId is absent', async () => { const bundle = buildAnalyticsClient() - const spacedHook = new AnalyticsHook({getSpaceId: async () => ''}) + const spacedHook = new AnalyticsHook({getIdentity: async () => ({spaceId: 'space-only'})}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildQueryTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const queryProps = findEmit(bundle.trackStub, AnalyticsEventNames.QUERY_COMPLETED) + expect(queryProps.space_id).to.equal('space-only') + expect(queryProps).to.not.have.property('team_id') + }) + + it('omits both fields when getIdentity returns {} (standalone project)', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getIdentity: async () => ({})}) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(curateProps).to.not.have.property('space_id') + expect(curateProps).to.not.have.property('team_id') + }) + + it('normalizes empty strings to omitted fields', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getIdentity: async () => ({spaceId: '', teamId: ''})}) spacedHook.setAnalyticsClient(bundle.client) const task = buildQueryTask() @@ -870,12 +901,13 @@ describe('AnalyticsHook', () => { const queryProps = findEmit(bundle.trackStub, AnalyticsEventNames.QUERY_COMPLETED) expect(queryProps).to.not.have.property('space_id') + expect(queryProps).to.not.have.property('team_id') }) - it('omits space_id and still emits when getSpaceId throws', async () => { + it('omits both fields and still emits when getIdentity throws', async () => { const bundle = buildAnalyticsClient() const spacedHook = new AnalyticsHook({ - async getSpaceId() { + async getIdentity() { throw new Error('config disk unreadable') }, }) @@ -887,13 +919,14 @@ describe('AnalyticsHook', () => { const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) expect(curateProps).to.not.have.property('space_id') - // Funnel emit still lands — getSpaceId failure must not block the run-completion emit. + expect(curateProps).to.not.have.property('team_id') + // Funnel emit still lands — getIdentity failure must not block the run-completion emit. expect(curateProps.task_type).to.equal('curate') }) - it('also stamps space_id on the failure-path emits (onTaskError / onTaskCancelled)', async () => { + it('also stamps both fields on the failure-path emits (onTaskError)', async () => { const bundle = buildAnalyticsClient() - const spacedHook = new AnalyticsHook({getSpaceId: async () => 'space-fail'}) + const spacedHook = new AnalyticsHook({getIdentity: async () => ({spaceId: 'space-fail', teamId: 'team-fail'})}) spacedHook.setAnalyticsClient(bundle.client) const task = buildCurateTask() @@ -903,6 +936,7 @@ describe('AnalyticsHook', () => { const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) expect(curateProps.outcome).to.equal('error') expect(curateProps.space_id).to.equal('space-fail') + expect(curateProps.team_id).to.equal('team-fail') }) }) }) diff --git a/test/unit/shared/analytics/events/curate-run-completed.test.ts b/test/unit/shared/analytics/events/curate-run-completed.test.ts index 144b0ba5f..63ebc7cfa 100644 --- a/test/unit/shared/analytics/events/curate-run-completed.test.ts +++ b/test/unit/shared/analytics/events/curate-run-completed.test.ts @@ -52,7 +52,17 @@ describe('CurateRunCompletedSchema', () => { expect(CurateRunCompletedSchema.safeParse({...baseValid, space_id: 'space-uuid-abc'}).success).to.equal(true) }) - it('accepts payloads with no space_id field (standalone project)', () => { + it('accepts a populated team_id', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, team_id: 'team-uuid-abc'}).success).to.equal(true) + }) + + it('accepts both space_id and team_id together', () => { + expect( + CurateRunCompletedSchema.safeParse({...baseValid, space_id: 'space-uuid', team_id: 'team-uuid'}).success, + ).to.equal(true) + }) + + it('accepts payloads with no space_id and no team_id (standalone project)', () => { expect(CurateRunCompletedSchema.safeParse(baseValid).success).to.equal(true) }) }) @@ -103,5 +113,10 @@ describe('CurateRunCompletedSchema', () => { expect(CurateRunCompletedSchema.safeParse({...baseValid, space_id: ''}).success).to.equal(false) expect(CurateRunCompletedSchema.safeParse({...baseValid, space_id: 'x'.repeat(65)}).success).to.equal(false) }) + + it('rejects empty / over-cap team_id', () => { + expect(CurateRunCompletedSchema.safeParse({...baseValid, team_id: ''}).success).to.equal(false) + expect(CurateRunCompletedSchema.safeParse({...baseValid, team_id: 'x'.repeat(65)}).success).to.equal(false) + }) }) }) diff --git a/test/unit/shared/analytics/events/query-completed.test.ts b/test/unit/shared/analytics/events/query-completed.test.ts index 4ffc41866..37a4c42e6 100644 --- a/test/unit/shared/analytics/events/query-completed.test.ts +++ b/test/unit/shared/analytics/events/query-completed.test.ts @@ -98,7 +98,17 @@ describe('QueryCompletedSchema', () => { expect(QueryCompletedSchema.safeParse({...baseValid, space_id: 'space-uuid-abc'}).success).to.equal(true) }) - it('accepts payloads omitting space_id (standalone project)', () => { + it('accepts a populated team_id', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, team_id: 'team-uuid-abc'}).success).to.equal(true) + }) + + it('accepts both space_id and team_id together', () => { + expect( + QueryCompletedSchema.safeParse({...baseValid, space_id: 'space-uuid', team_id: 'team-uuid'}).success, + ).to.equal(true) + }) + + it('accepts payloads omitting space_id / team_id (standalone project)', () => { expect(QueryCompletedSchema.safeParse(baseValid).success).to.equal(true) }) @@ -195,6 +205,11 @@ describe('QueryCompletedSchema', () => { expect(QueryCompletedSchema.safeParse({...baseValid, space_id: 'x'.repeat(65)}).success).to.equal(false) }) + it('rejects empty / over-cap team_id', () => { + expect(QueryCompletedSchema.safeParse({...baseValid, team_id: ''}).success).to.equal(false) + expect(QueryCompletedSchema.safeParse({...baseValid, team_id: 'x'.repeat(65)}).success).to.equal(false) + }) + it('rejects unknown extra fields inside an entry (strict)', () => { const entries = [{...baseEntry, mystery: 'oops'}] expect(QueryCompletedSchema.safeParse({...baseValid, read_paths_with_metadata: entries}).success).to.equal(false) From 7c247e052a17f883a503b39e469683be949c3ca5 Mon Sep 17 00:00:00 2001 From: Cuong Date: Fri, 29 May 2026 13:59:57 +0700 Subject: [PATCH 81/87] fix: [ENG-3020] address PR #739 review on AnalyticsHook structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the Claude bot review. #1 — Move `ProjectIdentity` + its docblock OUT of the gap between the `AnalyticsHook` class docblock and the `class AnalyticsHook` declaration. The previous layout left the class docblock orphaned — attaching visually to `type ProjectIdentity` instead of the class. Result: doc generators and IDE hover now resolve the class docblock correctly. #3 — Short-circuit `resolveIdentity` on `!this.isEnabled()`. Mirrors the existing `readFrontmatterFields` precedent (lines 699-700). When analytics is off the daemon no longer touches `projectStateLoader` on every task termination just to discard the result. Added a unit test that verifies getIdentity is NOT invoked when isEnabled() returns false — locks the short-circuit so a future refactor can't quietly re-introduce the wasted I/O. #2 — Acknowledged the staleness window in the `getIdentity` docblock. `projectStateLoader` only invalidates on the GET_PROJECT_CONFIG transport event (agent-process startup); mid-session config rewrites (`brv login`, `brv space switch`) keep returning the last-known-good identity until the next invalidation. Funnel analytics accepts last-known-good as the contract — a doc-line is enough. Anyone who later wants this accessor for billing or audit attribution is now explicitly warned to route through `shouldInvalidate`. #4 was praise on the test matrix — no code change. Full unit suite still green. --- src/server/infra/process/analytics-hook.ts | 44 ++++++++++++------- .../infra/process/analytics-hook.test.ts | 22 ++++++++++ 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index 8abdb79c7..91e539ced 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -197,21 +197,6 @@ type TaskAnalyticsState = CurateTaskAnalyticsState | QueryTaskAnalyticsState const isCurateLiteral = (value: string): value is CurateTaskTypeLiteral => CURATE_TASK_TYPE_SET.has(value) -/** - * Lifecycle hook that emits per-task analytics (curate_operation_applied, - * curate_run_completed, query_completed) into the daemon's - * `IAnalyticsClient`. Pure in-memory state keyed by `taskId`; no I/O of its own. - * - * Wired as a peer to `CurateLogHandler` / `QueryLogHandler` / - * `TaskHistoryHook` inside `TaskRouter.lifecycleHooks[]`. Does NOT modify the - * other handlers — read paths and curate-op accumulators are recomputed here - * via the shared `extractCurateOperations` parser and `task.toolCalls[]` - * shape, so analytics emit is decoupled from log persistence. - * - * M12.2 emits skeleton payloads (no frontmatter harvest). M12.3 layers - * `tags` / `keywords` / `related` arrays onto the curate-op and per-read-path - * payloads via a daemon-side post-op file read. - */ /** * Bundle of project-scoped identity fields stamped on terminal emits. * Each field is independently optional — a project may have a teamId @@ -233,6 +218,15 @@ type AnalyticsHookDeps = { * * Bundled (instead of one accessor per field) so a single config read * serves both stamps at terminal time. + * + * Staleness contract: `projectStateLoader` caches the config in-process + * and only invalidates when `GET_PROJECT_CONFIG` fires (agent-process + * startup). If `.brv/config.json` is rewritten mid-session by `brv login` + * or `brv space switch`, this accessor will keep returning the + * last-known-good identity until the next invalidation. That is the + * accepted contract for funnel analytics — last-known-good is fine. + * Do NOT reuse this accessor for billing or audit attribution without + * routing through `shouldInvalidate`. */ getIdentity?: (projectPath: string | undefined) => Promise /** @@ -251,6 +245,21 @@ type AnalyticsHookDeps = { readFile?: (filePath: string, encoding: 'utf8') => Promise } +/** + * Lifecycle hook that emits per-task analytics (curate_operation_applied, + * curate_run_completed, query_completed) into the daemon's + * `IAnalyticsClient`. Pure in-memory state keyed by `taskId`; no I/O of its own. + * + * Wired as a peer to `CurateLogHandler` / `QueryLogHandler` / + * `TaskHistoryHook` inside `TaskRouter.lifecycleHooks[]`. Does NOT modify the + * other handlers — read paths and curate-op accumulators are recomputed here + * via the shared `extractCurateOperations` parser and `task.toolCalls[]` + * shape, so analytics emit is decoupled from log persistence. + * + * M12.2 emits skeleton payloads (no frontmatter harvest). M12.3 layers + * `tags` / `keywords` / `related` arrays onto the curate-op and per-read-path + * payloads via a daemon-side post-op file read. + */ export class AnalyticsHook implements ITaskLifecycleHook { /** Lazy-injected by the daemon after `setupFeatureHandlers` constructs the client. */ private analyticsClient?: IAnalyticsClient @@ -732,8 +741,13 @@ export class AnalyticsHook implements ITaskLifecycleHook { * a getIdentity rejection (config-load failure, projectStateLoader race, * etc.) MUST NOT take down the terminal emit. Empty strings normalize to * `undefined` per-field so the payload spread omits each independently. + * + * Short-circuits on `!isEnabled()` so the daemon doesn't touch the + * project-state loader on every task termination when analytics is off. + * Mirrors the `readFrontmatterFields` precedent. */ private async resolveIdentity(projectPath: string | undefined): Promise { + if (!this.isEnabled()) return {} try { const raw = await this.getIdentity(projectPath) return { diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts index 6a9dce0bc..f69da4ecd 100644 --- a/test/unit/server/infra/process/analytics-hook.test.ts +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -938,5 +938,27 @@ describe('AnalyticsHook', () => { expect(curateProps.space_id).to.equal('space-fail') expect(curateProps.team_id).to.equal('team-fail') }) + + it('does not invoke getIdentity when analytics is disabled (short-circuit)', async () => { + const bundle = buildAnalyticsClient() + let calls = 0 + const spacedHook = new AnalyticsHook({ + async getIdentity() { + calls++ + return {spaceId: 'should-not-stamp', teamId: 'should-not-stamp'} + }, + isEnabled: () => false, + }) + spacedHook.setAnalyticsClient(bundle.client) + + const task = buildCurateTask() + await spacedHook.onTaskCreate(task) + await spacedHook.onTaskCompleted(task.taskId, '', task) + + expect(calls, 'getIdentity skipped when analytics disabled').to.equal(0) + const curateProps = findEmit(bundle.trackStub, AnalyticsEventNames.CURATE_RUN_COMPLETED) + expect(curateProps).to.not.have.property('space_id') + expect(curateProps).to.not.have.property('team_id') + }) }) }) From c6f7ccd4033fef985915b7630b1a6a84ce280964 Mon Sep 17 00:00:00 2001 From: bao-byterover Date: Fri, 29 May 2026 14:53:09 +0700 Subject: [PATCH 82/87] Feat/eng 3007 (#734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: [ENG-3004] expose analytics opt-in flag as brv settings analytics.enabled Surfaces the analytics opt-in via a new `analytics.enabled` boolean descriptor. SettingsHandler acts as a facade: GET/SET/RESET/LIST for the key route through GlobalConfigHandler instead of FileSettingsStore, so the canonical storage in config.json, the device-id seeding race fix, the analytics cache, and the abort-on-disable side-effect all stay unchanged. The descriptor carries a new `storage: 'global-config'` flag; the file store refuses to persist such keys and the validator pushes any hand-edited override into the invalid bucket. The disclosure prompt moves into `brv settings set analytics.enabled true` with a `--yes` flag for CI / non-interactive runs (CLIError + non-zero exit when no TTY and no --yes). The TUI settings page gains a confirm-disclosure mode that renders the markdown inline on the false-to-true toggle and waits for Enter / Esc. Disclosure markdown moved from `src/server/templates/sections/` to `src/shared/assets/` so the TUI can read it without crossing the `tui` -> `server` import boundary. Both surfaces consume it via the new canonical loader at `src/shared/utils/load-analytics-disclosure.ts`. The legacy `brv analytics enable` command delegates to the extracted disclosure helper at `src/oclif/lib/analytics-disclosure.ts` so the command file can be safely deleted in a follow-up. * fix: [ENG-3004] make TUI analytics-disclosure scroll in short terminals The disclosure overlay rendered the full markdown as a single Text block, which let wrapped long lines push the sticky `Enter / Esc` footer off the visible area in short terminal windows. Now the body lives in a sized viewport: each markdown line is its own `wrap="truncate-end"` Text so source-to-visual row mapping is 1:1, the body height is clamped to `terminalRows - 12` (defensive for the REPL's own bottom bar), and overflow shows as yellow `↑ more above` / `↓ more below` indicators. ↑/↓ scroll by line; PgUp/PgDn (and `b` / `Space`) scroll by page. * chore: [ENG-3007] delete the brv analytics command tree The `brv analytics enable | disable | status` commands are folded into the unified `brv settings` surface (`analytics.enabled` boolean facade and `analytics.status` readonly-info snapshot, shipped in earlier milestone tickets). The legacy command files and their test directory are removed; `brv analytics ` now exits with oclif's "Command not found" message. Lib re-exports survive the deletion so the M16.2 + M16.3 work keeps working: - `src/oclif/lib/analytics-disclosure.ts` (already extracted earlier) - `src/oclif/lib/analytics-status-formatter.ts` (NEW thin re-export from the canonical home at `src/shared/utils/format-analytics-status.ts`, so the AC's "importable from src/oclif/lib/" surface is preserved while the TUI keeps consuming the same renderer via shared/). Two replacement smoke tests confirm the oclif command path resolves both analytics keys to the `settings:get` transport event: - test/commands/settings/analytics-enabled.test.ts - test/commands/settings/analytics-status.test.ts Heavy lifting stays in the unit test suites that already cover the facade routing, disclosure flow, and formatter parity. The disclosure markdown content contract (5 required sections + privacy URL) is rescued from the deleted enable.test.ts into test/unit/shared/assets/analytics-disclosure-content.test.ts so a future copy edit cannot silently break the section headers. * fix: [ENG-3007] address PR #734 bot review - Disclosure markdown: brv analytics disable -> brv settings set analytics.enabled false - Tighten disclosure-content regex to require the new command form - settings set: warn when --yes is passed for non-analytics keys - settings set: JSON mode refuses interactive consent, returns requires_consent envelope - analytics-disclosure: translate inquirer ExitPromptError to declined consent - settings-page TUI: import ANALYTICS_ENABLED_KEY from shared/constants - settings-page TUI: surface transport rejection on disclosure-confirm toggle - settings-handler: facade-missing branches use code: 'misconfigured' - settings-handler: explicit guard for non-boolean global-config RESET - shared/transport: widen SettingsErrorDTO.code union with 'misconfigured' - file-settings-store: drop trailing blank line - rewrite historical doc-comment refs from brv analytics to brv settings --- package.json | 2 +- src/oclif/commands/analytics/disable.ts | 34 -- src/oclif/commands/analytics/enable.ts | 126 -------- src/oclif/commands/analytics/status.ts | 166 ---------- src/oclif/commands/settings/get.ts | 6 +- src/oclif/commands/settings/set.ts | 91 +++++- src/oclif/lib/analytics-disclosure.ts | 99 ++++++ src/oclif/lib/analytics-status-formatter.ts | 13 + src/server/core/domain/entities/settings.ts | 28 ++ .../analytics/i-analytics-backoff-policy.ts | 2 +- .../analytics/i-analytics-client.ts | 4 +- .../analytics/i-analytics-http-client.ts | 2 +- .../analytics/i-jsonl-analytics-store.ts | 6 +- .../infra/analytics/analytics-client.ts | 10 +- .../analytics/analytics-flush-scheduler.ts | 2 +- .../infra/analytics/http-analytics-sender.ts | 2 +- src/server/infra/process/feature-handlers.ts | 11 +- .../infra/storage/file-settings-store.ts | 24 +- .../infra/storage/settings-validator.ts | 13 + .../handlers/global-config-handler.ts | 25 +- .../transport/handlers/settings-handler.ts | 153 ++++++++- src/server/templates/skill/onboarding.md | 8 +- .../analytics/events/analytics-disabled.ts | 2 +- .../assets}/analytics-disclosure.md | 3 +- src/shared/constants/settings-keys.ts | 13 + .../transport/events/analytics-events.ts | 2 +- .../transport/events/global-config-events.ts | 5 +- .../transport/events/settings-events.ts | 18 +- src/shared/utils/format-analytics-status.ts | 7 +- src/shared/utils/load-analytics-disclosure.ts | 16 + .../settings/components/settings-page.tsx | 176 +++++++++- test/commands/analytics/disable.test.ts | 136 -------- test/commands/analytics/enable.test.ts | 258 --------------- test/commands/analytics/status.test.ts | 300 ------------------ .../settings/analytics-enabled.test.ts | 100 ++++++ .../settings/analytics-status.test.ts | 107 +++++++ test/commands/settings/set.test.ts | 86 +++++ test/e2e/analytics/dev-beta.e2e.ts | 6 +- test/e2e/analytics/lifecycle-db.e2e.ts | 2 +- test/e2e/analytics/lifecycle-wire.e2e.ts | 11 +- .../analytics/daemon-tracking.test.ts | 3 +- .../domain/entities/settings-registry.test.ts | 46 +++ .../infra/storage/file-settings-store.test.ts | 100 +++++- .../infra/storage/settings-validator.test.ts | 50 +++ .../handlers/settings-handler.test.ts | 241 ++++++++++++++ .../oclif/lib/analytics-disclosure.test.ts | 153 +++++++++ .../infra/analytics/analytics-client.test.ts | 4 +- .../analytics-disclosure-content.test.ts | 36 +++ 48 files changed, 1621 insertions(+), 1087 deletions(-) delete mode 100644 src/oclif/commands/analytics/disable.ts delete mode 100644 src/oclif/commands/analytics/enable.ts delete mode 100644 src/oclif/commands/analytics/status.ts create mode 100644 src/oclif/lib/analytics-disclosure.ts create mode 100644 src/oclif/lib/analytics-status-formatter.ts rename src/{server/templates/sections => shared/assets}/analytics-disclosure.md (90%) create mode 100644 src/shared/constants/settings-keys.ts create mode 100644 src/shared/utils/load-analytics-disclosure.ts delete mode 100644 test/commands/analytics/disable.test.ts delete mode 100644 test/commands/analytics/enable.test.ts delete mode 100644 test/commands/analytics/status.test.ts create mode 100644 test/commands/settings/analytics-enabled.test.ts create mode 100644 test/commands/settings/analytics-status.test.ts create mode 100644 test/unit/oclif/lib/analytics-disclosure.test.ts create mode 100644 test/unit/shared/assets/analytics-disclosure-content.test.ts diff --git a/package.json b/package.json index 3888a3451..c4e96f5bd 100644 --- a/package.json +++ b/package.json @@ -206,7 +206,7 @@ }, "repository": "campfirein/byterover-cli", "scripts": { - "build": "shx rm -rf dist && tsc -b && shx cp -r src/server/templates dist/server/templates && shx cp -r src/agent/resources dist/agent/resources && npm run build:ui", + "build": "shx rm -rf dist && tsc -b && shx cp -r src/server/templates dist/server/templates && shx cp -r src/agent/resources dist/agent/resources && shx cp -r src/shared/assets dist/shared/assets && npm run build:ui", "build:ui": "vite build src/webui --mode package", "build:ui:submodule": "node scripts/prepare-ui-submodule-links.mjs && vite build src/webui --mode submodule", "dev": "node bin/kill-daemon.js && npm run build && ./bin/dev.js", diff --git a/src/oclif/commands/analytics/disable.ts b/src/oclif/commands/analytics/disable.ts deleted file mode 100644 index 35f2add22..000000000 --- a/src/oclif/commands/analytics/disable.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {Command} from '@oclif/core' - -import { - GlobalConfigEvents, - type GlobalConfigSetAnalyticsResponse, -} from '../../../shared/transport/events/global-config-events.js' -import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' - -export default class Disable extends Command { - public static description = `Disable ByteRover CLI analytics. - -Stops anonymous usage telemetry. Re-enable any time with: brv analytics enable` - public static examples = ['<%= config.bin %> <%= command.id %>'] - - public async run(): Promise { - try { - const response = await this.setAnalytics(false, {projectPath: process.cwd()}) - this.log(response.previous === response.current ? 'Analytics already disabled' : 'Analytics disabled') - } catch (error) { - this.log(formatConnectionError(error)) - } - } - - protected async setAnalytics( - analytics: boolean, - options?: DaemonClientOptions, - ): Promise { - return withDaemonRetry( - async (client) => - client.requestWithAck(GlobalConfigEvents.SET_ANALYTICS, {analytics}), - options, - ) - } -} diff --git a/src/oclif/commands/analytics/enable.ts b/src/oclif/commands/analytics/enable.ts deleted file mode 100644 index 3567ad9d4..000000000 --- a/src/oclif/commands/analytics/enable.ts +++ /dev/null @@ -1,126 +0,0 @@ -import {confirm} from '@inquirer/prompts' -import {Command, Flags} from '@oclif/core' -import {readFile} from 'node:fs/promises' -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' - -import {PRIVACY_POLICY_URL} from '../../../shared/constants/privacy.js' -import { - GlobalConfigEvents, - type GlobalConfigGetResponse, - type GlobalConfigSetAnalyticsResponse, -} from '../../../shared/transport/events/global-config-events.js' -import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' - -// The disclosure markdown is a static asset (PM/legal own its copy) that the -// build copies into dist/server/templates/sections/. Reading it as a file from -// oclif is a runtime fs read, not a TypeScript import, so the oclif-server -// boundary rule is preserved. If a future need surfaces the disclosure to -// other surfaces (e.g. the M1.7 webui rendering it as HTML), consider moving -// the asset under src/shared/ or serving it via a daemon transport event. -const here = dirname(fileURLToPath(import.meta.url)) -const DISCLOSURE_PATH = resolve(here, '../../../server/templates/sections/analytics-disclosure.md') - -export default class Enable extends Command { - public static description = `Enable ByteRover CLI analytics. - -Anonymous usage telemetry will be collected to improve the product. -No content of your queries, files, or memory is collected. - -Privacy policy: ${PRIVACY_POLICY_URL} (placeholder until M1.5) -Disable any time with: brv analytics disable` - public static examples = [ - '<%= config.bin %> <%= command.id %>', - '<%= config.bin %> <%= command.id %> --yes', - ] - public static flags = { - yes: Flags.boolean({ - char: 'y', - default: false, - description: 'Skip the disclosure prompt (CI / non-interactive)', - }), - } - - protected async confirmDisclosure(): Promise { - return confirm({default: false, message: 'Enable analytics with the terms above?'}) - } - - protected async getCurrentAnalytics(options?: DaemonClientOptions): Promise { - return withDaemonRetry(async (client) => { - const response = await client.requestWithAck(GlobalConfigEvents.GET) - return response.analytics - }, options) - } - - protected isInteractive(): boolean { - return process.stdin.isTTY === true && process.stdout.isTTY === true - } - - protected async loadDisclosure(): Promise { - return readFile(DISCLOSURE_PATH, 'utf8') - } - - public async run(): Promise { - const {flags} = await this.parse(Enable) - - let alreadyEnabled: boolean - try { - alreadyEnabled = await this.getCurrentAnalytics({projectPath: process.cwd()}) - } catch (error) { - this.log(formatConnectionError(error)) - return - } - - if (alreadyEnabled) { - this.log('Analytics already enabled') - return - } - - // collectConsent may call this.error() for non-TTY without --yes; - // that throws CLIError and oclif's exit handler surfaces a non-zero - // exit code. Do NOT wrap it in try/catch. - const accepted = await this.collectConsent(flags.yes) - if (!accepted) { - this.log('Analytics not enabled') - return - } - - try { - await this.setAnalytics(true, {projectPath: process.cwd()}) - } catch (error) { - this.log(formatConnectionError(error)) - return - } - - this.log('Analytics enabled') - } - - protected async setAnalytics( - analytics: boolean, - options?: DaemonClientOptions, - ): Promise { - return withDaemonRetry( - async (client) => - client.requestWithAck(GlobalConfigEvents.SET_ANALYTICS, {analytics}), - options, - ) - } - - private async collectConsent(yesFlag: boolean): Promise { - const disclosure = await this.loadDisclosure() - this.log(disclosure) - - if (yesFlag) { - return true - } - - if (!this.isInteractive()) { - this.error( - 'Cannot enable analytics in non-interactive mode without confirmation.\n' + - 'Re-run in a terminal, or pass --yes to accept the disclosure non-interactively.', - ) - } - - return this.confirmDisclosure() - } -} diff --git a/src/oclif/commands/analytics/status.ts b/src/oclif/commands/analytics/status.ts deleted file mode 100644 index 99f21f1e0..000000000 --- a/src/oclif/commands/analytics/status.ts +++ /dev/null @@ -1,166 +0,0 @@ -/* eslint-disable camelcase -- JSON wire shape is snake_case per the M4.6 ticket schema. */ -import {Command, Flags} from '@oclif/core' - -import {PRIVACY_POLICY_URL} from '../../../shared/constants/privacy.js' -import {AnalyticsEvents, type AnalyticsStatusResponse} from '../../../shared/transport/events/analytics-events.js' -import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' -import {writeJsonResponse} from '../../lib/json-response.js' - -const COMMAND_ID = 'analytics:status' - -const MS_PER_MIN = 60_000 -const MS_PER_HOUR = 60 * MS_PER_MIN -const MS_PER_DAY = 24 * MS_PER_HOUR - -/** - * Humanise a millisecond delta to a short relative-time label, matching - * the M4.6 ticket example: `(5m ago)`. Cut points: - * - < 1 minute → "just now" - * - < 1 hour → "{n}m ago" - * - < 1 day → "{n}h ago" - * - >= 1 day → "{n}d ago" - * - * Exposed for tests (also exercised indirectly via the text-output cases). - */ -export function formatRelativeAgo(deltaMs: number): string { - if (!Number.isFinite(deltaMs) || deltaMs < MS_PER_MIN) return 'just now' - if (deltaMs < MS_PER_HOUR) return `${Math.floor(deltaMs / MS_PER_MIN)}m ago` - if (deltaMs < MS_PER_DAY) return `${Math.floor(deltaMs / MS_PER_HOUR)}h ago` - return `${Math.floor(deltaMs / MS_PER_DAY)}d ago` -} - -/** - * Humanise a forward-looking delay in milliseconds. Used by the - * backoff line so an operator sees "next attempt in 2m" instead of - * the wire-format "next_delay_ms=120000". Cut points mirror the M4.5 - * backoff schedule (30s, 60s, 2m, 5m). - */ -export function formatDelayMs(ms: number): string { - if (!Number.isFinite(ms) || ms < 0) return '0ms' - if (ms < 1000) return `${ms}ms` - if (ms < MS_PER_MIN) return `${Math.floor(ms / 1000)}s` - if (ms < MS_PER_HOUR) return `${Math.floor(ms / MS_PER_MIN)}m` - return `${Math.floor(ms / MS_PER_HOUR)}h` -} - -export default class Status extends Command { - public static description = `Show analytics state: enabled flag, last successful flush, queue depth, dropped event count, backoff state, endpoint. - -Analytics is opt-in (default: off). When enabled, ByteRover collects anonymous -usage telemetry (event names, CLI version, OS, Node version, environment) to -improve the product. No content of your queries, files, or memory is collected. - -Privacy policy: ${PRIVACY_POLICY_URL} (placeholder until M1.5) -Toggle: brv analytics enable | brv analytics disable` - public static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --format json'] - public static flags = { - format: Flags.string({ - char: 'f', - default: 'text', - description: 'Output format', - options: ['text', 'json'], - }), - } - - protected async fetchAnalyticsStatus(options?: DaemonClientOptions): Promise { - return withDaemonRetry( - async (client) => client.requestWithAck(AnalyticsEvents.STATUS), - options, - ) - } - - /** - * Test seam — overridden in unit tests to pin the relative-time - * calculation against a fixed wall-clock. Production uses Date.now(). - */ - protected now(): number { - return Date.now() - } - - public async run(): Promise { - const {flags} = await this.parse(Status) - const isJson = flags.format === 'json' - - let response: AnalyticsStatusResponse - try { - response = await this.fetchAnalyticsStatus({projectPath: process.cwd()}) - } catch (error) { - if (isJson) { - writeJsonResponse({command: COMMAND_ID, data: {error: formatConnectionError(error)}, success: false}) - } else { - this.log(formatConnectionError(error)) - } - - return - } - - if (isJson) { - writeJsonResponse({command: COMMAND_ID, data: this.toJsonShape(response), success: true}) - return - } - - this.renderText(response) - } - - /** - * Human-friendly summary of the backoff counters: a singular/plural - * "N consecutive failure(s)" plus the next-attempt delay as a - * humanized duration. Wire output (JSON) keeps the raw snake_case - * field names and ms units for programmatic consumers. - */ - private formatBackoffSummary(backoff: AnalyticsStatusResponse['backoff']): string { - const failurePart = - backoff.consecutiveFailures === 1 - ? '1 consecutive failure' - : `${backoff.consecutiveFailures} consecutive failures` - const delayPart = `next attempt in ${formatDelayMs(backoff.nextDelayMs)}` - return `${failurePart}, ${delayPart}` - } - - private formatLastFlush(lastFlushAt: number | undefined): string { - if (lastFlushAt === undefined) return 'never' - const iso = new Date(lastFlushAt).toISOString() - const ago = formatRelativeAgo(this.now() - lastFlushAt) - return `${iso} (${ago})` - } - - private renderText(response: AnalyticsStatusResponse): void { - if (!response.enabled) { - this.log('Analytics: disabled') - return - } - - this.log('Analytics: enabled') - this.log(`Last successful flush: ${this.formatLastFlush(response.lastFlushAt)}`) - this.log(`Queue depth: ${response.queueDepth} events`) - this.log(`Dropped events (this session): ${response.droppedCount}`) - this.log(`Backoff state: ${response.backoff.state} (${this.formatBackoffSummary(response.backoff)})`) - this.log(`Endpoint: ${response.endpoint}`) - } - - /** - * JSON wire shape per the ticket. `last_flush` is null when undefined; - * snake_case keys for compatibility with downstream JSON consumers. - */ - private toJsonShape(response: AnalyticsStatusResponse): { - backoff: {consecutive_failures: number; next_delay_ms: number; state: string} - dropped_events: number - enabled: boolean - endpoint: string - last_flush: null | string - queue_depth: number - } { - return { - backoff: { - consecutive_failures: response.backoff.consecutiveFailures, - next_delay_ms: response.backoff.nextDelayMs, - state: response.backoff.state, - }, - dropped_events: response.droppedCount, - enabled: response.enabled, - endpoint: response.endpoint, - last_flush: response.lastFlushAt === undefined ? null : new Date(response.lastFlushAt).toISOString(), - queue_depth: response.queueDepth, - } - } -} diff --git a/src/oclif/commands/settings/get.ts b/src/oclif/commands/settings/get.ts index 54fd0d6c8..dcdf41368 100644 --- a/src/oclif/commands/settings/get.ts +++ b/src/oclif/commands/settings/get.ts @@ -82,7 +82,8 @@ export default class SettingsGet extends Command { private printTextBlock(item: SettingsItemDTO): void { if (item.type === 'readonly-info') { // Print the snapshot text verbatim so `brv settings get analytics.status` - // matches the deleted `brv analytics status` output character-for-character. + // matches the deleted `brv analytics status` (its predecessor) + // output character-for-character. // No `` header / `current:` prefix / `scope:` footer — the chrome // is reserved for writable variants where it carries meaningful labels. this.log(formatReadonlyInfoValue(item.key, item.current)) @@ -105,7 +106,8 @@ export default class SettingsGet extends Command { private toJsonPayload(item: SettingsItemDTO): Record { // M16.3: `analytics.status` keeps the legacy snake_case envelope of - // the deleted `brv analytics status --format json` so callers that + // the deleted `brv analytics status --format json` (now + // `brv settings get analytics.status --format json`) so callers that // already script against that wire shape are not broken. if (item.key === SETTINGS_KEYS.ANALYTICS_STATUS) { return {...formatAnalyticsStatusJson(item.current)} diff --git a/src/oclif/commands/settings/set.ts b/src/oclif/commands/settings/set.ts index c0f4517b0..5bd9ec7dd 100644 --- a/src/oclif/commands/settings/set.ts +++ b/src/oclif/commands/settings/set.ts @@ -1,5 +1,6 @@ -import {Args, Command, Flags} from '@oclif/core' +import {Args, Command, Errors, Flags} from '@oclif/core' +import {SETTINGS_KEYS} from '../../../server/core/domain/entities/settings.js' import { SettingsEvents, type SettingsGetRequest, @@ -14,6 +15,7 @@ import { formatDuration, parseDuration, } from '../../../shared/utils/format-duration.js' +import {collectConsent} from '../../lib/analytics-disclosure.js' import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' import {writeJsonResponse} from '../../lib/json-response.js' @@ -41,6 +43,15 @@ export default class SettingsSet extends Command { description: 'Output format (text or json)', options: ['text', 'json'], }), + // Accepts the analytics disclosure non-interactively. Only meaningful when + // setting `analytics.enabled true` (the one consent-gated key). Passing it + // for any other key emits `this.warn(...)` so the user does not silently + // rely on a flag that has no behavioural effect for their command. + yes: Flags.boolean({ + char: 'y', + default: false, + description: 'Accept the analytics disclosure non-interactively (only meaningful for analytics.enabled)', + }), } protected async fetchDescriptor(key: string, options?: DaemonClientOptions): Promise { @@ -55,6 +66,15 @@ export default class SettingsSet extends Command { const {args, flags} = await this.parse(SettingsSet) const format = flags.format as 'json' | 'text' + // `--yes` is only meaningful for the one consent-gated key. Warn (don't + // refuse) for any other key so automation scripts don't silently rely on + // a flag that has no behavioural effect. + if (flags.yes && args.key !== SETTINGS_KEYS.ANALYTICS_ENABLED) { + this.warn( + `--yes is only meaningful for ${SETTINGS_KEYS.ANALYTICS_ENABLED}; ignored for '${args.key}'.`, + ) + } + try { const descriptor = await this.fetchDescriptor(args.key) if (!descriptor.ok) { @@ -100,6 +120,67 @@ export default class SettingsSet extends Command { return } + // Enable-to-true on `analytics.enabled` triggers the disclosure + // prompt. Idempotent (no prompt if already enabled), false-unchanged, + // and other keys unaffected. `collectConsent`'s `onError` calls + // `this.error()` which throws CLIError; we let it propagate to + // oclif's exit handler (clean message, non-zero exit). + if ( + args.key === SETTINGS_KEYS.ANALYTICS_ENABLED && + parsed.value === true && + descriptor.current !== true + ) { + // JSON output mode cannot host an interactive consent prompt: the + // disclosure markdown (and inquirer's own prompt frame) would land + // on stdout BEFORE the final JSON envelope, breaking parseability + // for any caller piping the output. Refuse with a structured error + // envelope instructing the caller to pass `--yes` (which both + // confirms consent and skips the markdown print). + if (format === 'json' && !flags.yes) { + process.exitCode = 1 + writeJsonResponse({ + command: 'settings set', + data: { + error: { + code: 'requires_consent', + key: args.key, + message: + `Enabling ${SETTINGS_KEYS.ANALYTICS_ENABLED} requires accepting the disclosure. ` + + 'Re-run with --yes to accept non-interactively, or omit --format json to see the disclosure.', + }, + }, + success: false, + }) + return + } + + // In JSON+--yes mode the consent gate is already satisfied. Skip + // `collectConsent` entirely so its `onLog(disclosureMarkdown)` does + // not pollute the JSON envelope on stdout. + const accepted = + format === 'json' && flags.yes + ? true + : await collectConsent({ + onError: (msg) => this.error(msg), + onLog: (msg) => this.log(msg), + yesFlag: flags.yes, + }) + + if (!accepted) { + if (format === 'json') { + writeJsonResponse({ + command: 'settings set', + data: {accepted: false, key: args.key}, + success: true, + }) + } else { + this.log('Analytics not enabled') + } + + return + } + } + const response = await this.writeSetting(args.key, parsed.value) if (response.ok) { @@ -124,6 +205,14 @@ export default class SettingsSet extends Command { this.log(response.error.message) } } catch (error) { + // CLIError thrown from `this.error()` (e.g. the non-TTY disclosure + // guard) carries its own clean message + exit code via oclif's exit + // handler — let it propagate untouched. Everything else gets the + // daemon-connection-friendly formatter. + if (error instanceof Errors.CLIError) { + throw error + } + process.exitCode = 1 if (format === 'json') { writeJsonResponse({command: 'settings set', data: {error: formatConnectionError(error)}, success: false}) diff --git a/src/oclif/lib/analytics-disclosure.ts b/src/oclif/lib/analytics-disclosure.ts new file mode 100644 index 000000000..9e2b0e983 --- /dev/null +++ b/src/oclif/lib/analytics-disclosure.ts @@ -0,0 +1,99 @@ +import {confirm} from '@inquirer/prompts' + +import {loadAnalyticsDisclosureText} from '../../shared/utils/load-analytics-disclosure.js' + +/** + * Disclosure markdown lives in `src/shared/assets/analytics-disclosure.md` + * so the same canonical text is consumed by oclif (CLI consent prompt), + * TUI (settings-page inline confirm), and any future WebUI render. + */ +export async function loadDisclosure(): Promise { + return loadAnalyticsDisclosureText() +} + +export async function confirmDisclosure(): Promise { + return confirm({default: false, message: 'Enable analytics with the terms above?'}) +} + +export function isInteractive(): boolean { + return process.stdin.isTTY === true && process.stdout.isTTY === true +} + +export interface CollectConsentDeps { + /** + * Optional override for `loadDisclosure`. Tests inject a fixture string + * to avoid touching disk. Defaults to the lib's `loadDisclosure` + * (reads `analytics-disclosure.md` from disk). + */ + readonly loadFn?: () => Promise + /** + * Called when consent cannot be collected (non-TTY without `--yes`). + * Implementations are expected to throw — oclif's `this.error()` + * surfaces a non-zero exit code via `CLIError`. Typed `never` so + * callers cannot forget to terminate. + */ + readonly onError: (message: string) => never + /** Receives the disclosure markdown text. Typically `this.log` from a Command. */ + readonly onLog: (message: string) => void + /** + * The prompt function. Defaults to `confirmDisclosure` (inquirer). + * Tests inject a stub to avoid mounting an interactive TTY. + */ + readonly promptFn?: () => Promise + /** + * TTY check. Defaults to `isInteractive`. Tests inject a stub to + * exercise both the TTY and non-TTY branches. + */ + readonly ttyCheck?: () => boolean + /** When true, skip the prompt and accept silently. CI / non-interactive use. */ + readonly yesFlag: boolean +} + +/** + * M1.4 disclosure consent flow. + * + * 1. Load and print the disclosure markdown (via `loadFn` if provided, + * else the default `loadDisclosure`). + * 2. If `--yes` is set, accept silently. + * 3. If the session is non-interactive (no TTY), call `onError` (which + * throws — non-zero exit). Re-running in a terminal or passing `--yes` + * is the documented escape. + * 4. Otherwise, prompt and return the user's choice. + * + * Extracted from the legacy `brv analytics enable` command in M16.2 so + * `brv settings set analytics.enabled true` can reuse the exact same + * consent gate. M16.4 then deleted the legacy command; this lib is now + * the sole consent surface. + */ +export async function collectConsent(deps: CollectConsentDeps): Promise { + const load = deps.loadFn ?? loadDisclosure + const disclosure = await load() + deps.onLog(disclosure) + + if (deps.yesFlag) return true + + const tty = deps.ttyCheck ?? isInteractive + if (!tty()) { + deps.onError( + 'Cannot enable analytics in non-interactive mode without confirmation.\n' + + 'Re-run in a terminal, or pass --yes to accept the disclosure non-interactively.', + ) + } + + const prompt = deps.promptFn ?? confirmDisclosure + try { + return await prompt() + } catch (error) { + // Ctrl-C inside `@inquirer/prompts` rejects with `ExitPromptError`. Without + // this guard, the rejection surfaces as a raw stack trace from the + // caller's `formatConnectionError` fallback. Treat Ctrl-C as a declined + // consent so the caller logs "Analytics not enabled" and exits cleanly. + // Match by `name` (not `instanceof`) because the running `@inquirer/core` + // copy depends on which nested node_modules path resolved at runtime. + if (error instanceof Error && error.name === 'ExitPromptError') { + return false + } + + throw error + } +} diff --git a/src/oclif/lib/analytics-status-formatter.ts b/src/oclif/lib/analytics-status-formatter.ts new file mode 100644 index 000000000..cb7c3526d --- /dev/null +++ b/src/oclif/lib/analytics-status-formatter.ts @@ -0,0 +1,13 @@ +/** + * Stable named re-exports of the analytics-status text and JSON + * formatters, satisfying the ticket AC that they be importable from + * `src/oclif/lib/`. The canonical home is `src/shared/utils/` because + * the TUI also consumes the same renderer and cannot import from + * `src/oclif/` per the architecture import boundary. + */ +export { + formatAnalyticsStatusJson, + formatAnalyticsStatusText, + formatDelayMs, + formatRelativeAgo, +} from '../../shared/utils/format-analytics-status.js' diff --git a/src/server/core/domain/entities/settings.ts b/src/server/core/domain/entities/settings.ts index 1c9221220..aa4e5557a 100644 --- a/src/server/core/domain/entities/settings.ts +++ b/src/server/core/domain/entities/settings.ts @@ -1,3 +1,4 @@ +import {ANALYTICS_ENABLED_KEY} from '../../../../shared/constants/settings-keys.js' import { AGENT_LLM_ITERATION_BUDGET_MS, AGENT_LLM_REQUEST_TIMEOUT_MS, @@ -35,16 +36,33 @@ type BaseSettingDescriptor = { readonly restartRequired: boolean } +/** + * Where the writable value's canonical storage lives. + * + * - `'file'` (default, omitted on most descriptors) — persisted to + * `/settings.json` via `FileSettingsStore`. + * - `'global-config'` — persisted to `/config.json` via + * `GlobalConfigHandler`. The settings handler routes GET/SET/RESET/LIST + * for these keys through the global-config facade; the file store + * refuses to persist them. + * + * Read-only descriptors (`readonly-info`) are never persisted and do + * not carry this field. + */ +export type SettingStorage = 'file' | 'global-config' + export type IntegerSettingDescriptor = BaseSettingDescriptor & { readonly default: number readonly max: number readonly min: number + readonly storage?: SettingStorage readonly type: 'integer' readonly unit?: SettingUnit } export type BooleanSettingDescriptor = BaseSettingDescriptor & { readonly default: boolean + readonly storage?: SettingStorage readonly type: 'boolean' } @@ -100,6 +118,7 @@ export type SettingItem = { export const SETTINGS_KEYS = { AGENT_POOL_MAX_CONCURRENT_TASKS: 'agentPool.maxConcurrentTasksPerProject', AGENT_POOL_MAX_SIZE: 'agentPool.maxSize', + ANALYTICS_ENABLED: ANALYTICS_ENABLED_KEY, ANALYTICS_STATUS: 'analytics.status', LLM_ITERATION_BUDGET_MS: 'llm.iterationBudgetMs', LLM_REQUEST_TIMEOUT_MS: 'llm.requestTimeoutMs', @@ -175,6 +194,15 @@ export const SETTINGS_REGISTRY: readonly SettingDescriptor[] = [ restartRequired: false, type: 'readonly-info', }, + { + category: 'analytics', + default: false, + description: 'Send anonymous telemetry to ByteRover. Local tracking is always on.', + key: SETTINGS_KEYS.ANALYTICS_ENABLED, + restartRequired: false, + storage: 'global-config', + type: 'boolean', + }, ] export function findSettingDescriptor(key: string): SettingDescriptor | undefined { diff --git a/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts b/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts index 75736b6ab..d86afb24b 100644 --- a/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts +++ b/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts @@ -11,7 +11,7 @@ * callbacks. * * The reachability state (healthy / degraded / unreachable) used by - * `brv analytics status` (M4.6) is DERIVED from `consecutiveFailures()` + * `brv settings get analytics.status` (M4.6) is DERIVED from `consecutiveFailures()` * by the caller, not exposed here. Mapping (M4.6 owns the labels): * - 0 failures → healthy * - 1-2 failures → degraded diff --git a/src/server/core/interfaces/analytics/i-analytics-client.ts b/src/server/core/interfaces/analytics/i-analytics-client.ts index 04367abb0..eaa6af706 100644 --- a/src/server/core/interfaces/analytics/i-analytics-client.ts +++ b/src/server/core/interfaces/analytics/i-analytics-client.ts @@ -18,7 +18,7 @@ import type {AnalyticsBatch} from '../../domain/analytics/batch.js' export interface IAnalyticsClient { /** * Cancel any in-flight `flush()`'s HTTP request. M4.4: invoked by - * `GlobalConfigHandler` when `brv analytics disable` flips the flag + * `GlobalConfigHandler` when `brv settings set analytics.enabled false` flips the flag * so the daemon doesn't half-ship a batch across an enable/disable * boundary. No-op when no flush is in flight. */ @@ -31,7 +31,7 @@ export interface IAnalyticsClient { flush: () => Promise /** - * M4.6: client-owned runtime state for the `brv analytics status` + * M4.6: client-owned runtime state for the `brv settings get analytics.status` * command. Returns the timestamp of the last successful flush (or * `undefined` if none has run this daemon-lifetime), the count of * records currently pending in JSONL, and the cumulative count of diff --git a/src/server/core/interfaces/analytics/i-analytics-http-client.ts b/src/server/core/interfaces/analytics/i-analytics-http-client.ts index e0002ccd4..bdc927b03 100644 --- a/src/server/core/interfaces/analytics/i-analytics-http-client.ts +++ b/src/server/core/interfaces/analytics/i-analytics-http-client.ts @@ -53,7 +53,7 @@ export type AnalyticsHttpSendResult = */ /** * Optional per-call controls. `signal` is the M4.4 cancellation hook - * used by `brv analytics disable` (and by the daemon shutdown path) to + * used by `brv settings set analytics.enabled false` (and by the daemon shutdown path) to * abort an in-flight send so the daemon doesn't half-ship a batch * across an enable/disable boundary. */ diff --git a/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts b/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts index bc90c1460..7c48c64bb 100644 --- a/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts +++ b/src/server/core/interfaces/analytics/i-jsonl-analytics-store.ts @@ -82,20 +82,20 @@ export interface IJsonlAnalyticsStore { * a clear in progress blocks subsequent appends. Atomic via tmp+rename. * * Counters (`droppedFullCount`, `droppedSentCount`) are NOT reset, - * they're cumulative lifetime stats surfaced by `brv analytics status`. + * they're cumulative lifetime stats surfaced by `brv settings get analytics.status`. */ clear: () => Promise /** * Cumulative count of `append` calls dropped because the cap was full * with no `'sent'` rows to evict (file saturated with pending+failed). - * Never reset; surfaced for `brv analytics status` (M4.6). + * Never reset; surfaced for `brv settings get analytics.status` (M4.6). */ droppedFullCount: () => number /** * Cumulative count of `'sent'` rows dropped by compaction across the - * store's lifetime. Never reset; surfaced for `brv analytics status` + * store's lifetime. Never reset; surfaced for `brv settings get analytics.status` * (M4.6). */ droppedSentCount: () => number diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index eebe0561f..ea3437327 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -94,7 +94,7 @@ export class AnalyticsClient implements IAnalyticsClient { private readonly deps: AnalyticsClientDeps // M4.6: timestamp of the last flush that actually shipped at least one // record cleanly (same gate as the M4.5 backoff `onSuccess()` path). - // Surfaced through `getRuntimeState()` for `brv analytics status`. + // Surfaced through `getRuntimeState()` for `brv settings get analytics.status`. // Daemon restart resets to undefined; status renders "never". private lastSuccessfulFlushAt: number | undefined // Single-flight slot for an in-flight `flush()`. Concurrent callers join the @@ -128,7 +128,7 @@ export class AnalyticsClient implements IAnalyticsClient { * which classifies aborted requests as `network` failures — JSONL * records stay `pending` (so they ship on the next enabled flush). * - * Called from `GlobalConfigHandler` when `brv analytics disable` + * Called from `GlobalConfigHandler` when `brv settings set analytics.enabled false` * flips the flag, so the daemon doesn't half-ship a batch across an * enable/disable boundary. No-op when no flush is in flight. */ @@ -156,7 +156,7 @@ export class AnalyticsClient implements IAnalyticsClient { * `flush()` is a thin caller — it does not inspect attempts. */ public async flush(): Promise { - // M4.4: `brv analytics disable` semantically means "stop shipping to + // M4.4: `brv settings set analytics.enabled false` semantically means "stop shipping to // remote" — local tracking (JSONL + queue) continues unconditionally. // Gate here, NOT in `track()`. Records stay at `status='pending'` in // JSONL; the next flush after re-enable picks them up automatically. @@ -176,7 +176,7 @@ export class AnalyticsClient implements IAnalyticsClient { } /** - * Snapshot of client-owned runtime state for `brv analytics status` + * Snapshot of client-owned runtime state for `brv settings get analytics.status` * (M4.6). Backoff state, endpoint, and the enabled flag are NOT here * — those are composed by the daemon-side status handler from other * sources (the policy + envConfig + GlobalConfigHandler). Async @@ -336,7 +336,7 @@ export class AnalyticsClient implements IAnalyticsClient { await this.deps.jsonlStore.updateStatus(result.succeeded, 'sent') // M4.4 N3 fix: when we cancelled the send ourselves (`abort()` fired - // because `brv analytics disable` flipped the flag), DO NOT mark the + // because `brv settings set analytics.enabled false` flipped the flag), DO NOT mark the // failed records as 'failed' — that bumps the M9.2 retry-cap // `attempts` counter on every cancel, and a few disable/enable // toggles during shipping could terminate records as `'failed'` diff --git a/src/server/infra/analytics/analytics-flush-scheduler.ts b/src/server/infra/analytics/analytics-flush-scheduler.ts index 657545e36..b6c138a42 100644 --- a/src/server/infra/analytics/analytics-flush-scheduler.ts +++ b/src/server/infra/analytics/analytics-flush-scheduler.ts @@ -10,7 +10,7 @@ export interface AnalyticsFlushSchedulerDeps { flush: () => Promise /** * Lazy analytics-enabled gate. Re-checked on every trigger so a runtime - * `brv analytics disable` (M1.4) immediately suspends scheduled flushes + * `brv settings set analytics.enabled false` (M1.4) immediately suspends scheduled flushes * without restarting the daemon. */ isEnabled: () => boolean diff --git a/src/server/infra/analytics/http-analytics-sender.ts b/src/server/infra/analytics/http-analytics-sender.ts index 50509636d..1070f8c87 100644 --- a/src/server/infra/analytics/http-analytics-sender.ts +++ b/src/server/infra/analytics/http-analytics-sender.ts @@ -77,7 +77,7 @@ export class HttpAnalyticsSender implements IAnalyticsSender { ...(sessionKey !== undefined && sessionKey !== '' ? {sessionId: sessionKey} : {}), userAgent: this.deps.userAgent, }, - // M4.4: forward the cancellation signal so `brv analytics disable` + // M4.4: forward the cancellation signal so `brv settings set analytics.enabled false` // (or shutdown) can abort an in-flight POST. The http client // classifies aborted requests as `network`, which maps here to // an all-failed result — same as any other transport failure. diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 60f9ca5b9..aa7a2bdee 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -277,7 +277,7 @@ export async function setupFeatureHandlers({ // M4.4: close the global-config-handler ↔ analyticsClient cycle. // The handler was constructed earlier (so its sync cache was // populated before the client read it); now that the client - // exists, register it so `brv analytics disable` can call + // exists, register it so `brv settings set analytics.enabled false` can call // `abort()` to cancel any in-flight HTTP. globalConfigHandler.setAnalyticsClient(analyticsClient) @@ -293,7 +293,7 @@ export async function setupFeatureHandlers({ // analyticsClient is in scope for M15.4 `setting_changed` / `setting_reset` // emits. M16.3 wires the `analytics.status` readonly-info provider so // `brv settings get analytics.status` returns the same operational - // snapshot as the legacy `brv analytics status` command. + // snapshot as the legacy `brv analytics status` (now `brv settings get analytics.status`). const analyticsStatusSnapshotDeps = { analyticsClient, backoffPolicy: analyticsBackoffPolicy, @@ -302,6 +302,11 @@ export async function setupFeatureHandlers({ } new SettingsHandler({ analyticsClient, + // Route `analytics.enabled` GET/SET/RESET/LIST through the + // global-config handler so the canonical storage in config.json, the + // device-id seeding race fix, the analytics cache, and the + // abort-on-disable side-effect all stay unchanged. + globalConfigHandler, infoProviders: new Map([ ['analytics.status', async () => buildAnalyticsStatusSnapshot(analyticsStatusSnapshotDeps)], ]), @@ -313,7 +318,7 @@ export async function setupFeatureHandlers({ // as the AnalyticsClient so reads see exactly what trackAsync persisted. new AnalyticsListHandler({jsonlStore: jsonlAnalyticsStore, transport}).setup() - // M4.6: `brv analytics status` read API. Composes runtime state + // M4.6: `brv settings get analytics.status` read API. Composes runtime state // (client) + backoff state (policy) + enabled flag (config) + endpoint // (env) into one wire response. Endpoint is `envConfig.analyticsBaseUrl` // or empty string; the handler substitutes "(not configured)" for the diff --git a/src/server/infra/storage/file-settings-store.ts b/src/server/infra/storage/file-settings-store.ts index f5ca13109..c1c89c9c3 100644 --- a/src/server/infra/storage/file-settings-store.ts +++ b/src/server/infra/storage/file-settings-store.ts @@ -77,8 +77,16 @@ export class FileSettingsStore implements ISettingsStore { public async get(key: string): Promise { const descriptor = this.validator.validateKey(key) + // readonly-info: value comes from the handler's info-provider map. + // global-config: value comes from GlobalConfigHandler via the settings handler facade. + // Either way, the file store has no value to surface — return the + // inert row shape so the handler can resolve through the right path. if (descriptor.type === 'readonly-info') { - return {current: undefined, key: descriptor.key, restartRequired: false} + return {current: undefined, key: descriptor.key, restartRequired: descriptor.restartRequired} + } + + if (descriptor.storage === 'global-config') { + return {current: undefined, key: descriptor.key, restartRequired: descriptor.restartRequired} } const overrides = await this.readOverrides() @@ -94,7 +102,11 @@ export class FileSettingsStore implements ISettingsStore { const overrides = await this.readOverrides() return this.registry.map((descriptor) => { if (descriptor.type === 'readonly-info') { - return {current: undefined, key: descriptor.key, restartRequired: false} + return {current: undefined, key: descriptor.key, restartRequired: descriptor.restartRequired} + } + + if (descriptor.storage === 'global-config') { + return {current: undefined, key: descriptor.key, restartRequired: descriptor.restartRequired} } return { @@ -121,6 +133,14 @@ export class FileSettingsStore implements ISettingsStore { throw new ReadonlySettingKeyError(key) } + if (descriptor.storage === 'global-config') { + throw new InvalidSettingValueError( + key, + undefined, + `'${key}' is stored in config.json, not settings.json; use the SettingsHandler facade`, + ) + } + const raw = await this.readRawValues() if (!(key in raw)) return diff --git a/src/server/infra/storage/settings-validator.ts b/src/server/infra/storage/settings-validator.ts index 880889c6a..590445394 100644 --- a/src/server/infra/storage/settings-validator.ts +++ b/src/server/infra/storage/settings-validator.ts @@ -105,6 +105,11 @@ export class SettingsValidator { continue } + if (descriptor.storage === 'global-config') { + invalid.push({key, reason: `'${key}' is stored in config.json, not settings.json`, value}) + continue + } + try { valid[key] = validateWritableAgainst(descriptor, value) } catch (error) { @@ -144,6 +149,14 @@ export class SettingsValidator { throw new ReadonlySettingKeyError(key) } + if (descriptor.storage === 'global-config') { + throw new InvalidSettingValueError( + key, + value, + `'${key}' is stored in config.json, not settings.json; use the SettingsHandler facade`, + ) + } + return validateWritableAgainst(descriptor, value) } diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts index eba5462d6..7eb773d41 100644 --- a/src/server/infra/transport/handlers/global-config-handler.ts +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -18,7 +18,7 @@ import {processLog} from '../../../utils/process-logger.js' export interface GlobalConfigHandlerDeps { /** * M4.4: optional analytics client used to cancel any in-flight HTTP - * send when `brv analytics disable` flips the flag from true → false. + * send when `brv settings set analytics.enabled false` flips the flag. * Disable does NOT drop the queue or clear JSONL — those stay so a * future re-enable ships the backlog. Optional for back-compat with * test harnesses that don't construct a real analytics client. @@ -90,6 +90,18 @@ export class GlobalConfigHandler { return this.cachedAnalytics } + /** + * Public async read of the persisted analytics flag. Surfaced for + * the SettingsHandler facade so `brv settings get analytics.enabled` + * resolves through the SAME `globalConfigStore.read()` path that + * `globalConfig:get` uses. Returns the on-disk value (or `false` + * when no config file exists). + */ + async getCurrentAnalytics(): Promise { + const response = await this.read() + return response.analytics + } + /** * Synchronously refreshes the cached analytics flag from disk. Daemon * bootstrap awaits this once before constructing AnalyticsClient so @@ -124,6 +136,17 @@ export class GlobalConfigHandler { this.analyticsClient = client } + /** + * Public write of the analytics flag. Surfaced for the SettingsHandler + * facade so `brv settings set analytics.enabled ` goes through + * the SAME write path as `globalConfig:setAnalytics` — concurrent-safe + * via `writeChain`, refreshes the cache, emits `analytics_disabled`, + * triggers the abort-on-disable on the analytics client. + */ + async setAnalyticsValue(value: boolean): Promise { + return this.setAnalytics(value) + } + setup(): void { this.transport.onRequest(GlobalConfigEvents.GET, async () => this.read()) this.transport.onRequest( diff --git a/src/server/infra/transport/handlers/settings-handler.ts b/src/server/infra/transport/handlers/settings-handler.ts index 22572c27f..0a82a8fd3 100644 --- a/src/server/infra/transport/handlers/settings-handler.ts +++ b/src/server/infra/transport/handlers/settings-handler.ts @@ -45,8 +45,30 @@ export type ReadonlyInfoSnapshot = boolean | number | Readonly Promise | ReadonlyInfoSnapshot +/** + * Facade over `GlobalConfigHandler` for the `analytics.enabled` setting. + * The settings handler routes GET/SET/RESET/LIST for that key through + * this facade instead of `FileSettingsStore`, so the canonical storage + * in `config.json`, the device-id seeding race fix, the sync analytics + * cache, and the abort-on-disable side-effect are all preserved. + * + * Structurally satisfied by `GlobalConfigHandler` (no `implements` + * needed); tests pass a hand-rolled stub. + */ +export interface AnalyticsEnabledFacade { + getCurrentAnalytics(): Promise + setAnalyticsValue(value: boolean): Promise<{current: boolean; previous: boolean}> +} + export interface SettingsHandlerDeps { readonly analyticsClient?: IAnalyticsClient + /** + * Facade for the `analytics.enabled` writable key. When set, + * GET/SET/RESET/LIST for `analytics.enabled` route through this facade + * instead of the file store. When unset, the key surfaces with + * `current: undefined`. + */ + readonly globalConfigHandler?: AnalyticsEnabledFacade /** * Live-value resolvers for `readonly-info` keys, keyed by descriptor key. * t3 (analytics.status) registers `'analytics.status' -> getAnalyticsStatus` @@ -83,6 +105,7 @@ export interface SettingsHandlerDeps { */ export class SettingsHandler { private readonly analyticsClient: IAnalyticsClient | undefined + private readonly globalConfigHandler: AnalyticsEnabledFacade | undefined private readonly infoProviders: ReadonlyMap private readonly registry: readonly SettingDescriptor[] private readonly store: ISettingsStore @@ -90,6 +113,7 @@ export class SettingsHandler { public constructor(deps: SettingsHandlerDeps) { this.analyticsClient = deps.analyticsClient + this.globalConfigHandler = deps.globalConfigHandler this.infoProviders = deps.infoProviders ?? new Map() this.registry = deps.registry ?? SETTINGS_REGISTRY this.store = deps.store @@ -163,6 +187,61 @@ export class SettingsHandler { return {error: readOnlyError(data.key), ok: false} } + // Global-config writables (analytics.enabled and any future ones) + // route through the injected facade. The file store stays + // untouched. Type check still applies (boolean for the only + // current case), so reuse `checkValueType` before delegating. + if (descriptor?.storage === 'global-config') { + const typeError = checkValueType(descriptor, data.key, data.value) + if (typeError !== undefined) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { + failure_kind: 'validation', + outcome: 'failure', + setting_key: data.key, + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ + return {error: typeError, ok: false} + } + + if (this.globalConfigHandler === undefined) { + return { + error: { + code: 'misconfigured', + key: data.key, + message: `'${data.key}' is stored in global config, but no globalConfigHandler facade was wired into SettingsHandler.`, + }, + ok: false, + } + } + + try { + await this.globalConfigHandler.setAnalyticsValue(data.value as boolean) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { + outcome: 'success', + setting_key: data.key, + value_changed_from_default: descriptorDefault(descriptor) === undefined + ? undefined + : data.value !== descriptorDefault(descriptor), + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ + return {ok: true, restartRequired: restartRequiredFor(descriptor)} + } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_CHANGED, { + failure_kind: classifySettingsFailure(error), + outcome: 'failure', + setting_key: data.key, + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ + return {error: errorToDTO(error, data.key, data.value), ok: false} + } + } + const typeError = checkValueType(descriptor, data.key, data.value) if (typeError !== undefined) { /* eslint-disable camelcase */ @@ -219,6 +298,59 @@ export class SettingsHandler { return {error: readOnlyError(data.key), ok: false} } + // Reset on a global-config writable means "back to descriptor.default". + // For analytics.enabled the default is `false`, so we flip via the facade. + if (descriptor?.storage === 'global-config') { + if (this.globalConfigHandler === undefined) { + return { + error: { + code: 'misconfigured', + key: data.key, + message: `'${data.key}' is stored in global config, but no globalConfigHandler facade was wired into SettingsHandler.`, + }, + ok: false, + } + } + + // The facade interface is boolean-only (`setAnalyticsValue(value: boolean)`). + // If a future descriptor is added with storage='global-config' and a + // non-boolean type, refuse explicitly instead of silently coercing + // the default to `false`. + if (descriptor.type !== 'boolean') { + return { + error: { + code: 'misconfigured', + key: data.key, + message: `'${data.key}' has storage='global-config' but type='${descriptor.type}'; the facade only supports boolean global-config keys.`, + }, + ok: false, + } + } + + try { + const defaultValue: boolean = descriptor.default + await this.globalConfigHandler.setAnalyticsValue(defaultValue) + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_RESET, { + outcome: 'success', + setting_key: data.key, + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ + return {ok: true, restartRequired: restartRequiredFor(descriptor)} + } catch (error) { + /* eslint-disable camelcase */ + this.emitAnalytics(AnalyticsEventNames.SETTING_RESET, { + failure_kind: classifySettingsFailure(error), + outcome: 'failure', + setting_key: data.key, + value_kind: writableValueKind(descriptor), + }) + /* eslint-enable camelcase */ + return {error: errorToDTO(error, data.key), ok: false} + } + } + try { await this.store.reset(data.key) /* eslint-disable camelcase */ @@ -275,14 +407,23 @@ export class SettingsHandler { descriptor: SettingDescriptor, stored: SettingItem | undefined, ): Promise { - if (descriptor.type !== 'readonly-info') { - if (stored?.current !== undefined) return stored.current - return descriptor.default + if (descriptor.type === 'readonly-info') { + const provider = this.infoProviders.get(descriptor.key) + if (provider === undefined) return undefined + return provider() + } + + if (descriptor.storage === 'global-config') { + // Global-config-stored values (analytics.enabled) live in + // config.json, not settings.json. Without an injected facade we + // cannot resolve — surface `undefined` so the row still renders + // rather than crashing. + if (this.globalConfigHandler === undefined) return undefined + return this.globalConfigHandler.getCurrentAnalytics() } - const provider = this.infoProviders.get(descriptor.key) - if (provider === undefined) return undefined - return provider() + if (stored?.current !== undefined) return stored.current + return descriptor.default } } diff --git a/src/server/templates/skill/onboarding.md b/src/server/templates/skill/onboarding.md index 5067ddc62..ce45f4258 100644 --- a/src/server/templates/skill/onboarding.md +++ b/src/server/templates/skill/onboarding.md @@ -287,14 +287,14 @@ After the tour closes, ask **once** whether the user wants to share anonymous us Place the ask _after_ the "Either way, you're set" close, as a single follow-up message — not bundled into Message 3: -> "One optional ask before you go: if you'd like to help us improve ByteRover, you can opt in to share anonymous usage telemetry — things like which commands ran and how long they took. No query content, file contents, or memory is ever sent. You can change your mind anytime with `brv analytics disable`. +> "One optional ask before you go: if you'd like to help us improve ByteRover, you can opt in to share anonymous usage telemetry — things like which commands ran and how long they took. No query content, file contents, or memory is ever sent. You can change your mind anytime with `brv settings set analytics.enabled false`. > > Want to opt in? Either answer is fine." Handling the response: -- **Yes** → run `brv analytics enable --yes` (or instruct the user to run it if you cannot), then confirm in one line: "Done — thanks. `brv analytics disable` reverses it anytime." -- **No / silence / "maybe later"** → one-line acknowledgement ("No problem — `brv analytics enable` is there whenever.") and stop. Do not re-ask in future sessions. +- **Yes** → run `brv settings set analytics.enabled true --yes` (or instruct the user to run it if you cannot), then confirm in one line: "Done — thanks. `brv settings set analytics.enabled false` reverses it anytime." +- **No / silence / "maybe later"** → one-line acknowledgement ("No problem — `brv settings set analytics.enabled true` is there whenever.") and stop. Do not re-ask in future sessions. Why this beat exists: @@ -304,7 +304,7 @@ Why this beat exists: Skip the ask entirely if: -- Sharing is already enabled (the `brv analytics status` flag is true). +- Sharing is already enabled (`brv settings get analytics.enabled` returns true). - The user signaled disengagement at the close ("ok", "got it", "thanks", no further input). Don't pull a yes/no out of someone who's already left. ## What NOT To Do diff --git a/src/shared/analytics/events/analytics-disabled.ts b/src/shared/analytics/events/analytics-disabled.ts index 716389454..88776cffe 100644 --- a/src/shared/analytics/events/analytics-disabled.ts +++ b/src/shared/analytics/events/analytics-disabled.ts @@ -4,7 +4,7 @@ import {z} from 'zod' * Per-event schema for `analytics_disabled`. * * No properties. The emit captures the moment the user opts out via - * `brv analytics disable`; identity is stamped by the per-event identity + * `brv settings set analytics.enabled false`; identity is stamped by the per-event identity * resolver and `client_kind` by the super-property layer. The disable * action itself is the entire signal. */ diff --git a/src/server/templates/sections/analytics-disclosure.md b/src/shared/assets/analytics-disclosure.md similarity index 90% rename from src/server/templates/sections/analytics-disclosure.md rename to src/shared/assets/analytics-disclosure.md index 471bc794e..04b8c672b 100644 --- a/src/server/templates/sections/analytics-disclosure.md +++ b/src/shared/assets/analytics-disclosure.md @@ -28,7 +28,8 @@ is permanently linked to your account. ## How to disable -Lorem ipsum: run `brv analytics disable` at any time to stop collection. +Lorem ipsum: run `brv settings set analytics.enabled false` at any time to +stop collection. ## Privacy policy diff --git a/src/shared/constants/settings-keys.ts b/src/shared/constants/settings-keys.ts new file mode 100644 index 000000000..3bdfd9afc --- /dev/null +++ b/src/shared/constants/settings-keys.ts @@ -0,0 +1,13 @@ +/** + * Cross-cutting settings key constants. The full registry of writable and + * readonly-info descriptors lives at + * `src/server/core/domain/entities/settings.ts` and may only be imported + * from `server/` and `agent/` callers. + * + * This module re-exposes the subset of key names that other layers (TUI, + * WebUI, oclif) need to refer to without crossing the `tui -> server` + * import boundary. Each constant is the literal wire key — a rename here + * is a typecheck error at every consuming site. + */ + +export const ANALYTICS_ENABLED_KEY = 'analytics.enabled' as const diff --git a/src/shared/transport/events/analytics-events.ts b/src/shared/transport/events/analytics-events.ts index d8cce9723..d67116e14 100644 --- a/src/shared/transport/events/analytics-events.ts +++ b/src/shared/transport/events/analytics-events.ts @@ -11,7 +11,7 @@ export const AnalyticsEvents = { /** * M4.6 `analytics:status` response. Surfaces operational metrics for - * `brv analytics status`: enabled flag (from GlobalConfig), client + * `brv settings get analytics.status`: enabled flag (from GlobalConfig), client * runtime state (last-flush timestamp, JSONL pending depth, dropped * count), backoff state (M4.5 policy + derived reachability label), * and the analytics endpoint URL. diff --git a/src/shared/transport/events/global-config-events.ts b/src/shared/transport/events/global-config-events.ts index 2d2b88667..946b916bd 100644 --- a/src/shared/transport/events/global-config-events.ts +++ b/src/shared/transport/events/global-config-events.ts @@ -7,9 +7,8 @@ export const GlobalConfigEvents = { } as const /** - * M13.2 Group C — `globalConfig:get` is a no-payload oclif call today (verified at - * `src/oclif/commands/analytics/status.ts:34`). Define the Request interface so - * M13.3 can attach `cli_metadata`. + * M13.2 Group C — `globalConfig:get` is a no-payload request. Define the + * Request interface so M13.3 can attach `cli_metadata`. */ export interface GlobalConfigGetRequest { cli_metadata?: CliMetadata diff --git a/src/shared/transport/events/settings-events.ts b/src/shared/transport/events/settings-events.ts index 9d3d4e07b..7b11b2f06 100644 --- a/src/shared/transport/events/settings-events.ts +++ b/src/shared/transport/events/settings-events.ts @@ -36,7 +36,23 @@ export interface SettingsItemDTO { } export interface SettingsErrorDTO { - code: 'invalid_value' | 'invalid_value_type' | 'read_only' | 'unknown_key' + /** + * Failure category for a settings:* request. + * + * - `'invalid_value'`: value violates a descriptor constraint (range, + * coupling, fractional integer, etc). + * - `'invalid_value_type'`: value's runtime `typeof` did not match the + * descriptor's declared type. + * - `'misconfigured'`: the daemon's wiring of this key is missing or + * incompatible (e.g. a `storage: 'global-config'` descriptor with no + * facade injected, or a non-boolean global-config descriptor — the + * only facade is boolean-only today). Distinct from `invalid_value` + * so logs and the WebUI can distinguish "user gave bad value" from + * "daemon wiring is wrong". + * - `'read_only'`: caller tried to write or reset a `readonly-info` key. + * - `'unknown_key'`: registry has no descriptor for the requested key. + */ + code: 'invalid_value' | 'invalid_value_type' | 'misconfigured' | 'read_only' | 'unknown_key' /** Expected runtime kind, only set when `code === 'invalid_value_type'`. */ expected?: 'boolean' | 'integer' /** `typeof` of the offending value, only set when `code === 'invalid_value_type'`. */ diff --git a/src/shared/utils/format-analytics-status.ts b/src/shared/utils/format-analytics-status.ts index 096a2e26d..d051361fe 100644 --- a/src/shared/utils/format-analytics-status.ts +++ b/src/shared/utils/format-analytics-status.ts @@ -1,4 +1,4 @@ -/* eslint-disable camelcase -- legacy `brv analytics status --format json` envelope is snake_case. */ +/* eslint-disable camelcase -- legacy `brv settings get analytics.status --format json` envelope is snake_case. */ import type {AnalyticsStatusResponse} from '../transport/events/analytics-events.js' import {AnalyticsStatusResponseSchema} from '../transport/events/analytics-events.js' @@ -39,7 +39,7 @@ export function formatDelayMs(ms: number): string { /** * Renders the analytics-status snapshot as the multi-line text block the - * legacy `brv analytics status` command printed. The output is consumed by + * legacy `brv analytics status` (now `brv settings get analytics.status`) printed. The output is consumed by * both `brv settings get analytics.status` and `brv settings list` * (per-key readonly-info formatter) — and by the TUI settings page via * the same shared registry. @@ -67,7 +67,8 @@ export function formatAnalyticsStatusText(value: unknown, now: () => number = Da /** * JSON wire shape matching the legacy `brv analytics status --format json` - * envelope (snake_case fields). Consumed by + * envelope (now `brv settings get analytics.status --format json`, snake_case + * fields preserved). Consumed by * `brv settings get analytics.status --format json` so callers depending * on the legacy programmatic shape do not break when the legacy command * is deleted in M16.4. diff --git a/src/shared/utils/load-analytics-disclosure.ts b/src/shared/utils/load-analytics-disclosure.ts new file mode 100644 index 000000000..8928f9824 --- /dev/null +++ b/src/shared/utils/load-analytics-disclosure.ts @@ -0,0 +1,16 @@ +import {readFile} from 'node:fs/promises' +import {dirname, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' + +/** + * Canonical disclosure markdown lives in `src/shared/assets/` so both + * oclif (CLI consent prompt) and TUI (settings-page inline confirm) + * can read it without crossing the import boundary. The build script + * copies `src/shared/assets/` to `dist/shared/assets/`. + */ +const here = dirname(fileURLToPath(import.meta.url)) +const DISCLOSURE_PATH = resolve(here, '../assets/analytics-disclosure.md') + +export async function loadAnalyticsDisclosureText(): Promise { + return readFile(DISCLOSURE_PATH, 'utf8') +} diff --git a/src/tui/features/settings/components/settings-page.tsx b/src/tui/features/settings/components/settings-page.tsx index db00b67cd..351fcdf4b 100644 --- a/src/tui/features/settings/components/settings-page.tsx +++ b/src/tui/features/settings/components/settings-page.tsx @@ -1,16 +1,18 @@ -import {Box, Text, useInput} from 'ink' +import {Box, Text, useInput, useStdout} from 'ink' import Spinner from 'ink-spinner' -import React, {useCallback, useMemo, useState} from 'react' +import React, {useCallback, useEffect, useMemo, useState} from 'react' import type {SettingsRow} from '../../../../shared/types/settings-row.js' import type {CustomDialogCallbacks} from '../../../types/commands.js' +import {ANALYTICS_ENABLED_KEY} from '../../../../shared/constants/settings-keys.js' import {buildSettingsRows, parseRowInput} from '../../../../shared/utils/format-settings.js' +import {loadAnalyticsDisclosureText} from '../../../../shared/utils/load-analytics-disclosure.js' import {useTheme} from '../../../hooks/index.js' import {useGetSettings, useResetSetting, useSetSetting} from '../api/settings-api.js' import {bottomHintFor, groupRowsByCategory, preFillBufferFor} from '../utils/format-settings.js' -type Mode = 'browse' | 'edit' | 'saving' +type Mode = 'browse' | 'confirm-disclosure' | 'edit' | 'saving' export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): React.ReactNode { const {data, error, isLoading} = useGetSettings() @@ -25,12 +27,36 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea const [editBuffer, setEditBuffer] = useState('') const [rowError, setRowError] = useState() const [dirtyKeys, setDirtyKeys] = useState>(new Set()) + const [disclosureText, setDisclosureText] = useState() + const [disclosureScroll, setDisclosureScroll] = useState(0) + const [pendingDisclosureRow, setPendingDisclosureRow] = useState() + + // Tracks terminal height so the disclosure overlay can slice the + // markdown to fit a short window. Falls back to a sensible default + // when stdout has no row count (e.g. piped output, tests). + const {stdout} = useStdout() + const [terminalRows, setTerminalRows] = useState(stdout?.rows ?? 24) + useEffect(() => { + if (!stdout) return + const handler = (): void => setTerminalRows(stdout.rows ?? 24) + stdout.on('resize', handler) + return () => { + stdout.off('resize', handler) + } + }, [stdout]) const rows = useMemo(() => (data ? buildSettingsRows(data.items) : []), [data]) const groups = useMemo(() => groupRowsByCategory(rows), [rows]) const focusedRow = rows[cursor] + // `hintMode` only feeds the bottom hint on the row-list render. The + // `confirm-disclosure` mode short-circuits that render entirely (its + // own hint is inlined below), so it never reaches `bottomHintFor`. const hintMode: 'browse' | 'edit' | 'edit-error' | 'saving' = - mode === 'edit' && rowError !== undefined ? 'edit-error' : mode + mode === 'confirm-disclosure' + ? 'browse' + : mode === 'edit' && rowError !== undefined + ? 'edit-error' + : mode // Restart warning fires only when at least one dirty key actually // requires a daemon restart. Boolean toggles (e.g. update.checkForUpdates, @@ -99,12 +125,11 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea [resetMutation], ) - const toggleBoolean = useCallback( - async (row: SettingsRow) => { - if (row.type !== 'boolean' || typeof row.current !== 'boolean') return + const performToggle = useCallback( + async (row: SettingsRow, nextValue: boolean) => { setMode('saving') setRowError(undefined) - const response = await setMutation.mutateAsync({key: row.key, value: !row.current}) + const response = await setMutation.mutateAsync({key: row.key, value: nextValue}) if (response.ok) { setDirtyKeys((previous) => { const next = new Set(previous) @@ -121,6 +146,35 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea [setMutation], ) + const toggleBoolean = useCallback( + async (row: SettingsRow) => { + if (row.type !== 'boolean' || typeof row.current !== 'boolean') return + + // analytics.enabled false -> true requires the disclosure consent + // prompt. Load the markdown and switch into the confirm-disclosure + // mode; the user must press Enter to accept (which fires the actual + // SET) or Esc to cancel. + if (row.key === ANALYTICS_ENABLED_KEY && row.current === false) { + setRowError(undefined) + setPendingDisclosureRow(row) + setDisclosureScroll(0) + try { + const text = await loadAnalyticsDisclosureText() + setDisclosureText(text) + setMode('confirm-disclosure') + } catch (error) { + setRowError(error instanceof Error ? error.message : String(error)) + setPendingDisclosureRow(undefined) + } + + return + } + + await performToggle(row, !row.current) + }, + [performToggle], + ) + useInput( (input, key) => { if (key.escape) { @@ -203,6 +257,74 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea {isActive: mode === 'saving'}, ) + // Disclosure confirm: Enter accepts and flips the flag, Esc cancels + // without flipping. Up/Down + PgUp/PgDn scroll the markdown when it + // doesn't fit in the terminal height. + const disclosureLines = useMemo( + () => (disclosureText === undefined ? [] : disclosureText.split('\n')), + [disclosureText], + ) + // Reserved chrome (defensive — includes the REPL's own bottom status + // bar that wraps this slash-command, and a margin for stray line + // wraps): header (1) + header-gap (1) + top-overflow (1) + + // bottom-overflow (1) + footer-gap (1) + footer (1) + REPL chrome (1) + // + wrap safety (5) = 12 rows. + const disclosureViewportRows = Math.max(2, terminalRows - 12) + const maxScroll = Math.max(0, disclosureLines.length - disclosureViewportRows) + const clampedScroll = Math.min(disclosureScroll, maxScroll) + const visibleLines = disclosureLines.slice(clampedScroll, clampedScroll + disclosureViewportRows) + const hasMoreAbove = clampedScroll > 0 + const hasMoreBelow = clampedScroll < maxScroll + + useInput( + (input, key) => { + if (key.escape) { + setMode('browse') + setPendingDisclosureRow(undefined) + return + } + + if (key.return) { + const row = pendingDisclosureRow + setPendingDisclosureRow(undefined) + if (row !== undefined) { + // `performToggle` itself calls `setRowError` on a non-`ok` response, + // but a thrown rejection (e.g. transport disconnect) would otherwise + // be swallowed and leave the UI in `browse` mode with no feedback + // about the failed enable. Surface the error message and restore + // the browse mode explicitly. + performToggle(row, true).catch((error_: unknown) => { + setRowError(error_ instanceof Error ? error_.message : String(error_)) + setMode('browse') + }) + } + + return + } + + if (key.upArrow) { + setDisclosureScroll((s) => Math.max(0, s - 1)) + return + } + + if (key.downArrow) { + setDisclosureScroll((s) => Math.min(maxScroll, s + 1)) + return + } + + // Page-scroll via PgUp/PgDn or space/b (mirroring `less`). + if (key.pageUp || input === 'b') { + setDisclosureScroll((s) => Math.max(0, s - disclosureViewportRows)) + return + } + + if (key.pageDown || input === ' ') { + setDisclosureScroll((s) => Math.min(maxScroll, s + disclosureViewportRows)) + } + }, + {isActive: mode === 'confirm-disclosure'}, + ) + React.useEffect(() => { if (error) { onComplete(`Failed to load settings: ${error.message}`) @@ -217,6 +339,44 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea ) } + // Disclosure overlay. Renders the markdown sliced to fit the terminal + // height so a short window still shows the Enter/Esc footer. Up/Down + // and PgUp/PgDn scroll through the body. The body Box uses an explicit + // height + flexShrink=1 so wrapped markdown lines cannot push the + // sticky footer off-screen. + if (mode === 'confirm-disclosure') { + return ( + + + ANALYTICS DISCLOSURE + + {disclosureText === undefined ? ( + + Loading disclosure... + + ) : ( + + {hasMoreAbove ? '↑ more above' : ' '} + {visibleLines.map((line, idx) => ( + // Each body line gets its own Text with truncate-end so a long + // markdown line in a narrow terminal stays one visual row and + // cannot push the sticky footer off-screen. + + {line.length === 0 ? ' ' : line} + + ))} + {hasMoreBelow ? '↓ more below' : ' '} + + )} + + + Enter: enable analytics | Esc: cancel | ↑↓ scroll + + + + ) + } + const keyWidth = Math.max(40, ...rows.map((r) => r.label.length)) const currentWidth = Math.max(7, ...rows.map((r) => r.displayCurrent.length)) const defaultWidth = Math.max(8, ...rows.map((r) => (r.displayDefault ?? '').length)) diff --git a/test/commands/analytics/disable.test.ts b/test/commands/analytics/disable.test.ts deleted file mode 100644 index 594dcbc05..000000000 --- a/test/commands/analytics/disable.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type {ConnectionResult, ITransportClient} from '@campfirein/brv-transport-client' -import type {Config} from '@oclif/core' - -import {NoInstanceRunningError} from '@campfirein/brv-transport-client' -import {Config as OclifConfig} from '@oclif/core' -import {expect} from 'chai' -import sinon, {restore, stub} from 'sinon' - -import type {DaemonClientOptions} from '../../../src/oclif/lib/daemon-client.js' -import type {GlobalConfigSetAnalyticsResponse} from '../../../src/shared/transport/events/global-config-events.js' - -import Disable from '../../../src/oclif/commands/analytics/disable.js' -import {GlobalConfigEvents} from '../../../src/shared/transport/events/global-config-events.js' - -class TestableDisableCommand extends Disable { - private readonly mockConnector: () => Promise - - constructor(mockConnector: () => Promise, config: Config, argv: string[] = []) { - super(argv, config) - this.mockConnector = mockConnector - } - - protected override async setAnalytics( - analytics: boolean, - options?: DaemonClientOptions, - ): Promise { - return super.setAnalytics(analytics, { - maxRetries: 1, - retryDelayMs: 0, - transportConnector: this.mockConnector, - ...options, - }) - } -} - -describe('analytics disable command', () => { - let config: Config - let loggedMessages: string[] - let mockClient: sinon.SinonStubbedInstance - let mockConnector: sinon.SinonStub<[], Promise> - - before(async () => { - config = await OclifConfig.load(import.meta.url) - }) - - beforeEach(() => { - loggedMessages = [] - - mockClient = { - connect: stub().resolves(), - disconnect: stub().resolves(), - getClientId: stub().returns('test-client-id'), - getDaemonVersion: stub(), - getState: stub().returns('connected'), - isConnected: stub().resolves(true), - joinRoom: stub().resolves(), - leaveRoom: stub().resolves(), - on: stub().returns(() => {}), - once: stub(), - onStateChange: stub().returns(() => {}), - request: stub() as unknown as ITransportClient['request'], - requestWithAck: stub().resolves({current: false, previous: true}), - } as unknown as sinon.SinonStubbedInstance - - mockConnector = stub<[], Promise>().resolves({ - client: mockClient as unknown as ITransportClient, - projectRoot: '/test/project', - }) - }) - - afterEach(() => { - restore() - }) - - function createCommand(argv: string[] = []): TestableDisableCommand { - const command = new TestableDisableCommand(mockConnector, config, argv) - stub(command, 'log').callsFake((msg?: string) => { - if (msg) loggedMessages.push(msg) - }) - return command - } - - function mockSetAnalyticsResponse(response: GlobalConfigSetAnalyticsResponse): void { - ;(mockClient.requestWithAck as sinon.SinonStub).resolves(response) - } - - describe('toggle from enabled to disabled', () => { - it('should print "Analytics disabled" when previous was true', async () => { - mockSetAnalyticsResponse({current: false, previous: true}) - - await createCommand().run() - - expect(loggedMessages.some((m) => m.includes('Analytics disabled'))).to.be.true - expect(loggedMessages.some((m) => m.includes('already'))).to.be.false - }) - }) - - describe('idempotent (already disabled)', () => { - it('should print "Analytics already disabled" when previous equals current', async () => { - mockSetAnalyticsResponse({current: false, previous: false}) - - await createCommand().run() - - expect(loggedMessages.some((m) => m.includes('Analytics already disabled'))).to.be.true - }) - }) - - describe('connection error', () => { - it('should print formatted connection error when daemon unavailable', async () => { - mockConnector.rejects(new NoInstanceRunningError()) - - await createCommand().run() - - expect(loggedMessages.some((m) => m.includes('Daemon failed to start automatically'))).to.be.true - }) - }) - - describe('transport contract', () => { - it('should issue exactly one SET_ANALYTICS request with {analytics: false}', async () => { - mockSetAnalyticsResponse({current: false, previous: true}) - - await createCommand().run() - - const requestStub = mockClient.requestWithAck as sinon.SinonStub - expect(requestStub.callCount).to.equal(1) - expect(requestStub.firstCall.args[0]).to.equal(GlobalConfigEvents.SET_ANALYTICS) - expect(requestStub.firstCall.args[1]).to.deep.equal({analytics: false}) - }) - }) - - describe('help text', () => { - it('should declare a description string', () => { - expect(Disable.description).to.be.a('string').and.not.be.empty - }) - }) -}) diff --git a/test/commands/analytics/enable.test.ts b/test/commands/analytics/enable.test.ts deleted file mode 100644 index 4a67834db..000000000 --- a/test/commands/analytics/enable.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import type {ConnectionResult, ITransportClient} from '@campfirein/brv-transport-client' -import type {Config} from '@oclif/core' - -import {Config as OclifConfig} from '@oclif/core' -import {expect} from 'chai' -import {readFile} from 'node:fs/promises' -import {dirname, resolve} from 'node:path' -import {fileURLToPath} from 'node:url' -import sinon, {restore, stub} from 'sinon' - -import type {DaemonClientOptions} from '../../../src/oclif/lib/daemon-client.js' -import type { - GlobalConfigGetResponse, - GlobalConfigSetAnalyticsResponse, -} from '../../../src/shared/transport/events/global-config-events.js' - -import Enable from '../../../src/oclif/commands/analytics/enable.js' -import {PRIVACY_POLICY_URL} from '../../../src/shared/constants/privacy.js' -import {GlobalConfigEvents} from '../../../src/shared/transport/events/global-config-events.js' - -interface TestHooks { - confirmCalled?: sinon.SinonStub - confirmResult?: boolean - disclosureText?: string - isTTY?: boolean -} - -class TestableEnableCommand extends Enable { - private readonly mockConnector: () => Promise - private readonly testHooks: TestHooks - - constructor( - mockConnector: () => Promise, - testHooks: TestHooks, - config: Config, - argv: string[] = [], - ) { - super(argv, config) - this.mockConnector = mockConnector - this.testHooks = testHooks - } - - protected override async confirmDisclosure(): Promise { - this.testHooks.confirmCalled?.() - return this.testHooks.confirmResult ?? true - } - - protected override async getCurrentAnalytics(options?: DaemonClientOptions): Promise { - return super.getCurrentAnalytics({ - maxRetries: 1, - retryDelayMs: 0, - transportConnector: this.mockConnector, - ...options, - }) - } - - protected override isInteractive(): boolean { - return this.testHooks.isTTY ?? true - } - - protected override async loadDisclosure(): Promise { - return this.testHooks.disclosureText ?? 'mock disclosure' - } - - protected override async setAnalytics( - analytics: boolean, - options?: DaemonClientOptions, - ): Promise { - return super.setAnalytics(analytics, { - maxRetries: 1, - retryDelayMs: 0, - transportConnector: this.mockConnector, - ...options, - }) - } -} - -describe('analytics enable command (M1.4 disclosure UX)', () => { - let config: Config - let loggedMessages: string[] - let mockClient: sinon.SinonStubbedInstance - let mockConnector: sinon.SinonStub<[], Promise> - - before(async () => { - config = await OclifConfig.load(import.meta.url) - }) - - beforeEach(() => { - loggedMessages = [] - - mockClient = { - connect: stub().resolves(), - disconnect: stub().resolves(), - getClientId: stub().returns('test-client-id'), - getDaemonVersion: stub(), - getState: stub().returns('connected'), - isConnected: stub().resolves(true), - joinRoom: stub().resolves(), - leaveRoom: stub().resolves(), - on: stub().returns(() => {}), - once: stub(), - onStateChange: stub().returns(() => {}), - request: stub() as unknown as ITransportClient['request'], - requestWithAck: stub(), - } as unknown as sinon.SinonStubbedInstance - - mockConnector = stub<[], Promise>().resolves({ - client: mockClient as unknown as ITransportClient, - projectRoot: '/test/project', - }) - }) - - afterEach(() => { - restore() - }) - - function mockGetThenSet(currentAnalytics: boolean, setResponse?: GlobalConfigSetAnalyticsResponse): void { - const requestStub = mockClient.requestWithAck as sinon.SinonStub - const getResponse: GlobalConfigGetResponse = { - analytics: currentAnalytics, - deviceId: 'test-device', - version: '1.0.0', - } - requestStub.withArgs(GlobalConfigEvents.GET).resolves(getResponse) - if (setResponse) { - requestStub.withArgs(GlobalConfigEvents.SET_ANALYTICS).resolves(setResponse) - } - } - - function createCommand(testHooks: TestHooks, argv: string[] = []): TestableEnableCommand { - const command = new TestableEnableCommand(mockConnector, testHooks, config, argv) - stub(command, 'log').callsFake((msg?: string) => { - if (msg) loggedMessages.push(msg) - }) - return command - } - - describe('1. interactive accept flips the flag', () => { - it('should call SET_ANALYTICS true and print confirmation when user accepts', async () => { - mockGetThenSet(false, {current: true, previous: false}) - const confirmCalled = stub() - - await createCommand({confirmCalled, confirmResult: true, isTTY: true}).run() - - expect(confirmCalled.calledOnce, 'disclosure prompt should be shown').to.be.true - expect(loggedMessages.some((m) => m.includes('Analytics enabled'))).to.be.true - expect(loggedMessages.some((m) => m.includes('not enabled'))).to.be.false - - const requestStub = mockClient.requestWithAck as sinon.SinonStub - const setCalls = requestStub.getCalls().filter((c) => c.args[0] === GlobalConfigEvents.SET_ANALYTICS) - expect(setCalls).to.have.lengthOf(1) - expect(setCalls[0].args[1]).to.deep.equal({analytics: true}) - }) - }) - - describe('2. interactive reject leaves flag unchanged', () => { - it('should NOT call SET_ANALYTICS and print "Analytics not enabled" when user rejects', async () => { - mockGetThenSet(false) - const confirmCalled = stub() - - await createCommand({confirmCalled, confirmResult: false, isTTY: true}).run() - - expect(confirmCalled.calledOnce, 'disclosure prompt should be shown').to.be.true - expect(loggedMessages.some((m) => m.includes('Analytics not enabled'))).to.be.true - - const requestStub = mockClient.requestWithAck as sinon.SinonStub - const setCalls = requestStub.getCalls().filter((c) => c.args[0] === GlobalConfigEvents.SET_ANALYTICS) - expect(setCalls, 'no SET_ANALYTICS write should occur on reject').to.have.lengthOf(0) - }) - }) - - describe('3. --yes flag bypasses the prompt', () => { - it('should flip the flag without showing the disclosure prompt', async () => { - mockGetThenSet(false, {current: true, previous: false}) - const confirmCalled = stub() - - await createCommand({confirmCalled, confirmResult: true, isTTY: true}, ['--yes']).run() - - expect(confirmCalled.called, 'prompt must NOT be shown when --yes is passed').to.be.false - expect(loggedMessages.some((m) => m.includes('Analytics enabled'))).to.be.true - - const requestStub = mockClient.requestWithAck as sinon.SinonStub - const setCalls = requestStub.getCalls().filter((c) => c.args[0] === GlobalConfigEvents.SET_ANALYTICS) - expect(setCalls).to.have.lengthOf(1) - }) - }) - - describe('4. already-enabled state skips the prompt entirely', () => { - it('should print "Analytics already enabled" with no prompt and no SET_ANALYTICS call', async () => { - mockGetThenSet(true) - const confirmCalled = stub() - - await createCommand({confirmCalled, confirmResult: true, isTTY: true}).run() - - expect(confirmCalled.called, 'prompt must NOT be shown when already enabled').to.be.false - expect(loggedMessages.some((m) => m.includes('Analytics already enabled'))).to.be.true - - const requestStub = mockClient.requestWithAck as sinon.SinonStub - const setCalls = requestStub.getCalls().filter((c) => c.args[0] === GlobalConfigEvents.SET_ANALYTICS) - expect(setCalls, 'no SET_ANALYTICS write when already enabled').to.have.lengthOf(0) - }) - }) - - describe('5. non-TTY without --yes refuses with clear error', () => { - it('should exit non-zero and print a clear error directing the user to --yes', async () => { - mockGetThenSet(false) - const confirmCalled = stub() - - const command = createCommand({confirmCalled, confirmResult: true, isTTY: false}) - const errorStub = stub(command, 'error').throws(new Error('non-interactive refusal')) - - let caught: unknown - try { - await command.run() - } catch (error) { - caught = error - } - - expect(caught, 'this.error must be invoked when stdin is non-TTY without --yes').to.be.instanceOf(Error) - expect(errorStub.calledOnce).to.be.true - const errorMessage = errorStub.firstCall.args[0] - expect(errorMessage).to.be.a('string').and.to.match(/--yes|non-interactive|interactive/i) - expect(confirmCalled.called, 'prompt must NOT be shown in non-TTY').to.be.false - - const requestStub = mockClient.requestWithAck as sinon.SinonStub - const setCalls = requestStub.getCalls().filter((c) => c.args[0] === GlobalConfigEvents.SET_ANALYTICS) - expect(setCalls, 'no SET_ANALYTICS write when refusing').to.have.lengthOf(0) - }) - }) - - describe('6. disclosure markdown contains all required sections', () => { - it('should include the five required sections plus the privacy policy link', async () => { - const here = dirname(fileURLToPath(import.meta.url)) - const disclosurePath = resolve(here, '../../../src/server/templates/sections/analytics-disclosure.md') - const text = await readFile(disclosurePath, 'utf8') - - expect(text, 'what-is-collected section').to.match(/what is collected/i) - expect(text, 'which-surfaces section').to.match(/which surfaces|surfaces are tracked/i) - expect(text, 'where-it-goes section').to.match(/where (it )?goes/i) - expect(text, 'cross-device alias section').to.match(/cross-device|alias/i) - expect(text, 'how-to-disable section').to.match(/how to disable|brv analytics disable/i) - expect(text, 'privacy policy link').to.include(PRIVACY_POLICY_URL) - }) - }) - - describe('7. privacy policy URL constant', () => { - it('should be a non-empty https URL', () => { - expect(PRIVACY_POLICY_URL).to.be.a('string').and.not.be.empty - expect(PRIVACY_POLICY_URL).to.match(/^https:\/\//) - }) - }) - - describe('help text', () => { - it('should declare a non-empty description string', () => { - expect(Enable.description).to.be.a('string').and.not.be.empty - }) - }) -}) diff --git a/test/commands/analytics/status.test.ts b/test/commands/analytics/status.test.ts deleted file mode 100644 index 64f54d999..000000000 --- a/test/commands/analytics/status.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type {ConnectionResult, ITransportClient} from '@campfirein/brv-transport-client' -import type {Config} from '@oclif/core' - -import {NoInstanceRunningError} from '@campfirein/brv-transport-client' -import {Config as OclifConfig} from '@oclif/core' -import {expect} from 'chai' -import sinon, {restore, stub} from 'sinon' - -import type {DaemonClientOptions} from '../../../src/oclif/lib/daemon-client.js' -import type {AnalyticsStatusResponse} from '../../../src/shared/transport/events/analytics-events.js' - -/* eslint-disable camelcase -- JSON wire shape is snake_case per the M4.6 ticket schema. */ -import Status from '../../../src/oclif/commands/analytics/status.js' -import {AnalyticsEvents} from '../../../src/shared/transport/events/analytics-events.js' - -/** - * M4.6 test surface: the command issues a single `analytics:status` - * request to the daemon and renders the response as text or JSON. The - * mock here returns shapes that exercise each branch of the renderer. - */ -class TestableStatusCommand extends Status { - private readonly mockConnector: () => Promise - - constructor(mockConnector: () => Promise, config: Config, argv: string[] = []) { - super(argv, config) - this.mockConnector = mockConnector - } - - protected override async fetchAnalyticsStatus(options?: DaemonClientOptions): Promise { - return super.fetchAnalyticsStatus({ - maxRetries: 1, - retryDelayMs: 0, - transportConnector: this.mockConnector, - ...options, - }) - } - - // M4.6: pin the clock so "Xm ago" assertions are deterministic. - protected override now(): number { - return 1_700_000_000_000 - } -} - -const HEALTHY_RESPONSE: AnalyticsStatusResponse = { - backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy'}, - droppedCount: 0, - enabled: true, - endpoint: 'https://telemetry-dev.byterover.dev', - queueDepth: 4, -} - -describe('analytics status command (M4.6)', () => { - let config: Config - let loggedMessages: string[] - let mockClient: sinon.SinonStubbedInstance - let mockConnector: sinon.SinonStub<[], Promise> - - before(async () => { - config = await OclifConfig.load(import.meta.url) - }) - - beforeEach(() => { - loggedMessages = [] - mockClient = { - connect: stub().resolves(), - disconnect: stub().resolves(), - getClientId: stub().returns('test-client-id'), - getDaemonVersion: stub(), - getState: stub().returns('connected'), - isConnected: stub().resolves(true), - joinRoom: stub().resolves(), - leaveRoom: stub().resolves(), - on: stub().returns(() => {}), - once: stub(), - onStateChange: stub().returns(() => {}), - request: stub() as unknown as ITransportClient['request'], - requestWithAck: stub().resolves(HEALTHY_RESPONSE), - } as unknown as sinon.SinonStubbedInstance - - mockConnector = stub<[], Promise>().resolves({ - client: mockClient as unknown as ITransportClient, - projectRoot: '/test/project', - }) - }) - - afterEach(() => { - restore() - }) - - function createCommand(argv: string[] = []): TestableStatusCommand { - const command = new TestableStatusCommand(mockConnector, config, argv) - stub(command, 'log').callsFake((msg?: string) => { - if (msg !== undefined) loggedMessages.push(msg) - }) - return command - } - - function mockStatusResponse(response: AnalyticsStatusResponse): void { - ;(mockClient.requestWithAck as sinon.SinonStub).resolves(response) - } - - function captureJson(argv: string[] = ['--format', 'json']): Promise<{captured: string}> { - return new Promise((resolve) => { - let captured = '' - const writeStub = stub(process.stdout, 'write').callsFake((chunk) => { - captured += chunk - return true - }) - - new TestableStatusCommand(mockConnector, config, argv).run().finally(() => { - writeStub.restore() - resolve({captured}) - }).catch(() => { - // The .finally above already resolves the outer promise; the - // .catch here keeps lint happy about an unhandled rejection on - // the underlying chain (the renderer never throws — error paths - // write a JSON error envelope and return). - }) - }) - } - - describe('text output', () => { - it('disabled state: only shows "disabled" (other fields suppressed)', async () => { - mockStatusResponse({...HEALTHY_RESPONSE, enabled: false}) - - await createCommand().run() - - expect(loggedMessages.some((m) => m.includes('Analytics: disabled'))).to.be.true - expect(loggedMessages.some((m) => m.includes('Queue depth'))).to.be.false - expect(loggedMessages.some((m) => m.includes('Backoff state'))).to.be.false - expect(loggedMessages.some((m) => m.includes('Endpoint'))).to.be.false - }) - - it('enabled, never flushed: "Last successful flush: never"', async () => { - mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: undefined}) - - await createCommand().run() - - expect(loggedMessages.some((m) => m.includes('Analytics: enabled'))).to.be.true - expect(loggedMessages.some((m) => m.includes('Last successful flush: never'))).to.be.true - }) - - it('enabled, flushed 5 minutes ago: ISO timestamp with relative time', async () => { - // Pinned `now()` = 1_700_000_000_000 → ISO 2023-11-14T22:13:20.000Z. - // lastFlushAt 5 minutes earlier. - const fiveMinutesAgo = 1_700_000_000_000 - 5 * 60_000 - mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: fiveMinutesAgo}) - - await createCommand().run() - - const flushLine = loggedMessages.find((m) => m.includes('Last successful flush')) - expect(flushLine, 'flush line present').to.not.equal(undefined) - expect(flushLine, 'shows ISO timestamp').to.include('2023-11-14T22:08:20') - expect(flushLine, 'shows relative time').to.include('(5m ago)') - }) - - it('"just now" for sub-minute deltas', async () => { - mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: 1_700_000_000_000 - 30_000}) - - await createCommand().run() - - const flushLine = loggedMessages.find((m) => m.includes('Last successful flush')) - expect(flushLine).to.include('(just now)') - }) - - it('hours-then-days relative formatting', async () => { - mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: 1_700_000_000_000 - 3 * 60 * 60_000}) - await createCommand().run() - expect(loggedMessages.find((m) => m.includes('Last successful flush'))).to.include('(3h ago)') - - loggedMessages.length = 0 - mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: 1_700_000_000_000 - 2 * 24 * 60 * 60_000}) - await createCommand().run() - expect(loggedMessages.find((m) => m.includes('Last successful flush'))).to.include('(2d ago)') - }) - - it('backoff state "degraded": shows label + consecutive failures + next delay (humanized)', async () => { - mockStatusResponse({ - ...HEALTHY_RESPONSE, - backoff: {consecutiveFailures: 2, nextDelayMs: 120_000, state: 'degraded'}, - }) - - await createCommand().run() - - const backoffLine = loggedMessages.find((m) => m.toLowerCase().includes('backoff')) - expect(backoffLine, 'shows label').to.include('degraded') - expect(backoffLine, 'shows plural failure count').to.include('2 consecutive failures') - expect(backoffLine, 'humanizes next-delay ms (120000 → 2m)').to.include('next attempt in 2m') - }) - - it('singularises "1 consecutive failure" (no trailing s) on a single-failure backoff', async () => { - mockStatusResponse({ - ...HEALTHY_RESPONSE, - backoff: {consecutiveFailures: 1, nextDelayMs: 60_000, state: 'degraded'}, - }) - - await createCommand().run() - - const backoffLine = loggedMessages.find((m) => m.toLowerCase().includes('backoff')) - expect(backoffLine, 'singular').to.include('1 consecutive failure') - expect(backoffLine, 'humanized delay').to.include('next attempt in 1m') - }) - - it('endpoint not configured: shows literal placeholder', async () => { - mockStatusResponse({ - ...HEALTHY_RESPONSE, - backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'unreachable'}, - endpoint: '(not configured)', - }) - - await createCommand().run() - - const endpointLine = loggedMessages.find((m) => m.includes('Endpoint')) - expect(endpointLine).to.include('(not configured)') - const backoffLine = loggedMessages.find((m) => m.toLowerCase().includes('backoff')) - expect(backoffLine, 'overridden to unreachable').to.include('unreachable') - }) - - it('shows queue depth and dropped events on enabled state', async () => { - mockStatusResponse({...HEALTHY_RESPONSE, droppedCount: 7, queueDepth: 12}) - - await createCommand().run() - - expect(loggedMessages.some((m) => m.includes('Queue depth: 12 events'))).to.be.true - expect(loggedMessages.some((m) => m.includes('Dropped events') && m.includes('7'))).to.be.true - }) - }) - - describe('JSON output', () => { - it('emits the documented snake_case schema on enabled state', async () => { - const flushAt = 1_700_000_000_000 - 5 * 60_000 - mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: flushAt}) - - const {captured} = await captureJson() - const parsed = JSON.parse(captured) as { - data: { - backoff: {consecutive_failures: number; next_delay_ms: number; state: string} - dropped_events: number - enabled: boolean - endpoint: string - last_flush: null | string - queue_depth: number - } - success: boolean - } - - expect(parsed.success).to.equal(true) - expect(parsed.data.enabled).to.equal(true) - expect(parsed.data.last_flush, 'ISO 8601 string').to.equal(new Date(flushAt).toISOString()) - expect(parsed.data.queue_depth).to.equal(4) - expect(parsed.data.dropped_events).to.equal(0) - expect(parsed.data.backoff).to.deep.equal({consecutive_failures: 0, next_delay_ms: 30_000, state: 'healthy'}) - expect(parsed.data.endpoint).to.equal('https://telemetry-dev.byterover.dev') - }) - - it('emits last_flush: null when never flushed', async () => { - mockStatusResponse({...HEALTHY_RESPONSE, lastFlushAt: undefined}) - - const {captured} = await captureJson() - const parsed = JSON.parse(captured) as {data: {last_flush: null | string}} - expect(parsed.data.last_flush).to.equal(null) - }) - - it('keeps stable shape on disabled state (full fields present)', async () => { - mockStatusResponse({...HEALTHY_RESPONSE, enabled: false}) - - const {captured} = await captureJson() - const parsed = JSON.parse(captured) as {data: Record} - expect(parsed.data.enabled).to.equal(false) - // Ticket schema: shape doesn't depend on enabled flag. - expect(parsed.data).to.have.all.keys('backoff', 'dropped_events', 'enabled', 'endpoint', 'last_flush', 'queue_depth') - }) - - it('returns success=false on connection error', async () => { - mockConnector.rejects(new NoInstanceRunningError()) - - const {captured} = await captureJson() - const parsed = JSON.parse(captured) as {success: boolean} - expect(parsed.success).to.equal(false) - }) - }) - - describe('transport contract', () => { - it('issues exactly one request against AnalyticsEvents.STATUS', async () => { - mockStatusResponse(HEALTHY_RESPONSE) - - await createCommand().run() - - const requestStub = mockClient.requestWithAck as sinon.SinonStub - expect(requestStub.callCount).to.equal(1) - expect(requestStub.firstCall.args[0]).to.equal(AnalyticsEvents.STATUS) - }) - }) - - describe('help text', () => { - it('declares a description string and does not throw on construction', () => { - expect(Status.description).to.be.a('string').and.not.be.empty - }) - }) -}) diff --git a/test/commands/settings/analytics-enabled.test.ts b/test/commands/settings/analytics-enabled.test.ts new file mode 100644 index 000000000..5cddd5185 --- /dev/null +++ b/test/commands/settings/analytics-enabled.test.ts @@ -0,0 +1,100 @@ +import type {ConnectionResult, ITransportClient} from '@campfirein/brv-transport-client' +import type {Config} from '@oclif/core' + +import {Config as OclifConfig} from '@oclif/core' +import {expect} from 'chai' +import sinon, {restore, stub} from 'sinon' + +import SettingsGet from '../../../src/oclif/commands/settings/get.js' +import {SettingsEvents} from '../../../src/shared/transport/events/settings-events.js' + +class TestableSettingsGet extends SettingsGet { + private readonly mockConnector: () => Promise + + public constructor(argv: string[], mockConnector: () => Promise, config: Config) { + super(argv, config) + this.mockConnector = mockConnector + } + + protected override async fetchSetting(key: string) { + return super.fetchSetting(key, { + maxRetries: 1, + retryDelayMs: 0, + transportConnector: this.mockConnector, + }) + } +} + +/** + * Smoke coverage for the post-M16.4 surface of `analytics.enabled`. + * + * The wire-shape behaviour, facade routing, and disclosure flow are + * exercised in depth by: + * - test/unit/infra/transport/handlers/settings-handler.test.ts + * - test/unit/oclif/lib/analytics-disclosure.test.ts + * + * This file only confirms the oclif command path resolves the key to + * the unified `settings:get` transport event — i.e. the legacy `brv + * analytics enable / disable` deletion does not leave the value + * unreachable via the CLI. + */ +describe('brv settings get analytics.enabled (M16.4 smoke)', () => { + let config: Config + let mockClient: sinon.SinonStubbedInstance + let mockConnector: sinon.SinonStub<[], Promise> + let originalExitCode: number | string | undefined + + before(async () => { + config = await OclifConfig.load(import.meta.url) + }) + + beforeEach(() => { + originalExitCode = process.exitCode + + mockClient = { + connect: stub().resolves(), + disconnect: stub().resolves(), + getClientId: stub().returns('test-client-id'), + getDaemonVersion: stub(), + getState: stub().returns('connected'), + isConnected: stub().resolves(true), + joinRoom: stub().resolves(), + leaveRoom: stub().resolves(), + on: stub().returns(() => {}), + once: stub(), + onStateChange: stub().returns(() => {}), + request: stub() as unknown as ITransportClient['request'], + requestWithAck: stub().resolves({ + category: 'analytics', + current: false, + default: false, + description: 'Send anonymous telemetry to ByteRover.', + key: 'analytics.enabled', + ok: true, + restartRequired: false, + type: 'boolean', + }), + } as unknown as sinon.SinonStubbedInstance + + mockConnector = stub<[], Promise>().resolves({ + client: mockClient as unknown as ITransportClient, + projectRoot: '/test/project', + }) + }) + + afterEach(() => { + process.exitCode = originalExitCode + restore() + }) + + it('routes to the SettingsEvents.GET transport event with key=analytics.enabled', async () => { + const command = new TestableSettingsGet(['analytics.enabled'], mockConnector, config) + stub(command, 'log').callsFake(() => {}) + await command.run() + + const calls = (mockClient.requestWithAck as sinon.SinonStub).getCalls() + expect(calls.length, 'one requestWithAck call').to.equal(1) + expect(calls[0].args[0]).to.equal(SettingsEvents.GET) + expect(calls[0].args[1]).to.deep.equal({key: 'analytics.enabled'}) + }) +}) diff --git a/test/commands/settings/analytics-status.test.ts b/test/commands/settings/analytics-status.test.ts new file mode 100644 index 000000000..619bdf8db --- /dev/null +++ b/test/commands/settings/analytics-status.test.ts @@ -0,0 +1,107 @@ +import type {ConnectionResult, ITransportClient} from '@campfirein/brv-transport-client' +import type {Config} from '@oclif/core' + +import {Config as OclifConfig} from '@oclif/core' +import {expect} from 'chai' +import sinon, {restore, stub} from 'sinon' + +import SettingsGet from '../../../src/oclif/commands/settings/get.js' +import {SettingsEvents} from '../../../src/shared/transport/events/settings-events.js' + +class TestableSettingsGet extends SettingsGet { + private readonly mockConnector: () => Promise + + public constructor(argv: string[], mockConnector: () => Promise, config: Config) { + super(argv, config) + this.mockConnector = mockConnector + } + + protected override async fetchSetting(key: string) { + return super.fetchSetting(key, { + maxRetries: 1, + retryDelayMs: 0, + transportConnector: this.mockConnector, + }) + } +} + +/** + * Smoke coverage for the post-M16.4 surface of `analytics.status`. + * + * The text + JSON parity and the snapshot composition are exercised by: + * - test/unit/shared/utils/format-analytics-status.test.ts + * - test/unit/server/infra/analytics/build-status-snapshot.test.ts + * - test/unit/infra/transport/handlers/settings-handler.test.ts + * + * This file only confirms the oclif command path resolves the key to + * the unified `settings:get` transport event — i.e. the legacy + * `brv analytics status` deletion does not leave the value unreachable + * via the CLI. + */ +describe('brv settings get analytics.status (M16.4 smoke)', () => { + let config: Config + let mockClient: sinon.SinonStubbedInstance + let mockConnector: sinon.SinonStub<[], Promise> + let originalExitCode: number | string | undefined + + before(async () => { + config = await OclifConfig.load(import.meta.url) + }) + + beforeEach(() => { + originalExitCode = process.exitCode + + const snapshot = { + backoff: {consecutiveFailures: 0, nextDelayMs: 30_000, state: 'healthy' as const}, + droppedCount: 0, + enabled: false, + endpoint: 'https://telemetry-dev.byterover.dev', + queueDepth: 0, + } + + mockClient = { + connect: stub().resolves(), + disconnect: stub().resolves(), + getClientId: stub().returns('test-client-id'), + getDaemonVersion: stub(), + getState: stub().returns('connected'), + isConnected: stub().resolves(true), + joinRoom: stub().resolves(), + leaveRoom: stub().resolves(), + on: stub().returns(() => {}), + once: stub(), + onStateChange: stub().returns(() => {}), + request: stub() as unknown as ITransportClient['request'], + requestWithAck: stub().resolves({ + category: 'analytics', + current: snapshot, + description: 'Live analytics shipping snapshot', + key: 'analytics.status', + ok: true, + restartRequired: false, + type: 'readonly-info', + }), + } as unknown as sinon.SinonStubbedInstance + + mockConnector = stub<[], Promise>().resolves({ + client: mockClient as unknown as ITransportClient, + projectRoot: '/test/project', + }) + }) + + afterEach(() => { + process.exitCode = originalExitCode + restore() + }) + + it('routes to the SettingsEvents.GET transport event with key=analytics.status', async () => { + const command = new TestableSettingsGet(['analytics.status'], mockConnector, config) + stub(command, 'log').callsFake(() => {}) + await command.run() + + const calls = (mockClient.requestWithAck as sinon.SinonStub).getCalls() + expect(calls.length, 'one requestWithAck call').to.equal(1) + expect(calls[0].args[0]).to.equal(SettingsEvents.GET) + expect(calls[0].args[1]).to.deep.equal({key: 'analytics.status'}) + }) +}) diff --git a/test/commands/settings/set.test.ts b/test/commands/settings/set.test.ts index 4364d3613..3c97c26e1 100644 --- a/test/commands/settings/set.test.ts +++ b/test/commands/settings/set.test.ts @@ -82,6 +82,7 @@ describe('brv settings set', () => { let config: Config let loggedMessages: string[] let stdoutOutput: string[] + let warnMessages: string[] let mockClient: sinon.SinonStubbedInstance let mockConnector: sinon.SinonStub<[], Promise> let originalExitCode: number | string | undefined @@ -93,6 +94,7 @@ describe('brv settings set', () => { beforeEach(() => { loggedMessages = [] stdoutOutput = [] + warnMessages = [] originalExitCode = process.exitCode mockClient = { @@ -127,6 +129,10 @@ describe('brv settings set', () => { stub(command, 'log').callsFake((msg?: string) => { if (msg !== undefined) loggedMessages.push(msg) }) + stub(command, 'warn').callsFake((msg: Error | string) => { + warnMessages.push(typeof msg === 'string' ? msg : msg.message) + return msg as Error & string + }) return command } @@ -135,6 +141,10 @@ describe('brv settings set', () => { stub(command, 'log').callsFake((msg?: string) => { if (msg !== undefined) loggedMessages.push(msg) }) + stub(command, 'warn').callsFake((msg: Error | string) => { + warnMessages.push(typeof msg === 'string' ? msg : msg.message) + return msg as Error & string + }) stub(process.stdout, 'write').callsFake((chunk: string | Uint8Array) => { stdoutOutput.push(String(chunk)) return true @@ -402,4 +412,80 @@ describe('brv settings set', () => { expect(output).to.match(/Run `brv restart` to apply/) }) }) + + describe('--yes flag scope (bot review #2)', () => { + it('warns when --yes is passed for a key other than analytics.enabled', async () => { + dispatchByEvent((event) => { + if (event === SettingsEvents.GET) return makeGetResponse('agentPool.maxSize', 10) + if (event === SettingsEvents.SET) return {ok: true, restartRequired: true} + throw new Error('unexpected event') + }) + + await createCommand('agentPool.maxSize', '25', '-y').run() + + // SET still proceeds — the warning is informational, not a refusal. + const requestStub = mockClient.requestWithAck as sinon.SinonStub + const setCall = requestStub.getCalls().find((c) => c.args[0] === SettingsEvents.SET) + expect(setCall, 'SET dispatched even with stray --yes').to.exist + + const warn = warnMessages.join('\n') + expect(warn).to.match(/--yes/) + expect(warn).to.match(/analytics\.enabled/) + }) + + it('does NOT warn when --yes is passed for analytics.enabled', async () => { + dispatchByEvent((event) => { + if (event === SettingsEvents.GET) return makeBooleanGetResponse('analytics.enabled', false) + if (event === SettingsEvents.SET) return {ok: true, restartRequired: false} + throw new Error('unexpected event') + }) + + await createCommand('analytics.enabled', 'true', '-y').run() + + expect(warnMessages, 'no leaky-flag warning on the analytics key').to.deep.equal([]) + }) + }) + + describe('--format json + interactive consent (bot review #3)', () => { + it('refuses to prompt in JSON mode without --yes and emits a requires_consent error envelope', async () => { + dispatchByEvent((event) => { + if (event === SettingsEvents.GET) return makeBooleanGetResponse('analytics.enabled', false) + if (event === SettingsEvents.SET) { + throw new Error('SET must not be dispatched when consent is required and refused') + } + + throw new Error('unexpected event') + }) + + await createJsonCommand('analytics.enabled', 'true').run() + + const json = parseJsonOutput() + expect(json.command).to.equal('settings set') + expect(json.success).to.be.false + const {error} = json.data as {error: {code: string; key: string; message: string}} + expect(error.code).to.equal('requires_consent') + expect(error.key).to.equal('analytics.enabled') + expect(error.message.toLowerCase()).to.match(/--yes|disclosure/) + expect(process.exitCode).to.equal(1) + + // The disclosure markdown MUST NOT have been printed (would pollute stdout + // and break the JSON envelope). + const logged = loggedMessages.join('\n') + expect(logged, 'disclosure markdown leaked to stdout in JSON mode').to.not.match(/analytics disclosure/i) + }) + + it('passes through in JSON mode WITH --yes (consent gate satisfied silently)', async () => { + dispatchByEvent((event) => { + if (event === SettingsEvents.GET) return makeBooleanGetResponse('analytics.enabled', false) + if (event === SettingsEvents.SET) return {ok: true, restartRequired: false} + throw new Error('unexpected event') + }) + + await createJsonCommand('analytics.enabled', 'true', '-y').run() + + const json = parseJsonOutput() + expect(json.success).to.be.true + expect(process.exitCode ?? 0).to.equal(0) + }) + }) }) diff --git a/test/e2e/analytics/dev-beta.e2e.ts b/test/e2e/analytics/dev-beta.e2e.ts index c4058a5cd..be29389dd 100644 --- a/test/e2e/analytics/dev-beta.e2e.ts +++ b/test/e2e/analytics/dev-beta.e2e.ts @@ -110,11 +110,11 @@ function bootDaemon(env: NodeJS.ProcessEnv): {ok: boolean; reason?: string} { } function enableAnalytics(env: NodeJS.ProcessEnv): {ok: boolean; reason?: string} { - return runBrv(['analytics', 'enable', '--yes'], env) + return runBrv(['settings', 'set', 'analytics.enabled', 'true', '--yes'], env) } function disableAnalytics(env: NodeJS.ProcessEnv): {ok: boolean; reason?: string} { - return runBrv(['analytics', 'disable'], env) + return runBrv(['settings', 'set', 'analytics.enabled', 'false'], env) } function jsonlPath(dataDir: string): string { @@ -264,7 +264,7 @@ async function startAcceptProxy(port: number): Promise<{close: () => Promise | undefined { - const result = spawnSync(process.execPath, [BRV_BIN, 'analytics', 'status', '--format', 'json'], { + const result = spawnSync(process.execPath, [BRV_BIN, 'settings', 'get', 'analytics.status', '--format', 'json'], { env, timeout: 15_000, }) diff --git a/test/e2e/analytics/lifecycle-db.e2e.ts b/test/e2e/analytics/lifecycle-db.e2e.ts index b33fe17f0..1d1472b44 100644 --- a/test/e2e/analytics/lifecycle-db.e2e.ts +++ b/test/e2e/analytics/lifecycle-db.e2e.ts @@ -332,7 +332,7 @@ describe('analytics lifecycle DB roundtrip e2e (M14 / M15.6)', function () { scenario = makeScenarioEnv() cleanupDirs.push(scenario.dataDir, scenario.home) - expect(runBrv(['analytics', 'enable', '--yes'], scenario.env), 'analytics enable').to.deep.include({ok: true}) + expect(runBrv(['settings', 'set', 'analytics.enabled', 'true', '--yes'], scenario.env), 'analytics enable').to.deep.include({ok: true}) expect(runBrv(['status'], scenario.env), 'daemon boot via status').to.deep.include({ok: true}) }) diff --git a/test/e2e/analytics/lifecycle-wire.e2e.ts b/test/e2e/analytics/lifecycle-wire.e2e.ts index be4a3ad52..5d52595aa 100644 --- a/test/e2e/analytics/lifecycle-wire.e2e.ts +++ b/test/e2e/analytics/lifecycle-wire.e2e.ts @@ -233,11 +233,12 @@ describe('analytics lifecycle wire e2e (M14 / M15.6)', function () { scenario = makeScenarioEnv(backend.url) cleanupDirs.push(scenario.dataDir, scenario.home) - // Match dev-beta.e2e.ts: enable BEFORE boot. `analytics enable` itself - // starts a daemon via transport autostart, AND the analytics flush - // scheduler reads the enabled flag at boot time. If we boot first (with - // analytics disabled) then flip the flag, the scheduler stays dormant. - expect(runBrv(['analytics', 'enable', '--yes'], scenario.env), 'analytics enable').to.deep.include({ok: true}) + // Match dev-beta.e2e.ts: enable BEFORE boot. `settings set + // analytics.enabled true` itself starts a daemon via transport autostart, + // AND the analytics flush scheduler reads the enabled flag at boot time. + // If we boot first (with analytics disabled) then flip the flag, the + // scheduler stays dormant. + expect(runBrv(['settings', 'set', 'analytics.enabled', 'true', '--yes'], scenario.env), 'analytics enable').to.deep.include({ok: true}) expect(runBrv(['status'], scenario.env), 'daemon boot via status').to.deep.include({ok: true}) }) diff --git a/test/integration/analytics/daemon-tracking.test.ts b/test/integration/analytics/daemon-tracking.test.ts index ce7783b66..f2cd44621 100644 --- a/test/integration/analytics/daemon-tracking.test.ts +++ b/test/integration/analytics/daemon-tracking.test.ts @@ -65,7 +65,8 @@ describe('daemon analytics tracking integration (ticket scenario 6)', () => { it('should land daemon_start in the queue with full identity + super properties when analytics is enabled', async () => { // Pre-seed the on-disk config so analytics is enabled and deviceId is stable - // for assertions. This mirrors what M1.3 `brv analytics enable` writes. + // for assertions. This mirrors what M1.3's `brv analytics enable` + // (now `brv settings set analytics.enabled true`) writes. const seeded = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) if (!seeded) throw new Error('test fixture: seeded GlobalConfig must be valid') await store.write(seeded) diff --git a/test/unit/core/domain/entities/settings-registry.test.ts b/test/unit/core/domain/entities/settings-registry.test.ts index 35d2d42e6..3e0b7e7fe 100644 --- a/test/unit/core/domain/entities/settings-registry.test.ts +++ b/test/unit/core/domain/entities/settings-registry.test.ts @@ -214,6 +214,52 @@ describe('settings registry — M7 T2 shape', () => { }) }) + describe('analytics.enabled descriptor (M16.2)', () => { + it('exposes ANALYTICS_ENABLED on SETTINGS_KEYS', () => { + expect(SETTINGS_KEYS.ANALYTICS_ENABLED).to.equal('analytics.enabled') + }) + + it('registers a descriptor for analytics.enabled', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_ENABLED) + expect(descriptor, 'descriptor must exist in SETTINGS_REGISTRY').to.exist + }) + + it('declares the descriptor as type=boolean, default=false, category=analytics, restartRequired=false', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_ENABLED) + expect(descriptor?.type).to.equal('boolean') + if (descriptor?.type === 'boolean') { + expect(descriptor.default).to.equal(false) + } + + expect(descriptor?.category).to.equal('analytics') + expect(descriptor?.restartRequired).to.equal(false) + }) + + it('declares storage=global-config so the file store skips persistence', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_ENABLED) + // `storage` is an optional field on writable descriptors; defaults to 'file'. + // analytics.enabled lives in `config.json`, not `settings.json`. + if (descriptor?.type === 'boolean') { + expect(descriptor.storage).to.equal('global-config') + } else { + expect.fail('expected boolean descriptor for analytics.enabled') + } + }) + + it('description fits the 80-char tooltip budget', () => { + const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_ENABLED) + expect(descriptor?.description.length).to.be.at.most(80) + }) + + it('existing writable descriptors omit the storage field (defaults to file)', () => { + const maxSize = findSettingDescriptor(SETTINGS_KEYS.AGENT_POOL_MAX_SIZE) + if (maxSize?.type === 'integer') { + // Optional field; existing descriptors do not declare it. + expect(maxSize.storage === undefined || maxSize.storage === 'file').to.equal(true) + } + }) + }) + describe('analytics.status descriptor (M16.3)', () => { it('exposes ANALYTICS_STATUS on SETTINGS_KEYS', () => { expect(SETTINGS_KEYS.ANALYTICS_STATUS).to.equal('analytics.status') diff --git a/test/unit/infra/storage/file-settings-store.test.ts b/test/unit/infra/storage/file-settings-store.test.ts index 4f3e636fc..085847d87 100644 --- a/test/unit/infra/storage/file-settings-store.test.ts +++ b/test/unit/infra/storage/file-settings-store.test.ts @@ -54,6 +54,7 @@ describe('FileSettingsStore', () => { expect(keys).to.deep.equal([ 'agentPool.maxConcurrentTasksPerProject', 'agentPool.maxSize', + 'analytics.enabled', 'analytics.status', 'llm.iterationBudgetMs', 'llm.requestTimeoutMs', @@ -61,9 +62,11 @@ describe('FileSettingsStore', () => { 'update.checkForUpdates', ]) for (const item of items) { - // readonly-info rows carry current/default both undefined; writable - // rows have current === default when no override is present. - if (item.key === 'analytics.status') { + // Non-file-stored rows carry current/default both undefined: + // - analytics.status (readonly-info) + // - analytics.enabled (storage=global-config) + // Writable file-stored rows have current === default when no override is present. + if (item.key === 'analytics.status' || item.key === 'analytics.enabled') { expect(item.current).to.equal(undefined) expect(item.default).to.equal(undefined) } else { @@ -614,6 +617,97 @@ describe('FileSettingsStore', () => { }) }) + describe('global-config storage (M16.2)', () => { + const globalConfigRegistry: readonly SettingDescriptor[] = [ + { + category: 'analytics', + default: false, + description: 'analytics opt-in (global config)', + key: '_test.global', + restartRequired: false, + storage: 'global-config', + type: 'boolean', + }, + { + category: 'concurrency', + default: 10, + description: 'test writable file-stored', + key: '_test.writable', + max: 100, + min: 1, + restartRequired: true, + type: 'integer', + }, + ] + + let gcStore: FileSettingsStore + let gcDir: string + + beforeEach(async () => { + gcDir = join(tmpdir(), `brv-settings-gc-${Date.now()}-${Math.random().toString(36).slice(2)}`) + await mkdir(gcDir, {recursive: true}) + gcStore = new FileSettingsStore({ + baseDir: gcDir, + registry: globalConfigRegistry, + validator: new SettingsValidator({registry: globalConfigRegistry}), + }) + }) + + afterEach(async () => { + await rm(gcDir, {force: true, recursive: true}) + }) + + it('list() returns current=undefined for a global-config-stored key', async () => { + const items = await gcStore.list() + const row = items.find((i) => i.key === '_test.global') + expect(row).to.exist + expect(row?.current).to.equal(undefined) + expect(row?.default).to.equal(undefined) + expect(row?.restartRequired).to.equal(false) + }) + + it('get() returns current=undefined for a global-config-stored key', async () => { + const item = await gcStore.get('_test.global') + expect(item.key).to.equal('_test.global') + expect(item.current).to.equal(undefined) + expect(item.default).to.equal(undefined) + }) + + it('set() throws InvalidSettingValueError for a global-config-stored key', async () => { + try { + await gcStore.set('_test.global', true) + expect.fail('expected throw') + } catch (error) { + expect(error).to.be.instanceOf(InvalidSettingValueError) + } + }) + + it('does NOT create the settings file when refusing a global-config write', async () => { + try { + await gcStore.set('_test.global', true) + } catch { + // expected + } + + expect(existsSync(join(gcDir, SETTINGS_FILENAME))).to.equal(false) + }) + + it('reset() throws InvalidSettingValueError for a global-config-stored key', async () => { + try { + await gcStore.reset('_test.global') + expect.fail('expected throw') + } catch (error) { + expect(error).to.be.instanceOf(InvalidSettingValueError) + } + }) + + it('still serves writable file-stored keys alongside global-config keys', async () => { + const item = await gcStore.get('_test.writable') + expect(item.current).to.equal(10) + expect(item.default).to.equal(10) + }) + }) + describe('list', () => { it('includes the readonly-info row with current=undefined and default omitted', async () => { const items = await isolatedStore.list() diff --git a/test/unit/infra/storage/settings-validator.test.ts b/test/unit/infra/storage/settings-validator.test.ts index cc88a1caa..322671969 100644 --- a/test/unit/infra/storage/settings-validator.test.ts +++ b/test/unit/infra/storage/settings-validator.test.ts @@ -356,6 +356,56 @@ describe('SettingsValidator', () => { expect(result.invalid).to.deep.equal([]) }) + it('still partitions writable keys correctly when mixed with global-config-stored entries (M16.2)', () => { + const mixedRegistry: readonly SettingDescriptor[] = [ + { + category: 'analytics', + default: false, + description: 'analytics opt-in (global config)', + key: '_test.global', + restartRequired: false, + storage: 'global-config', + type: 'boolean', + }, + { + category: 'concurrency', + default: 10, + description: 'file-stored writable', + key: '_test.writable', + max: 100, + min: 1, + restartRequired: true, + type: 'integer', + }, + ] + const isolated = new SettingsValidator({registry: mixedRegistry}) + const result = isolated.partition({ + '_test.global': true, + '_test.writable': 25, + }) + expect(result.valid).to.deep.equal({'_test.writable': 25}) + expect(result.invalid).to.have.lengthOf(1) + expect(result.invalid[0].key).to.equal('_test.global') + expect(result.invalid[0].reason.toLowerCase()).to.match(/config\.json|global/) + }) + + it('throws InvalidSettingValueError when validate() is called on a global-config-stored key (M16.2)', () => { + const isolated = new SettingsValidator({ + registry: [ + { + category: 'analytics', + default: false, + description: 'analytics opt-in (global config)', + key: '_test.global', + restartRequired: false, + storage: 'global-config', + type: 'boolean', + }, + ], + }) + expect(() => isolated.validate('_test.global', true)).to.throw(InvalidSettingValueError, /config\.json|global/) + }) + it('still partitions writable keys correctly when mixed with readonly-info entries', () => { const mixedRegistry: readonly SettingDescriptor[] = [ ...readonlyInfoRegistry, diff --git a/test/unit/infra/transport/handlers/settings-handler.test.ts b/test/unit/infra/transport/handlers/settings-handler.test.ts index 9181af9e5..c386571bb 100644 --- a/test/unit/infra/transport/handlers/settings-handler.test.ts +++ b/test/unit/infra/transport/handlers/settings-handler.test.ts @@ -4,6 +4,7 @@ import type { SettingDescriptor, SettingItem, } from '../../../../../src/server/core/domain/entities/settings.js' +import type {IAnalyticsClient} from '../../../../../src/server/core/interfaces/analytics/i-analytics-client.js' import type { ISettingsStore, SettingsStartupSnapshot, @@ -93,6 +94,7 @@ describe('SettingsHandler', () => { expect(result.items.map((i) => i.key).sort()).to.deep.equal([ 'agentPool.maxConcurrentTasksPerProject', 'agentPool.maxSize', + 'analytics.enabled', 'analytics.status', 'llm.iterationBudgetMs', 'llm.requestTimeoutMs', @@ -602,6 +604,245 @@ describe('SettingsHandler', () => { }) }) + describe('analytics.enabled facade routing (M16.2 — production registry)', () => { + type AnalyticsFacadeStub = { + readonly calls: Array<{args: unknown[]; method: string}> + currentValue: boolean + getCurrentAnalytics: () => Promise + setAnalyticsValue: (value: boolean) => Promise<{current: boolean; previous: boolean}> + } + + function makeFacade(initial: boolean): AnalyticsFacadeStub { + const stub: AnalyticsFacadeStub = { + calls: [], + currentValue: initial, + async getCurrentAnalytics() { + return stub.currentValue + }, + async setAnalyticsValue(value: boolean) { + stub.calls.push({args: [value], method: 'setAnalyticsValue'}) + const previous = stub.currentValue + stub.currentValue = value + return {current: value, previous} + }, + } + return stub + } + + it('GET on analytics.enabled reads from the injected globalConfigHandler (true)', async () => { + const facade = makeFacade(true) + const localStore = new StubSettingsStore() + localStore.listResult = [{current: undefined, key: 'analytics.enabled', restartRequired: false}] + const localTransport = createMockTransportServer() + new SettingsHandler({ + globalConfigHandler: facade, + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.GET) + if (!handler) throw new Error('GET handler not registered') + const result = (await handler({key: 'analytics.enabled'}, 'test-client')) as SettingsGetResponse + + expect(result.ok).to.be.true + if (result.ok) { + expect(result.type).to.equal('boolean') + expect(result.current).to.equal(true) + expect(result.default).to.equal(false) + expect(result.category).to.equal('analytics') + } + }) + + it('SET on analytics.enabled calls globalConfigHandler.setAnalyticsValue, NOT store.set', async () => { + const facade = makeFacade(false) + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({ + globalConfigHandler: facade, + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.SET) + if (!handler) throw new Error('SET handler not registered') + const result = (await handler({key: 'analytics.enabled', value: true}, 'test-client')) as SettingsSetResponse + + expect(result.ok).to.be.true + if (result.ok) { + expect(result.restartRequired).to.equal(false) + } + + const setCalls = facade.calls.filter((c) => c.method === 'setAnalyticsValue') + expect(setCalls).to.have.lengthOf(1) + expect(setCalls[0].args).to.deep.equal([true]) + const storeSetCalls = localStore.calls.filter((c) => c.method === 'set') + expect(storeSetCalls, 'file store must not be touched').to.have.lengthOf(0) + }) + + it('RESET on analytics.enabled flips the globalConfig value to false, NOT store.reset', async () => { + const facade = makeFacade(true) + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({ + globalConfigHandler: facade, + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.RESET) + if (!handler) throw new Error('RESET handler not registered') + const result = (await handler({key: 'analytics.enabled'}, 'test-client')) as SettingsResetResponse + + expect(result.ok).to.be.true + const setCalls = facade.calls.filter((c) => c.method === 'setAnalyticsValue') + expect(setCalls).to.have.lengthOf(1) + expect(setCalls[0].args).to.deep.equal([false]) + const storeResetCalls = localStore.calls.filter((c) => c.method === 'reset') + expect(storeResetCalls, 'file store must not be touched').to.have.lengthOf(0) + }) + + it('LIST surfaces analytics.enabled with the value from globalConfigHandler', async () => { + const facade = makeFacade(true) + const localStore = new StubSettingsStore() + localStore.listResult = [] + const localTransport = createMockTransportServer() + new SettingsHandler({ + globalConfigHandler: facade, + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.LIST) + if (!handler) throw new Error('LIST handler not registered') + const result = (await handler(undefined, 'test-client')) as SettingsListResponse + + const row = result.items.find((i) => i.key === 'analytics.enabled') + expect(row, 'analytics.enabled row present').to.exist + expect(row?.type).to.equal('boolean') + expect(row?.current).to.equal(true) + expect(row?.default).to.equal(false) + }) + + it('SET on analytics.enabled emits SETTING_CHANGED with value_kind=boolean and outcome=success', async () => { + const facade = makeFacade(false) + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + const trackCalls: Array<{args: unknown[]}> = [] + const fakeClient = {track: (...args: unknown[]) => trackCalls.push({args})} as unknown as IAnalyticsClient + + new SettingsHandler({ + analyticsClient: fakeClient, + globalConfigHandler: facade, + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.SET) + if (!handler) throw new Error('SET handler not registered') + await handler({key: 'analytics.enabled', value: true}, 'test-client') + + const setting = trackCalls.find((c) => (c.args[0] as string).endsWith('setting_changed')) + expect(setting, 'SETTING_CHANGED emitted').to.exist + const props = setting!.args[1] as {outcome: string; value_kind: string} + expect(props.outcome).to.equal('success') + expect(props.value_kind).to.equal('boolean') + }) + + it('GET on analytics.enabled with NO injected facade returns current=undefined (graceful)', async () => { + const localStore = new StubSettingsStore() + localStore.listResult = [{current: undefined, key: 'analytics.enabled', restartRequired: false}] + const localTransport = createMockTransportServer() + new SettingsHandler({store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.GET) + if (!handler) throw new Error('GET handler not registered') + const result = (await handler({key: 'analytics.enabled'}, 'test-client')) as SettingsGetResponse + + expect(result.ok).to.be.true + if (result.ok) { + expect(result.current).to.equal(undefined) + } + }) + + it('SET on analytics.enabled with NO injected facade returns code=misconfigured (not invalid_value)', async () => { + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.SET) + if (!handler) throw new Error('SET handler not registered') + const result = (await handler({key: 'analytics.enabled', value: true}, 'test-client')) as SettingsSetResponse + + expect(result.ok).to.be.false + if (!result.ok) { + // Bot review (#7): a missing facade is a daemon wiring problem, not a + // user-supplied bad value. Distinct code so logs / WebUI can route + // the alert at the right team. + expect(result.error.code).to.equal('misconfigured') + expect(result.error.key).to.equal('analytics.enabled') + expect(result.error.message.toLowerCase()).to.match(/global ?config|facade/) + } + }) + + it('RESET on analytics.enabled with NO injected facade returns code=misconfigured (not invalid_value)', async () => { + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({store: localStore, transport: localTransport}).setup() + + const handler = localTransport._handlers.get(SettingsEvents.RESET) + if (!handler) throw new Error('RESET handler not registered') + const result = (await handler({key: 'analytics.enabled'}, 'test-client')) as SettingsResetResponse + + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('misconfigured') + expect(result.error.key).to.equal('analytics.enabled') + } + }) + + it('RESET refuses non-boolean global-config descriptors with code=misconfigured (future-proofing for bot review #6)', async () => { + // The facade interface (`setAnalyticsValue(value: boolean)`) is + // structurally boolean-only. A future PR that adds an integer + // descriptor with `storage: 'global-config'` would otherwise hit + // the `: false` fallback in RESET and silently coerce to boolean. + // Tighten with a custom registry stub so this is enforced today. + const intGlobalDescriptor: SettingDescriptor = { + category: 'analytics', + default: 42, + description: 'fake integer global-config descriptor — test only', + key: '_test.integerGlobal', + max: 100, + min: 0, + restartRequired: false, + storage: 'global-config', + type: 'integer', + } + const facade = { + getCurrentAnalytics: async () => false, + setAnalyticsValue: async (value: boolean) => ({current: value, previous: false}), + } + const localStore = new StubSettingsStore() + const localTransport = createMockTransportServer() + new SettingsHandler({ + globalConfigHandler: facade, + registry: [intGlobalDescriptor], + store: localStore, + transport: localTransport, + }).setup() + + const handler = localTransport._handlers.get(SettingsEvents.RESET) + if (!handler) throw new Error('RESET handler not registered') + const result = (await handler({key: '_test.integerGlobal'}, 'test-client')) as SettingsResetResponse + + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.error.code).to.equal('misconfigured') + expect(result.error.key).to.equal('_test.integerGlobal') + expect(result.error.message.toLowerCase()).to.match(/boolean|facade/) + } + }) + }) + describe('analytics.status routing (M16.3 — production registry)', () => { it('GET resolves analytics.status via the registered provider against the production registry', async () => { const localStore = new StubSettingsStore() diff --git a/test/unit/oclif/lib/analytics-disclosure.test.ts b/test/unit/oclif/lib/analytics-disclosure.test.ts new file mode 100644 index 000000000..198057e73 --- /dev/null +++ b/test/unit/oclif/lib/analytics-disclosure.test.ts @@ -0,0 +1,153 @@ +import {expect} from 'chai' + +import { + collectConsent, + isInteractive, + loadDisclosure, +} from '../../../../src/oclif/lib/analytics-disclosure.js' + +describe('analytics-disclosure (M16.2 extracted lib)', () => { + describe('loadDisclosure', () => { + it('returns the disclosure markdown text (non-empty)', async () => { + const text = await loadDisclosure() + expect(text).to.be.a('string') + expect(text.length).to.be.greaterThan(0) + }) + }) + + describe('isInteractive', () => { + it('returns a boolean', () => { + // The value depends on the test runner's TTY state; we just assert the + // shape. Specific TTY/non-TTY behavior is exercised through collectConsent. + expect(isInteractive()).to.be.a('boolean') + }) + }) + + describe('collectConsent', () => { + it('returns true without prompting when yesFlag is set', async () => { + const logs: string[] = [] + let promptCalled = false + const result = await collectConsent({ + onError(): never { + throw new Error('onError should not be called when yesFlag is set') + }, + onLog: (msg) => logs.push(msg), + async promptFn() { + promptCalled = true + return true + }, + ttyCheck: () => false, // non-TTY + yesFlag: true, + }) + + expect(result).to.equal(true) + expect(promptCalled, 'prompt skipped when yesFlag is set').to.equal(false) + expect(logs.length, 'disclosure markdown logged once').to.equal(1) + }) + + it('calls onError when non-interactive and yesFlag is false', async () => { + const errors: string[] = [] + const logs: string[] = [] + class StopError extends Error {} + + try { + await collectConsent({ + onError(msg: string): never { + errors.push(msg) + throw new StopError() + }, + onLog: (msg) => logs.push(msg), + promptFn: async () => true, + ttyCheck: () => false, + yesFlag: false, + }) + expect.fail('expected onError to throw') + } catch (error) { + expect(error).to.be.instanceOf(StopError) + } + + expect(errors.length).to.equal(1) + expect(errors[0].toLowerCase()).to.match(/non-interactive|yes/) + }) + + it('calls the prompt and returns its result when interactive without yesFlag', async () => { + const logs: string[] = [] + const result = await collectConsent({ + onError(): never { + throw new Error('onError should not be called when TTY+prompt') + }, + onLog: (msg) => logs.push(msg), + promptFn: async () => true, + ttyCheck: () => true, + yesFlag: false, + }) + + expect(result).to.equal(true) + expect(logs.length).to.equal(1) + }) + + it('returns false when the prompt is declined', async () => { + const logs: string[] = [] + const result = await collectConsent({ + onError(): never { + throw new Error('not expected') + }, + onLog: (msg) => logs.push(msg), + promptFn: async () => false, + ttyCheck: () => true, + yesFlag: false, + }) + + expect(result).to.equal(false) + }) + + it('translates inquirer ExitPromptError (Ctrl-C) to a declined consent', async () => { + const logs: string[] = [] + // inquirer's ExitPromptError sets `name = 'ExitPromptError'`. We detect + // by name so this test does not depend on which `@inquirer/core` copy + // the running command actually loads (nested vs. hoisted node_modules). + class FakeExitPromptError extends Error { + public override readonly name = 'ExitPromptError' + } + + const result = await collectConsent({ + onError(): never { + throw new Error('onError should not be called on Ctrl-C') + }, + onLog: (msg) => logs.push(msg), + async promptFn() { + throw new FakeExitPromptError('User force closed the prompt with SIGINT') + }, + ttyCheck: () => true, + yesFlag: false, + }) + + expect(result).to.equal(false) + expect(logs.length, 'disclosure markdown was still logged before the prompt').to.equal(1) + }) + + it('re-throws non-ExitPromptError prompt failures so they are not swallowed', async () => { + const logs: string[] = [] + class BoomError extends Error { + public override readonly name = 'BoomError' + } + + try { + await collectConsent({ + onError(): never { + throw new Error('onError should not be called on non-Exit prompt failure') + }, + onLog: (msg) => logs.push(msg), + async promptFn() { + throw new BoomError('transport hiccup') + }, + ttyCheck: () => true, + yesFlag: false, + }) + expect.fail('expected BoomError to propagate') + } catch (error) { + expect(error).to.be.instanceOf(BoomError) + } + }) + }) +}) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index 91f04ddba..b75036b8c 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -185,7 +185,7 @@ describe('AnalyticsClient', () => { // Pre-M4.4 this test asserted "no-op when disabled" (no JSONL append, // no queue push, no resolver calls). Post-M4.4 the semantic is // "local tracking always; remote send only when enabled" — disable - // gates the FLUSH layer, not the TRACK layer. `brv analytics disable` + // gates the FLUSH layer, not the TRACK layer. `brv settings set analytics.enabled false` // means "stop shipping to remote", not "stop collecting locally". it('still tracks (JSONL + queue + resolvers) when isEnabled returns false; flush is the gate', async () => { const queue = new BoundedQueue() @@ -1525,7 +1525,7 @@ describe('AnalyticsClient', () => { describe('M4.6 runtime state tracking', () => { /** - * `lastSuccessfulFlushAt` is the timestamp shown by `brv analytics status` + * `lastSuccessfulFlushAt` is the timestamp shown by `brv settings get analytics.status` * as "Last successful flush". Updated ONLY on a real clean ship — * same gate as M4.5's backoff `onSuccess()`. Aborted, 4xx, failed, * and empty-batch outcomes leave it untouched. The `now: () => number` diff --git a/test/unit/shared/assets/analytics-disclosure-content.test.ts b/test/unit/shared/assets/analytics-disclosure-content.test.ts new file mode 100644 index 000000000..135333565 --- /dev/null +++ b/test/unit/shared/assets/analytics-disclosure-content.test.ts @@ -0,0 +1,36 @@ +import {expect} from 'chai' +import {readFile} from 'node:fs/promises' +import {dirname, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' + +import {PRIVACY_POLICY_URL} from '../../../../src/shared/constants/privacy.js' + +/** + * Contract test on the analytics disclosure markdown content. + * + * Originally lived in the deleted `test/commands/analytics/enable.test.ts` + * ("6. disclosure markdown contains all required sections"). Preserved + * here because the markdown contract is independent of any specific + * surface that renders it — it pins what PM/legal copy MUST contain. + * + * Section headers are load-bearing per the file's own preamble; a + * future copy edit that accidentally drops one fails here loudly. + */ +describe('analytics-disclosure.md content contract', () => { + it('includes the five required sections plus the privacy policy link', async () => { + const here = dirname(fileURLToPath(import.meta.url)) + const disclosurePath = resolve(here, '../../../../src/shared/assets/analytics-disclosure.md') + const text = await readFile(disclosurePath, 'utf8') + + expect(text, 'what-is-collected section').to.match(/what is collected/i) + expect(text, 'which-surfaces section').to.match(/which surfaces|surfaces are tracked/i) + expect(text, 'where-it-goes section').to.match(/where (it )?goes/i) + expect(text, 'cross-device alias section').to.match(/cross-device|alias/i) + // Pin the new disable instruction to the post-M16.4 surface. A regression + // that re-introduces the deleted `brv analytics disable` command (or any + // other variant) fails here loudly. + expect(text, 'how-to-disable section').to.match(/brv settings set analytics\.enabled false/i) + expect(text, 'how-to-disable must not reference the deleted command').to.not.match(/brv analytics disable/i) + expect(text, 'privacy policy link').to.include(PRIVACY_POLICY_URL) + }) +}) From 6b7ee2958bdbe827f0a8513919371d73f9e503fb Mon Sep 17 00:00:00 2001 From: bao-byterover Date: Fri, 29 May 2026 15:00:13 +0700 Subject: [PATCH 83/87] Feat/eng 3019 (#740) * feat: [ENG-3019] rotate device_id on logout, account-switch, and refresh-failure Rotate the global device_id after user-initiated identity transitions so the machine's analytics identity does not survive a session boundary. Mirrors PostHog/Mixpanel/Amplitude/Segment reset() semantics. Triggers: - Explicit brv logout when previously authenticated - brv login account switch (userA to userB with no logout in between) - Refresh-token exchange failure (treated as full sign-out: clear token, emit auth_logout {failure_kind:'refresh_failed'}, broadcast STATE_CHANGED, rotate) Does NOT trigger on: - Logout while already anonymous (or with an expired stored token) - Polling-observed token expiry without an attempted refresh - Same-user re-login Rotation runs AFTER the auth_login / auth_logout emit so the transition event still carries the OLD device_id, preserving attribution to the departing user's history. identity-resolver re-reads per call, so the next track() picks up the new id automatically. New IGlobalConfigRotator interface; GlobalConfigHandler implements it. rotateDeviceId() chains through the same writeChain as setAnalytics to serialize against concurrent flag toggles. AuthHandler depends on the narrow interface (optional, mirroring the existing optional analyticsClient pattern). * fix: [ENG-3019] address PR #740 bot review Narrow the setupRefresh sign-out catch to refreshToken() ONLY. A failure there means the auth server rejected the refresh (definitive sign-out). Failures AFTER refresh succeeded (user fetch 5xx, save disk error) are post-refresh APPLICATION failures and must not burn a device_id rotation or be attributed as refresh_failed in the analytics funnel: return {success:false} silently instead. Adds a processLog breadcrumb on each branch so refresh sign-outs leave at least one daemon-log line. Other PR-bot feedback addressed: - Move IGlobalConfigRotator from core/interfaces/state/ to core/interfaces/storage/ (rotator writes to disk via the config store, not in-memory state). - Loosen failure_kind assertion regex from [a-z_] to [a-z0-9_] so future tags like oauth_v2_flow or http_429 are not rejected at the test boundary. - Pin UUID v4 shape on the rotation test so a regression that swaps randomUUID for a non-UUID source fails loudly. - Document the safeLoadToken trade-off (returning undefined on a transient token-store error silently skips one rotation; the alternative would discard a freshly-exchanged OAuth token). --- .../core/domain/entities/global-config.ts | 20 + .../storage/i-global-config-rotator.ts | 19 + src/server/infra/process/feature-handlers.ts | 4 + .../infra/transport/handlers/auth-handler.ts | 159 ++++- .../handlers/global-config-handler.ts | 37 +- .../transport/handlers/auth-handler.test.ts | 596 +++++++++++++++++- .../handlers/global-config-handler.test.ts | 100 +++ .../domain/entities/global-config.test.ts | 45 ++ 8 files changed, 973 insertions(+), 7 deletions(-) create mode 100644 src/server/core/interfaces/storage/i-global-config-rotator.ts diff --git a/src/server/core/domain/entities/global-config.ts b/src/server/core/domain/entities/global-config.ts index 96325a347..1b7656353 100644 --- a/src/server/core/domain/entities/global-config.ts +++ b/src/server/core/domain/entities/global-config.ts @@ -130,4 +130,24 @@ export class GlobalConfig { version: this.version, }) } + + /** + * Returns a new GlobalConfig with the deviceId replaced. The analytics flag + * and version are preserved. The original instance is not mutated. + * + * @param deviceId - The new device identifier (must be non-empty) + * @returns A new GlobalConfig instance + * @throws Error if deviceId is empty or whitespace-only + */ + public withDeviceId(deviceId: string): GlobalConfig { + if (deviceId.trim().length === 0) { + throw new Error('Device ID cannot be empty') + } + + return new GlobalConfig({ + analytics: this.analytics, + deviceId, + version: this.version, + }) + } } diff --git a/src/server/core/interfaces/storage/i-global-config-rotator.ts b/src/server/core/interfaces/storage/i-global-config-rotator.ts new file mode 100644 index 000000000..2064183d0 --- /dev/null +++ b/src/server/core/interfaces/storage/i-global-config-rotator.ts @@ -0,0 +1,19 @@ +/** + * Rotates the device identity in the global config. Used by the auth RPC + * handlers when an explicit user-initiated identity transition occurs + * (logout, account-switch on login, refresh-failure sign-out) so the + * machine-level analytics identity does not survive the transition. + * + * Narrow interface so AuthHandler does not need a dependency on the full + * GlobalConfigHandler. + */ +export interface IGlobalConfigRotator { + /** + * Rewrites the on-disk `deviceId` with a fresh UUID, preserving the + * analytics flag and config version. No-ops when the config file does + * not yet exist (analytics never enabled — nothing to retire). + * + * @returns `true` if a rotation was performed, `false` if it was a no-op. + */ + rotateDeviceId(): Promise +} diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index aa7a2bdee..08b2bfae0 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -340,6 +340,10 @@ export async function setupFeatureHandlers({ authStateStore, browserLauncher: new SystemBrowserLauncher(), callbackHandler: new CallbackHandler(), + // The handler doubles as the device_id rotator (it owns the same + // writeChain that serializes analytics-flag toggles, so rotation + // cannot race a concurrent enable/disable into a stale config). + globalConfigRotator: globalConfigHandler, projectConfigStore, providerConfigStore, resolveProjectPath, diff --git a/src/server/infra/transport/handlers/auth-handler.ts b/src/server/infra/transport/handlers/auth-handler.ts index 65c25f64c..952340f43 100644 --- a/src/server/infra/transport/handlers/auth-handler.ts +++ b/src/server/infra/transport/handlers/auth-handler.ts @@ -10,6 +10,7 @@ import type {IProviderConfigStore} from '../../../core/interfaces/i-provider-con import type {IBrowserLauncher} from '../../../core/interfaces/services/i-browser-launcher.js' import type {IUserService} from '../../../core/interfaces/services/i-user-service.js' import type {IAuthStateStore} from '../../../core/interfaces/state/i-auth-state-store.js' +import type {IGlobalConfigRotator} from '../../../core/interfaces/storage/i-global-config-rotator.js' import type {IProjectConfigStore} from '../../../core/interfaces/storage/i-project-config-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' import type {ProjectPathResolver} from './handler-types.js' @@ -60,6 +61,14 @@ export interface AuthHandlerDeps { authStateStore: IAuthStateStore browserLauncher: IBrowserLauncher callbackHandler: ICallbackHandler + /** + * Optional. When provided, the handler rotates the global `device_id` + * on user-initiated identity transitions: explicit logout (if previously + * authenticated), account-switch on login (userA → userB), and + * refresh-failure sign-out. Optional so existing test harnesses don't + * have to thread the dep through. Wired in `feature-handlers.ts`. + */ + globalConfigRotator?: IGlobalConfigRotator projectConfigStore: IProjectConfigStore providerConfigStore: IProviderConfigStore resolveProjectPath: ProjectPathResolver @@ -78,6 +87,7 @@ export class AuthHandler { private readonly authStateStore: IAuthStateStore private readonly browserLauncher: IBrowserLauncher private readonly callbackHandler: ICallbackHandler + private readonly globalConfigRotator: IGlobalConfigRotator | undefined private readonly projectConfigStore: IProjectConfigStore private readonly providerConfigStore: IProviderConfigStore private readonly resolveProjectPath: ProjectPathResolver @@ -91,6 +101,7 @@ export class AuthHandler { this.authStateStore = deps.authStateStore this.browserLauncher = deps.browserLauncher this.callbackHandler = deps.callbackHandler + this.globalConfigRotator = deps.globalConfigRotator this.projectConfigStore = deps.projectConfigStore this.providerConfigStore = deps.providerConfigStore this.resolveProjectPath = deps.resolveProjectPath @@ -171,6 +182,16 @@ export class AuthHandler { const {code} = await this.callbackHandler.waitForCallback(authContext.state, 5 * 60 * 1000) const tokenData = await this.authService.exchangeCodeForToken(code, authContext, redirectUri) const user = await this.userService.getCurrentUser(tokenData.sessionKey) + + // Snapshot the previous live identity BEFORE save — drives the + // account-switch rotation rule below. Expired previous tokens do not + // count (the device was not actively claimed at switch time). + // safeLoadToken treats a read failure as "no previous identity": a + // transient token-store error must NOT discard a freshly-exchanged + // OAuth token. + const previousToken = await this.safeLoadToken() + const previousUserId = previousToken?.isValid() ? previousToken.userId : undefined + const authToken = new AuthToken({ accessToken: tokenData.accessToken, expiresAt: tokenData.expiresAt, @@ -195,6 +216,13 @@ export class AuthHandler { // success}`). this.emitAnalytics(AnalyticsEventNames.AUTH_LOGIN, {outcome: 'success'}) + // Rotate AFTER emit so this auth_login row carries the OLD device_id + // — the switch is attributed to the departing user's history. Only + // rotates on true account switch (live previous identity ≠ new). + if (previousUserId !== undefined && previousUserId !== user.id) { + await this.safeRotateDeviceId() + } + this.transport.broadcast(AuthEvents.LOGIN_COMPLETED, { success: true, user: toUserDTO(user), @@ -220,6 +248,46 @@ export class AuthHandler { } } + /** + * Reads the stored token, swallowing any error to `undefined`. Used by + * the auth RPC handlers to snapshot the pre-transition identity for + * rotation decisions; a transient read failure here must NOT abort the + * RPC (e.g. discard a freshly-exchanged OAuth token, or fail a logout). + * The default `FileTokenStore` already swallows internally, but custom + * stores might not — this keeps the handlers defensive. + */ + private async safeLoadToken(): Promise { + try { + return await this.tokenStore.load() + } catch (error) { + processLog(`[Auth] token load failed: ${getErrorMessage(error)}`) + // TRADE-OFF: returning undefined on a transient token-store error + // means the caller treats the prior identity as absent — so an + // account-switch login or logout on a flaky disk will silently + // skip the device_id rotation. We accept this because the + // alternative (rejecting the RPC) would discard a freshly-exchanged + // OAuth token or fail a user-initiated logout, which are worse. + // Disk errors are rare; the processLog above gives forensic + // breadcrumbs. + return undefined + } + } + + /** + * Rotates `device_id` without failing the calling RPC. Rotation MUST NOT + * block or fail an auth transition — it is post-hoc bookkeeping so the + * next analytics event ships under a fresh anonymous identity. + */ + private async safeRotateDeviceId(): Promise { + const rotator = this.globalConfigRotator + if (!rotator) return + try { + await rotator.rotateDeviceId() + } catch (error) { + processLog(`[Auth] device_id rotation failed: ${getErrorMessage(error)}`) + } + } + /** * Registers callbacks on AuthStateStore to broadcast auth events when * external changes are detected (CLI login, token expiry, token refresh). @@ -297,6 +365,15 @@ export class AuthHandler { async (data) => { try { const user = await this.userService.getCurrentUser(data.apiKey) + + // Snapshot the previous live identity BEFORE save — drives the + // account-switch rotation rule below. Expired tokens do not count + // (the device was not actively claimed at switch time). + // safeLoadToken swallows read failures to undefined so a + // transient token-store error does not fail the login RPC. + const previousToken = await this.safeLoadToken() + const previousUserId = previousToken?.isValid() ? previousToken.userId : undefined + const authToken = new AuthToken({ accessToken: 'unnecessary', expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days @@ -314,6 +391,12 @@ export class AuthHandler { // Emit AFTER loadToken (same identity-stamping rationale as the OAuth path). this.emitAnalytics(AnalyticsEventNames.AUTH_LOGIN, {outcome: 'success'}) + // Rotate AFTER emit so this auth_login row carries the OLD device_id. + // Only rotates when a live previous identity is replaced by a different user. + if (previousUserId !== undefined && previousUserId !== user.id) { + await this.safeRotateDeviceId() + } + this.transport.broadcast(AuthEvents.STATE_CHANGED, { isAuthorized: true, user: toUserDTO(user), @@ -335,6 +418,14 @@ export class AuthHandler { private setupLogout(): void { this.transport.onRequest(AuthEvents.LOGOUT, async () => { + // Snapshot identity BEFORE clearing — drives the "skip rotation when + // already anonymous" rule. An expired token is treated as anonymous + // (the device has not been actively claimed by a live session). + // safeLoadToken swallows read failures so a transient token-store + // error does not reject the logout RPC. + const previousToken = await this.safeLoadToken() + const wasAuthenticated = previousToken !== undefined && previousToken.isValid() + try { await this.tokenStore.clear() await this.disconnectByteRoverProvider() @@ -353,6 +444,14 @@ export class AuthHandler { // `device_id`. this.emitAnalytics(AnalyticsEventNames.AUTH_LOGOUT, {outcome: 'success'}) + // Rotate AFTER the emit so this auth_logout row still carries the + // OLD device_id (the one the departing user's history is keyed on). + // Subsequent track() calls will pick up the new id automatically — + // identity-resolver re-reads the config per event. + if (wasAuthenticated) { + await this.safeRotateDeviceId() + } + this.transport.broadcast(AuthEvents.STATE_CHANGED, {isAuthorized: false}) return {success: true} } catch { @@ -361,6 +460,9 @@ export class AuthHandler { // logged-in (clear failed first) or anonymous (clear succeeded but a // later step failed); both are valid for diagnostic purposes. // `failure_kind` is a coarse tag — never raw `error.message`. + // Do NOT rotate on the failure branch: state is indeterminate (we + // may not actually be signed out) and rotating now could burn a + // device_id while the user is still effectively the previous identity. // eslint-disable-next-line camelcase this.emitAnalytics(AnalyticsEventNames.AUTH_LOGOUT, {failure_kind: 'logout_flow', outcome: 'failure'}) @@ -371,13 +473,59 @@ export class AuthHandler { private setupRefresh(): void { this.transport.onRequest(AuthEvents.REFRESH, async () => { + // safeLoadToken so a read failure short-circuits to {success:false} + // instead of rejecting the RPC. + const token = await this.safeLoadToken() + if (!token) { + return {success: false} + } + + // Narrow the sign-out catch to refreshToken() ONLY. A failure here + // means the auth server rejected the refresh (revoked, expired + // refresh token, network 401/403) — a definitive sign-out. Failures + // AFTER refresh succeeded (user-fetch 5xx, save() disk error) are + // post-refresh application failures: they should NOT burn a + // device_id rotation or be attributed as `refresh_failed` in the + // analytics funnel. + let refreshedTokenData try { - const token = await this.tokenStore.load() - if (!token) { - return {success: false} + refreshedTokenData = await this.authService.refreshToken(token.refreshToken) + } catch (error) { + processLog(`[Auth] refreshToken exchange failed: ${getErrorMessage(error)}`) + // Sign-out side effects mirror the logout success branch (clear → + // disconnect → reload → emit → rotate → broadcast). Each wrapped + // so a cascading failure does not skip the rest. Returning + // {success:false} preserves the prior contract. + await this.tokenStore.clear().catch((clearError: unknown) => { + processLog(`[Auth] token clear failed during refresh sign-out: ${getErrorMessage(clearError)}`) + }) + await this.disconnectByteRoverProvider() + await this.authStateStore.loadToken().catch((loadError: unknown) => { + processLog(`[Auth] authStateStore reload failed during refresh sign-out: ${getErrorMessage(loadError)}`) + }) + + // eslint-disable-next-line camelcase + this.emitAnalytics(AnalyticsEventNames.AUTH_LOGOUT, {failure_kind: 'refresh_failed', outcome: 'failure'}) + + if (token.isValid()) { + // Only retire the device when the pre-refresh identity was live. + // An already-expired token observed by the refresh RPC is not an + // active claim on the device. + await this.safeRotateDeviceId() } - const refreshedTokenData = await this.authService.refreshToken(token.refreshToken) + // Explicit STATE_CHANGED broadcast (symmetric with the logout + // success branch). The onAuthChanged listener also broadcasts + // after loadToken transitions the cached token, but the explicit + // call here delivers synchronously before this RPC returns. + this.transport.broadcast(AuthEvents.STATE_CHANGED, {isAuthorized: false}) + return {success: false} + } + + // Refresh exchange succeeded. Anything below is a post-refresh + // application failure (user fetch, save, etc.) — return + // {success:false} silently, do NOT trigger sign-out semantics. + try { const user = await this.userService.getCurrentUser(refreshedTokenData.sessionKey) const newToken = new AuthToken({ accessToken: refreshedTokenData.accessToken, @@ -399,7 +547,8 @@ export class AuthHandler { }) return {success: true} - } catch { + } catch (error) { + processLog(`[Auth] post-refresh application failed (token NOT applied, no sign-out): ${getErrorMessage(error)}`) return {success: false} } }) diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts index 7eb773d41..f5b975d5b 100644 --- a/src/server/infra/transport/handlers/global-config-handler.ts +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -1,6 +1,7 @@ import {randomUUID} from 'node:crypto' import type {IAnalyticsClient} from '../../../core/interfaces/analytics/i-analytics-client.js' +import type {IGlobalConfigRotator} from '../../../core/interfaces/storage/i-global-config-rotator.js' import type {IGlobalConfigStore} from '../../../core/interfaces/storage/i-global-config-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' @@ -50,7 +51,7 @@ export interface GlobalConfigHandlerDeps { * existing on-disk config. Transport responses still read fresh from * disk — the cache is purely an in-process bridge for sync consumers. */ -export class GlobalConfigHandler { +export class GlobalConfigHandler implements IGlobalConfigRotator { private analyticsClient: IAnalyticsClient | undefined private cachedAnalytics: boolean | undefined private readonly globalConfigStore: IGlobalConfigStore @@ -123,6 +124,29 @@ export class GlobalConfigHandler { } } + /** + * Rotates the on-disk `deviceId` with a fresh UUID. Preserves the + * analytics flag + version. No-ops if no config file exists (analytics + * never enabled → nothing to retire). Goes through the same + * `writeChain` as `setAnalytics` so a concurrent enable/disable cannot + * race the rotation onto a stale read. + * + * Does NOT emit an analytics event — rotation is implied by the next + * tracked event carrying the new `device_id`. + * + * Does NOT touch `cachedAnalytics` — the flag is unchanged. + * + * @returns `true` if a rotation occurred, `false` on the no-config no-op. + */ + public async rotateDeviceId(): Promise { + const next = this.writeChain.then(async () => this.doRotateDeviceId()) + this.writeChain = next.then( + () => {}, + () => {}, + ) + return next + } + /** * M4.4: late-bound analytics client setter. The composition root * constructs `GlobalConfigHandler` BEFORE `AnalyticsClient` exists @@ -155,6 +179,17 @@ export class GlobalConfigHandler { ) } + private async doRotateDeviceId(): Promise { + const existing = await this.globalConfigStore.read() + if (!existing) { + return false + } + + const updated = existing.withDeviceId(randomUUID()) + await this.globalConfigStore.write(updated) + return true + } + private async doSetAnalytics(analytics: boolean): Promise { const existing = await this.globalConfigStore.read() const previous = existing?.analytics ?? false diff --git a/test/unit/infra/transport/handlers/auth-handler.test.ts b/test/unit/infra/transport/handlers/auth-handler.test.ts index 2deb98c31..161e0147d 100644 --- a/test/unit/infra/transport/handlers/auth-handler.test.ts +++ b/test/unit/infra/transport/handlers/auth-handler.test.ts @@ -11,6 +11,7 @@ import type {IProviderConfigStore} from '../../../../../src/server/core/interfac import type {IBrowserLauncher} from '../../../../../src/server/core/interfaces/services/i-browser-launcher.js' import type {IUserService} from '../../../../../src/server/core/interfaces/services/i-user-service.js' import type {IAuthStateStore} from '../../../../../src/server/core/interfaces/state/i-auth-state-store.js' +import type {IGlobalConfigRotator} from '../../../../../src/server/core/interfaces/storage/i-global-config-rotator.js' import type {IProjectConfigStore} from '../../../../../src/server/core/interfaces/storage/i-project-config-store.js' import type {ITransportServer} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' @@ -108,7 +109,35 @@ function assertFailureKindDiscipline(value: unknown, label: string): void { const tag = value as string expect(tag.length, `${label}: failure_kind must be non-empty`).to.be.greaterThan(0) expect(tag.length, `${label}: failure_kind must be ≤64 chars (got ${tag.length})`).to.be.lessThanOrEqual(64) - expect(tag, `${label}: failure_kind must be snake_case (a-z + _), got "${tag}"`).to.match(/^[a-z][a-z_]*$/) + expect(tag, `${label}: failure_kind must be snake_case (a-z0-9 + _), got "${tag}"`).to.match(/^[a-z][a-z0-9_]*$/) +} + +function makeRotatorStub(rotated = true): IGlobalConfigRotator & {rotateSpy: ReturnType} { + const rotateSpy = stub().resolves(rotated) + return { + rotateDeviceId: rotateSpy, + rotateSpy, + } as unknown as IGlobalConfigRotator & {rotateSpy: ReturnType} +} + +function makeTokenForUser(userId: string): AuthToken { + return new AuthToken({ + accessToken: 'access', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'refresh', + sessionKey: 'session', + tokenType: 'Bearer', + userEmail: `${userId}@example.com`, + userId, + }) +} + +function tokenStoreWithPrevious(previous?: AuthToken): ITokenStore { + return { + clear: stub().resolves(), + load: stub().resolves(previous), + save: stub().resolves(), + } as unknown as ITokenStore } function makeValidTokenStoreFixture(): ITokenStore { @@ -702,6 +731,571 @@ describe('AuthHandler — setupExternalAuthSync', () => { }) }) + describe('setupRefresh — failure path treats as full sign-out', () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + function makeRefreshHarness(opts: { + previousToken: AuthToken | undefined + refreshThrows: boolean + rotator: IGlobalConfigRotator + }): { + analyticsClient: ReturnType + callOrder: string[] + callRefresh: () => Promise + tokenStore: ITokenStore & {clearSpy: ReturnType} + } { + const callOrder: string[] = [] + const analyticsClient = makeFakeAnalyticsClient() + analyticsClient.trackSpy.callsFake((event: string) => { + callOrder.push(`track:${event}`) + }) + const clearSpy = stub().callsFake(async () => { + callOrder.push('tokenStore.clear') + }) + const tokenStore = { + clear: clearSpy, + clearSpy, + load: stub().resolves(opts.previousToken), + save: stub().resolves(), + } as unknown as ITokenStore & {clearSpy: ReturnType} + + const refreshStub = opts.refreshThrows + ? stub().rejects(new Error('refresh denied')) + : stub().resolves({ + accessToken: 'new-a', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'new-r', + sessionKey: 'new-s', + tokenType: 'Bearer', + }) + + const localTransport = createMockTransport() + const localBroadcast = stub().callsFake((event: string) => { + if (event === AuthEvents.STATE_CHANGED) callOrder.push('broadcast:STATE_CHANGED') + }) + ;(localTransport as unknown as {broadcast: typeof localBroadcast}).broadcast = localBroadcast + + new AuthHandler({ + analyticsClient, + authService: { + exchangeCodeForToken: stub(), + initiateAuthorization: stub(), + refreshToken: refreshStub, + } as unknown as IAuthService, + authStateStore, + browserLauncher: {open: stub()} as unknown as IBrowserLauncher, + callbackHandler: { + getPort: stub().returns(3000), + start: stub().resolves(), + stop: stub().resolves(), + waitForCallback: stub().resolves({code: 'test'}), + } as unknown as ICallbackHandler, + globalConfigRotator: opts.rotator, + projectConfigStore, + providerConfigStore, + resolveProjectPath: stub().returns('/test/project'), + tokenStore, + transport: localTransport, + userService, + }).setup() + + return { + analyticsClient, + callOrder, + async callRefresh() { + const handler = localTransport._handlers.get(AuthEvents.REFRESH)! + return handler(undefined, 'client-1') + }, + tokenStore, + } + } + + it('clears the token, emits auth_logout {failure_kind:"refresh_failed"}, rotates, and broadcasts STATE_CHANGED', async () => { + const rotator = makeRotatorStub() + const harness = makeRefreshHarness({ + previousToken: createValidToken(), + refreshThrows: true, + rotator, + }) + + const result = await harness.callRefresh() + + expect(result).to.deep.equal({success: false}) + expect(harness.tokenStore.clearSpy.calledOnce, 'token cleared on refresh failure').to.be.true + expect(rotator.rotateSpy.calledOnce, 'device_id rotated on refresh failure').to.be.true + + const trackCalls = harness.analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGOUT) + expect(trackCalls.length, 'auth_logout fires once on refresh-fail sign-out').to.equal(1) + const props = trackCalls[0].args[1] as {failure_kind?: string; outcome: string} + expect(props.outcome).to.equal('failure') + expect(props.failure_kind).to.equal('refresh_failed') + assertFailureKindDiscipline(props.failure_kind, 'refresh-fail sign-out emit') + + expect(harness.callOrder, 'STATE_CHANGED broadcast fired on refresh-fail').to.include('broadcast:STATE_CHANGED') + }) + + it('does NOT rotate when the previous token is expired (no live identity)', async () => { + const expired = new AuthToken({ + accessToken: 'a', + expiresAt: new Date(Date.now() - 60_000), + refreshToken: 'r', + sessionKey: 's', + tokenType: 'Bearer', + userEmail: 'old@example.com', + userId: 'user-OLD', + }) + const rotator = makeRotatorStub() + const harness = makeRefreshHarness({previousToken: expired, refreshThrows: true, rotator}) + + await harness.callRefresh() + + expect(rotator.rotateSpy.called, 'expired token before refresh means no live identity to retire').to.be.false + }) + + it('early-returns success=false without emitting or rotating when no token is loaded', async () => { + const rotator = makeRotatorStub() + const harness = makeRefreshHarness({previousToken: undefined, refreshThrows: false, rotator}) + + const result = await harness.callRefresh() + + expect(result).to.deep.equal({success: false}) + expect(rotator.rotateSpy.called, 'no rotation when there was nothing to refresh').to.be.false + const trackCalls = harness.analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGOUT) + expect(trackCalls.length, 'no auth_logout emit on the early-return branch').to.equal(0) + }) + + it('does NOT rotate or emit on successful refresh (same user, token replaced)', async () => { + const rotator = makeRotatorStub() + const harness = makeRefreshHarness({ + previousToken: createValidToken(), + refreshThrows: false, + rotator, + }) + + const result = await harness.callRefresh() + + expect(result).to.deep.equal({success: true}) + expect(rotator.rotateSpy.called, 'successful refresh keeps the same identity').to.be.false + const trackCalls = harness.analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGOUT) + expect(trackCalls.length, 'no auth_logout on successful refresh').to.equal(0) + }) + + it('disconnects the byterover provider (symmetric with logout success + onAuthExpired)', async () => { + providerConfigStore = createMockProviderConfigStore({isConnected: true}) + const rotator = makeRotatorStub() + const harness = makeRefreshHarness({ + previousToken: createValidToken(), + refreshThrows: true, + rotator, + }) + + await harness.callRefresh() + + expect(providerConfigStore.disconnectProvider.calledOnceWith('byterover'), 'byterover must be disconnected on refresh-fail sign-out').to.be.true + }) + + it('does NOT treat post-refresh user-fetch failure as a refresh-fail sign-out (narrow catch)', async () => { + // refreshToken() succeeds; userService.getCurrentUser() then 5xx-s. + // This is a post-refresh APPLICATION failure — not a refresh-fail. + // The narrowed catch must NOT clear, rotate, emit auth_logout, or + // broadcast isAuthorized:false. + userService.getCurrentUser = stub().rejects(new Error('5xx')) as unknown as typeof userService.getCurrentUser + const rotator = makeRotatorStub() + const harness = makeRefreshHarness({ + previousToken: createValidToken(), + refreshThrows: false, + rotator, + }) + + const result = await harness.callRefresh() + + expect(result).to.deep.equal({success: false}) + expect(harness.tokenStore.clearSpy.called, 'token must NOT be cleared on post-refresh failure').to.be.false + expect(rotator.rotateSpy.called, 'device_id must NOT rotate on post-refresh failure').to.be.false + const trackCalls = harness.analyticsClient.trackSpy + .getCalls() + .filter((c: {args: unknown[]}) => c.args[0] === AnalyticsEventNames.AUTH_LOGOUT) + expect(trackCalls.length, 'no auth_logout emit on post-refresh failure').to.equal(0) + expect( + harness.callOrder.includes('broadcast:STATE_CHANGED'), + 'no isAuthorized:false broadcast on post-refresh failure', + ).to.be.false + }) + + it('does NOT throw when rotation fails on the refresh sign-out path', async () => { + const rotator = makeRotatorStub() + rotator.rotateSpy.rejects(new Error('disk full')) + const harness = makeRefreshHarness({ + previousToken: createValidToken(), + refreshThrows: true, + rotator, + }) + + const result = await harness.callRefresh() + + expect(result).to.deep.equal({success: false}) + }) + }) + + describe('device_id rotation on login — account switch', () => { + describe('API-key path', () => { + it('does NOT rotate on fresh login (no previous token)', async () => { + const rotator = makeRotatorStub() + + createHandler({ + globalConfigRotator: rotator, + tokenStore: tokenStoreWithPrevious(), + }) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + await handler({apiKey: 'k'}, 'client-1') + + expect(rotator.rotateSpy.called, 'fresh login does not retire a non-existent identity').to.be.false + }) + + it('does NOT rotate when re-asserting the same user', async () => { + const rotator = makeRotatorStub() + + createHandler({ + globalConfigRotator: rotator, + tokenStore: tokenStoreWithPrevious(makeTokenForUser('user-123')), + }) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + await handler({apiKey: 'k'}, 'client-1') + + expect(rotator.rotateSpy.called, 'same userId means no switch').to.be.false + }) + + it('rotates AFTER the auth_login emit when previous user differs from new user', async () => { + const callOrder: string[] = [] + const analyticsClient = makeFakeAnalyticsClient() + analyticsClient.trackSpy.callsFake((event: string) => { + callOrder.push(`track:${event}`) + }) + const rotator = makeRotatorStub() + rotator.rotateSpy.callsFake(async () => { + callOrder.push('rotate') + return true + }) + + createHandler({ + analyticsClient, + globalConfigRotator: rotator, + tokenStore: tokenStoreWithPrevious(makeTokenForUser('user-OLD')), + }) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + await handler({apiKey: 'k'}, 'client-1') + + expect(rotator.rotateSpy.calledOnce, 'rotation runs exactly once on switch').to.be.true + expect(callOrder.indexOf(`track:${AnalyticsEventNames.AUTH_LOGIN}`), 'emit happens before rotation').to.be.lessThan( + callOrder.indexOf('rotate'), + ) + }) + + it('does NOT rotate when the previous token is expired (not a live identity)', async () => { + const expired = new AuthToken({ + accessToken: 'a', + expiresAt: new Date(Date.now() - 60_000), + refreshToken: 'r', + sessionKey: 's', + tokenType: 'Bearer', + userEmail: 'old@example.com', + userId: 'user-OLD', + }) + const rotator = makeRotatorStub() + + createHandler({ + globalConfigRotator: rotator, + tokenStore: tokenStoreWithPrevious(expired), + }) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + await handler({apiKey: 'k'}, 'client-1') + + expect(rotator.rotateSpy.called, 'expired token does not count as a live previous identity').to.be.false + }) + + it('does NOT fail the login RPC when rotation throws', async () => { + const rotator = makeRotatorStub() + rotator.rotateSpy.rejects(new Error('disk full')) + + createHandler({ + globalConfigRotator: rotator, + tokenStore: tokenStoreWithPrevious(makeTokenForUser('user-OLD')), + }) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + const result = await handler({apiKey: 'k'}, 'client-1') + + expect(result.success).to.equal(true) + }) + + it('does NOT rotate on the login failure branch (no token committed)', async () => { + const rotator = makeRotatorStub() + userService.getCurrentUser = stub().rejects( + new Error('invalid key'), + ) as unknown as typeof userService.getCurrentUser + + createHandler({ + globalConfigRotator: rotator, + tokenStore: tokenStoreWithPrevious(makeTokenForUser('user-OLD')), + }) + + const handler = transport._handlers.get(AuthEvents.LOGIN_WITH_API_KEY)! + const result = await handler({apiKey: 'bad'}, 'client-1') + + expect(result.success).to.equal(false) + expect(rotator.rotateSpy.called, 'failed login never claims the device for the new user').to.be.false + }) + }) + + describe('OAuth (processLoginCallback) path', () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + function setupOAuthHandler(opts: {previousToken: AuthToken | undefined; rotator: IGlobalConfigRotator}): { + callOrder: string[] + run: () => Promise + } { + const callOrder: string[] = [] + const oauthTransport = createMockTransport() + const oauthAuthStateStore = { + getToken: stub(), + loadToken: stub().callsFake(async () => { + callOrder.push('loadToken') + }), + onAuthChanged: stub(), + onAuthExpired: stub(), + startPolling: stub(), + stopPolling: stub(), + } as unknown as SinonStubbedInstance + + const analyticsClient = makeFakeAnalyticsClient() + analyticsClient.trackSpy.callsFake((event: string) => { + callOrder.push(`track:${event}`) + }) + + new AuthHandler({ + analyticsClient, + authService: { + exchangeCodeForToken: stub().resolves({ + accessToken: 'a', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'r', + sessionKey: 's', + tokenType: 'Bearer', + }), + initiateAuthorization: stub().returns({authUrl: 'https://auth.test', state: 'st'}), + refreshToken: stub(), + } as unknown as IAuthService, + authStateStore: oauthAuthStateStore, + browserLauncher: {open: stub().resolves()} as unknown as IBrowserLauncher, + callbackHandler: { + getPort: stub().returns(3000), + start: stub().resolves(), + stop: stub().resolves(), + waitForCallback: stub().resolves({code: 'c'}), + } as unknown as ICallbackHandler, + globalConfigRotator: opts.rotator, + projectConfigStore, + providerConfigStore: createMockProviderConfigStore(), + resolveProjectPath: stub().returns('/test'), + tokenStore: { + clear: stub().resolves(), + load: stub().resolves(opts.previousToken), + save: stub().callsFake(async () => { + callOrder.push('tokenStore.save') + }), + } as unknown as ITokenStore, + transport: oauthTransport, + userService, + }).setup() + + return { + callOrder, + async run() { + const handler = oauthTransport._handlers.get(AuthEvents.START_LOGIN)! + await handler({}, 'client-1') + // Wait for fire-and-forget processLoginCallback to finish. + await new Promise((resolve) => { + setTimeout(resolve, 50) + }) + }, + } + } + + it('does NOT rotate on fresh OAuth login (no previous token)', async () => { + const rotator = makeRotatorStub() + const harness = setupOAuthHandler({previousToken: undefined, rotator}) + + await harness.run() + + expect(rotator.rotateSpy.called).to.be.false + }) + + it('does NOT rotate when OAuth re-issues for the same user', async () => { + const rotator = makeRotatorStub() + const sameUserToken = new AuthToken({ + accessToken: 'a', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'r', + sessionKey: 's', + tokenType: 'Bearer', + userEmail: 'test@example.com', + userId: 'user-123', + }) + const harness = setupOAuthHandler({previousToken: sameUserToken, rotator}) + + await harness.run() + + expect(rotator.rotateSpy.called).to.be.false + }) + + it('rotates AFTER the auth_login emit when OAuth switches users', async () => { + const rotator = makeRotatorStub() + const otherUserToken = new AuthToken({ + accessToken: 'a', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'r', + sessionKey: 's', + tokenType: 'Bearer', + userEmail: 'old@example.com', + userId: 'user-OLD', + }) + const harness = setupOAuthHandler({previousToken: otherUserToken, rotator}) + rotator.rotateSpy.callsFake(async () => { + harness.callOrder.push('rotate') + return true + }) + + await harness.run() + + expect(rotator.rotateSpy.calledOnce).to.be.true + expect(harness.callOrder.indexOf(`track:${AnalyticsEventNames.AUTH_LOGIN}`)).to.be.lessThan( + harness.callOrder.indexOf('rotate'), + ) + }) + }) + }) + + describe('device_id rotation on logout', () => { + it('rotates device_id AFTER the auth_logout emit when previously authenticated', async () => { + const callOrder: string[] = [] + const analyticsClient = makeFakeAnalyticsClient() + analyticsClient.trackSpy.callsFake((event: string) => { + callOrder.push(`track:${event}`) + }) + const rotator = makeRotatorStub() + rotator.rotateSpy.callsFake(async () => { + callOrder.push('rotate') + return true + }) + + createHandler({ + analyticsClient, + globalConfigRotator: rotator, + tokenStore: makeValidTokenStoreFixture(), + }) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + expect(rotator.rotateSpy.calledOnce, 'rotateDeviceId called once on authenticated logout').to.be.true + expect(callOrder.indexOf(`track:${AnalyticsEventNames.AUTH_LOGOUT}`), 'emit happens before rotation').to.be.lessThan( + callOrder.indexOf('rotate'), + ) + }) + + it('does NOT rotate when token store returns undefined (already-anonymous logout)', async () => { + const rotator = makeRotatorStub() + + createHandler({ + globalConfigRotator: rotator, + tokenStore: makeMissingTokenStoreFixture(), + }) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + expect(rotator.rotateSpy.called, 'no rotation on already-anonymous logout').to.be.false + }) + + it('does NOT rotate when the stored token is expired (treated as already-anonymous)', async () => { + const rotator = makeRotatorStub() + + createHandler({ + globalConfigRotator: rotator, + tokenStore: makeExpiredTokenStoreFixture(), + }) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + expect(rotator.rotateSpy.called, 'expired token means no live identity to retire').to.be.false + }) + + it('does NOT fail the logout RPC when rotation throws', async () => { + const rotator = makeRotatorStub() + rotator.rotateSpy.rejects(new Error('disk full')) + + createHandler({ + globalConfigRotator: rotator, + tokenStore: makeValidTokenStoreFixture(), + }) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + }) + + it('does NOT rotate on the logout failure branch (indeterminate identity)', async () => { + const rotator = makeRotatorStub() + const tokenStore = { + clear: stub().rejects(new Error('disk full')), + load: stub().resolves(createValidToken()), + save: stub().resolves(), + } as unknown as ITokenStore + + createHandler({globalConfigRotator: rotator, tokenStore}) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: false}) + expect(rotator.rotateSpy.called, 'rotation skipped when logout flow failed mid-way').to.be.false + }) + + it('is a no-op when no globalConfigRotator is injected (optional dep)', async () => { + // Mirrors the existing optional-analyticsClient backward-compat pattern. + createHandler({tokenStore: makeValidTokenStoreFixture()}) + + const handler = transport._handlers.get(AuthEvents.LOGOUT)! + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({success: true}) + }) + }) + + describe('opportunistic token expiry — onAuthExpired callback', () => { + it('does NOT rotate device_id (passive expiry is not a sign-out trigger)', () => { + const rotator = makeRotatorStub() + createHandler({globalConfigRotator: rotator}) + + capturedAuthExpired!(createValidToken()) + + expect(rotator.rotateSpy.called, 'polling-observed expiry is out of scope for rotation').to.be.false + }) + }) + describe('setupGetState', () => { it('returns isAuthorized=true and skips brvConfig when body is undefined (TUI sends no body)', async () => { createHandler({tokenStore: makeValidTokenStoreFixture()}) diff --git a/test/unit/infra/transport/handlers/global-config-handler.test.ts b/test/unit/infra/transport/handlers/global-config-handler.test.ts index 05e27c4e7..d3ee25f8b 100644 --- a/test/unit/infra/transport/handlers/global-config-handler.test.ts +++ b/test/unit/infra/transport/handlers/global-config-handler.test.ts @@ -369,6 +369,106 @@ describe('GlobalConfigHandler', () => { }) }) + describe('rotateDeviceId', () => { + it('returns false and does NOT write when no config file exists', async () => { + store.read.resolves() + + const rotated = await handler.rotateDeviceId() + + expect(rotated).to.be.false + expect(store.write.called, 'must not seed a config just to rotate').to.be.false + }) + + it('writes a new deviceId, preserves analytics flag + version, and returns true', async () => { + const before = GlobalConfig.create('device-old').withAnalytics(true) + store.read.resolves(before) + + const rotated = await handler.rotateDeviceId() + + expect(rotated).to.be.true + expect(store.write.calledOnce).to.be.true + const written = store.write.firstCall.args[0] + expect(written.deviceId).to.not.equal('device-old') + // Pin UUID v4 shape so a regression that swaps in a non-UUID source + // (e.g. Date.now().toString()) fails loudly at the test boundary. + expect(written.deviceId, 'rotated to a UUID v4').to.match( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ) + expect(written.analytics, 'analytics flag preserved').to.equal(before.analytics) + expect(written.version, 'version preserved').to.equal(before.version) + }) + + it('serializes concurrent rotate + setAnalytics through writeChain', async () => { + // Pre-existing config so neither call hits the idempotent no-op path. + const before = GlobalConfig.create('device-A').withAnalytics(false) + store.read.resolves(before) + + const writeOrder: string[] = [] + let resolveFirst!: () => void + const firstWriteGate = new Promise((resolve) => { + resolveFirst = resolve + }) + + // First write call (rotation) gates on firstWriteGate so we can + // observe whether the second call (setAnalytics) waits for it. + // Label by call ordinal (operation identity), NOT by `cfg.analytics` + // — the seed could change in the future and a content-based label + // would silently mislabel. + store.write.callsFake(async (_cfg: GlobalConfig) => { + const ordinal = store.write.callCount + if (ordinal === 1) { + await firstWriteGate + } + + writeOrder.push(ordinal === 1 ? 'rotate' : 'setAnalytics') + store.read.resolves(_cfg) + }) + + const rotatePromise = handler.rotateDeviceId() + const setPromise = (async () => { + const fn = transport._handlers.get(GlobalConfigEvents.SET_ANALYTICS) + if (!fn) throw new Error('SET_ANALYTICS handler not registered') + return fn({analytics: true}, 'client-1') + })() + + // Give the event loop a tick so both calls enter the chain. + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(writeOrder, 'second write must NOT have started while first is gated').to.have.lengthOf(0) + resolveFirst() + + await Promise.all([rotatePromise, setPromise]) + + expect(writeOrder).to.deep.equal(['rotate', 'setAnalytics']) + }) + + it('does NOT mutate cachedAnalytics', async () => { + const before = GlobalConfig.create('device-1').withAnalytics(true) + store.read.resolves(before) + await handler.refreshCache() + expect(handler.getCachedAnalytics(), 'cache starts true').to.be.true + + await handler.rotateDeviceId() + + expect(handler.getCachedAnalytics(), 'rotation must leave the cached flag untouched').to.be.true + }) + + it('does NOT emit any analytics event', async () => { + const analyticsClient = makeTrackingClient() + const handlerWithClient = new GlobalConfigHandler({analyticsClient, globalConfigStore: store, transport}) + handlerWithClient.setup() + + const before = GlobalConfig.create('device-old').withAnalytics(true) + store.read.resolves(before) + + await handlerWithClient.rotateDeviceId() + + expect(analyticsClient.track.called, 'rotation is implicit — no analytics event fires').to.be.false + }) + }) + describe('analytics_disabled emit', () => { it('emits analytics_disabled exactly once on enable→disable transition', async () => { const analyticsClient = makeTrackingClient() diff --git a/test/unit/server/core/domain/entities/global-config.test.ts b/test/unit/server/core/domain/entities/global-config.test.ts index 89d900357..346d16d1c 100644 --- a/test/unit/server/core/domain/entities/global-config.test.ts +++ b/test/unit/server/core/domain/entities/global-config.test.ts @@ -218,6 +218,51 @@ describe('GlobalConfig', () => { }) }) + describe('withDeviceId()', () => { + const newDeviceId = '11111111-2222-3333-4444-555555555555' + + it('should produce a new instance with the given deviceId, preserving analytics + version', () => { + const original = GlobalConfig.fromJson({ + analytics: true, + deviceId: validDeviceId, + version: '0.0.1', + }) + if (!original) throw new Error('fromJson returned undefined for valid input') + + const updated = original.withDeviceId(newDeviceId) + + expect(updated.deviceId).to.equal(newDeviceId) + expect(updated.analytics).to.equal(true) + expect(updated.version).to.equal('0.0.1') + }) + + it('should not mutate the original instance', () => { + const original = GlobalConfig.create(validDeviceId) + original.withDeviceId(newDeviceId) + + expect(original.deviceId).to.equal(validDeviceId) + }) + + it('should return a new instance object', () => { + const original = GlobalConfig.create(validDeviceId) + const updated = original.withDeviceId(newDeviceId) + + expect(updated).to.not.equal(original) + }) + + it('should throw when deviceId is empty', () => { + const original = GlobalConfig.create(validDeviceId) + + expect(() => original.withDeviceId('')).to.throw('Device ID cannot be empty') + }) + + it('should throw when deviceId is only whitespace', () => { + const original = GlobalConfig.create(validDeviceId) + + expect(() => original.withDeviceId(' ')).to.throw('Device ID cannot be empty') + }) + }) + describe('immutability', () => { it('should have readonly properties', () => { const config = GlobalConfig.create(validDeviceId) From 5d96619eee92035fba19ca7611e114744a4b645d Mon Sep 17 00:00:00 2001 From: Nguyen Cong Nhat Thien Date: Sat, 30 May 2026 08:52:17 +0700 Subject: [PATCH 84/87] feat: [ENG-2621] open the analytics disclosure by default in the Privacy tab (#742) Default detailsOpen to true so the "What data will be collected?" panel is expanded on first render. Helps the user see the disclosure without having to click to reveal it, which matches the spirit of an opt-in privacy surface. --- src/webui/features/analytics/components/analytics-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webui/features/analytics/components/analytics-panel.tsx b/src/webui/features/analytics/components/analytics-panel.tsx index d9cd57634..16add8131 100644 --- a/src/webui/features/analytics/components/analytics-panel.tsx +++ b/src/webui/features/analytics/components/analytics-panel.tsx @@ -18,7 +18,7 @@ export function AnalyticsPanel() { const {data, error, isError, isLoading, refetch} = useGetGlobalConfig() const setAnalytics = useSetAnalytics() const [pendingIntent, setPendingIntent] = useState<'disable' | 'enable' | undefined>() - const [detailsOpen, setDetailsOpen] = useState(false) + const [detailsOpen, setDetailsOpen] = useState(true) const analytics = data?.analytics ?? false From 32634740ad9bd30aa46e69f5f13a197daa8f52d9 Mon Sep 17 00:00:00 2001 From: bao-byterover Date: Sat, 30 May 2026 15:23:24 +0700 Subject: [PATCH 85/87] Feat/eng 2658 (#743) * fix: [ENG-2658] address PR #726 review on analytics - Classify missing-deviceId send as http_4xx instead of failed-without-reason - Drop out-of-project basename from relative_path; emit bare sentinel (privacy) - Rename production NoopAnalyticsSender -> DrainingAnalyticsSender (disambiguate from test-seam NoOpAnalyticsSender) - Pin migrate_run failure_kind assertions to archive_exists / no_archive - Sync backoff decision-table doc + analytics-client test comment to the http_4xx change * feat: [ENG-2658] honor Retry-After from 429/503 responses - HTTP client: classify 429 (app throttler) and 503 (nginx edge backstop) as rate_limited; extract the delay from the Retry-After header, then the retry_after_seconds body, then a 60s default (WARN logged on fallback). - Backoff policy: applyServerHint(ms) overrides the next delay with max(hint, scheduled) so a server can stretch but never accelerate retries; isRateLimited() exposes the state. A rate-limit never advances consecutiveFailures, so a throttled endpoint is never marked unreachable. - AnalyticsClient: honor a rate_limited send via applyServerHint instead of onFailure; the flush scheduler re-arms from nextDelayMs() automatically. - Status: surface a distinct rate_limited reachability state (wire schema + build-status-snapshot + format-analytics-status), separate from unreachable. * fix: [ENG-2658] cap Retry-After delay, gate burst flush while rate-limited, log dropped hint Addresses design/logic review findings on the M5.4 Retry-After feature: - Backoff policy: clamp an honored server Retry-After to a 1h maximum (MAX_SERVER_HINT_MS). An unbounded value could overflow Node's setTimeout (> 2^31-1 ms fires immediately, IGNORING the rate-limit) or stall shipping for days. The lower bound (max with the schedule) is unchanged. - Flush scheduler: suppress the 20-event burst trigger while an active server rate-limit (429/503) is in effect, via a new isRateLimited dep wired from the backoff policy. A burst must not hammer a backend that asked us to wait; the stretched periodic tick ships the backlog. Normal batching and the failure-backoff path are unchanged. - AnalyticsClient: log instead of silently dropping a rate_limited result that arrives without retryAfterMs (contract-violation visibility). * fix: [ENG-2658] address PR #743 review on Retry-After handling - HTTP client: route 503 through classifyRateLimited so a 503 that carries a Retry-After (maintenance page, alternate ingress, CDN) is honored instead of being forced to the 60s default. Default-path WARN is now status-aware. - HTTP client: parse the RFC 7231 HTTP-date form of Retry-After in addition to delay-seconds; a past/unparseable value yields no hint (falls back to default). Far-future dates remain bounded by the policy's 1h MAX_SERVER_HINT_MS cap. - AnalyticsClient: when a rate_limited result arrives without retryAfterMs, still mark the policy rate-limited via applyServerHint(NaN) (no delay floor) so the scheduler's burst gate stays closed; otherwise the next 20-event burst would hammer a backend that just told us to back off. Log line retained. - Test: pin the invariant that applyServerHint(0/-1/NaN/Infinity) flips isRateLimited() without changing the schedule floor. --- src/server/config/environment.ts | 2 +- .../analytics/i-analytics-backoff-policy.ts | 20 ++++ .../analytics/i-analytics-http-client.ts | 22 +++-- .../analytics/i-analytics-sender.ts | 12 ++- .../analytics/analytics-backoff-policy.ts | 42 +++++++- .../infra/analytics/analytics-client.ts | 38 ++++++- .../analytics/analytics-flush-scheduler.ts | 28 +++++- .../analytics/axios-analytics-http-client.ts | 81 ++++++++++++++- .../infra/analytics/build-status-snapshot.ts | 9 +- .../analytics/draining-analytics-sender.ts | 37 +++++++ .../infra/analytics/http-analytics-sender.ts | 20 +++- .../infra/analytics/noop-analytics-sender.ts | 40 -------- src/server/infra/process/analytics-hook.ts | 18 ++-- .../process/wire-analytics-flush-scheduler.ts | 5 + .../process/wire-analytics-http-sender.ts | 10 +- .../transport/events/analytics-events.ts | 6 +- .../wire-analytics-http-sender.test.ts | 20 ++-- .../migrate-handler-analytics.test.ts | 15 ++- .../analytics-backoff-policy.test.ts | 83 ++++++++++++++++ .../infra/analytics/analytics-client.test.ts | 82 ++++++++++++++-- .../analytics-flush-scheduler.test.ts | 28 ++++++ .../axios-analytics-http-client.test.ts | 98 ++++++++++++++++--- .../analytics/build-status-snapshot.test.ts | 21 +++- ...t.ts => draining-analytics-sender.test.ts} | 16 +-- .../analytics/http-analytics-sender.test.ts | 31 +++++- .../infra/process/analytics-hook-m14.test.ts | 11 ++- .../infra/process/analytics-hook.test.ts | 30 +++--- .../handlers/analytics-status-handler.test.ts | 8 +- .../utils/format-analytics-status.test.ts | 11 +++ 29 files changed, 700 insertions(+), 144 deletions(-) create mode 100644 src/server/infra/analytics/draining-analytics-sender.ts delete mode 100644 src/server/infra/analytics/noop-analytics-sender.ts rename test/unit/server/infra/analytics/{noop-analytics-sender.test.ts => draining-analytics-sender.test.ts} (74%) diff --git a/src/server/config/environment.ts b/src/server/config/environment.ts index 96b2aba83..e3c950e8d 100644 --- a/src/server/config/environment.ts +++ b/src/server/config/environment.ts @@ -33,7 +33,7 @@ type EnvironmentConfig = { * malformed. There is NO code-side fallback to a shared default; see * `resolveAnalyticsBaseUrl` below for the rationale. Consumers * downstream MUST handle the `undefined` case - * (`wireAnalyticsHttpSender` swaps in `NoopAnalyticsSender`; the + * (`wireAnalyticsHttpSender` swaps in `DrainingAnalyticsSender`; the * status snapshot coalesces to `''` and surfaces `(not configured)`). */ analyticsBaseUrl: string | undefined diff --git a/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts b/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts index d86afb24b..c7fb6b394 100644 --- a/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts +++ b/src/server/core/interfaces/analytics/i-analytics-backoff-policy.ts @@ -18,6 +18,18 @@ * - 3+ failures → unreachable */ export interface IAnalyticsBackoffPolicy { + /** + * M5.4 (ENG-2658): honor a server-supplied retry delay (HTTP 429 + * `Retry-After`, or a 503 from the nginx edge backstop). Overrides the + * next effective delay with `max(retryAfterMs, scheduled delay)` so a + * misbehaving server can never pull retries below the safe minimum, and + * marks the policy rate-limited (see `isRateLimited()`). Does NOT advance + * `consecutiveFailures()` — a throttled endpoint is reachable, not failing, + * so it must never tip the daemon into the "unreachable" reachability band. + * Cleared by the next `onSuccess()` or `onFailure()`. + */ + applyServerHint(retryAfterMs: number): void + /** * Number of failures since the last `onSuccess()`. Unbounded — used * by M4.6 to classify reachability beyond the backoff cap (a daemon @@ -26,6 +38,14 @@ export interface IAnalyticsBackoffPolicy { */ consecutiveFailures(): number + /** + * M5.4 (ENG-2658): true while the last flush outcome was a server-driven + * rate-limit (429/503) and no success/failure has cleared it since. The + * M4.6 status snapshot maps this to a distinct `rate_limited` reachability + * state so on-call can tell throttling apart from an unreachable backend. + */ + isRateLimited(): boolean + /** * Effective next-tick delay in milliseconds. Reading this method is * pure: it does NOT advance the schedule. Callers should treat the diff --git a/src/server/core/interfaces/analytics/i-analytics-http-client.ts b/src/server/core/interfaces/analytics/i-analytics-http-client.ts index bdc927b03..bac48f92a 100644 --- a/src/server/core/interfaces/analytics/i-analytics-http-client.ts +++ b/src/server/core/interfaces/analytics/i-analytics-http-client.ts @@ -24,13 +24,17 @@ export type AnalyticsHttpHeaders = Readonly<{ * arbitrary error objects. * * Reasons: - * - `timeout` — request exceeded the 5 second budget. - * - `http_4xx` — backend rejected the payload (validation, auth, etc). - * - `http_5xx` — backend error; eligible for backoff retry. - * - `network` — connection refused / DNS / TLS / abort before response. + * - `timeout` — request exceeded the 5 second budget. + * - `http_4xx` — backend rejected the payload (validation, auth, etc). + * - `http_5xx` — backend error (non-503); eligible for backoff retry. + * - `network` — connection refused / DNS / TLS / abort before response. + * - `rate_limited` — M5.4 (ENG-2658): the app throttler returned 429, or the + * nginx edge backstop returned 503. `retryAfterMs` carries the delay the + * caller must honor (server `Retry-After` header, then a `retry_after_seconds` + * body field, then a 60s default when the server supplies neither). * - * `status` is populated only for `http_4xx` / `http_5xx` paths so the - * caller can log the exact code. + * `status` is populated for the HTTP-status paths so the caller can log the + * exact code. `retryAfterMs` is present only on the `rate_limited` variant. */ export type AnalyticsHttpSendResult = | Readonly<{ @@ -38,6 +42,12 @@ export type AnalyticsHttpSendResult = reason: 'http_4xx' | 'http_5xx' | 'network' | 'timeout' status?: number }> + | Readonly<{ + ok: false + reason: 'rate_limited' + retryAfterMs: number + status?: number + }> | Readonly<{ok: true}> /** diff --git a/src/server/core/interfaces/analytics/i-analytics-sender.ts b/src/server/core/interfaces/analytics/i-analytics-sender.ts index fbf6ffae8..97500d3f9 100644 --- a/src/server/core/interfaces/analytics/i-analytics-sender.ts +++ b/src/server/core/interfaces/analytics/i-analytics-sender.ts @@ -9,8 +9,12 @@ import type {StoredAnalyticsRecord} from '../../../../shared/analytics/stored-re * - `http_5xx` - server error. Transient → back off. * - `http_4xx` - backend rejected the payload shape. NOT transient — the * caller MUST NOT advance backoff on 4xx; retrying won't help. + * - `rate_limited` - M5.4 (ENG-2658): server throttle (429) or nginx edge + * backstop (503). The caller honors the paired `retryAfterMs` via + * `backoffPolicy.applyServerHint` and MUST NOT advance the failure counter + * (a throttled endpoint is reachable, not failing). */ -export type SendFailureReason = 'http_4xx' | 'http_5xx' | 'network' | 'timeout' +export type SendFailureReason = 'http_4xx' | 'http_5xx' | 'network' | 'rate_limited' | 'timeout' /** * Per-send outcome. Each input record's `id` is mirrored back in exactly @@ -28,6 +32,12 @@ export type SendResult = Readonly<{ * (no-op senders, tests) may continue to ignore this field. */ reason?: SendFailureReason + /** + * M5.4 (ENG-2658): server-supplied retry delay in milliseconds. Present + * only alongside `reason: 'rate_limited'`; the caller feeds it to + * `backoffPolicy.applyServerHint`. + */ + retryAfterMs?: number succeeded: string[] }> diff --git a/src/server/infra/analytics/analytics-backoff-policy.ts b/src/server/infra/analytics/analytics-backoff-policy.ts index d14221eaa..425374ac0 100644 --- a/src/server/infra/analytics/analytics-backoff-policy.ts +++ b/src/server/infra/analytics/analytics-backoff-policy.ts @@ -4,6 +4,15 @@ import type {IAnalyticsBackoffPolicy} from '../../core/interfaces/analytics/i-an // Index = consecutiveFailures clamped to [0, length - 1]. const BACKOFF_STEPS_MS: readonly number[] = [30_000, 60_000, 120_000, 300_000] +// M5.4 (ENG-2658): upper bound on an honored server Retry-After. A hint above +// this is clamped down. Rationale: (1) well under Node's setTimeout ceiling +// (2^31-1 ms ≈ 24.8 days), past which a delay silently overflows and fires +// immediately — which would IGNORE the rate-limit; (2) a throttle window longer +// than an hour is unreasonable for opt-in telemetry and would otherwise stall +// shipping for days. The lower bound is handled separately by `nextDelayMs`'s +// max() with the schedule. +const MAX_SERVER_HINT_MS = 3_600_000 // 1 hour + /** * In-memory exponential-backoff policy. See `IAnalyticsBackoffPolicy` * for the contract. @@ -18,21 +27,52 @@ const BACKOFF_STEPS_MS: readonly number[] = [30_000, 60_000, 120_000, 300_000] */ export class AnalyticsBackoffPolicy implements IAnalyticsBackoffPolicy { private failures = 0 + // M5.4: true while the last outcome was a server-driven rate-limit. Distinct + // from `failures` because a throttled endpoint is reachable, not failing. + private rateLimited = false + // M5.4 (ENG-2658): one-shot server-supplied delay from a 429 `Retry-After` + // or a 503 edge backstop. `undefined` = no active hint. Cleared on the next + // success or transient failure so it never outlives the rate-limit window. + private serverHintMs: number | undefined = undefined + + public applyServerHint(retryAfterMs: number): void { + // Ignore a non-positive / NaN hint for the delay floor (a bad server value + // must not shorten the wait), and clamp an absurdly large one to the safe + // maximum (a bad value must not overflow setTimeout or stall shipping for + // days). Either way, record that we were rate-limited. + if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) { + this.serverHintMs = Math.min(retryAfterMs, MAX_SERVER_HINT_MS) + } + + this.rateLimited = true + } public consecutiveFailures(): number { return this.failures } + public isRateLimited(): boolean { + return this.rateLimited + } + public nextDelayMs(): number { const index = Math.min(this.failures, BACKOFF_STEPS_MS.length - 1) - return BACKOFF_STEPS_MS[index] + // Take the larger of the scheduled delay and any server hint so a server + // can stretch the wait but never accelerate it below the safe minimum. + return Math.max(BACKOFF_STEPS_MS[index], this.serverHintMs ?? 0) } public onFailure(): void { this.failures += 1 + // A genuine transient failure supersedes any prior rate-limit hint: resume + // the pure exponential schedule rather than honoring a stale 429 delay. + this.serverHintMs = undefined + this.rateLimited = false } public onSuccess(): void { this.failures = 0 + this.serverHintMs = undefined + this.rateLimited = false } } diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index ea3437327..dcdc9be42 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -261,10 +261,12 @@ export class AnalyticsClient implements IAnalyticsClient { * Decision table (skip = call neither onSuccess nor onFailure): * - policy not wired → skip * - aborted (M4.4 disable cancel) → skip (user action, not a backend signal) - * - reason = `http_4xx` → skip (payload-shape, not a health signal) + * - reason = `http_4xx` → skip (payload-shape, not a + * health signal; e.g. HttpAnalyticsSender's `missing-deviceId` path, + * which classifies as `http_4xx` rather than shipping) * - reason undefined AND succeeded.length === 0 → skip (empty no-op - * race, or HttpAnalyticsSender's `missing-deviceId` path that - * returns failed-without-reason; neither is a clean ship) + * race, or an uncategorized failed-without-reason result; no health + * signal either way) * - reason undefined AND succeeded.length > 0 → onSuccess() + M4.6 timestamp stamp * - reason = `timeout` / `network` / `http_5xx` → onFailure() * @@ -287,6 +289,36 @@ export class AnalyticsClient implements IAnalyticsClient { return } + if (result.reason === 'rate_limited') { + // M5.4 (ENG-2658): a 429 (app throttler) or 503 (nginx edge backstop) is + // a "slow down", not a backend failure. Honor the server's delay via + // `applyServerHint` (the scheduler re-arms from `nextDelayMs()` after this + // flush settles) and DO NOT advance the failure counter, so a throttled + // endpoint never tips the reachability band into "unreachable". + if (policy === undefined) return + if (result.retryAfterMs === undefined) { + // The sender contract pairs `retryAfterMs` with every `rate_limited` + // result. If a future producer breaks that, surface it in the log AND + // still flip the policy's rate-limited bit — via a non-finite sentinel, + // so no delay floor is set but `isRateLimited()` turns true. That keeps + // the scheduler's burst gate closed so the next 20-event burst doesn't + // hammer a server we were just told to back off from; the M4.5 schedule + // still drives the next-tick delay. + this.deps.log?.( + 'analytics.backoff: rate_limited result missing retryAfterMs hint — falling back to the schedule, burst suppressed', + ) + policy.applyServerHint(Number.NaN) + return + } + + policy.applyServerHint(result.retryAfterMs) + this.deps.log?.( + `analytics.backoff: rate_limited, honoring server hint retry_after=${result.retryAfterMs}ms ` + + `(next=${policy.nextDelayMs()}ms, consecutive_failures=${policy.consecutiveFailures()})`, + ) + return + } + if (result.reason === undefined) { if (result.succeeded.length === 0) return // empty no-op or uncategorized failure: no signal // M4.6: stamp the timestamp on the same gate as the backoff diff --git a/src/server/infra/analytics/analytics-flush-scheduler.ts b/src/server/infra/analytics/analytics-flush-scheduler.ts index b6c138a42..1237a7f41 100644 --- a/src/server/infra/analytics/analytics-flush-scheduler.ts +++ b/src/server/infra/analytics/analytics-flush-scheduler.ts @@ -14,6 +14,16 @@ export interface AnalyticsFlushSchedulerDeps { * without restarting the daemon. */ isEnabled: () => boolean + /** + * M5.4 (ENG-2658): live "is the backend currently rate-limiting us?" gate, + * wired to `AnalyticsBackoffPolicy.isRateLimited()`. When true, the + * threshold (burst) trigger is suppressed so a 20-event burst cannot hammer a + * backend that returned 429/503 and asked us to wait — the periodic tick, + * already stretched to the server's `Retry-After` via `nextIntervalMs`, ships + * the backlog once the window elapses. Defaults to never-rate-limited so the + * periodic-tick path and existing callers/tests are unaffected. + */ + isRateLimited?: () => boolean /** * M4.5: live next-tick delay in milliseconds. Read AFTER each tick * settles, when the scheduler arms its `setTimeout` for the next @@ -73,9 +83,13 @@ export type FlushFinalOptions = { * invoke `notifyPushed()` after enqueuing a record; if the queue * has grown by `thresholdCount` since the last threshold fire, a * flush is scheduled via `setImmediate` so `track()` stays - * synchronous from the consumer's view. The threshold path is - * intentionally NOT throttled by backoff — single-flight rate-limits - * it, and gating the 20-event burst would defeat its purpose. + * synchronous from the consumer's view. The threshold path is NOT + * throttled by the M4.5 *backoff schedule* (failures) — single-flight + * rate-limits it, and gating the 20-event burst on transient failures + * would defeat its batching purpose. It IS suppressed, however, while + * an explicit server *rate-limit* is active (M5.4 / ENG-2658 + * `isRateLimited`): a 429/503 means "stop sending", so the burst path + * stands down and the stretched periodic tick ships the backlog. * * Single-flight: while a flush is in flight, any new trigger is dropped * (NOT queued). The in-flight promise is exposed via `flushFinal()` so @@ -122,6 +136,7 @@ export class AnalyticsFlushScheduler { this.deps = { flush: deps.flush, isEnabled: deps.isEnabled, + isRateLimited: deps.isRateLimited ?? (() => false), nextIntervalMs: deps.nextIntervalMs ?? (() => DEFAULT_INTERVAL_MS), pendingCount: deps.pendingCount, queueSize: deps.queueSize, @@ -186,6 +201,13 @@ export class AnalyticsFlushScheduler { */ public notifyPushed(): void { if (!this.deps.isEnabled()) return + // M5.4 (ENG-2658): while the backend is rate-limiting us (429/503), suppress + // the burst trigger so a 20-event burst cannot hammer a backend that asked + // us to wait. The periodic tick — already stretched to the server's + // Retry-After via `nextIntervalMs` — ships the backlog once the window + // elapses. The threshold baseline is intentionally left untouched here, so + // once the rate-limit clears the next push can still trigger a flush. + if (this.deps.isRateLimited()) return const size = this.deps.queueSize() if (size < this.lastTriggerQueueSize) this.lastTriggerQueueSize = 0 if (size - this.lastTriggerQueueSize < this.deps.thresholdCount) return diff --git a/src/server/infra/analytics/axios-analytics-http-client.ts b/src/server/infra/analytics/axios-analytics-http-client.ts index 85045ed7a..8a75b96aa 100644 --- a/src/server/infra/analytics/axios-analytics-http-client.ts +++ b/src/server/infra/analytics/axios-analytics-http-client.ts @@ -10,11 +10,21 @@ import type { IAnalyticsHttpClient, } from '../../core/interfaces/analytics/i-analytics-http-client.js' +import {processLog} from '../../utils/process-logger.js' + const DEFAULT_TIMEOUT_MS = 5000 const EVENTS_PATH = '/v1/events' +// M5.4 (ENG-2658): delay applied when a 429 carries no `Retry-After` hint, or +// when the nginx edge backstop trips with a bare 503 (which never carries one). +const DEFAULT_RETRY_AFTER_MS = 60_000 type AxiosAnalyticsHttpClientOptions = { baseUrl: string + /** + * Sink for operational WARN lines (M5.4 default-backoff fallback). Defaults + * to the daemon `processLog`; tests inject a spy to assert the WARN fired. + */ + log?: (message: string) => void /** Override request timeout (default 5000ms). Test-only escape hatch. */ timeoutMs?: number } @@ -38,8 +48,10 @@ type AxiosAnalyticsHttpClientOptions = { */ export class AxiosAnalyticsHttpClient implements IAnalyticsHttpClient { private readonly axios: AxiosInstance + private readonly log: (message: string) => void public constructor(options: AxiosAnalyticsHttpClientOptions) { + this.log = options.log ?? processLog this.axios = axios.create({ baseURL: options.baseUrl.replace(/\/+$/, ''), // `validateStatus` returning true delegates HTTP-status classification @@ -65,9 +77,9 @@ export class AxiosAnalyticsHttpClient implements IAnalyticsHttpClient { // (client-side termination, not a server-side condition). ...(options?.signal === undefined ? {} : {signal: options.signal}), }) - return classifyResponse(response) + return classifyResponse(response, this.log) } catch (error: unknown) { - return classifyError(error) + return classifyError(error, this.log) } } @@ -85,24 +97,83 @@ export class AxiosAnalyticsHttpClient implements IAnalyticsHttpClient { } } -const classifyResponse = (response: AxiosResponse): AnalyticsHttpSendResult => { +const classifyResponse = (response: AxiosResponse, log: (message: string) => void): AnalyticsHttpSendResult => { const {status} = response if (status >= 200 && status < 300) return {ok: true} + // M5.4 (ENG-2658): the app throttler (@nestjs/throttler) returns 429 with a + // server-supplied `Retry-After`. Honor it (header, then `retry_after_seconds` + // body, then default) rather than treating it as a payload-shape 4xx. + if (status === 429) return classifyRateLimited(response, status, log) if (status >= 400 && status < 500) return {ok: false, reason: 'http_4xx', status} + // M5.4: a bare 503 is typically the nginx edge backstop tripping (usually no + // `Retry-After`). Route it through the same rate-limit path as 429 so a 503 + // that DOES carry a server hint (maintenance page, alternate ingress, CDN) is + // honored rather than forced to the default — otherwise default delay + WARN. + // NOT an unreachable backend (the endpoint is up, we're being shed). Other + // 5xx stay `http_5xx` (genuine transient errors that drive exponential backoff). + if (status === 503) return classifyRateLimited(response, status, log) + if (status >= 500 && status < 600) return {ok: false, reason: 'http_5xx', status} // 1xx / 3xx without redirect handling reach here. Treat as network-level // anomaly so callers see a tagged result rather than silently succeeding. return {ok: false, reason: 'network'} } -const classifyError = (error: unknown): AnalyticsHttpSendResult => { +const classifyRateLimited = ( + response: AxiosResponse, + status: number, + log: (message: string) => void, +): AnalyticsHttpSendResult => { + const fromHeader = parseRetryAfterHeaderMs(response.headers) + if (fromHeader !== undefined) return {ok: false, reason: 'rate_limited', retryAfterMs: fromHeader, status} + const fromBody = parseRetryAfterBodyMs(response.data) + if (fromBody !== undefined) return {ok: false, reason: 'rate_limited', retryAfterMs: fromBody, status} + log( + `analytics.http: ${status} without a usable Retry-After header or retry_after_seconds body, ` + + `applying default ${DEFAULT_RETRY_AFTER_MS}ms backoff`, + ) + return {ok: false, reason: 'rate_limited', retryAfterMs: DEFAULT_RETRY_AFTER_MS, status} +} + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +/** Parse a `Retry-After` header (RFC 7231) — delay-seconds OR HTTP-date — to milliseconds. */ +const parseRetryAfterHeaderMs = (headers: unknown): number | undefined => { + if (!isObject(headers)) return undefined + // axios lowercases response header keys. + const raw = headers['retry-after'] + if (typeof raw !== 'string' && typeof raw !== 'number') return undefined + // Preferred form: delay-seconds. + const asSeconds = Number(raw) + if (Number.isFinite(asSeconds) && asSeconds > 0) return Math.round(asSeconds * 1000) + // Alternate form: an HTTP-date — convert to a forward-looking delay. A date in + // the past (or an unparseable value) yields no usable hint. An absurdly + // far-future date is bounded downstream by the policy's MAX_SERVER_HINT_MS + // cap, so there is no setTimeout-overflow risk here. + const targetMs = Date.parse(String(raw)) + if (!Number.isFinite(targetMs)) return undefined + const deltaMs = targetMs - Date.now() + return deltaMs > 0 ? deltaMs : undefined +} + +/** Parse a `retry_after_seconds` JSON body field (the throttler's fallback). */ +const parseRetryAfterBodyMs = (data: unknown): number | undefined => { + if (!isObject(data)) return undefined + const seconds = data.retry_after_seconds + return typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0 + ? Math.round(seconds * 1000) + : undefined +} + +const classifyError = (error: unknown, log: (message: string) => void): AnalyticsHttpSendResult => { if (axios.isAxiosError(error)) { // Timeout: axios surfaces this as `ECONNABORTED` with `code === 'ECONNABORTED'`, // or `ETIMEDOUT` on socket-level timeouts. if (isTimeoutCode(error)) return {ok: false, reason: 'timeout'} // Response present but classifyResponse didn't run (shouldn't happen given // `validateStatus: () => true`, but defensively re-classify here). - if (error.response !== undefined) return classifyResponse(error.response) + if (error.response !== undefined) return classifyResponse(error.response, log) return {ok: false, reason: 'network'} } diff --git a/src/server/infra/analytics/build-status-snapshot.ts b/src/server/infra/analytics/build-status-snapshot.ts index f47a75c06..bf1da48a7 100644 --- a/src/server/infra/analytics/build-status-snapshot.ts +++ b/src/server/infra/analytics/build-status-snapshot.ts @@ -17,7 +17,7 @@ import type {IAnalyticsClient} from '../../core/interfaces/analytics/i-analytics * (the most optimistic label) rather than throw, so a malformed counter * never breaks the status command's hot path. */ -export type ReachabilityState = 'degraded' | 'healthy' | 'unreachable' +export type ReachabilityState = 'degraded' | 'healthy' | 'rate_limited' | 'unreachable' export function consecutiveFailuresToReachabilityState(consecutiveFailures: number): ReachabilityState { if (!Number.isFinite(consecutiveFailures) || consecutiveFailures < 1) return 'healthy' @@ -65,8 +65,13 @@ export async function buildAnalyticsStatusSnapshot( // M4.6 override: when no endpoint is configured the daemon has // nothing to be "healthy" against — surface unreachable so the user // doesn't see a misleading "healthy" label paired with "(not configured)". + // M5.4 (ENG-2658): a server-driven rate-limit (429 / 503 edge backstop) is + // a distinct state — the backend is reachable but throttling us — and takes + // precedence over the failure-count band (rate-limits never bump that count). const state: ReachabilityState = endpointConfigured - ? consecutiveFailuresToReachabilityState(consecutiveFailures) + ? deps.backoffPolicy.isRateLimited() + ? 'rate_limited' + : consecutiveFailuresToReachabilityState(consecutiveFailures) : 'unreachable' return { diff --git a/src/server/infra/analytics/draining-analytics-sender.ts b/src/server/infra/analytics/draining-analytics-sender.ts new file mode 100644 index 000000000..3fc2b37d0 --- /dev/null +++ b/src/server/infra/analytics/draining-analytics-sender.ts @@ -0,0 +1,37 @@ +import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' +import type { + AnalyticsSenderOptions, + IAnalyticsSender, + SendResult, +} from '../../core/interfaces/analytics/i-analytics-sender.js' + +/** + * Draining sender: reports every input record as `succeeded` without any + * network I/O, so the flush wiring transitions the matching JSONL rows to + * `status='sent'` and the pending count stays at 0. + * + * `wireAnalyticsHttpSender` swaps this in when `BRV_ANALYTICS_BASE_URL` + * resolves to `undefined` (absent, empty after trim, or malformed). No HTTP + * client is constructed; the axios layer is never touched, so a misconfigured + * build never burns retries or leaks events into the upstream backend. This + * optimizes for the "never ship" case (open-source forks, CI, air-gapped + * installs). + * + * Contrast with the test-seam `NoOpAnalyticsSender` (no-op-analytics-sender.ts), + * which returns BOTH arrays empty so the JSONL rows stay `pending` — used by + * tests to assert the "leave-JSONL-untouched" invariant; never wired in + * production. The behavioural name `Draining` is deliberately distinct from + * the test seam's `NoOp` so the production wiring can never grab the wrong + * sender (the prior `Noop`/`NoOp` pair differed by a single letter). + */ +export class DrainingAnalyticsSender implements IAnalyticsSender { + public async send( + records: readonly StoredAnalyticsRecord[], + _options?: AnalyticsSenderOptions, + ): Promise { + // `_options.signal` intentionally ignored: there is no transport to + // cancel. Accepting the parameter keeps structural assignability to + // `IAnalyticsSender` clean. + return {failed: [], succeeded: records.map((record) => record.id)} + } +} diff --git a/src/server/infra/analytics/http-analytics-sender.ts b/src/server/infra/analytics/http-analytics-sender.ts index 1070f8c87..b482f1f64 100644 --- a/src/server/infra/analytics/http-analytics-sender.ts +++ b/src/server/infra/analytics/http-analytics-sender.ts @@ -61,11 +61,14 @@ export class HttpAnalyticsSender implements IAnalyticsSender { const config = await this.deps.globalConfigStore.read() const deviceId = config?.deviceId if (deviceId === undefined || deviceId === '') { - // Backend requires `x-byterover-device-id` on every batch. - // Without it the request would be rejected with 400; ship the - // records as failed so the retry-cap policy bumps attempts and - // eventually terminates them rather than looping forever. - return {failed: [...ids], succeeded: []} + // Backend requires `x-byterover-device-id` on every batch. Without + // it the request would be 400-rejected, so classify the failure as + // `http_4xx` (a payload-shape problem, not a transient backend + // signal). The M4.5 backoff policy then suppresses advancement + // rather than churning on this daemon-side misconfig, while the + // retry-cap still bumps attempts and eventually terminates the rows + // — same terminal classification any other failure reason gets. + return {failed: [...ids], reason: 'http_4xx', succeeded: []} } const sessionKey = this.deps.authStateReader.getToken()?.sessionKey @@ -85,6 +88,13 @@ export class HttpAnalyticsSender implements IAnalyticsSender { ) if (httpResult.ok) return {failed: [], succeeded: [...ids]} + // M5.4 (ENG-2658): `rate_limited` (429 / 503 edge backstop) carries the + // server's retry delay; forward it so `AnalyticsClient` can honor it via + // `backoffPolicy.applyServerHint` instead of advancing the failure count. + if (httpResult.reason === 'rate_limited') { + return {failed: [...ids], reason: 'rate_limited', retryAfterMs: httpResult.retryAfterMs, succeeded: []} + } + // M4.5: surface the http-level failure reason so AnalyticsClient // can feed it into the backoff policy. `http_4xx` is intentionally // forwarded as-is so the caller can suppress backoff advancement diff --git a/src/server/infra/analytics/noop-analytics-sender.ts b/src/server/infra/analytics/noop-analytics-sender.ts deleted file mode 100644 index 9eaca9af6..000000000 --- a/src/server/infra/analytics/noop-analytics-sender.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type {StoredAnalyticsRecord} from '../../../shared/analytics/stored-record.js' -import type { - AnalyticsSenderOptions, - IAnalyticsSender, - SendResult, -} from '../../core/interfaces/analytics/i-analytics-sender.js' - -/** - * Graceful-degradation sender. `wireAnalyticsHttpSender` swaps this in - * when `BRV_ANALYTICS_BASE_URL` resolves to `undefined` (absent, empty - * after trim, or malformed). No HTTP client is constructed; the axios - * layer is never touched, so a misconfigured build never burns retries - * or leaks events into the upstream backend. - * - * Every input record is reported as `succeeded` so the flush wiring - * transitions the matching JSONL rows to `status='sent'` and the - * pending count stays at 0. This drains the queue and optimizes for the - * "never ship" case (open-source forks, CI, air-gapped installs). - * - * Contrast with the test-seam `NoOpAnalyticsSender` at - * `no-op-analytics-sender.ts`: - * - `NoOpAnalyticsSender` - both arrays empty, JSONL stays pending. - * Used by tests to assert the - * "leave-JSONL-untouched" invariant; NOT - * wired in production. - * - `NoopAnalyticsSender` - this class. Marks all-succeeded, JSONL - * drains. Wired in production whenever - * the env var is absent or unusable. - */ -export class NoopAnalyticsSender implements IAnalyticsSender { - public async send( - records: readonly StoredAnalyticsRecord[], - _options?: AnalyticsSenderOptions, - ): Promise { - // `_options.signal` intentionally ignored: there is no transport to - // cancel. Accepting the parameter keeps structural assignability to - // `IAnalyticsSender` clean. - return {failed: [], succeeded: records.map((record) => record.id)} - } -} diff --git a/src/server/infra/process/analytics-hook.ts b/src/server/infra/process/analytics-hook.ts index 91e539ced..9d0c036ea 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -1,6 +1,6 @@ /* eslint-disable camelcase */ import {readFile as readFileAsync} from 'node:fs/promises' -import {basename, isAbsolute as isAbsolutePath, relative as relativePath} from 'node:path' +import {isAbsolute as isAbsolutePath, relative as relativePath} from 'node:path' import type {AnalyticsEventName} from '../../../shared/analytics/event-names.js' import type {CurateRunCompletedProps} from '../../../shared/analytics/events/curate-run-completed.js' @@ -79,13 +79,17 @@ const OUTSIDE_PROJECT_PATH = '' * * PR #722 review: `path.relative('/proj', '/Users/dev/other/x.md')` yields * `'../../Users/dev/other/x.md'` — still encodes the host layout. When the - * relative path escapes the project root (or projectPath is unset), surface - * a stable sentinel + basename rather than the raw absolute path. The - * sentinel preserves enough signal for backend grouping without becoming - * PII. + * relative path escapes the project root (or projectPath is unset), collapse + * to a bare sentinel rather than the raw absolute path. + * + * PR #726 review: the basename of an out-of-project file is itself PII (e.g. + * `passwords.md`, `client-acme.md`, `interview-john-doe.md`) and carries + * little analytical value — the file is outside the project being measured. + * So the leaf token is dropped too: only the fact (and count) of an + * outside-project read survives the wire, never its identity. */ function toRelativePath(filePath: string, projectPath?: string): string { - if (!projectPath) return `${OUTSIDE_PROJECT_PATH}/${basename(filePath)}` + if (!projectPath) return OUTSIDE_PROJECT_PATH const rel = relativePath(projectPath, filePath) // `path.relative` returns '' when paths are identical — defensively // surface a leaf token rather than emit a zero-length wire string that @@ -94,7 +98,7 @@ function toRelativePath(filePath: string, projectPath?: string): string { // Anything that escapes the project root (`../foo`) or stays absolute // (Windows drive letter switches) is treated as outside-project. if (rel.startsWith('..') || isAbsolutePath(rel)) { - return `${OUTSIDE_PROJECT_PATH}/${basename(filePath)}` + return OUTSIDE_PROJECT_PATH } return rel diff --git a/src/server/infra/process/wire-analytics-flush-scheduler.ts b/src/server/infra/process/wire-analytics-flush-scheduler.ts index faad29425..2f48a35ca 100644 --- a/src/server/infra/process/wire-analytics-flush-scheduler.ts +++ b/src/server/infra/process/wire-analytics-flush-scheduler.ts @@ -73,6 +73,11 @@ export function wireAnalyticsFlushScheduler( return new AnalyticsFlushScheduler({ flush: () => wiring.analyticsClient.flush(), isEnabled: wiring.isEnabled, + // M5.4 (ENG-2658): wire the burst-trigger rate-limit gate to the policy so a + // 429/503 suppresses threshold flushes (the stretched periodic tick ships + // the backlog). Omitted when there's no policy; the scheduler then defaults + // to never-rate-limited. + ...(policy === undefined ? {} : {isRateLimited: (): boolean => policy.isRateLimited()}), ...(nextIntervalMs === undefined ? {} : {nextIntervalMs}), pendingCount: async () => (await wiring.jsonlStore.loadPending()).length, queueSize: () => wiring.queue.size(), diff --git a/src/server/infra/process/wire-analytics-http-sender.ts b/src/server/infra/process/wire-analytics-http-sender.ts index 1191faf97..bab1ebda0 100644 --- a/src/server/infra/process/wire-analytics-http-sender.ts +++ b/src/server/infra/process/wire-analytics-http-sender.ts @@ -3,15 +3,15 @@ import type {IAuthStateReader} from '../../core/interfaces/analytics/i-identity- import type {IGlobalConfigStore} from '../../core/interfaces/storage/i-global-config-store.js' import {AxiosAnalyticsHttpClient} from '../analytics/axios-analytics-http-client.js' +import {DrainingAnalyticsSender} from '../analytics/draining-analytics-sender.js' import {HttpAnalyticsSender} from '../analytics/http-analytics-sender.js' -import {NoopAnalyticsSender} from '../analytics/noop-analytics-sender.js' export type AnalyticsHttpSenderWiring = { /** * Resolved `BRV_ANALYTICS_BASE_URL`. `undefined` signals "no working * remote endpoint" (env unset, empty, or malformed — see * `resolveAnalyticsBaseUrl`). The factory then returns a - * `NoopAnalyticsSender` and the axios client is never constructed. + * `DrainingAnalyticsSender` and the axios client is never constructed. */ analyticsBaseUrl: string | undefined authStateReader: IAuthStateReader @@ -38,16 +38,16 @@ export type AnalyticsHttpSenderWiring = { * for M4.5 backoff) lands at one obvious seam. * * When `wiring.analyticsBaseUrl === undefined` (env unset, empty, or - * malformed) the factory short-circuits to `NoopAnalyticsSender` so the + * malformed) the factory short-circuits to `DrainingAnalyticsSender` so the * axios client is never constructed and no outbound HTTP fires. Local * JSONL tracking via `AnalyticsClient.track()` keeps working unchanged; - * the noop drains the pending queue on each flush. + * the draining sender drains the pending queue on each flush. * * The returned value is the `IAnalyticsSender` consumed by * `AnalyticsClient.flush()`. */ export function wireAnalyticsHttpSender(wiring: AnalyticsHttpSenderWiring): IAnalyticsSender { - if (wiring.analyticsBaseUrl === undefined) return new NoopAnalyticsSender() + if (wiring.analyticsBaseUrl === undefined) return new DrainingAnalyticsSender() const httpClient = new AxiosAnalyticsHttpClient({baseUrl: wiring.analyticsBaseUrl}) return new HttpAnalyticsSender({ diff --git a/src/shared/transport/events/analytics-events.ts b/src/shared/transport/events/analytics-events.ts index d67116e14..141e6c413 100644 --- a/src/shared/transport/events/analytics-events.ts +++ b/src/shared/transport/events/analytics-events.ts @@ -22,12 +22,16 @@ export const AnalyticsEvents = { * `endpoint` is the resolved `BRV_ANALYTICS_BASE_URL` or the literal * `"(not configured)"` placeholder; when not configured, `backoff.state` * is forced to `"unreachable"` regardless of `consecutiveFailures`. + * + * `state: 'rate_limited'` (M5.4 / ENG-2658) is distinct from `unreachable`: + * the backend is up but throttling us (429 / 503 edge backstop), so on-call + * should wait out the server-supplied delay rather than chase an outage. */ export const AnalyticsStatusResponseSchema = z.object({ backoff: z.object({ consecutiveFailures: z.number().int().min(0), nextDelayMs: z.number().int().min(0), - state: z.enum(['healthy', 'degraded', 'unreachable']), + state: z.enum(['healthy', 'degraded', 'rate_limited', 'unreachable']), }), droppedCount: z.number().int().min(0), enabled: z.boolean(), diff --git a/test/integration/server/infra/process/wire-analytics-http-sender.test.ts b/test/integration/server/infra/process/wire-analytics-http-sender.test.ts index d8ef5be8c..ee29ec338 100644 --- a/test/integration/server/infra/process/wire-analytics-http-sender.test.ts +++ b/test/integration/server/infra/process/wire-analytics-http-sender.test.ts @@ -10,7 +10,7 @@ import type {IGlobalConfigStore} from '../../../../../src/server/core/interfaces import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' import {GlobalConfig} from '../../../../../src/server/core/domain/entities/global-config.js' -import {NoopAnalyticsSender} from '../../../../../src/server/infra/analytics/noop-analytics-sender.js' +import {DrainingAnalyticsSender} from '../../../../../src/server/infra/analytics/draining-analytics-sender.js' import {wireAnalyticsHttpSender} from '../../../../../src/server/infra/process/wire-analytics-http-sender.js' /** @@ -126,7 +126,10 @@ describe('M4.2 wireAnalyticsHttpSender (integration)', () => { }) it('returns failed=ids when the backend returns 5xx (sender swap surface preserved)', async () => { - nock(baseUrl).post('/v1/events').reply(503, {}) + // Use 500, not 503: M5.4 (ENG-2658) reclassifies a bare 503 as the nginx + // edge backstop (`rate_limited`), so a generic transient server error is + // exercised with 500. + nock(baseUrl).post('/v1/events').reply(500, {}) const sender = wireAnalyticsHttpSender({ analyticsBaseUrl: baseUrl, @@ -157,9 +160,10 @@ describe('M4.2 wireAnalyticsHttpSender (integration)', () => { expect(result).to.deep.equal({failed: [], succeeded: []}) }) - it('treats missing deviceId from config as a batch failure (no HTTP traffic)', async () => { - // Same disable-net-connect guard: empty record-set means HTTP must - // not fire, regardless of why. + it('treats missing deviceId from config as an http_4xx batch failure (no HTTP traffic)', async () => { + // Same disable-net-connect guard: a missing device id means HTTP must + // not fire. The failure is classified `http_4xx` (payload-shape) so the + // M4.5 backoff policy does not churn on the daemon-side misconfig. const emptyStore: IGlobalConfigStore = { read: stub().resolves(), write: stub().resolves(), @@ -173,10 +177,10 @@ describe('M4.2 wireAnalyticsHttpSender (integration)', () => { const result = await sender.send([makeRecord({id: 'r1'})]) - expect(result).to.deep.equal({failed: ['r1'], succeeded: []}) + expect(result).to.deep.equal({failed: ['r1'], reason: 'http_4xx', succeeded: []}) }) - it('returns a NoopAnalyticsSender when analyticsBaseUrl is undefined (no HTTP traffic, all ids drained)', async () => { + it('returns a DrainingAnalyticsSender when analyticsBaseUrl is undefined (no HTTP traffic, all ids drained)', async () => { // Strict: no nock scope registered. With `disableNetConnect`, any // axios construction that actually issues a request would throw. // We additionally assert the sender's class identity to lock-in @@ -188,7 +192,7 @@ describe('M4.2 wireAnalyticsHttpSender (integration)', () => { version: '3.12.0', }) - expect(sender).to.be.instanceOf(NoopAnalyticsSender) + expect(sender).to.be.instanceOf(DrainingAnalyticsSender) const result = await sender.send([makeRecord({id: 'r1'}), makeRecord({id: 'r2'})]) expect(result).to.deep.equal({failed: [], succeeded: ['r1', 'r2']}) diff --git a/test/unit/infra/transport/handlers/migrate-handler-analytics.test.ts b/test/unit/infra/transport/handlers/migrate-handler-analytics.test.ts index 4e2569525..c26a2101c 100644 --- a/test/unit/infra/transport/handlers/migrate-handler-analytics.test.ts +++ b/test/unit/infra/transport/handlers/migrate-handler-analytics.test.ts @@ -135,7 +135,13 @@ describe('MigrateHandler analytics emits', () => { if (props.mode !== 'forward') throw new Error(`expected forward, got ${props.mode}`) expect(props.outcome).to.equal('failure') expect(props.dry_run).to.equal(false) - expect(props.failure_kind).to.be.a('string').and.not.empty + // Pin the exact classification, not just "a non-empty string": the + // orchestrator throws `Migration already ran today; ...` here, which + // `classifyMigrateFailure` prefix-matches to `archive_exists`. If that + // sentinel message is ever reworded the classifier degrades to + // `unknown` — this assertion fails the test instead of silently + // dropping `archive_exists` from the warehouse. + expect(props.failure_kind).to.equal('archive_exists') }) }) @@ -181,7 +187,12 @@ describe('MigrateHandler analytics emits', () => { if (props.mode !== 'rollback') throw new Error(`expected rollback, got ${props.mode}`) expect(props.outcome).to.equal('failure') expect(props.dry_run).to.equal(true) - expect(props.failure_kind).to.be.a('string').and.not.empty + // Pin the exact classification: the orchestrator throws + // `No archive to roll back. ...` here, which `classifyMigrateFailure` + // prefix-matches to `no_archive`. Asserting the value (not just + // "non-empty string") turns a future sentinel-message reword into a + // failing test rather than a silent `unknown` in the warehouse. + expect(props.failure_kind).to.equal('no_archive') }) }) diff --git a/test/unit/server/infra/analytics/analytics-backoff-policy.test.ts b/test/unit/server/infra/analytics/analytics-backoff-policy.test.ts index bf124cd91..bfd2fb3da 100644 --- a/test/unit/server/infra/analytics/analytics-backoff-policy.test.ts +++ b/test/unit/server/infra/analytics/analytics-backoff-policy.test.ts @@ -127,4 +127,87 @@ describe('AnalyticsBackoffPolicy (M4.5)', () => { expect(policy.consecutiveFailures(), 'first success collapses any unreachable count').to.equal(0) }) }) + + describe('server-hint override (M5.4 honor Retry-After — ENG-2658)', () => { + it('applyServerHint overrides the base 30s delay with the larger server value', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(120_000) + expect(policy.nextDelayMs(), 'server asked for 120s, base is 30s -> 120s').to.equal(120_000) + }) + + it('clamps an absurdly large server hint to the 1h safe maximum (no setTimeout overflow / multi-day stall)', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(315_360_000_000) // 10 years — would overflow Node's setTimeout (> 2^31-1 ms) + expect( + policy.nextDelayMs(), + 'a hostile/buggy server cannot stall shipping for days nor overflow setTimeout', + ).to.equal(3_600_000) // capped at 1 hour + }) + + it('honors a server hint at the cap boundary verbatim', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(3_600_000) // exactly 1h — within the cap + expect(policy.nextDelayMs()).to.equal(3_600_000) + }) + + it('applyServerHint with a non-positive / NaN / Infinity hint still flips isRateLimited (no delay floor)', () => { + // Load-bearing for the rate_limited reachability classification AND for the + // contract-violation path in AnalyticsClient (which marks the policy + // rate-limited via applyServerHint(NaN) so the burst gate stays closed). + for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(bad) + expect(policy.isRateLimited(), `hint=${bad} must still surface rate-limited`).to.equal(true) + expect(policy.nextDelayMs(), `hint=${bad} must not change the schedule floor`).to.equal(30_000) + } + }) + + it('applyServerHint never accelerates below the current schedule delay', () => { + const policy = new AnalyticsBackoffPolicy() + policy.onFailure() // current schedule delay is now 60s + policy.applyServerHint(5000) + expect( + policy.nextDelayMs(), + 'a misbehaving server cannot pull retries under the safe minimum', + ).to.equal(60_000) + }) + + it('does NOT count a server hint as a consecutive failure (429/503 is not unreachable)', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(120_000) + expect(policy.consecutiveFailures(), 'rate-limit is not a reachability failure').to.equal(0) + }) + + it('isRateLimited() flips true on applyServerHint and is false from a clean state', () => { + const policy = new AnalyticsBackoffPolicy() + expect(policy.isRateLimited(), 'clean state is not rate-limited').to.equal(false) + policy.applyServerHint(30_000) + expect(policy.isRateLimited()).to.equal(true) + }) + + it('three consecutive server hints stay rate-limited with zero failures (never unreachable)', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(60_000) + policy.applyServerHint(60_000) + policy.applyServerHint(60_000) + expect(policy.consecutiveFailures(), 'repeated 429s do not bump the unreachable counter').to.equal(0) + expect(policy.isRateLimited()).to.equal(true) + }) + + it('onSuccess clears the server hint and the rate-limited flag', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(300_000) + policy.onSuccess() + expect(policy.nextDelayMs(), 'success drops back to the base interval').to.equal(30_000) + expect(policy.isRateLimited()).to.equal(false) + }) + + it('a real transient failure supersedes the server hint and resumes the exponential schedule', () => { + const policy = new AnalyticsBackoffPolicy() + policy.applyServerHint(300_000) + policy.onFailure() + expect(policy.isRateLimited(), 'a 5xx after a 429 is no longer a rate-limit').to.equal(false) + expect(policy.nextDelayMs(), 'pure exponential after the failure (1 failure -> 60s)').to.equal(60_000) + }) + }) }) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index b75036b8c..8e56b38a6 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -168,14 +168,22 @@ async function seedPending(client: AnalyticsClient, count: number): Promise r.id)} } - return {failed: records.map((r) => r.id), reason, succeeded: []} + return { + failed: records.map((r) => r.id), + reason, + ...(retryAfterMs === undefined ? {} : {retryAfterMs}), + succeeded: [], + } }, } } @@ -1342,7 +1350,9 @@ describe('AnalyticsClient', () => { describe('M4.5 backoff policy feedback', () => { type StubPolicy = { + applyServerHint: ReturnType consecutiveFailures: () => number + isRateLimited: () => boolean nextDelayMs: () => number onFailure: ReturnType onSuccess: ReturnType @@ -1350,7 +1360,9 @@ describe('AnalyticsClient', () => { function makePolicyStub(): StubPolicy { return { + applyServerHint: stub(), consecutiveFailures: () => 0, + isRateLimited: () => false, nextDelayMs: () => 30_000, onFailure: stub(), onSuccess: stub(), @@ -1445,6 +1457,57 @@ describe('AnalyticsClient', () => { expect(policy.onSuccess.called, '4xx is not a success either').to.be.false }) + it('honors a rate_limited result via applyServerHint and does NOT advance the failure counter (M5.4 — ENG-2658)', async () => { + const policy = makePolicyStub() + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + queue: new BoundedQueue(), + sender: makeSenderWithReason('rate_limited', 120_000), + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + expect(policy.applyServerHint.calledOnceWithExactly(120_000), 'server hint is forwarded to the policy').to.be + .true + expect(policy.onFailure.called, 'a 429/503 is reachable, not a failure').to.be.false + expect(policy.onSuccess.called, 'a rate-limit is not a success either').to.be.false + }) + + it('on a rate_limited result without retryAfterMs: logs AND still marks the policy rate-limited so the burst gate engages (M5.4 — ENG-2658)', async () => { + const policy = makePolicyStub() + const logs: string[] = [] + const client = new AnalyticsClient({ + backoffPolicy: policy, + identityResolver: makeStubIdentityResolver(makeAnonIdentity()), + isEnabled: () => true, + jsonlStore: makeFakeJsonlStore(), + log: (m) => logs.push(m), + queue: new BoundedQueue(), + sender: makeSenderWithReason('rate_limited'), // no retryAfterMs — malformed per the contract + superPropsResolver: makeStubSuperPropsResolver(makeSuperProps()), + }) + await seedPending(client, 1) + await client.flush() + + // Still flip the rate-limited bit (with an invalid sentinel so no delay + // floor is set) so the scheduler's burst gate stays closed — otherwise the + // next 20-event burst would hammer a server we were just told to back off from. + expect(policy.applyServerHint.calledOnce, 'the rate-limited bit must still be flipped').to.be.true + expect( + Number.isFinite(policy.applyServerHint.firstCall.args[0]), + 'called with a non-finite sentinel so no delay floor is applied', + ).to.equal(false) + expect(policy.onFailure.called, 'still not a reachability failure').to.be.false + expect( + logs.some((m) => m.includes('rate_limited') && /retryAfterMs|hint|burst|suppress/i.test(m)), + 'the dropped hint must be surfaced in the daemon log, not silently swallowed', + ).to.equal(true) + }) + it('does NOT touch the policy when abort() fired during the flush (user-driven cancel, not a backend signal)', async () => { const policy = makePolicyStub() let releaseSend!: () => void @@ -1492,17 +1555,18 @@ describe('AnalyticsClient', () => { // No assertion needed beyond "did not throw". }) - it('does NOT call onSuccess() on failed-without-reason (missing-deviceId / uncategorized failure)', async () => { + it('does NOT call onSuccess() on an uncategorized failed-without-reason result', async () => { // Regression for review finding I1: prior code treated `reason === undefined` - // as success and called `onSuccess()`. The missing-deviceId path in - // `HttpAnalyticsSender` returns `{failed: ids, succeeded: [], reason: undefined}` - // — a "we never tried" outcome, NOT a clean ship. Resetting backoff - // here would wrongly clear the unreachable counter on a first-boot - // config bug. Should skip entirely. + // as success and called `onSuccess()`. A `{failed: ids, succeeded: [], + // reason: undefined}` result is a "we never shipped" outcome, NOT a clean + // ship — resetting backoff here would wrongly clear the unreachable + // counter. (The missing-deviceId path now classifies as `http_4xx` and is + // guarded separately above; this case covers any other uncategorized + // failure.) Should skip entirely. const policy = makePolicyStub() const sender: IAnalyticsSender = { async send(records) { - // Mimic the missing-deviceId path: failed-with-no-reason. + // Mimic an uncategorized failure: failed-with-no-reason. return {failed: records.map((r) => r.id), succeeded: []} }, } diff --git a/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts b/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts index 01ea01b3f..f1a891363 100644 --- a/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts +++ b/test/unit/server/infra/analytics/analytics-flush-scheduler.test.ts @@ -266,6 +266,34 @@ describe('AnalyticsFlushScheduler', () => { expect(deps.flush.called).to.equal(false) }) + it('does NOT fire a threshold flush while rate-limited; the stretched periodic tick handles it (M5.4 — ENG-2658)', async () => { + const deps = buildDeps({size: 100}) // well past the threshold + const scheduler = new AnalyticsFlushScheduler({ + ...deps, + isRateLimited: () => true, // an active server 429/503 rate-limit + thresholdCount: 20, + }) + + scheduler.notifyPushed() + await flushMicrotasks() + + expect(deps.flush.called, 'a burst must not hammer a backend that asked us to wait').to.equal(false) + }) + + it('still fires the threshold flush when NOT rate-limited (normal batching preserved)', async () => { + const deps = buildDeps({size: 100}) + const scheduler = new AnalyticsFlushScheduler({ + ...deps, + isRateLimited: () => false, + thresholdCount: 20, + }) + + scheduler.notifyPushed() + await flushMicrotasks() + + expect(deps.flush.calledOnce, 'normal-case batching is unchanged').to.equal(true) + }) + it('does NOT re-trigger between threshold multiples (regression: queue mirror is monotonic past 20 → every push would fire)', async () => { // The queue mirror only decrements on auth-transition drain, NOT on a // successful flush. Without a moving baseline, queueSize >= 20 stays diff --git a/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts b/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts index 72702f1cc..680a467e6 100644 --- a/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts +++ b/test/unit/server/infra/analytics/axios-analytics-http-client.test.ts @@ -110,20 +110,8 @@ describe('AxiosAnalyticsHttpClient', () => { expect(result).to.deep.equal({ok: false, reason: 'http_4xx', status: 400}) }) - it('returns ok=false reason=http_4xx with status for a 429', async () => { - nock(baseUrl).post('/v1/events').reply(429, {message: 'too many requests'}) - const client = new AxiosAnalyticsHttpClient({baseUrl}) - - const result = await client.send(makeBatch(1), { - deviceId: validDeviceId, - userAgent: 'brv-cli/3.12.0', - }) - - expect(result).to.deep.equal({ok: false, reason: 'http_4xx', status: 429}) - }) - - it('returns ok=false reason=http_5xx with status for a 503', async () => { - nock(baseUrl).post('/v1/events').reply(503, {message: 'unavailable'}) + it('returns ok=false reason=http_5xx with status for a 500 (non-503 server errors stay transient)', async () => { + nock(baseUrl).post('/v1/events').reply(500, {message: 'boom'}) const client = new AxiosAnalyticsHttpClient({baseUrl}) const result = await client.send(makeBatch(1), { @@ -131,7 +119,7 @@ describe('AxiosAnalyticsHttpClient', () => { userAgent: 'brv-cli/3.12.0', }) - expect(result).to.deep.equal({ok: false, reason: 'http_5xx', status: 503}) + expect(result).to.deep.equal({ok: false, reason: 'http_5xx', status: 500}) }) it('returns ok=false reason=network when the connection cannot be established', async () => { @@ -170,6 +158,86 @@ describe('AxiosAnalyticsHttpClient', () => { }) }) + describe('rate-limit classification (M5.4 honor Retry-After — ENG-2658)', () => { + it('429 with a Retry-After header returns rate_limited + retryAfterMs from the header', async () => { + nock(baseUrl).post('/v1/events').reply(429, {message: 'slow down'}, {'retry-after': '30'}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + expect(result).to.deep.equal({ok: false, reason: 'rate_limited', retryAfterMs: 30_000, status: 429}) + }) + + it('429 with no header but a retry_after_seconds body falls back to the body value', async () => { + nock(baseUrl).post('/v1/events').reply(429, {retry_after_seconds: 30}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + expect(result).to.deep.equal({ok: false, reason: 'rate_limited', retryAfterMs: 30_000, status: 429}) + }) + + it('429 with neither header nor body falls back to a 60s default and logs a WARN', async () => { + nock(baseUrl).post('/v1/events').reply(429, {message: 'too many requests'}) + const logged: string[] = [] + const client = new AxiosAnalyticsHttpClient({baseUrl, log: (m) => logged.push(m)}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + expect(result).to.deep.equal({ok: false, reason: 'rate_limited', retryAfterMs: 60_000, status: 429}) + expect( + logged.some((m) => m.includes('429') && /default/i.test(m)), + 'a WARN is logged when falling back to the default delay', + ).to.equal(true) + }) + + it('503 from the edge backstop returns rate_limited with the default delay and logs a WARN (not unreachable)', async () => { + nock(baseUrl).post('/v1/events').reply(503, {message: 'unavailable'}) + const logged: string[] = [] + const client = new AxiosAnalyticsHttpClient({baseUrl, log: (m) => logged.push(m)}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + expect(result).to.deep.equal({ok: false, reason: 'rate_limited', retryAfterMs: 60_000, status: 503}) + expect(logged.some((m) => m.includes('503')), 'a WARN is logged for the 503 edge backstop').to.equal(true) + }) + + it('503 WITH a Retry-After header honors it instead of forcing the 60s default (PR #743 review)', async () => { + nock(baseUrl).post('/v1/events').reply(503, {}, {'retry-after': '45'}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + expect(result).to.deep.equal({ok: false, reason: 'rate_limited', retryAfterMs: 45_000, status: 503}) + }) + + it('429 with a future HTTP-date Retry-After converts to a forward-looking delay (RFC 7231)', async () => { + const aboutTwoMin = new Date(Date.now() + 120_000).toUTCString() + nock(baseUrl).post('/v1/events').reply(429, {}, {'retry-after': aboutTwoMin}) + const client = new AxiosAnalyticsHttpClient({baseUrl}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + if (result.ok || result.reason !== 'rate_limited') throw new Error('expected a rate_limited result') + // ~120s, allowing tolerance for second-truncation + elapsed time during the call. + expect(result.retryAfterMs).to.be.greaterThan(110_000) + expect(result.retryAfterMs).to.be.at.most(120_000) + expect(result.status).to.equal(429) + }) + + it('429 with an HTTP-date Retry-After already in the past falls back to the 60s default', async () => { + const past = new Date(Date.now() - 60_000).toUTCString() + nock(baseUrl).post('/v1/events').reply(429, {}, {'retry-after': past}) + const logged: string[] = [] + const client = new AxiosAnalyticsHttpClient({baseUrl, log: (m) => logged.push(m)}) + + const result = await client.send(makeBatch(1), {deviceId: validDeviceId, userAgent: 'brv-cli/3.12.0'}) + + expect(result).to.deep.equal({ok: false, reason: 'rate_limited', retryAfterMs: 60_000, status: 429}) + expect(logged.some((m) => /default/i.test(m)), 'a past date is no usable hint -> default + WARN').to.equal(true) + }) + }) + describe('contract guarantees', () => { it('does NOT throw on any failure path', async () => { // Combine the slowest failure mode (timeout) with a tight client diff --git a/test/unit/server/infra/analytics/build-status-snapshot.test.ts b/test/unit/server/infra/analytics/build-status-snapshot.test.ts index c0bb0c154..6c28d6701 100644 --- a/test/unit/server/infra/analytics/build-status-snapshot.test.ts +++ b/test/unit/server/infra/analytics/build-status-snapshot.test.ts @@ -22,9 +22,15 @@ function makeClientStub(state: RuntimeStateSnapshot): IAnalyticsClient { } } -function makePolicyStub(consecutiveFailures: number, nextDelayMs: number): IAnalyticsBackoffPolicy { +function makePolicyStub( + consecutiveFailures: number, + nextDelayMs: number, + isRateLimited = false, +): IAnalyticsBackoffPolicy { return { + applyServerHint() {}, consecutiveFailures: () => consecutiveFailures, + isRateLimited: () => isRateLimited, nextDelayMs: () => nextDelayMs, onFailure() {}, onSuccess() {}, @@ -109,4 +115,17 @@ describe('buildAnalyticsStatusSnapshot (M16.3)', () => { expect(snapshot.backoff.state).to.equal('unreachable') }) + + it('maps a rate-limited policy to the distinct rate_limited state, even at 0 failures (M5.4 — ENG-2658)', async () => { + const snapshot = await buildAnalyticsStatusSnapshot({ + analyticsClient: makeClientStub({droppedCount: 0, lastSuccessfulFlushAt: undefined, queueDepth: 1}), + // 0 consecutive failures (429s never bump the counter) but rate-limited: + // must surface as `rate_limited`, NOT `healthy` and NOT `unreachable`. + backoffPolicy: makePolicyStub(0, 60_000, true), + endpoint: 'https://telemetry-dev.byterover.dev', + isAnalyticsEnabled: () => true, + }) + + expect(snapshot.backoff.state).to.equal('rate_limited') + }) }) diff --git a/test/unit/server/infra/analytics/noop-analytics-sender.test.ts b/test/unit/server/infra/analytics/draining-analytics-sender.test.ts similarity index 74% rename from test/unit/server/infra/analytics/noop-analytics-sender.test.ts rename to test/unit/server/infra/analytics/draining-analytics-sender.test.ts index 6a0468040..6cd6169f5 100644 --- a/test/unit/server/infra/analytics/noop-analytics-sender.test.ts +++ b/test/unit/server/infra/analytics/draining-analytics-sender.test.ts @@ -3,7 +3,7 @@ import {expect} from 'chai' import type {StoredAnalyticsRecord} from '../../../../../src/shared/analytics/stored-record.js' -import {NoopAnalyticsSender} from '../../../../../src/server/infra/analytics/noop-analytics-sender.js' +import {DrainingAnalyticsSender} from '../../../../../src/server/infra/analytics/draining-analytics-sender.js' function makeRecord(id: string): StoredAnalyticsRecord { return { @@ -17,21 +17,21 @@ function makeRecord(id: string): StoredAnalyticsRecord { } satisfies StoredAnalyticsRecord } -describe('NoopAnalyticsSender (graceful-degradation sender)', () => { +describe('DrainingAnalyticsSender (graceful-degradation sender)', () => { it('marks every input id as succeeded so JSONL drains', async () => { - const sender = new NoopAnalyticsSender() + const sender = new DrainingAnalyticsSender() const result = await sender.send([makeRecord('a'), makeRecord('b'), makeRecord('c')]) expect(result).to.deep.equal({failed: [], succeeded: ['a', 'b', 'c']}) }) it('returns empty arrays for an empty batch', async () => { - const sender = new NoopAnalyticsSender() + const sender = new DrainingAnalyticsSender() const result = await sender.send([]) expect(result).to.deep.equal({failed: [], succeeded: []}) }) it('ignores the AbortSignal option and never throws', async () => { - const sender = new NoopAnalyticsSender() + const sender = new DrainingAnalyticsSender() const controller = new AbortController() controller.abort() const result = await sender.send([makeRecord('a')], {signal: controller.signal}) @@ -40,10 +40,10 @@ describe('NoopAnalyticsSender (graceful-degradation sender)', () => { }) it('does not invoke any collaborator (no deps to inject means none can be touched)', async () => { - // Structural assertion: NoopAnalyticsSender has a zero-arg constructor. + // Structural assertion: DrainingAnalyticsSender has a zero-arg constructor. // If a future refactor introduces deps, this no-arg construction line // would fail to type-check, surfacing the regression at compile time. - const sender = new NoopAnalyticsSender() - expect(sender).to.be.an.instanceOf(NoopAnalyticsSender) + const sender = new DrainingAnalyticsSender() + expect(sender).to.be.an.instanceOf(DrainingAnalyticsSender) }) }) diff --git a/test/unit/server/infra/analytics/http-analytics-sender.test.ts b/test/unit/server/infra/analytics/http-analytics-sender.test.ts index f7ecb12cb..8ddefae0f 100644 --- a/test/unit/server/infra/analytics/http-analytics-sender.test.ts +++ b/test/unit/server/infra/analytics/http-analytics-sender.test.ts @@ -205,6 +205,25 @@ describe('HttpAnalyticsSender', () => { expect(result).to.deep.equal({failed: ['only'], reason: 'http_4xx', succeeded: []}) }) + it('forwards rate_limited reason AND the retryAfterMs hint (M5.4 — ENG-2658)', async () => { + const httpClient = makeCapturingHttpClient({ok: false, reason: 'rate_limited', retryAfterMs: 30_000, status: 429}) + const sender = new HttpAnalyticsSender({ + authStateReader: makeAuthReader(), + globalConfigStore: makeStubConfigStore(), + httpClient, + userAgent: 'brv-cli/3.12.0', + }) + + const result = await sender.send([makeRecord({id: 'r1'}), makeRecord({id: 'r2'})]) + + expect(result).to.deep.equal({ + failed: ['r1', 'r2'], + reason: 'rate_limited', + retryAfterMs: 30_000, + succeeded: [], + }) + }) + it('does NOT set reason on a successful send (M4.5 caller treats absence as success)', async () => { const httpClient = makeCapturingHttpClient({ok: true}) const sender = new HttpAnalyticsSender({ @@ -250,12 +269,14 @@ describe('HttpAnalyticsSender', () => { expect(httpClient.calls).to.have.lengthOf(0) }) - it('treats missing deviceId as a failure (anonymous batches still need a device id per backend contract)', async () => { + it('treats missing deviceId as an http_4xx failure (anonymous batches still need a device id per backend contract)', async () => { // GlobalConfigStore returns undefined (first-run before the daemon // has provisioned a device id). Per the backend contract, batches - // without `x-byterover-device-id` are 400-rejected; sender refuses - // to ship and counts the records as failed so the flush mirror - // (M10.2) increments their attempts. + // without `x-byterover-device-id` are 400-rejected; the sender refuses + // to ship and classifies the failure as `http_4xx` (a payload-shape + // problem, not a transient signal) so the M4.5 backoff policy does not + // churn on a daemon-side misconfig, while the flush mirror (M10.2) + // still increments attempts toward the retry cap. const emptyStore: IGlobalConfigStore = { read: stub().resolves(), write: stub().resolves(), @@ -270,7 +291,7 @@ describe('HttpAnalyticsSender', () => { const result = await sender.send([makeRecord({id: 'r1'})]) - expect(result).to.deep.equal({failed: ['r1'], succeeded: []}) + expect(result).to.deep.equal({failed: ['r1'], reason: 'http_4xx', succeeded: []}) expect(httpClient.calls).to.have.lengthOf(0) }) }) diff --git a/test/unit/server/infra/process/analytics-hook-m14.test.ts b/test/unit/server/infra/process/analytics-hook-m14.test.ts index e26f36397..4d83ef7e7 100644 --- a/test/unit/server/infra/process/analytics-hook-m14.test.ts +++ b/test/unit/server/infra/process/analytics-hook-m14.test.ts @@ -276,7 +276,7 @@ describe('AnalyticsHook M14.3 generic task_* emit simulation', () => { }) describe('toRelativePath outside-project guard (PR #722)', () => { - it('replaces escaping ../ paths with /basename sentinel', async () => { + it('replaces escaping ../ paths with the bare sentinel (no basename leak)', async () => { const task = buildTask('curate', {projectPath: '/Users/dev/proj', taskId: 'task-outside'}) await hook.onTaskCreate(task) const result: LlmToolResultEvent = { @@ -292,10 +292,10 @@ describe('AnalyticsHook M14.3 generic task_* emit simulation', () => { await hook.onToolResult('task-outside', result) const op = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.CURATE_OPERATION_APPLIED) - expect((op?.args[1] as Record).relative_path).to.equal('/x.md') + expect((op?.args[1] as Record).relative_path).to.equal('') }) - it('replaces raw absolute path with /basename when projectPath is undefined', async () => { + it('replaces a raw absolute path with the bare sentinel when projectPath is undefined', async () => { const task = buildTask('curate', {projectPath: undefined, taskId: 'task-no-proj'}) await hook.onTaskCreate(task) const result: LlmToolResultEvent = { @@ -311,7 +311,10 @@ describe('AnalyticsHook M14.3 generic task_* emit simulation', () => { await hook.onToolResult('task-no-proj', result) const op = trackStub.getCalls().find((c) => c.args[0] === AnalyticsEventNames.CURATE_OPERATION_APPLIED) - expect((op?.args[1] as Record).relative_path).to.equal('/secret.md') + // The leaf token (`secret.md`) must NOT survive: a file outside the + // project root carries the highest PII risk and the least analytical + // value, so it collapses to the bare sentinel. + expect((op?.args[1] as Record).relative_path).to.equal('') }) }) diff --git a/test/unit/server/infra/process/analytics-hook.test.ts b/test/unit/server/infra/process/analytics-hook.test.ts index f69da4ecd..272208688 100644 --- a/test/unit/server/infra/process/analytics-hook.test.ts +++ b/test/unit/server/infra/process/analytics-hook.test.ts @@ -152,8 +152,10 @@ describe('AnalyticsHook', () => { expect(m12Calls()[0].args[0]).to.equal(AnalyticsEventNames.CURATE_OPERATION_APPLIED) const firstProps = m12Calls()[0].args[1] as Record // buildCurateTask sets projectPath:'/project'; /a.md escapes the - // project root → PR #722 outside-project sentinel + basename. - expect(firstProps.relative_path).to.equal('/a.md') + // project root → bare PR #722 outside-project sentinel (the basename is + // dropped so an out-of-project filename never reaches the wire). The op + // stays identifiable via knowledge_path below. + expect(firstProps.relative_path).to.equal('') expect(firstProps.knowledge_path).to.equal('notes/a') expect(firstProps.operation_type).to.equal('ADD') expect(firstProps.needs_review).to.equal(false) @@ -339,11 +341,14 @@ describe('AnalyticsHook', () => { expect(props.matched_doc_count).to.equal(7) const paths = props.read_paths_with_metadata as Array> expect(paths).to.have.lengthOf(3) - // sorted lexicographically; relativized against projectPath:'/project' + // sorted lexicographically; relativized against projectPath:'/project'. + // All three escape the root, so each collapses to the bare sentinel. + // The count (3) still distinguishes them — dedup is on the original + // absolute path, not on this relativized output. expect(paths.map((p) => p.relative_path)).to.deep.equal([ - '/a.md', - '/b.md', - '/c.md', + '', + '', + '', ]) // each entry has empty keywords/tags arrays and an empty related_paths // list — no frontmatter source files exist in this in-memory test. @@ -733,9 +738,11 @@ describe('AnalyticsHook', () => { expect(filterM12(bundle.trackStub)).to.have.lengthOf(2) const first = filterM12(bundle.trackStub)[0].args[1] as Record const second = filterM12(bundle.trackStub)[1].args[1] as Record - // buildCurateTask projectPath:'/project'; absolute paths relativize with '../' prefix - expect(first.relative_path, 'first emit must be op1').to.equal('/op1.md') - expect(second.relative_path, 'second emit must be op2').to.equal('/op2.md') + // Both files escape projectPath:'/project', so relative_path collapses + // to the same bare sentinel for both — distinguish the ops by their + // knowledge_path instead to prove arrival-order emit (op1 before op2). + expect(first.knowledge_path, 'first emit must be op1').to.equal('notes/op1') + expect(second.knowledge_path, 'second emit must be op2').to.equal('notes/op2') }) it('onTaskCompleted waits for in-flight onToolResult work before emitting CURATE_RUN_COMPLETED', async () => { @@ -787,8 +794,9 @@ describe('AnalyticsHook', () => { expect(filterM12(bundle.trackStub)).to.have.lengthOf(1) const props = filterM12(bundle.trackStub)[0].args[1] as Record - // /missing.md escapes the '/project' root — PR #722 outside-project sentinel. - expect(props.relative_path).to.equal('/missing.md') + // /missing.md escapes the '/project' root — bare PR #722 outside-project + // sentinel (basename dropped). + expect(props.relative_path).to.equal('') expect(props.keywords).to.deep.equal([]) expect(props.tags).to.deep.equal([]) expect(props).to.not.have.property('related') diff --git a/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts index c7901fce5..f1aa88d21 100644 --- a/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts +++ b/test/unit/server/infra/transport/handlers/analytics-status-handler.test.ts @@ -37,9 +37,15 @@ function makeClientStub(state: RuntimeStateSnapshot): IAnalyticsClient { } } -function makePolicyStub(consecutiveFailures: number, nextDelayMs: number): IAnalyticsBackoffPolicy { +function makePolicyStub( + consecutiveFailures: number, + nextDelayMs: number, + isRateLimited = false, +): IAnalyticsBackoffPolicy { return { + applyServerHint() {}, consecutiveFailures: () => consecutiveFailures, + isRateLimited: () => isRateLimited, nextDelayMs: () => nextDelayMs, onFailure() {}, onSuccess() {}, diff --git a/test/unit/shared/utils/format-analytics-status.test.ts b/test/unit/shared/utils/format-analytics-status.test.ts index 4eaba8a08..34f4ec98f 100644 --- a/test/unit/shared/utils/format-analytics-status.test.ts +++ b/test/unit/shared/utils/format-analytics-status.test.ts @@ -85,6 +85,17 @@ describe('format-analytics-status (M16.3)', () => { expect(text).to.include('Backoff state: unreachable') }) + it('renders the distinct rate_limited backoff state (M5.4 — ENG-2658), not unreachable', () => { + const text = format({ + ...HEALTHY, + // 429/503 throttle: zero failures but a server-supplied delay. + backoff: {consecutiveFailures: 0, nextDelayMs: 120_000, state: 'rate_limited'}, + }) + expect(text).to.include('Backoff state: rate_limited') + expect(text).to.not.include('unreachable') + expect(text).to.include('next attempt in 2m') + }) + it('shows queue depth and dropped events on enabled state', () => { const text = format({...HEALTHY, droppedCount: 7, queueDepth: 12}) expect(text).to.include('Queue depth: 12 events') From 5364e7d84c5c363103832b1f02a876dde8886de5 Mon Sep 17 00:00:00 2001 From: Bao Ha Date: Sat, 30 May 2026 16:24:48 +0700 Subject: [PATCH 86/87] =?UTF-8?q?fix:=20address=20PR=20#726=20review=20?= =?UTF-8?q?=E2=80=94=20analytics=20settings=20category,=20wire=20.strict()?= =?UTF-8?q?,=20failure-kind,=20dedup,=20log=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch: proj/analytics-system-tool-mode - format-settings: map the 'analytics' settings category (was falling through to 'other') - batch: .strict() on IdentityWireSchema — reject unexpected identity fields at the wire boundary - context-tree-handler: classify path-traversal as failure_kind 'invalid_path' instead of 'conflict' - context-tree-events: remove duplicate JSDoc on the paths field - brv-server: consolidate readCliVersion onto the shared src/server/utils/read-cli-version util - synthetic-tool-result-emit: wrap log calls so a throwing sink can't escape (sync + async paths) - analytics-client: document why onAuthTransition intentionally does not await the in-flight flush --- src/server/core/domain/analytics/batch.ts | 21 +++++++++++------- .../infra/analytics/analytics-client.ts | 10 +++++++++ src/server/infra/daemon/brv-server.ts | 22 ++----------------- .../process/synthetic-tool-result-emit.ts | 17 ++++++++++++-- .../handlers/context-tree-handler.ts | 4 +++- .../transport/events/context-tree-events.ts | 1 - src/shared/utils/format-settings.ts | 8 ++++++- .../context-tree-handler-analytics.test.ts | 4 ++-- .../core/domain/analytics/batch.test.ts | 15 +++++++++++++ .../synthetic-tool-result-emit.test.ts | 16 ++++++++++++++ .../unit/shared/utils/format-settings.test.ts | 5 +++++ 11 files changed, 88 insertions(+), 35 deletions(-) diff --git a/src/server/core/domain/analytics/batch.ts b/src/server/core/domain/analytics/batch.ts index edcac956a..4a5c9123b 100644 --- a/src/server/core/domain/analytics/batch.ts +++ b/src/server/core/domain/analytics/batch.ts @@ -30,14 +30,19 @@ export type AnalyticsBatchJson = Readonly<{ * `as Record` casts that violate CLAUDE.md's * "avoid `as Type` assertions" rule). */ -const IdentityWireSchema = z.object({ - device_id: z.string().refine((s) => s.trim().length > 0, { - message: 'device_id must be non-empty', - }), - email: z.string().optional(), - name: z.string().optional(), - user_id: z.string().optional(), -}) +const IdentityWireSchema = z + .object({ + device_id: z.string().refine((s) => s.trim().length > 0, { + message: 'device_id must be non-empty', + }), + email: z.string().optional(), + name: z.string().optional(), + user_id: z.string().optional(), + }) + // `.strict()` mirrors the event-level schema below: an unexpected field + // nested in `identity` (a forbidden/PII key, or residue from a pre-upgrade + // producer) is rejected at the wire boundary, not silently stripped. + .strict() // `.strict()` mirrors the backend's `forbidNonWhitelisted` validator // (byterover-telemetry PR #21): any residual field from a pre-upgrade diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index dcdc9be42..16dfceb0d 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -216,6 +216,16 @@ export class AnalyticsClient implements IAnalyticsClient { // into a fresh queue → prior-session record stays visible to webui. this.deps.queue.drain() + // NOTE: we intentionally do NOT await an in-flight `flush()` (the + // `pendingFlush` slot) before clearing. If a flush is mid-send when this + // runs, it already loaded its records and will later call + // `jsonlStore.updateStatus(...)` on ids the clear below removed — which is + // a safe no-op (the store ignores non-matching ids). That flush ships + // those pre-transition events under the OLD identity, which is exactly + // what the M4.4 pre-transition flush hook (`wireAnalyticsAuthPreTransition`) + // is for; clearing afterward drops whatever it didn't carry. Awaiting the + // flush here would only add latency to the auth transition for no + // correctness gain, so the barrier is on tracks + queue, not the flush. try { await this.deps.jsonlStore.clear() } catch (error) { diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index 4a061b29a..2cabbcd16 100644 --- a/src/server/infra/daemon/brv-server.ts +++ b/src/server/infra/daemon/brv-server.ts @@ -25,7 +25,7 @@ import {GlobalInstanceManager} from '@campfirein/brv-transport-client' import express from 'express' import {fork, type StdioOptions} from 'node:child_process' -import {mkdirSync, readdirSync, readFileSync, unlinkSync} from 'node:fs' +import {mkdirSync, readdirSync, unlinkSync} from 'node:fs' import {dirname, join} from 'node:path' import {fileURLToPath} from 'node:url' @@ -53,6 +53,7 @@ import { import {buildReviewUrl} from '../../utils/build-review-url.js' import {getGlobalDataDir} from '../../utils/global-data-path.js' import {crashLog, processLog} from '../../utils/process-logger.js' +import {readCliVersion} from '../../utils/read-cli-version.js' import {createBillingStateHandler} from '../billing/billing-state-endpoint.js' import {ClientManager} from '../client/client-manager.js' import {ProjectConfigStore} from '../config/file-config-store.js' @@ -101,25 +102,6 @@ function log(msg: string): void { processLog(`[Daemon] ${msg}`) } -/** - * Reads the CLI version from package.json. - * Walks up from the compiled file location to find the project root. - */ -function readCliVersion(): string { - try { - const currentDir = dirname(fileURLToPath(import.meta.url)) - // Both src/ and dist/ are 4 levels deep: server/infra/daemon/brv-server - const pkgPath = join(currentDir, '..', '..', '..', '..', 'package.json') - const pkg: unknown = JSON.parse(readFileSync(pkgPath, 'utf8')) - if (typeof pkg === 'object' && pkg !== null && 'version' in pkg && typeof pkg.version === 'string') { - return pkg.version - } - } catch { - // Best-effort — return fallback - } - - return 'unknown' -} /** * Removes old daemon log files, keeping the most recent ones. diff --git a/src/server/infra/process/synthetic-tool-result-emit.ts b/src/server/infra/process/synthetic-tool-result-emit.ts index 36a6962b6..1861fdc88 100644 --- a/src/server/infra/process/synthetic-tool-result-emit.ts +++ b/src/server/infra/process/synthetic-tool-result-emit.ts @@ -49,15 +49,28 @@ function safeDispatch( log: ((msg: string) => void) | undefined, context: string, ): void { + // A logging sink that itself throws must never escalate into the telemetry + // path: on the sync path it would escape this function, and inside the + // async `.catch` it would become a fresh unhandled rejection — the very + // failure mode this wrapper exists to prevent. Swallow any error from the + // sink itself. + const safeLog = (msg: string): void => { + try { + log?.(msg) + } catch { + /* logging is best-effort — never let the sink crash the daemon */ + } + } + try { const result = transport.request(event, payload) as unknown if (result && typeof (result as PromiseLike).then === 'function') { ;(result as Promise).catch((error: unknown) => { - log?.(`${context}: async rejection — ${error instanceof Error ? error.message : String(error)}`) + safeLog(`${context}: async rejection — ${error instanceof Error ? error.message : String(error)}`) }) } } catch (error) { - log?.(`${context}: sync throw — ${error instanceof Error ? error.message : String(error)}`) + safeLog(`${context}: sync throw — ${error instanceof Error ? error.message : String(error)}`) } } diff --git a/src/server/infra/transport/handlers/context-tree-handler.ts b/src/server/infra/transport/handlers/context-tree-handler.ts index 386fa8e01..e5b545c4d 100644 --- a/src/server/infra/transport/handlers/context-tree-handler.ts +++ b/src/server/infra/transport/handlers/context-tree-handler.ts @@ -310,7 +310,9 @@ export class ContextTreeHandler { } function classifyUpdateFileFailure(error: unknown): string { - if (error instanceof Error && error.message.includes('traversal')) return 'conflict' + // A path-traversal rejection is a rejected-input/security signal, not a + // write conflict — classify it accordingly for the analytics funnel. + if (error instanceof Error && error.message.includes('traversal')) return 'invalid_path' if (error instanceof Error && 'code' in error) { const code = String((error as {code: unknown}).code) if (code.startsWith('E')) return 'fs_access' diff --git a/src/shared/transport/events/context-tree-events.ts b/src/shared/transport/events/context-tree-events.ts index ce6baa332..93ae0b4c2 100644 --- a/src/shared/transport/events/context-tree-events.ts +++ b/src/shared/transport/events/context-tree-events.ts @@ -86,7 +86,6 @@ export interface ContextTreeUpdateFileResponse { export interface ContextTreeGetFileMetadataRequest { cli_metadata?: CliMetadata - /** File paths to fetch metadata for. */ /** * File or folder paths to fetch metadata for. Folder paths resolve to the * latest commit that modified any descendant of the folder. diff --git a/src/shared/utils/format-settings.ts b/src/shared/utils/format-settings.ts index 9e7e520b2..fa166e697 100644 --- a/src/shared/utils/format-settings.ts +++ b/src/shared/utils/format-settings.ts @@ -160,7 +160,13 @@ function renderBoolean(value: boolean): string { } function toRowCategory(category: SettingsItemDTO['category']): SettingsRowCategory { - if (category === 'concurrency' || category === 'llm' || category === 'task-history' || category === 'updates') { + if ( + category === 'analytics' || + category === 'concurrency' || + category === 'llm' || + category === 'task-history' || + category === 'updates' + ) { return category } diff --git a/test/unit/infra/transport/handlers/context-tree-handler-analytics.test.ts b/test/unit/infra/transport/handlers/context-tree-handler-analytics.test.ts index 8200eac53..09a026972 100644 --- a/test/unit/infra/transport/handlers/context-tree-handler-analytics.test.ts +++ b/test/unit/infra/transport/handlers/context-tree-handler-analytics.test.ts @@ -120,7 +120,7 @@ describe('ContextTreeHandler analytics emits', () => { expect(props.byte_delta).to.be.lessThan(0) }) - it('emits context_tree_file_edited outcome=failure failure_kind=conflict on path traversal', async () => { + it('emits context_tree_file_edited outcome=failure failure_kind=invalid_path on path traversal', async () => { try { await requestHandlers[ContextTreeEvents.UPDATE_FILE]({content: 'x', path: '../../etc/passwd'}, 'c1') expect.fail('should have thrown') @@ -132,7 +132,7 @@ describe('ContextTreeHandler analytics emits', () => { expect(calls.length).to.equal(1) const props = calls[0].args[1] as {failure_kind?: string; outcome: string; project_path_hash: string} expect(props.outcome).to.equal('failure') - expect(props.failure_kind).to.equal('conflict') + expect(props.failure_kind).to.equal('invalid_path') expect(props.project_path_hash).to.match(sha256HexRegex) }) diff --git a/test/unit/server/core/domain/analytics/batch.test.ts b/test/unit/server/core/domain/analytics/batch.test.ts index 1e9155705..440a4b50e 100644 --- a/test/unit/server/core/domain/analytics/batch.test.ts +++ b/test/unit/server/core/domain/analytics/batch.test.ts @@ -148,6 +148,21 @@ describe('AnalyticsBatch', () => { expect(AnalyticsBatch.fromJson(json)).to.be.undefined }) + it('should return undefined when identity carries an unexpected extra field (.strict())', () => { + const json = { + events: [ + { + created_at: '2023-11-14T22:13:20+00:00', + identity: {device_id: '550e8400-e29b-41d4-a716-446655440000', extra_field: 'leak'}, + name: 'x', + properties: {}, + }, + ], + schema_version: 2, + } + expect(AnalyticsBatch.fromJson(json)).to.be.undefined + }) + it('should return undefined when an event is missing created_at', () => { const json = { events: [{identity: validIdentity, name: 'x', properties: {}}], diff --git a/test/unit/server/infra/process/synthetic-tool-result-emit.test.ts b/test/unit/server/infra/process/synthetic-tool-result-emit.test.ts index 278cb367c..9011f769d 100644 --- a/test/unit/server/infra/process/synthetic-tool-result-emit.test.ts +++ b/test/unit/server/infra/process/synthetic-tool-result-emit.test.ts @@ -125,6 +125,22 @@ describe('synthetic-tool-result-emit (M17 tool-mode gap fix)', () => { expect(logStub.firstCall.args[0]).to.include('sync throw') }) + it('does not let a throwing log callback escape on the sync-throw path', () => { + const requestStub = sinon.stub().throws(new Error('boom')) + const transport = {request: requestStub} as unknown as ITransportClient + const throwingLog = sinon.stub().throws(new Error('log sink exploded')) + + expect(() => + emitSyntheticCurateToolResult({ + log: throwingLog, + operations: [{path: 'x', status: 'success', type: 'ADD'}], + taskId: 'task-1', + transport, + }), + ).to.not.throw() + expect(throwingLog.calledOnce, 'the log sink was invoked (and its throw swallowed)').to.equal(true) + }) + it('swallows async transport rejections and logs (PR #728 review fix)', async () => { const requestStub = sinon.stub().rejects(new Error('socket dead')) const transport = {request: requestStub} as unknown as ITransportClient diff --git a/test/unit/shared/utils/format-settings.test.ts b/test/unit/shared/utils/format-settings.test.ts index d0fbcfe95..27c453b34 100644 --- a/test/unit/shared/utils/format-settings.test.ts +++ b/test/unit/shared/utils/format-settings.test.ts @@ -146,6 +146,11 @@ describe('format-settings (shared)', () => { expect(rows[0].category).to.equal('other') expect(rows[0].unit).to.equal('count') }) + + it('maps the analytics category onto the row instead of falling back to other', () => { + const rows = buildSettingsRows([makeItem({category: 'analytics', key: 'analytics.enabled'})]) + expect(rows[0].category).to.equal('analytics') + }) }) describe('parseRowInput', () => { From 0f44a290465767affc19bd4ecedd26a692e1ff66 Mon Sep 17 00:00:00 2001 From: bao-byterover Date: Sat, 30 May 2026 19:45:57 +0700 Subject: [PATCH 87/87] feat: [ENG-3035] rename user-facing analytics setting key analytics.enabled to analytics.share (#746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key name now reflects what it controls: local analytics tracking is always on (regardless of auth or this setting); the flag only gates whether collected events are shared with the remote telemetry backend. - Rename the user-facing key value analytics.enabled -> analytics.share across settings set/get/list, the onboarding opt-in script, and the disclosure command reference. Hard rename (no alias) — the feature is unreleased. - Behavior unchanged: tracking stays always-on locally; the flag still gates only remote sharing. - Internals kept (value-only rename): GlobalConfig.analytics field, the globalConfig:setAnalytics transport event, in-process method names, and the key-constant identifier names. --- src/oclif/commands/settings/set.ts | 6 +-- src/oclif/lib/analytics-disclosure.ts | 2 +- .../analytics/i-analytics-client.ts | 2 +- .../analytics/i-analytics-http-client.ts | 2 +- .../infra/analytics/analytics-client.ts | 6 +-- .../analytics/analytics-flush-scheduler.ts | 2 +- .../infra/analytics/http-analytics-sender.ts | 2 +- src/server/infra/process/feature-handlers.ts | 4 +- .../handlers/global-config-handler.ts | 6 +-- .../transport/handlers/settings-handler.ts | 12 ++--- src/server/templates/skill/onboarding.md | 8 ++-- .../analytics/events/analytics-disabled.ts | 2 +- src/shared/assets/analytics-disclosure.md | 2 +- src/shared/constants/settings-keys.ts | 2 +- .../settings/components/settings-page.tsx | 2 +- .../settings/analytics-enabled.test.ts | 12 ++--- test/commands/settings/set.test.ts | 20 ++++---- test/e2e/analytics/dev-beta.e2e.ts | 4 +- test/e2e/analytics/lifecycle-db.e2e.ts | 2 +- test/e2e/analytics/lifecycle-wire.e2e.ts | 4 +- .../analytics/daemon-tracking.test.ts | 2 +- .../domain/entities/settings-registry.test.ts | 12 ++--- .../infra/storage/file-settings-store.test.ts | 6 +-- .../handlers/settings-handler.test.ts | 46 +++++++++---------- .../infra/analytics/analytics-client.test.ts | 2 +- .../analytics-disclosure-content.test.ts | 2 +- .../unit/shared/utils/format-settings.test.ts | 2 +- 27 files changed, 87 insertions(+), 87 deletions(-) diff --git a/src/oclif/commands/settings/set.ts b/src/oclif/commands/settings/set.ts index 5bd9ec7dd..cd11d6aba 100644 --- a/src/oclif/commands/settings/set.ts +++ b/src/oclif/commands/settings/set.ts @@ -44,13 +44,13 @@ export default class SettingsSet extends Command { options: ['text', 'json'], }), // Accepts the analytics disclosure non-interactively. Only meaningful when - // setting `analytics.enabled true` (the one consent-gated key). Passing it + // setting `analytics.share true` (the one consent-gated key). Passing it // for any other key emits `this.warn(...)` so the user does not silently // rely on a flag that has no behavioural effect for their command. yes: Flags.boolean({ char: 'y', default: false, - description: 'Accept the analytics disclosure non-interactively (only meaningful for analytics.enabled)', + description: 'Accept the analytics disclosure non-interactively (only meaningful for analytics.share)', }), } @@ -120,7 +120,7 @@ export default class SettingsSet extends Command { return } - // Enable-to-true on `analytics.enabled` triggers the disclosure + // Enable-to-true on `analytics.share` triggers the disclosure // prompt. Idempotent (no prompt if already enabled), false-unchanged, // and other keys unaffected. `collectConsent`'s `onError` calls // `this.error()` which throws CLIError; we let it propagate to diff --git a/src/oclif/lib/analytics-disclosure.ts b/src/oclif/lib/analytics-disclosure.ts index 9e2b0e983..9fd6e278c 100644 --- a/src/oclif/lib/analytics-disclosure.ts +++ b/src/oclif/lib/analytics-disclosure.ts @@ -61,7 +61,7 @@ export interface CollectConsentDeps { * 4. Otherwise, prompt and return the user's choice. * * Extracted from the legacy `brv analytics enable` command in M16.2 so - * `brv settings set analytics.enabled true` can reuse the exact same + * `brv settings set analytics.share true` can reuse the exact same * consent gate. M16.4 then deleted the legacy command; this lib is now * the sole consent surface. */ diff --git a/src/server/core/interfaces/analytics/i-analytics-client.ts b/src/server/core/interfaces/analytics/i-analytics-client.ts index eaa6af706..4bd550183 100644 --- a/src/server/core/interfaces/analytics/i-analytics-client.ts +++ b/src/server/core/interfaces/analytics/i-analytics-client.ts @@ -18,7 +18,7 @@ import type {AnalyticsBatch} from '../../domain/analytics/batch.js' export interface IAnalyticsClient { /** * Cancel any in-flight `flush()`'s HTTP request. M4.4: invoked by - * `GlobalConfigHandler` when `brv settings set analytics.enabled false` flips the flag + * `GlobalConfigHandler` when `brv settings set analytics.share false` flips the flag * so the daemon doesn't half-ship a batch across an enable/disable * boundary. No-op when no flush is in flight. */ diff --git a/src/server/core/interfaces/analytics/i-analytics-http-client.ts b/src/server/core/interfaces/analytics/i-analytics-http-client.ts index bac48f92a..3452a987e 100644 --- a/src/server/core/interfaces/analytics/i-analytics-http-client.ts +++ b/src/server/core/interfaces/analytics/i-analytics-http-client.ts @@ -63,7 +63,7 @@ export type AnalyticsHttpSendResult = */ /** * Optional per-call controls. `signal` is the M4.4 cancellation hook - * used by `brv settings set analytics.enabled false` (and by the daemon shutdown path) to + * used by `brv settings set analytics.share false` (and by the daemon shutdown path) to * abort an in-flight send so the daemon doesn't half-ship a batch * across an enable/disable boundary. */ diff --git a/src/server/infra/analytics/analytics-client.ts b/src/server/infra/analytics/analytics-client.ts index 16dfceb0d..387c7a01d 100644 --- a/src/server/infra/analytics/analytics-client.ts +++ b/src/server/infra/analytics/analytics-client.ts @@ -128,7 +128,7 @@ export class AnalyticsClient implements IAnalyticsClient { * which classifies aborted requests as `network` failures — JSONL * records stay `pending` (so they ship on the next enabled flush). * - * Called from `GlobalConfigHandler` when `brv settings set analytics.enabled false` + * Called from `GlobalConfigHandler` when `brv settings set analytics.share false` * flips the flag, so the daemon doesn't half-ship a batch across an * enable/disable boundary. No-op when no flush is in flight. */ @@ -156,7 +156,7 @@ export class AnalyticsClient implements IAnalyticsClient { * `flush()` is a thin caller — it does not inspect attempts. */ public async flush(): Promise { - // M4.4: `brv settings set analytics.enabled false` semantically means "stop shipping to + // M4.4: `brv settings set analytics.share false` semantically means "stop shipping to // remote" — local tracking (JSONL + queue) continues unconditionally. // Gate here, NOT in `track()`. Records stay at `status='pending'` in // JSONL; the next flush after re-enable picks them up automatically. @@ -378,7 +378,7 @@ export class AnalyticsClient implements IAnalyticsClient { await this.deps.jsonlStore.updateStatus(result.succeeded, 'sent') // M4.4 N3 fix: when we cancelled the send ourselves (`abort()` fired - // because `brv settings set analytics.enabled false` flipped the flag), DO NOT mark the + // because `brv settings set analytics.share false` flipped the flag), DO NOT mark the // failed records as 'failed' — that bumps the M9.2 retry-cap // `attempts` counter on every cancel, and a few disable/enable // toggles during shipping could terminate records as `'failed'` diff --git a/src/server/infra/analytics/analytics-flush-scheduler.ts b/src/server/infra/analytics/analytics-flush-scheduler.ts index 1237a7f41..24b4ce995 100644 --- a/src/server/infra/analytics/analytics-flush-scheduler.ts +++ b/src/server/infra/analytics/analytics-flush-scheduler.ts @@ -10,7 +10,7 @@ export interface AnalyticsFlushSchedulerDeps { flush: () => Promise /** * Lazy analytics-enabled gate. Re-checked on every trigger so a runtime - * `brv settings set analytics.enabled false` (M1.4) immediately suspends scheduled flushes + * `brv settings set analytics.share false` (M1.4) immediately suspends scheduled flushes * without restarting the daemon. */ isEnabled: () => boolean diff --git a/src/server/infra/analytics/http-analytics-sender.ts b/src/server/infra/analytics/http-analytics-sender.ts index b482f1f64..279f526d1 100644 --- a/src/server/infra/analytics/http-analytics-sender.ts +++ b/src/server/infra/analytics/http-analytics-sender.ts @@ -80,7 +80,7 @@ export class HttpAnalyticsSender implements IAnalyticsSender { ...(sessionKey !== undefined && sessionKey !== '' ? {sessionId: sessionKey} : {}), userAgent: this.deps.userAgent, }, - // M4.4: forward the cancellation signal so `brv settings set analytics.enabled false` + // M4.4: forward the cancellation signal so `brv settings set analytics.share false` // (or shutdown) can abort an in-flight POST. The http client // classifies aborted requests as `network`, which maps here to // an all-failed result — same as any other transport failure. diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 08b2bfae0..92a7a4e86 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -277,7 +277,7 @@ export async function setupFeatureHandlers({ // M4.4: close the global-config-handler ↔ analyticsClient cycle. // The handler was constructed earlier (so its sync cache was // populated before the client read it); now that the client - // exists, register it so `brv settings set analytics.enabled false` can call + // exists, register it so `brv settings set analytics.share false` can call // `abort()` to cancel any in-flight HTTP. globalConfigHandler.setAnalyticsClient(analyticsClient) @@ -302,7 +302,7 @@ export async function setupFeatureHandlers({ } new SettingsHandler({ analyticsClient, - // Route `analytics.enabled` GET/SET/RESET/LIST through the + // Route `analytics.share` GET/SET/RESET/LIST through the // global-config handler so the canonical storage in config.json, the // device-id seeding race fix, the analytics cache, and the // abort-on-disable side-effect all stay unchanged. diff --git a/src/server/infra/transport/handlers/global-config-handler.ts b/src/server/infra/transport/handlers/global-config-handler.ts index f5b975d5b..5ec19e100 100644 --- a/src/server/infra/transport/handlers/global-config-handler.ts +++ b/src/server/infra/transport/handlers/global-config-handler.ts @@ -19,7 +19,7 @@ import {processLog} from '../../../utils/process-logger.js' export interface GlobalConfigHandlerDeps { /** * M4.4: optional analytics client used to cancel any in-flight HTTP - * send when `brv settings set analytics.enabled false` flips the flag. + * send when `brv settings set analytics.share false` flips the flag. * Disable does NOT drop the queue or clear JSONL — those stay so a * future re-enable ships the backlog. Optional for back-compat with * test harnesses that don't construct a real analytics client. @@ -93,7 +93,7 @@ export class GlobalConfigHandler implements IGlobalConfigRotator { /** * Public async read of the persisted analytics flag. Surfaced for - * the SettingsHandler facade so `brv settings get analytics.enabled` + * the SettingsHandler facade so `brv settings get analytics.share` * resolves through the SAME `globalConfigStore.read()` path that * `globalConfig:get` uses. Returns the on-disk value (or `false` * when no config file exists). @@ -162,7 +162,7 @@ export class GlobalConfigHandler implements IGlobalConfigRotator { /** * Public write of the analytics flag. Surfaced for the SettingsHandler - * facade so `brv settings set analytics.enabled ` goes through + * facade so `brv settings set analytics.share ` goes through * the SAME write path as `globalConfig:setAnalytics` — concurrent-safe * via `writeChain`, refreshes the cache, emits `analytics_disabled`, * triggers the abort-on-disable on the analytics client. diff --git a/src/server/infra/transport/handlers/settings-handler.ts b/src/server/infra/transport/handlers/settings-handler.ts index 0a82a8fd3..e625c8449 100644 --- a/src/server/infra/transport/handlers/settings-handler.ts +++ b/src/server/infra/transport/handlers/settings-handler.ts @@ -46,7 +46,7 @@ export type ReadonlyInfoSnapshot = boolean | number | Readonly Promise | ReadonlyInfoSnapshot /** - * Facade over `GlobalConfigHandler` for the `analytics.enabled` setting. + * Facade over `GlobalConfigHandler` for the `analytics.share` setting. * The settings handler routes GET/SET/RESET/LIST for that key through * this facade instead of `FileSettingsStore`, so the canonical storage * in `config.json`, the device-id seeding race fix, the sync analytics @@ -63,8 +63,8 @@ export interface AnalyticsEnabledFacade { export interface SettingsHandlerDeps { readonly analyticsClient?: IAnalyticsClient /** - * Facade for the `analytics.enabled` writable key. When set, - * GET/SET/RESET/LIST for `analytics.enabled` route through this facade + * Facade for the `analytics.share` writable key. When set, + * GET/SET/RESET/LIST for `analytics.share` route through this facade * instead of the file store. When unset, the key surfaces with * `current: undefined`. */ @@ -187,7 +187,7 @@ export class SettingsHandler { return {error: readOnlyError(data.key), ok: false} } - // Global-config writables (analytics.enabled and any future ones) + // Global-config writables (analytics.share and any future ones) // route through the injected facade. The file store stays // untouched. Type check still applies (boolean for the only // current case), so reuse `checkValueType` before delegating. @@ -299,7 +299,7 @@ export class SettingsHandler { } // Reset on a global-config writable means "back to descriptor.default". - // For analytics.enabled the default is `false`, so we flip via the facade. + // For analytics.share the default is `false`, so we flip via the facade. if (descriptor?.storage === 'global-config') { if (this.globalConfigHandler === undefined) { return { @@ -414,7 +414,7 @@ export class SettingsHandler { } if (descriptor.storage === 'global-config') { - // Global-config-stored values (analytics.enabled) live in + // Global-config-stored values (analytics.share) live in // config.json, not settings.json. Without an injected facade we // cannot resolve — surface `undefined` so the row still renders // rather than crashing. diff --git a/src/server/templates/skill/onboarding.md b/src/server/templates/skill/onboarding.md index ce45f4258..6e4cecc40 100644 --- a/src/server/templates/skill/onboarding.md +++ b/src/server/templates/skill/onboarding.md @@ -287,14 +287,14 @@ After the tour closes, ask **once** whether the user wants to share anonymous us Place the ask _after_ the "Either way, you're set" close, as a single follow-up message — not bundled into Message 3: -> "One optional ask before you go: if you'd like to help us improve ByteRover, you can opt in to share anonymous usage telemetry — things like which commands ran and how long they took. No query content, file contents, or memory is ever sent. You can change your mind anytime with `brv settings set analytics.enabled false`. +> "One optional ask before you go: if you'd like to help us improve ByteRover, you can opt in to share anonymous usage telemetry — things like which commands ran and how long they took. No query content, file contents, or memory is ever sent. You can change your mind anytime with `brv settings set analytics.share false`. > > Want to opt in? Either answer is fine." Handling the response: -- **Yes** → run `brv settings set analytics.enabled true --yes` (or instruct the user to run it if you cannot), then confirm in one line: "Done — thanks. `brv settings set analytics.enabled false` reverses it anytime." -- **No / silence / "maybe later"** → one-line acknowledgement ("No problem — `brv settings set analytics.enabled true` is there whenever.") and stop. Do not re-ask in future sessions. +- **Yes** → run `brv settings set analytics.share true --yes` (or instruct the user to run it if you cannot), then confirm in one line: "Done — thanks. `brv settings set analytics.share false` reverses it anytime." +- **No / silence / "maybe later"** → one-line acknowledgement ("No problem — `brv settings set analytics.share true` is there whenever.") and stop. Do not re-ask in future sessions. Why this beat exists: @@ -304,7 +304,7 @@ Why this beat exists: Skip the ask entirely if: -- Sharing is already enabled (`brv settings get analytics.enabled` returns true). +- Sharing is already enabled (`brv settings get analytics.share` returns true). - The user signaled disengagement at the close ("ok", "got it", "thanks", no further input). Don't pull a yes/no out of someone who's already left. ## What NOT To Do diff --git a/src/shared/analytics/events/analytics-disabled.ts b/src/shared/analytics/events/analytics-disabled.ts index 88776cffe..f45a6ee10 100644 --- a/src/shared/analytics/events/analytics-disabled.ts +++ b/src/shared/analytics/events/analytics-disabled.ts @@ -4,7 +4,7 @@ import {z} from 'zod' * Per-event schema for `analytics_disabled`. * * No properties. The emit captures the moment the user opts out via - * `brv settings set analytics.enabled false`; identity is stamped by the per-event identity + * `brv settings set analytics.share false`; identity is stamped by the per-event identity * resolver and `client_kind` by the super-property layer. The disable * action itself is the entire signal. */ diff --git a/src/shared/assets/analytics-disclosure.md b/src/shared/assets/analytics-disclosure.md index 04b8c672b..4e34fef22 100644 --- a/src/shared/assets/analytics-disclosure.md +++ b/src/shared/assets/analytics-disclosure.md @@ -28,7 +28,7 @@ is permanently linked to your account. ## How to disable -Lorem ipsum: run `brv settings set analytics.enabled false` at any time to +Lorem ipsum: run `brv settings set analytics.share false` at any time to stop collection. ## Privacy policy diff --git a/src/shared/constants/settings-keys.ts b/src/shared/constants/settings-keys.ts index 3bdfd9afc..0ddbea229 100644 --- a/src/shared/constants/settings-keys.ts +++ b/src/shared/constants/settings-keys.ts @@ -10,4 +10,4 @@ * is a typecheck error at every consuming site. */ -export const ANALYTICS_ENABLED_KEY = 'analytics.enabled' as const +export const ANALYTICS_ENABLED_KEY = 'analytics.share' as const diff --git a/src/tui/features/settings/components/settings-page.tsx b/src/tui/features/settings/components/settings-page.tsx index 351fcdf4b..95041b21d 100644 --- a/src/tui/features/settings/components/settings-page.tsx +++ b/src/tui/features/settings/components/settings-page.tsx @@ -150,7 +150,7 @@ export function SettingsPage({onCancel, onComplete}: CustomDialogCallbacks): Rea async (row: SettingsRow) => { if (row.type !== 'boolean' || typeof row.current !== 'boolean') return - // analytics.enabled false -> true requires the disclosure consent + // analytics.share false -> true requires the disclosure consent // prompt. Load the markdown and switch into the confirm-disclosure // mode; the user must press Enter to accept (which fires the actual // SET) or Esc to cancel. diff --git a/test/commands/settings/analytics-enabled.test.ts b/test/commands/settings/analytics-enabled.test.ts index 5cddd5185..5a6b6856a 100644 --- a/test/commands/settings/analytics-enabled.test.ts +++ b/test/commands/settings/analytics-enabled.test.ts @@ -26,7 +26,7 @@ class TestableSettingsGet extends SettingsGet { } /** - * Smoke coverage for the post-M16.4 surface of `analytics.enabled`. + * Smoke coverage for the post-M16.4 surface of `analytics.share`. * * The wire-shape behaviour, facade routing, and disclosure flow are * exercised in depth by: @@ -38,7 +38,7 @@ class TestableSettingsGet extends SettingsGet { * analytics enable / disable` deletion does not leave the value * unreachable via the CLI. */ -describe('brv settings get analytics.enabled (M16.4 smoke)', () => { +describe('brv settings get analytics.share (M16.4 smoke)', () => { let config: Config let mockClient: sinon.SinonStubbedInstance let mockConnector: sinon.SinonStub<[], Promise> @@ -69,7 +69,7 @@ describe('brv settings get analytics.enabled (M16.4 smoke)', () => { current: false, default: false, description: 'Send anonymous telemetry to ByteRover.', - key: 'analytics.enabled', + key: 'analytics.share', ok: true, restartRequired: false, type: 'boolean', @@ -87,14 +87,14 @@ describe('brv settings get analytics.enabled (M16.4 smoke)', () => { restore() }) - it('routes to the SettingsEvents.GET transport event with key=analytics.enabled', async () => { - const command = new TestableSettingsGet(['analytics.enabled'], mockConnector, config) + it('routes to the SettingsEvents.GET transport event with key=analytics.share', async () => { + const command = new TestableSettingsGet(['analytics.share'], mockConnector, config) stub(command, 'log').callsFake(() => {}) await command.run() const calls = (mockClient.requestWithAck as sinon.SinonStub).getCalls() expect(calls.length, 'one requestWithAck call').to.equal(1) expect(calls[0].args[0]).to.equal(SettingsEvents.GET) - expect(calls[0].args[1]).to.deep.equal({key: 'analytics.enabled'}) + expect(calls[0].args[1]).to.deep.equal({key: 'analytics.share'}) }) }) diff --git a/test/commands/settings/set.test.ts b/test/commands/settings/set.test.ts index 3c97c26e1..61e4dfc65 100644 --- a/test/commands/settings/set.test.ts +++ b/test/commands/settings/set.test.ts @@ -414,7 +414,7 @@ describe('brv settings set', () => { }) describe('--yes flag scope (bot review #2)', () => { - it('warns when --yes is passed for a key other than analytics.enabled', async () => { + it('warns when --yes is passed for a key other than analytics.share', async () => { dispatchByEvent((event) => { if (event === SettingsEvents.GET) return makeGetResponse('agentPool.maxSize', 10) if (event === SettingsEvents.SET) return {ok: true, restartRequired: true} @@ -430,17 +430,17 @@ describe('brv settings set', () => { const warn = warnMessages.join('\n') expect(warn).to.match(/--yes/) - expect(warn).to.match(/analytics\.enabled/) + expect(warn).to.match(/analytics\.share/) }) - it('does NOT warn when --yes is passed for analytics.enabled', async () => { + it('does NOT warn when --yes is passed for analytics.share', async () => { dispatchByEvent((event) => { - if (event === SettingsEvents.GET) return makeBooleanGetResponse('analytics.enabled', false) + if (event === SettingsEvents.GET) return makeBooleanGetResponse('analytics.share', false) if (event === SettingsEvents.SET) return {ok: true, restartRequired: false} throw new Error('unexpected event') }) - await createCommand('analytics.enabled', 'true', '-y').run() + await createCommand('analytics.share', 'true', '-y').run() expect(warnMessages, 'no leaky-flag warning on the analytics key').to.deep.equal([]) }) @@ -449,7 +449,7 @@ describe('brv settings set', () => { describe('--format json + interactive consent (bot review #3)', () => { it('refuses to prompt in JSON mode without --yes and emits a requires_consent error envelope', async () => { dispatchByEvent((event) => { - if (event === SettingsEvents.GET) return makeBooleanGetResponse('analytics.enabled', false) + if (event === SettingsEvents.GET) return makeBooleanGetResponse('analytics.share', false) if (event === SettingsEvents.SET) { throw new Error('SET must not be dispatched when consent is required and refused') } @@ -457,14 +457,14 @@ describe('brv settings set', () => { throw new Error('unexpected event') }) - await createJsonCommand('analytics.enabled', 'true').run() + await createJsonCommand('analytics.share', 'true').run() const json = parseJsonOutput() expect(json.command).to.equal('settings set') expect(json.success).to.be.false const {error} = json.data as {error: {code: string; key: string; message: string}} expect(error.code).to.equal('requires_consent') - expect(error.key).to.equal('analytics.enabled') + expect(error.key).to.equal('analytics.share') expect(error.message.toLowerCase()).to.match(/--yes|disclosure/) expect(process.exitCode).to.equal(1) @@ -476,12 +476,12 @@ describe('brv settings set', () => { it('passes through in JSON mode WITH --yes (consent gate satisfied silently)', async () => { dispatchByEvent((event) => { - if (event === SettingsEvents.GET) return makeBooleanGetResponse('analytics.enabled', false) + if (event === SettingsEvents.GET) return makeBooleanGetResponse('analytics.share', false) if (event === SettingsEvents.SET) return {ok: true, restartRequired: false} throw new Error('unexpected event') }) - await createJsonCommand('analytics.enabled', 'true', '-y').run() + await createJsonCommand('analytics.share', 'true', '-y').run() const json = parseJsonOutput() expect(json.success).to.be.true diff --git a/test/e2e/analytics/dev-beta.e2e.ts b/test/e2e/analytics/dev-beta.e2e.ts index be29389dd..f0fd06180 100644 --- a/test/e2e/analytics/dev-beta.e2e.ts +++ b/test/e2e/analytics/dev-beta.e2e.ts @@ -110,11 +110,11 @@ function bootDaemon(env: NodeJS.ProcessEnv): {ok: boolean; reason?: string} { } function enableAnalytics(env: NodeJS.ProcessEnv): {ok: boolean; reason?: string} { - return runBrv(['settings', 'set', 'analytics.enabled', 'true', '--yes'], env) + return runBrv(['settings', 'set', 'analytics.share', 'true', '--yes'], env) } function disableAnalytics(env: NodeJS.ProcessEnv): {ok: boolean; reason?: string} { - return runBrv(['settings', 'set', 'analytics.enabled', 'false'], env) + return runBrv(['settings', 'set', 'analytics.share', 'false'], env) } function jsonlPath(dataDir: string): string { diff --git a/test/e2e/analytics/lifecycle-db.e2e.ts b/test/e2e/analytics/lifecycle-db.e2e.ts index 1d1472b44..b2129d7ca 100644 --- a/test/e2e/analytics/lifecycle-db.e2e.ts +++ b/test/e2e/analytics/lifecycle-db.e2e.ts @@ -332,7 +332,7 @@ describe('analytics lifecycle DB roundtrip e2e (M14 / M15.6)', function () { scenario = makeScenarioEnv() cleanupDirs.push(scenario.dataDir, scenario.home) - expect(runBrv(['settings', 'set', 'analytics.enabled', 'true', '--yes'], scenario.env), 'analytics enable').to.deep.include({ok: true}) + expect(runBrv(['settings', 'set', 'analytics.share', 'true', '--yes'], scenario.env), 'analytics enable').to.deep.include({ok: true}) expect(runBrv(['status'], scenario.env), 'daemon boot via status').to.deep.include({ok: true}) }) diff --git a/test/e2e/analytics/lifecycle-wire.e2e.ts b/test/e2e/analytics/lifecycle-wire.e2e.ts index 5d52595aa..ae9dd616f 100644 --- a/test/e2e/analytics/lifecycle-wire.e2e.ts +++ b/test/e2e/analytics/lifecycle-wire.e2e.ts @@ -234,11 +234,11 @@ describe('analytics lifecycle wire e2e (M14 / M15.6)', function () { cleanupDirs.push(scenario.dataDir, scenario.home) // Match dev-beta.e2e.ts: enable BEFORE boot. `settings set - // analytics.enabled true` itself starts a daemon via transport autostart, + // analytics.share true` itself starts a daemon via transport autostart, // AND the analytics flush scheduler reads the enabled flag at boot time. // If we boot first (with analytics disabled) then flip the flag, the // scheduler stays dormant. - expect(runBrv(['settings', 'set', 'analytics.enabled', 'true', '--yes'], scenario.env), 'analytics enable').to.deep.include({ok: true}) + expect(runBrv(['settings', 'set', 'analytics.share', 'true', '--yes'], scenario.env), 'analytics enable').to.deep.include({ok: true}) expect(runBrv(['status'], scenario.env), 'daemon boot via status').to.deep.include({ok: true}) }) diff --git a/test/integration/analytics/daemon-tracking.test.ts b/test/integration/analytics/daemon-tracking.test.ts index f2cd44621..e0122010c 100644 --- a/test/integration/analytics/daemon-tracking.test.ts +++ b/test/integration/analytics/daemon-tracking.test.ts @@ -66,7 +66,7 @@ describe('daemon analytics tracking integration (ticket scenario 6)', () => { it('should land daemon_start in the queue with full identity + super properties when analytics is enabled', async () => { // Pre-seed the on-disk config so analytics is enabled and deviceId is stable // for assertions. This mirrors what M1.3's `brv analytics enable` - // (now `brv settings set analytics.enabled true`) writes. + // (now `brv settings set analytics.share true`) writes. const seeded = GlobalConfig.fromJson({analytics: true, deviceId: validDeviceId, version: '0.0.1'}) if (!seeded) throw new Error('test fixture: seeded GlobalConfig must be valid') await store.write(seeded) diff --git a/test/unit/core/domain/entities/settings-registry.test.ts b/test/unit/core/domain/entities/settings-registry.test.ts index 3e0b7e7fe..48eddf92d 100644 --- a/test/unit/core/domain/entities/settings-registry.test.ts +++ b/test/unit/core/domain/entities/settings-registry.test.ts @@ -155,7 +155,7 @@ describe('settings registry — M7 T2 shape', () => { category: 'analytics', default: false, description: 'analytics opt-in', - key: '_test.analytics.enabled', + key: '_test.analytics.share', restartRequired: false, type: 'boolean', } @@ -214,12 +214,12 @@ describe('settings registry — M7 T2 shape', () => { }) }) - describe('analytics.enabled descriptor (M16.2)', () => { + describe('analytics.share descriptor (M16.2)', () => { it('exposes ANALYTICS_ENABLED on SETTINGS_KEYS', () => { - expect(SETTINGS_KEYS.ANALYTICS_ENABLED).to.equal('analytics.enabled') + expect(SETTINGS_KEYS.ANALYTICS_ENABLED).to.equal('analytics.share') }) - it('registers a descriptor for analytics.enabled', () => { + it('registers a descriptor for analytics.share', () => { const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_ENABLED) expect(descriptor, 'descriptor must exist in SETTINGS_REGISTRY').to.exist }) @@ -238,11 +238,11 @@ describe('settings registry — M7 T2 shape', () => { it('declares storage=global-config so the file store skips persistence', () => { const descriptor = findSettingDescriptor(SETTINGS_KEYS.ANALYTICS_ENABLED) // `storage` is an optional field on writable descriptors; defaults to 'file'. - // analytics.enabled lives in `config.json`, not `settings.json`. + // analytics.share lives in `config.json`, not `settings.json`. if (descriptor?.type === 'boolean') { expect(descriptor.storage).to.equal('global-config') } else { - expect.fail('expected boolean descriptor for analytics.enabled') + expect.fail('expected boolean descriptor for analytics.share') } }) diff --git a/test/unit/infra/storage/file-settings-store.test.ts b/test/unit/infra/storage/file-settings-store.test.ts index 085847d87..982ce7944 100644 --- a/test/unit/infra/storage/file-settings-store.test.ts +++ b/test/unit/infra/storage/file-settings-store.test.ts @@ -54,7 +54,7 @@ describe('FileSettingsStore', () => { expect(keys).to.deep.equal([ 'agentPool.maxConcurrentTasksPerProject', 'agentPool.maxSize', - 'analytics.enabled', + 'analytics.share', 'analytics.status', 'llm.iterationBudgetMs', 'llm.requestTimeoutMs', @@ -64,9 +64,9 @@ describe('FileSettingsStore', () => { for (const item of items) { // Non-file-stored rows carry current/default both undefined: // - analytics.status (readonly-info) - // - analytics.enabled (storage=global-config) + // - analytics.share (storage=global-config) // Writable file-stored rows have current === default when no override is present. - if (item.key === 'analytics.status' || item.key === 'analytics.enabled') { + if (item.key === 'analytics.status' || item.key === 'analytics.share') { expect(item.current).to.equal(undefined) expect(item.default).to.equal(undefined) } else { diff --git a/test/unit/infra/transport/handlers/settings-handler.test.ts b/test/unit/infra/transport/handlers/settings-handler.test.ts index c386571bb..fb36733d9 100644 --- a/test/unit/infra/transport/handlers/settings-handler.test.ts +++ b/test/unit/infra/transport/handlers/settings-handler.test.ts @@ -94,7 +94,7 @@ describe('SettingsHandler', () => { expect(result.items.map((i) => i.key).sort()).to.deep.equal([ 'agentPool.maxConcurrentTasksPerProject', 'agentPool.maxSize', - 'analytics.enabled', + 'analytics.share', 'analytics.status', 'llm.iterationBudgetMs', 'llm.requestTimeoutMs', @@ -604,7 +604,7 @@ describe('SettingsHandler', () => { }) }) - describe('analytics.enabled facade routing (M16.2 — production registry)', () => { + describe('analytics.share facade routing (M16.2 — production registry)', () => { type AnalyticsFacadeStub = { readonly calls: Array<{args: unknown[]; method: string}> currentValue: boolean @@ -629,10 +629,10 @@ describe('SettingsHandler', () => { return stub } - it('GET on analytics.enabled reads from the injected globalConfigHandler (true)', async () => { + it('GET on analytics.share reads from the injected globalConfigHandler (true)', async () => { const facade = makeFacade(true) const localStore = new StubSettingsStore() - localStore.listResult = [{current: undefined, key: 'analytics.enabled', restartRequired: false}] + localStore.listResult = [{current: undefined, key: 'analytics.share', restartRequired: false}] const localTransport = createMockTransportServer() new SettingsHandler({ globalConfigHandler: facade, @@ -642,7 +642,7 @@ describe('SettingsHandler', () => { const handler = localTransport._handlers.get(SettingsEvents.GET) if (!handler) throw new Error('GET handler not registered') - const result = (await handler({key: 'analytics.enabled'}, 'test-client')) as SettingsGetResponse + const result = (await handler({key: 'analytics.share'}, 'test-client')) as SettingsGetResponse expect(result.ok).to.be.true if (result.ok) { @@ -653,7 +653,7 @@ describe('SettingsHandler', () => { } }) - it('SET on analytics.enabled calls globalConfigHandler.setAnalyticsValue, NOT store.set', async () => { + it('SET on analytics.share calls globalConfigHandler.setAnalyticsValue, NOT store.set', async () => { const facade = makeFacade(false) const localStore = new StubSettingsStore() const localTransport = createMockTransportServer() @@ -665,7 +665,7 @@ describe('SettingsHandler', () => { const handler = localTransport._handlers.get(SettingsEvents.SET) if (!handler) throw new Error('SET handler not registered') - const result = (await handler({key: 'analytics.enabled', value: true}, 'test-client')) as SettingsSetResponse + const result = (await handler({key: 'analytics.share', value: true}, 'test-client')) as SettingsSetResponse expect(result.ok).to.be.true if (result.ok) { @@ -679,7 +679,7 @@ describe('SettingsHandler', () => { expect(storeSetCalls, 'file store must not be touched').to.have.lengthOf(0) }) - it('RESET on analytics.enabled flips the globalConfig value to false, NOT store.reset', async () => { + it('RESET on analytics.share flips the globalConfig value to false, NOT store.reset', async () => { const facade = makeFacade(true) const localStore = new StubSettingsStore() const localTransport = createMockTransportServer() @@ -691,7 +691,7 @@ describe('SettingsHandler', () => { const handler = localTransport._handlers.get(SettingsEvents.RESET) if (!handler) throw new Error('RESET handler not registered') - const result = (await handler({key: 'analytics.enabled'}, 'test-client')) as SettingsResetResponse + const result = (await handler({key: 'analytics.share'}, 'test-client')) as SettingsResetResponse expect(result.ok).to.be.true const setCalls = facade.calls.filter((c) => c.method === 'setAnalyticsValue') @@ -701,7 +701,7 @@ describe('SettingsHandler', () => { expect(storeResetCalls, 'file store must not be touched').to.have.lengthOf(0) }) - it('LIST surfaces analytics.enabled with the value from globalConfigHandler', async () => { + it('LIST surfaces analytics.share with the value from globalConfigHandler', async () => { const facade = makeFacade(true) const localStore = new StubSettingsStore() localStore.listResult = [] @@ -716,14 +716,14 @@ describe('SettingsHandler', () => { if (!handler) throw new Error('LIST handler not registered') const result = (await handler(undefined, 'test-client')) as SettingsListResponse - const row = result.items.find((i) => i.key === 'analytics.enabled') - expect(row, 'analytics.enabled row present').to.exist + const row = result.items.find((i) => i.key === 'analytics.share') + expect(row, 'analytics.share row present').to.exist expect(row?.type).to.equal('boolean') expect(row?.current).to.equal(true) expect(row?.default).to.equal(false) }) - it('SET on analytics.enabled emits SETTING_CHANGED with value_kind=boolean and outcome=success', async () => { + it('SET on analytics.share emits SETTING_CHANGED with value_kind=boolean and outcome=success', async () => { const facade = makeFacade(false) const localStore = new StubSettingsStore() const localTransport = createMockTransportServer() @@ -739,7 +739,7 @@ describe('SettingsHandler', () => { const handler = localTransport._handlers.get(SettingsEvents.SET) if (!handler) throw new Error('SET handler not registered') - await handler({key: 'analytics.enabled', value: true}, 'test-client') + await handler({key: 'analytics.share', value: true}, 'test-client') const setting = trackCalls.find((c) => (c.args[0] as string).endsWith('setting_changed')) expect(setting, 'SETTING_CHANGED emitted').to.exist @@ -748,15 +748,15 @@ describe('SettingsHandler', () => { expect(props.value_kind).to.equal('boolean') }) - it('GET on analytics.enabled with NO injected facade returns current=undefined (graceful)', async () => { + it('GET on analytics.share with NO injected facade returns current=undefined (graceful)', async () => { const localStore = new StubSettingsStore() - localStore.listResult = [{current: undefined, key: 'analytics.enabled', restartRequired: false}] + localStore.listResult = [{current: undefined, key: 'analytics.share', restartRequired: false}] const localTransport = createMockTransportServer() new SettingsHandler({store: localStore, transport: localTransport}).setup() const handler = localTransport._handlers.get(SettingsEvents.GET) if (!handler) throw new Error('GET handler not registered') - const result = (await handler({key: 'analytics.enabled'}, 'test-client')) as SettingsGetResponse + const result = (await handler({key: 'analytics.share'}, 'test-client')) as SettingsGetResponse expect(result.ok).to.be.true if (result.ok) { @@ -764,14 +764,14 @@ describe('SettingsHandler', () => { } }) - it('SET on analytics.enabled with NO injected facade returns code=misconfigured (not invalid_value)', async () => { + it('SET on analytics.share with NO injected facade returns code=misconfigured (not invalid_value)', async () => { const localStore = new StubSettingsStore() const localTransport = createMockTransportServer() new SettingsHandler({store: localStore, transport: localTransport}).setup() const handler = localTransport._handlers.get(SettingsEvents.SET) if (!handler) throw new Error('SET handler not registered') - const result = (await handler({key: 'analytics.enabled', value: true}, 'test-client')) as SettingsSetResponse + const result = (await handler({key: 'analytics.share', value: true}, 'test-client')) as SettingsSetResponse expect(result.ok).to.be.false if (!result.ok) { @@ -779,24 +779,24 @@ describe('SettingsHandler', () => { // user-supplied bad value. Distinct code so logs / WebUI can route // the alert at the right team. expect(result.error.code).to.equal('misconfigured') - expect(result.error.key).to.equal('analytics.enabled') + expect(result.error.key).to.equal('analytics.share') expect(result.error.message.toLowerCase()).to.match(/global ?config|facade/) } }) - it('RESET on analytics.enabled with NO injected facade returns code=misconfigured (not invalid_value)', async () => { + it('RESET on analytics.share with NO injected facade returns code=misconfigured (not invalid_value)', async () => { const localStore = new StubSettingsStore() const localTransport = createMockTransportServer() new SettingsHandler({store: localStore, transport: localTransport}).setup() const handler = localTransport._handlers.get(SettingsEvents.RESET) if (!handler) throw new Error('RESET handler not registered') - const result = (await handler({key: 'analytics.enabled'}, 'test-client')) as SettingsResetResponse + const result = (await handler({key: 'analytics.share'}, 'test-client')) as SettingsResetResponse expect(result.ok).to.be.false if (!result.ok) { expect(result.error.code).to.equal('misconfigured') - expect(result.error.key).to.equal('analytics.enabled') + expect(result.error.key).to.equal('analytics.share') } }) diff --git a/test/unit/server/infra/analytics/analytics-client.test.ts b/test/unit/server/infra/analytics/analytics-client.test.ts index 8e56b38a6..57b521b9b 100644 --- a/test/unit/server/infra/analytics/analytics-client.test.ts +++ b/test/unit/server/infra/analytics/analytics-client.test.ts @@ -193,7 +193,7 @@ describe('AnalyticsClient', () => { // Pre-M4.4 this test asserted "no-op when disabled" (no JSONL append, // no queue push, no resolver calls). Post-M4.4 the semantic is // "local tracking always; remote send only when enabled" — disable - // gates the FLUSH layer, not the TRACK layer. `brv settings set analytics.enabled false` + // gates the FLUSH layer, not the TRACK layer. `brv settings set analytics.share false` // means "stop shipping to remote", not "stop collecting locally". it('still tracks (JSONL + queue + resolvers) when isEnabled returns false; flush is the gate', async () => { const queue = new BoundedQueue() diff --git a/test/unit/shared/assets/analytics-disclosure-content.test.ts b/test/unit/shared/assets/analytics-disclosure-content.test.ts index 135333565..4f437628b 100644 --- a/test/unit/shared/assets/analytics-disclosure-content.test.ts +++ b/test/unit/shared/assets/analytics-disclosure-content.test.ts @@ -29,7 +29,7 @@ describe('analytics-disclosure.md content contract', () => { // Pin the new disable instruction to the post-M16.4 surface. A regression // that re-introduces the deleted `brv analytics disable` command (or any // other variant) fails here loudly. - expect(text, 'how-to-disable section').to.match(/brv settings set analytics\.enabled false/i) + expect(text, 'how-to-disable section').to.match(/brv settings set analytics\.share false/i) expect(text, 'how-to-disable must not reference the deleted command').to.not.match(/brv analytics disable/i) expect(text, 'privacy policy link').to.include(PRIVACY_POLICY_URL) }) diff --git a/test/unit/shared/utils/format-settings.test.ts b/test/unit/shared/utils/format-settings.test.ts index 27c453b34..896acc5bd 100644 --- a/test/unit/shared/utils/format-settings.test.ts +++ b/test/unit/shared/utils/format-settings.test.ts @@ -148,7 +148,7 @@ describe('format-settings (shared)', () => { }) it('maps the analytics category onto the row instead of falling back to other', () => { - const rows = buildSettingsRows([makeItem({category: 'analytics', key: 'analytics.enabled'})]) + const rows = buildSettingsRows([makeItem({category: 'analytics', key: 'analytics.share'})]) expect(rows[0].category).to.equal('analytics') }) })