Skip to content

Commit 2a2e87c

Browse files
committed
refactor(common): consolidate analytics (Commit 2.8)
- Create common/src/analytics/ directory with organized modules - Add analytics/core.ts (PostHog client factory) - Add analytics/track-event.ts (server-side tracking) - Add analytics/dispatcher.ts (cross-platform dispatching) - Add analytics/log-helpers.ts (log data conversion) - Add unified analytics/index.ts re-exports - Add MAX_BUFFER_SIZE limit to dispatcher - Update all consumers to use @codebuff/common/analytics
1 parent ca34cf4 commit 2a2e87c

File tree

11 files changed

+440
-11
lines changed

11 files changed

+440
-11
lines changed

cli/src/utils/__tests__/analytics-client.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, mock } from 'bun:test'
22

33
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
44

5-
import type { AnalyticsClientWithIdentify } from '@codebuff/common/analytics-core'
5+
import type { AnalyticsClientWithIdentify } from '@codebuff/common/analytics'
66

77
import {
88
initAnalytics,

cli/src/utils/analytics.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
generateAnonymousId,
44
type AnalyticsClientWithIdentify,
55
type PostHogClientOptions,
6-
} from '@codebuff/common/analytics-core'
6+
} from '@codebuff/common/analytics'
77
import {
88
env as defaultEnv,
99
IS_PROD as defaultIsProd,
@@ -14,7 +14,7 @@ import type { AnalyticsEvent } from '@codebuff/common/constants/analytics-events
1414

1515

1616
// Re-export types from core for backwards compatibility
17-
export type { AnalyticsClientWithIdentify as AnalyticsClient } from '@codebuff/common/analytics-core'
17+
export type { AnalyticsClientWithIdentify as AnalyticsClient } from '@codebuff/common/analytics'
1818

1919
export enum AnalyticsErrorStage {
2020
Init = 'init',

cli/src/utils/logger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import path, { dirname } from 'path'
33
import { format as stringFormat } from 'util'
44

55
import { env, IS_DEV, IS_TEST, IS_CI } from '@codebuff/common/env'
6-
import { createAnalyticsDispatcher } from '@codebuff/common/util/analytics-dispatcher'
6+
import { createAnalyticsDispatcher } from '@codebuff/common/analytics'
77
import { pino } from 'pino'
88

99
import {

common/src/analytics/core.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { PostHog } from 'posthog-node'
2+
3+
/** Interface for PostHog client methods used for event capture */
4+
export interface AnalyticsClient {
5+
capture: (params: {
6+
distinctId: string
7+
event: string
8+
properties?: Record<string, any>
9+
}) => void
10+
flush: () => Promise<void>
11+
}
12+
13+
/** Extended client interface with identify, alias, and exception capture (used by CLI) */
14+
export interface AnalyticsClientWithIdentify extends AnalyticsClient {
15+
identify: (params: {
16+
distinctId: string
17+
properties?: Record<string, any>
18+
}) => void
19+
/** Links an alias (previous anonymous ID) to a distinctId (real user ID) */
20+
alias: (data: { distinctId: string; alias: string }) => void
21+
captureException: (
22+
error: any,
23+
distinctId: string,
24+
properties?: Record<string, any>,
25+
) => void
26+
}
27+
28+
/** Environment name type */
29+
export type AnalyticsEnvName = 'dev' | 'test' | 'prod'
30+
31+
/** Base analytics configuration */
32+
export interface AnalyticsConfig {
33+
envName: AnalyticsEnvName
34+
posthogApiKey: string
35+
posthogHostUrl: string
36+
}
37+
38+
/** Options for creating a PostHog client */
39+
export interface PostHogClientOptions {
40+
host: string
41+
flushAt?: number
42+
flushInterval?: number
43+
enableExceptionAutocapture?: boolean
44+
}
45+
46+
export function createPostHogClient(
47+
apiKey: string,
48+
options: PostHogClientOptions,
49+
): AnalyticsClientWithIdentify {
50+
return new PostHog(apiKey, options) as AnalyticsClientWithIdentify
51+
}
52+
53+
export function generateAnonymousId(): string {
54+
return `anon_${crypto.randomUUID()}`
55+
}

common/src/analytics/dispatcher.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
2+
3+
import {
4+
getAnalyticsEventId,
5+
toTrackableAnalyticsPayload,
6+
type AnalyticsLogData,
7+
type TrackableAnalyticsPayload,
8+
} from './log-helpers'
9+
10+
const MAX_BUFFER_SIZE = 100
11+
12+
export type AnalyticsDispatchInput = {
13+
data: unknown
14+
level: string
15+
msg: string
16+
fallbackUserId?: string
17+
}
18+
19+
export type AnalyticsDispatchPayload = TrackableAnalyticsPayload
20+
21+
/** Runtime-agnostic router for analytics events with dev gating and optional buffering. */
22+
export function createAnalyticsDispatcher({
23+
envName,
24+
bufferWhenNoUser = false,
25+
}: {
26+
envName: string
27+
bufferWhenNoUser?: boolean
28+
}) {
29+
const buffered: AnalyticsDispatchInput[] = []
30+
const isDevEnv = envName === 'dev'
31+
32+
function flushBufferWithUser(
33+
userId: string,
34+
): AnalyticsDispatchPayload[] {
35+
if (!buffered.length) {
36+
return []
37+
}
38+
39+
const toSend: AnalyticsDispatchPayload[] = []
40+
for (const item of buffered.splice(0)) {
41+
const rebuilt = toTrackableAnalyticsPayload({
42+
...item,
43+
fallbackUserId: userId,
44+
})
45+
if (rebuilt) {
46+
toSend.push(rebuilt)
47+
}
48+
}
49+
return toSend
50+
}
51+
52+
function process(
53+
input: AnalyticsDispatchInput,
54+
): AnalyticsDispatchPayload[] {
55+
if (isDevEnv) {
56+
return []
57+
}
58+
59+
const payload = toTrackableAnalyticsPayload(input)
60+
if (payload) {
61+
const toSend = flushBufferWithUser(payload.userId)
62+
toSend.push(payload)
63+
return toSend
64+
}
65+
66+
if (
67+
bufferWhenNoUser &&
68+
getAnalyticsEventId(input.data as AnalyticsLogData)
69+
) {
70+
if (buffered.length >= MAX_BUFFER_SIZE) {
71+
buffered.shift()
72+
}
73+
buffered.push(input)
74+
}
75+
76+
return []
77+
}
78+
79+
return { process }
80+
}

common/src/analytics/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export {
2+
type AnalyticsClient,
3+
type AnalyticsClientWithIdentify,
4+
type AnalyticsConfig,
5+
type AnalyticsEnvName,
6+
type PostHogClientOptions,
7+
createPostHogClient,
8+
generateAnonymousId,
9+
} from './core'
10+
11+
export { trackEvent, flushAnalytics } from './track-event'
12+
13+
export {
14+
type AnalyticsLogData,
15+
type TrackableAnalyticsPayload,
16+
getAnalyticsEventId,
17+
toTrackableAnalyticsPayload,
18+
} from './log-helpers'
19+
20+
export {
21+
type AnalyticsDispatchInput,
22+
type AnalyticsDispatchPayload,
23+
createAnalyticsDispatcher,
24+
} from './dispatcher'
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
2+
3+
export type AnalyticsLogData = {
4+
eventId?: unknown
5+
userId?: unknown
6+
user_id?: unknown
7+
user?: { id?: unknown }
8+
[key: string]: unknown
9+
}
10+
11+
export type TrackableAnalyticsPayload = {
12+
event: AnalyticsEvent
13+
userId: string
14+
properties: Record<string, unknown>
15+
}
16+
17+
const analyticsEvents = new Set<AnalyticsEvent>(Object.values(AnalyticsEvent))
18+
19+
const toStringOrNull = (value: unknown): string | null =>
20+
typeof value === 'string' ? value : null
21+
22+
const getUserId = (
23+
record: AnalyticsLogData,
24+
fallbackUserId?: string,
25+
): string | null =>
26+
toStringOrNull(record.userId) ??
27+
toStringOrNull(record.user_id) ??
28+
toStringOrNull(record.user?.id) ??
29+
toStringOrNull(fallbackUserId)
30+
31+
export function getAnalyticsEventId(data: unknown): AnalyticsEvent | null {
32+
if (!data || typeof data !== 'object') {
33+
return null
34+
}
35+
const eventId = (data as AnalyticsLogData).eventId
36+
return analyticsEvents.has(eventId as AnalyticsEvent)
37+
? (eventId as AnalyticsEvent)
38+
: null
39+
}
40+
41+
// Allowlist of properties safe to send to analytics.
42+
// Be conservative - only include properties that are clearly non-PII.
43+
const SAFE_ANALYTICS_PROPERTIES = new Set([
44+
// Event metadata
45+
'eventId',
46+
'level',
47+
'msg',
48+
// Timing/metrics
49+
'duration',
50+
'durationMs',
51+
'latency',
52+
'latencyMs',
53+
'timestamp',
54+
// Counts/sizes
55+
'count',
56+
'size',
57+
'length',
58+
'total',
59+
// Status/type identifiers
60+
'status',
61+
'type',
62+
'action',
63+
'source',
64+
'target',
65+
'category',
66+
// Agent/model info
67+
'agentId',
68+
'agentType',
69+
'modelId',
70+
'modelName',
71+
// Feature flags/versions
72+
'version',
73+
'feature',
74+
'variant',
75+
// Error info (without stack traces or sensitive details)
76+
'errorCode',
77+
'errorType',
78+
// Boolean flags
79+
'success',
80+
'enabled',
81+
'cached',
82+
// Run/step identifiers
83+
'runId',
84+
'stepNumber',
85+
'stepId',
86+
])
87+
88+
// Properties that should never be sent to analytics (PII/sensitive)
89+
const BLOCKED_ANALYTICS_PROPERTIES = new Set([
90+
'userId',
91+
'user_id',
92+
'user',
93+
'email',
94+
'name',
95+
'password',
96+
'token',
97+
'apiKey',
98+
'secret',
99+
'authorization',
100+
'cookie',
101+
'session',
102+
'ip',
103+
'ipAddress',
104+
'fingerprint',
105+
'deviceId',
106+
])
107+
108+
function extractSafeProperties(
109+
record: AnalyticsLogData,
110+
): Record<string, unknown> {
111+
const safeProps: Record<string, unknown> = {}
112+
113+
for (const [key, value] of Object.entries(record)) {
114+
// Skip blocked properties
115+
if (BLOCKED_ANALYTICS_PROPERTIES.has(key)) continue
116+
// Skip complex objects that might contain PII
117+
if (value !== null && typeof value === 'object') continue
118+
// Only include properties in the allowlist
119+
if (SAFE_ANALYTICS_PROPERTIES.has(key)) {
120+
safeProps[key] = value
121+
}
122+
}
123+
124+
return safeProps
125+
}
126+
127+
export function toTrackableAnalyticsPayload({
128+
data,
129+
level,
130+
msg,
131+
fallbackUserId,
132+
}: {
133+
data: unknown
134+
level: string
135+
msg: string
136+
fallbackUserId?: string
137+
}): TrackableAnalyticsPayload | null {
138+
if (!data || typeof data !== 'object') {
139+
return null
140+
}
141+
142+
const record = data as AnalyticsLogData
143+
const eventId = getAnalyticsEventId(record)
144+
if (!eventId) {
145+
return null
146+
}
147+
148+
const userId = getUserId(record, fallbackUserId)
149+
150+
if (!userId) {
151+
return null
152+
}
153+
154+
return {
155+
event: eventId,
156+
userId,
157+
properties: {
158+
...extractSafeProperties(record),
159+
level,
160+
msg,
161+
},
162+
}
163+
}

0 commit comments

Comments
 (0)