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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/server/infra/daemon/brv-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,11 @@ async function main(): Promise<void> {
// 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}
},
Comment thread
cuongdo-byterover marked this conversation as resolved.
isEnabled: () => isAnalyticsEnabledRef(),
})

Expand Down
105 changes: 88 additions & 17 deletions src/server/infra/process/analytics-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
cuongdo-byterover marked this conversation as resolved.

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<ProjectIdentity>
/**
* Returns the daemon's cached analytics-enabled flag. Used by M12.3 to
* short-circuit frontmatter file reads when analytics is disabled (avoids
Expand All @@ -229,9 +245,25 @@ type AnalyticsHookDeps = {
readFile?: (filePath: string, encoding: 'utf8') => Promise<string>
}

/**
* 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<ProjectIdentity>
private readonly isEnabled: () => boolean
/**
* Per-task FIFO of in-flight `onToolResult` processing. Without this the
Expand All @@ -248,6 +280,7 @@ export class AnalyticsHook implements ITaskLifecycleHook {
private readonly tasks = new Map<string, TaskAnalyticsState>()

constructor(deps: AnalyticsHookDeps = {}) {
this.getIdentity = deps.getIdentity ?? (async (): Promise<ProjectIdentity> => ({}))
this.isEnabled = deps.isEnabled ?? (() => true)
this.readFile = deps.readFile ?? readFileAsync
}
Expand Down Expand Up @@ -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}),
)
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}),
}
}
Expand All @@ -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}),
)
}
}
Expand Down Expand Up @@ -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<ProjectIdentity> {
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 {}
}
}
Comment thread
cuongdo-byterover marked this conversation as resolved.
}

/**
Expand Down
13 changes: 13 additions & 0 deletions src/shared/analytics/events/curate-run-completed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
13 changes: 13 additions & 0 deletions src/shared/analytics/events/query-completed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading