Skip to content
5 changes: 5 additions & 0 deletions src/server/infra/process/feature-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,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 {
AnalyticsDisclosureHandler,
AnalyticsHandler,
AnalyticsListHandler,
AnalyticsStatusHandler,
Expand Down Expand Up @@ -300,6 +301,10 @@ export async function setupFeatureHandlers({
// (TUI, oclif, MCP, webui) to the same singleton.
new AnalyticsHandler({analyticsClient, transport}).setup()

// Serves the canonical analytics disclosure markdown to the local
// web UI so it renders the same text as the CLI consent prompt.
new AnalyticsDisclosureHandler({transport}).setup()

// Global SettingsHandler (no project context). Deferred from line 180 so
// analyticsClient is in scope for M15.4 `setting_changed` / `setting_reset`
// emits. M16.3 wires the `analytics.status` readonly-info provider so
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js'

import {
type AnalyticsDisclosureResponse,
type AnalyticsDisclosureSection,
AnalyticsEvents,
} from '../../../../shared/transport/events/analytics-events.js'
import {loadAnalyticsDisclosureText} from '../../../../shared/utils/load-analytics-disclosure.js'
import {parseAnalyticsDisclosure} from '../../../../shared/utils/parse-analytics-disclosure.js'

export interface AnalyticsDisclosureHandlerDeps {
readonly loadDisclosure?: () => Promise<string>
readonly transport: ITransportServer
}

export class AnalyticsDisclosureHandler {
private cachedSections: AnalyticsDisclosureSection[] | undefined
private readonly loadDisclosure: () => Promise<string>
private readonly transport: ITransportServer

public constructor(deps: AnalyticsDisclosureHandlerDeps) {
this.loadDisclosure = deps.loadDisclosure ?? loadAnalyticsDisclosureText
this.transport = deps.transport
}

public setup(): void {
this.transport.onRequest<void, AnalyticsDisclosureResponse>(AnalyticsEvents.GET_DISCLOSURE, async () => ({
sections: await this.getSections(),
}))
}

private async getSections(): Promise<AnalyticsDisclosureSection[]> {
if (this.cachedSections) return this.cachedSections

const markdown = await this.loadDisclosure()
const sections = parseAnalyticsDisclosure(markdown)
if (sections.length === 0) {
throw new Error('Analytics disclosure is missing or contains no sections.')
}

this.cachedSections = sections
return sections
}
Comment thread
ncnthien marked this conversation as resolved.
}
2 changes: 2 additions & 0 deletions src/server/infra/transport/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export {AnalyticsDisclosureHandler} from './analytics-disclosure-handler.js'
export type {AnalyticsDisclosureHandlerDeps} from './analytics-disclosure-handler.js'
export {AnalyticsHandler} from './analytics-handler.js'
export type {AnalyticsHandlerDeps} from './analytics-handler.js'
export {AnalyticsListHandler} from './analytics-list-handler.js'
Expand Down
4 changes: 2 additions & 2 deletions src/shared/assets/analytics-disclosure.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ is permanently linked to your ByteRover account.

You can stop sharing at any time by running:

```
```bash
brv settings set analytics.share false
```

Expand All @@ -53,4 +53,4 @@ You can also toggle the `analytics.share` setting from the Settings page in the

For full details on how ByteRover handles your data, see:

https://byterover.dev/privacy
https://byterover.dev/services/privacy
7 changes: 1 addition & 6 deletions src/shared/constants/privacy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1 @@
/**
* 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'
export const PRIVACY_POLICY_URL = 'https://www.byterover.dev/services/privacy'
19 changes: 19 additions & 0 deletions src/shared/transport/events/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,30 @@ import {CliRequestBaseSchema} from '../../analytics/cli-metadata-schema.js'
import {StoredAnalyticsRecordSchema} from '../../analytics/stored-record.js'

export const AnalyticsEvents = {
GET_DISCLOSURE: 'analytics:getDisclosure',
LIST: 'analytics:list',
STATUS: 'analytics:status',
TRACK: 'analytics:track',
} as const

/**
* Response schema for `analytics:getDisclosure`. Sections are parsed
* daemon-side from `src/shared/assets/analytics-disclosure.md` (one entry per
* `## H2` heading) so the local web UI can render them in its icon-grid
* layout. Single source of truth stays the markdown file.
*/
export const AnalyticsDisclosureSectionSchema = z.object({
body: z.string().min(1),
label: z.string().min(1),
})

export const AnalyticsDisclosureResponseSchema = z.object({
sections: z.array(AnalyticsDisclosureSectionSchema).min(1),
})
Comment thread
ncnthien marked this conversation as resolved.

export type AnalyticsDisclosureSection = z.infer<typeof AnalyticsDisclosureSectionSchema>
export type AnalyticsDisclosureResponse = z.infer<typeof AnalyticsDisclosureResponseSchema>

/**
* M4.6 `analytics:status` response. Surfaces operational metrics for
* `brv settings get analytics.status`: enabled flag (from GlobalConfig), client
Expand Down
33 changes: 33 additions & 0 deletions src/shared/utils/parse-analytics-disclosure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export type AnalyticsDisclosureSection = {
body: string
label: string
}

const H2_PATTERN = /^##\s+(.+?)\s*$/

export function parseAnalyticsDisclosure(markdown: string): AnalyticsDisclosureSection[] {
const sections: AnalyticsDisclosureSection[] = []
let currentLabel: string | undefined
let currentBodyLines: string[] = []

function pushCurrent() {
if (currentLabel === undefined) return
const body = currentBodyLines.join('\n').trim()
if (body.length > 0) sections.push({body, label: currentLabel})
}

for (const line of markdown.split('\n')) {
const match = H2_PATTERN.exec(line)
if (match) {
pushCurrent()
currentLabel = match[1]
currentBodyLines = []
continue
}

if (currentLabel !== undefined) currentBodyLines.push(line)
}

pushCurrent()
return sections
}
32 changes: 32 additions & 0 deletions src/webui/features/analytics/api/get-analytics-disclosure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {queryOptions, useQuery} from '@tanstack/react-query'

import type {QueryConfig} from '../../../lib/react-query'

import {
type AnalyticsDisclosureResponse,
AnalyticsEvents,
} from '../../../../shared/transport/events/analytics-events.js'
import {useTransportStore} from '../../../stores/transport-store'

export const getAnalyticsDisclosure = (): Promise<AnalyticsDisclosureResponse> => {
const {apiClient} = useTransportStore.getState()
if (!apiClient) return Promise.reject(new Error('Not connected'))
return apiClient.request<AnalyticsDisclosureResponse, void>(AnalyticsEvents.GET_DISCLOSURE)
}

export const getAnalyticsDisclosureQueryOptions = () =>
queryOptions({
queryFn: getAnalyticsDisclosure,
queryKey: ['analyticsDisclosure'],
staleTime: Number.POSITIVE_INFINITY,
})
Comment thread
ncnthien marked this conversation as resolved.

type UseGetAnalyticsDisclosureOptions = {
queryConfig?: QueryConfig<typeof getAnalyticsDisclosureQueryOptions>
}

export const useGetAnalyticsDisclosure = ({queryConfig}: UseGetAnalyticsDisclosureOptions = {}) =>
useQuery({
...getAnalyticsDisclosureQueryOptions(),
...queryConfig,
})
6 changes: 3 additions & 3 deletions src/webui/features/analytics/components/analytics-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import {ChevronDown, ExternalLink, ShieldCheck} from 'lucide-react'
import {useState} from 'react'
import {toast} from 'sonner'

import {PRIVACY_POLICY_URL} from '../../../../shared/constants/privacy.js'
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'
Expand Down Expand Up @@ -103,12 +103,12 @@ export function AnalyticsPanel() {

<a
className="text-foreground/80 hover:text-foreground inline-flex items-center gap-2 border-t px-5 py-3 text-sm transition-colors"
href={ANALYTICS_PRIVACY_URL}
href={PRIVACY_POLICY_URL}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink className="size-3.5 text-primary" />
<span className="text-primary">docs.byterover.dev/privacy</span>
<span className="text-primary">{PRIVACY_POLICY_URL.replace(/^https?:\/\/(www\.)?/, '')}</span>
</a>
Comment thread
ncnthien marked this conversation as resolved.
</div>
)}
Expand Down
69 changes: 63 additions & 6 deletions src/webui/features/analytics/components/disclosure-details.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,75 @@
import {ANALYTICS_DISCLOSURE_SECTIONS} from '../constants'
import {Skeleton} from '@campfirein/byterover-packages/components/skeleton'
import {Database, Eye, Info, Link2, type LucideIcon, PowerOff, Server} from 'lucide-react'

import type {AnalyticsDisclosureSection} from '../../../../shared/transport/events/analytics-events.js'

import {formatError} from '../../../lib/error-messages'
import {noop} from '../../../lib/noop'
import {MarkdownView} from '../../context/components/markdown-view'
import {useGetAnalyticsDisclosure} from '../api/get-analytics-disclosure'

const SECTION_ICONS: Record<string, LucideIcon> = {
'cross-device alias': Link2,
'how to disable': PowerOff,
'what is collected': Database,
'where it goes': Server,
'which surfaces are tracked': Eye,
}

function iconForLabel(label: string): LucideIcon {
return SECTION_ICONS[label.trim().toLowerCase()] ?? Info
}

function isVisibleSection(section: AnalyticsDisclosureSection): boolean {
return !section.label.trim().toLowerCase().includes('privacy')
}

export function DisclosureDetails() {
const {data, error, isError, isLoading, refetch} = useGetAnalyticsDisclosure()

if (isLoading) {
return (
<div className="grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2">
{['a', 'b', 'c', 'd'].map((slot) => (
<div className="flex flex-col gap-2" key={slot}>
<Skeleton className="size-4" />
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-full" />
</div>
))}
</div>
)
}

if (isError) {
return (
<p className="text-destructive text-sm">
✗ {formatError(error, 'Failed to load disclosure')}
{' · '}
<button className="underline underline-offset-2" onClick={() => refetch().catch(noop)} type="button">
retry
</button>
</p>
)
}

const sections = (data?.sections ?? []).filter((section) => isVisibleSection(section))

return (
<div className="grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2">
{ANALYTICS_DISCLOSURE_SECTIONS.map((section) => {
const Icon = section.icon
{sections.map((section) => {
const Icon = iconForLabel(section.label)
return (
<div className="flex flex-col gap-2" key={section.label}>
<Icon className="size-4 text-muted-foreground" strokeWidth={1.75} />
<Icon className="text-muted-foreground size-4" strokeWidth={1.75} />
<div className="flex flex-col gap-1">
<span className="text-foreground text-[0.6875rem] font-semibold tracking-wider">
<span className="text-foreground text-[0.6875rem] font-semibold tracking-wider uppercase">
{section.label}
</span>
<p className="text-muted-foreground text-[0.8125rem] leading-relaxed">{section.body}</p>
<MarkdownView
className="text-muted-foreground space-y-2 break-words text-[0.8125rem] leading-relaxed"
content={section.body}
/>
Comment thread
ncnthien marked this conversation as resolved.
</div>
</div>
)
Expand Down
39 changes: 0 additions & 39 deletions src/webui/features/analytics/constants.ts

This file was deleted.

Loading
Loading