diff --git a/src/server/infra/daemon/brv-server.ts b/src/server/infra/daemon/brv-server.ts index cd8f7bc89..4a061b29a 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 getIdentity(projectPath) { + if (!projectPath) return {} + const config = await projectStateLoader.getProjectConfig(projectPath) + 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 c3c4b8b8d..91e539ced 100644 --- a/src/server/infra/process/analytics-hook.ts +++ b/src/server/infra/process/analytics-hook.ts @@ -198,21 +198,37 @@ 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 + * without a spaceId (mid-onboarding) or neither (standalone). */ +type ProjectIdentity = { + spaceId?: string + teamId?: string +} + type AnalyticsHookDeps = { + /** + * 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. + * + * 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 /** * Returns the daemon's cached analytics-enabled flag. Used by M12.3 to * short-circuit frontmatter file reads when analytics is disabled (avoids @@ -229,9 +245,25 @@ 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 + private readonly getIdentity: (projectPath: string | undefined) => Promise private readonly isEnabled: () => boolean /** * Per-task FIFO of in-flight `onToolResult` processing. Without this the @@ -248,6 +280,7 @@ export class AnalyticsHook implements ITaskLifecycleHook { private readonly tasks = new Map() constructor(deps: AnalyticsHookDeps = {}) { + this.getIdentity = deps.getIdentity ?? (async (): Promise => ({})) this.isEnabled = deps.isEnabled ?? (() => true) this.readFile = deps.readFile ?? readFileAsync } @@ -277,14 +310,16 @@ export class AnalyticsHook implements ITaskLifecycleHook { if (state.flavor === 'curate') { const outcome = state.counters.failed > 0 ? 'partial' : 'completed' + const identity = await this.resolveIdentity(task.projectPath ?? state.projectPath) this.emit( AnalyticsEventNames.CURATE_RUN_COMPLETED, - this.buildCurateRunPayload({outcome, state, task, taskId}), + this.buildCurateRunPayload({identity, outcome, state, task, taskId}), ) } else { + const identity = await this.resolveIdentity(task.projectPath) this.emit( AnalyticsEventNames.QUERY_COMPLETED, - await this.buildQueryCompletedPayload({outcome: 'completed', state, task, taskId}), + await this.buildQueryCompletedPayload({identity, outcome: 'completed', state, task, taskId}), ) } } @@ -378,11 +413,13 @@ export class AnalyticsHook implements ITaskLifecycleHook { } private buildCurateRunPayload({ + identity, outcome, state, task, taskId, }: { + identity: ProjectIdentity outcome: 'cancelled' | 'completed' | 'error' | 'partial' state: CurateTaskAnalyticsState task: TaskInfo @@ -398,17 +435,21 @@ export class AnalyticsHook implements ITaskLifecycleHook { outcome, pending_review_count: state.counters.pendingReview, ...projectPathHashOptional(task.projectPath ?? state.projectPath), + ...(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, state, task, taskId, }: { + identity: ProjectIdentity outcome: 'cancelled' | 'completed' | 'error' state: QueryTaskAnalyticsState task: TaskInfo @@ -491,8 +532,10 @@ export class AnalyticsHook implements ITaskLifecycleHook { ...(readPathsWithMetadata.length > 0 ? {read_paths_with_metadata: readPathsWithMetadata} : {}), read_tool_call_count: readToolCallCount, search_call_count: searchCallCount, + ...(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}), } } @@ -506,14 +549,16 @@ export class AnalyticsHook implements ITaskLifecycleHook { await this.pendingByTask.get(taskId) if (state.flavor === 'curate') { + const identity = await this.resolveIdentity(task.projectPath ?? state.projectPath) this.emit( AnalyticsEventNames.CURATE_RUN_COMPLETED, - this.buildCurateRunPayload({outcome, state, task, taskId}), + this.buildCurateRunPayload({identity, outcome, state, task, taskId}), ) } else { + const identity = await this.resolveIdentity(task.projectPath) this.emit( AnalyticsEventNames.QUERY_COMPLETED, - await this.buildQueryCompletedPayload({outcome, state, task, taskId}), + await this.buildQueryCompletedPayload({identity, outcome, state, task, taskId}), ) } } @@ -690,6 +735,32 @@ export class AnalyticsHook implements ITaskLifecycleHook { return {} } } + + /** + * 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. + * + * 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 { + 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: getIdentity failed: ${error instanceof Error ? error.message : String(error)}`, + ) + return {} + } + } } /** diff --git a/src/shared/analytics/events/curate-run-completed.ts b/src/shared/analytics/events/curate-run-completed.ts index f4e5cba6e..2f12309e2 100644 --- a/src/shared/analytics/events/curate-run-completed.ts +++ b/src/shared/analytics/events/curate-run-completed.ts @@ -28,8 +28,21 @@ 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), + /** + * 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 b77007748..2ab860981 100644 --- a/src/shared/analytics/events/query-completed.ts +++ b/src/shared/analytics/events/query-completed.ts @@ -67,8 +67,21 @@ 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), + /** + * 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 6e3ee8051..f69da4ecd 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,147 @@ describe('AnalyticsHook', () => { expect(filterM12(bundle.trackStub), 'no replay after cleanup').to.have.lengthOf(2) }) }) + + 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({getIdentity: async () => ({spaceId: 'space-abc', teamId: 'team-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') + expect(curateProps.team_id).to.equal('team-abc') + }) + + it('stamps both space_id and team_id on query_completed when getIdentity returns them', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getIdentity: async () => ({spaceId: 'space-xyz', teamId: 'team-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') + expect(queryProps.team_id).to.equal('team-xyz') + }) + + it('stamps team_id alone when spaceId is absent (mid-onboarding state)', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getIdentity: async () => ({teamId: 'team-only'})}) + 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.team_id).to.equal('team-only') + expect(curateProps).to.not.have.property('space_id') + }) + + it('stamps space_id alone when teamId is absent', async () => { + const bundle = buildAnalyticsClient() + 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() + 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') + expect(queryProps).to.not.have.property('team_id') + }) + + it('omits both fields and still emits when getIdentity throws', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({ + async getIdentity() { + 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') + 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 both fields on the failure-path emits (onTaskError)', async () => { + const bundle = buildAnalyticsClient() + const spacedHook = new AnalyticsHook({getIdentity: async () => ({spaceId: 'space-fail', teamId: 'team-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') + 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') + }) + }) }) 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..63ebc7cfa 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,24 @@ 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 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) + }) }) describe('invalid payloads', () => { @@ -90,5 +108,15 @@ 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) + }) + + 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 197df52ef..37a4c42e6 100644 --- a/test/unit/shared/analytics/events/query-completed.test.ts +++ b/test/unit/shared/analytics/events/query-completed.test.ts @@ -94,6 +94,24 @@ 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 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) + }) + it('accepts related_paths with up to 50 structured entries', () => { const fifty = Array.from({length: 50}, (_, i) => ({ keywords: [], @@ -182,6 +200,16 @@ 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 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)