Skip to content

Commit a72c434

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@65a331719108871b760db132c2334b82362ec629
1 parent c1ad570 commit a72c434

7 files changed

Lines changed: 181 additions & 8 deletions

File tree

bun.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/src/utils/analytics.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ import {
1010
DEBUG_ANALYTICS,
1111
} from '@codebuff/common/env'
1212
import { shouldTrackAnalyticsEvent } from '@codebuff/common/util/analytics-sampling'
13+
import { shouldMirrorAnalyticsEvent } from '@codebuff/common/util/log-mirror'
1314

14-
import type { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
15+
import { enqueueClientLog } from './log-shipper'
16+
17+
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
1518

1619

1720
// Re-export types from core for backwards compatibility
@@ -229,6 +232,26 @@ export function trackEvent(
229232
properties,
230233
})
231234
}
235+
236+
// Mirror analytics events into the Axiom logs sink too (PostHog stays the
237+
// product-analytics source of truth). The shipper batches and ships even
238+
// before login (anonymously), so pre-auth events like app_launched reach
239+
// Axiom — making install→login funnels queryable in APL. We correlate on the
240+
// anonymous/run id so pre- and post-login events join. CLI_LOG is excluded
241+
// because the logger already mirrors log rows to Axiom (avoids double-ship).
242+
if (event !== AnalyticsEvent.CLI_LOG && shouldMirrorAnalyticsEvent(event)) {
243+
try {
244+
enqueueClientLog({
245+
level: 'info',
246+
event,
247+
message: event,
248+
client_session_id: anonymousId ?? currentUserId,
249+
data: properties,
250+
})
251+
} catch {
252+
// Best-effort mirror; never let it affect analytics or the app.
253+
}
254+
}
232255
}
233256

234257
export function identifyUser(userId: string, properties?: Record<string, any>) {

cli/src/utils/log-shipper.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,19 @@ export async function flushClientLogs(): Promise<void> {
7171
const batch = buffer.splice(0, MAX_BATCH)
7272
try {
7373
const client = getApiClient()
74-
if (!client.authToken) {
75-
// Not logged in yet — put the batch back (bounded by MAX_BUFFER) so we
76-
// can ship it once auth is available.
77-
buffer.unshift(...batch)
78-
return
79-
}
74+
// Ship whether or not we're logged in. With a token the server stamps the
75+
// authenticated user_id; without one it accepts the batch anonymously
76+
// (rate-limited, user_id=null) so pre-auth events like app_launched still
77+
// reach Axiom. Records carry client_session_id for correlation. See
78+
// /api/logs and docs/logging.md.
8079
await client.post(
8180
'/api/logs',
8281
{ records: batch },
83-
{ includeAuth: true, retry: false, timeoutMs: 5_000 },
82+
{
83+
includeAuth: Boolean(client.authToken),
84+
retry: false,
85+
timeoutMs: 5_000,
86+
},
8487
)
8588
} catch {
8689
// Best-effort: drop on error rather than risk unbounded growth.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from 'bun:test'
2+
3+
import {
4+
AXIOM_MIRROR_DENYLIST,
5+
shouldMirrorAnalyticsEvent,
6+
} from '../log-mirror'
7+
8+
describe('shouldMirrorAnalyticsEvent', () => {
9+
it('drops high-volume PostHog auto-events from the Axiom mirror', () => {
10+
for (const denied of AXIOM_MIRROR_DENYLIST) {
11+
expect(shouldMirrorAnalyticsEvent(denied)).toBe(false)
12+
}
13+
expect(shouldMirrorAnalyticsEvent('$snapshot')).toBe(false)
14+
expect(shouldMirrorAnalyticsEvent('$autocapture')).toBe(false)
15+
})
16+
17+
it('keeps named product events and useful $ events', () => {
18+
expect(shouldMirrorAnalyticsEvent('cli.login')).toBe(true)
19+
expect(shouldMirrorAnalyticsEvent('cli.app_launched')).toBe(true)
20+
expect(shouldMirrorAnalyticsEvent('web.signup')).toBe(true)
21+
expect(shouldMirrorAnalyticsEvent('$pageview')).toBe(true)
22+
expect(shouldMirrorAnalyticsEvent('$identify')).toBe(true)
23+
expect(shouldMirrorAnalyticsEvent('$exception')).toBe(true)
24+
})
25+
26+
it('treats empty/null event names as mirror-eligible (logs without an event)', () => {
27+
expect(shouldMirrorAnalyticsEvent(null)).toBe(true)
28+
expect(shouldMirrorAnalyticsEvent(undefined)).toBe(true)
29+
expect(shouldMirrorAnalyticsEvent('')).toBe(true)
30+
})
31+
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from 'bun:test'
2+
3+
import { createFixedWindowRateLimiter } from '../rate-limit'
4+
5+
describe('createFixedWindowRateLimiter', () => {
6+
it('allows up to `max` requests per window, then limits', () => {
7+
const rl = createFixedWindowRateLimiter({ windowMs: 1000, max: 3 })
8+
const t = 0
9+
expect(rl.limited('a', t)).toBe(false) // 1
10+
expect(rl.limited('a', t)).toBe(false) // 2
11+
expect(rl.limited('a', t)).toBe(false) // 3
12+
expect(rl.limited('a', t)).toBe(true) // 4 -> over
13+
})
14+
15+
it('resets after the window elapses', () => {
16+
const rl = createFixedWindowRateLimiter({ windowMs: 1000, max: 1 })
17+
expect(rl.limited('a', 0)).toBe(false)
18+
expect(rl.limited('a', 500)).toBe(true) // still in window
19+
expect(rl.limited('a', 1000)).toBe(false) // window rolled over
20+
})
21+
22+
it('tracks keys independently', () => {
23+
const rl = createFixedWindowRateLimiter({ windowMs: 1000, max: 1 })
24+
expect(rl.limited('a', 0)).toBe(false)
25+
expect(rl.limited('b', 0)).toBe(false) // different key, own budget
26+
expect(rl.limited('a', 0)).toBe(true)
27+
})
28+
})

common/src/util/log-mirror.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Which analytics events get mirrored into the Axiom logs dataset.
3+
*
4+
* PostHog stays the product-analytics system of record (it keeps EVERY event).
5+
* Axiom is the SQL-queryable copy for debugging/ops, where a handful of
6+
* extremely high-volume, low-query-value PostHog auto-events would otherwise
7+
* dominate ingest cost and bury the events we actually query (named product
8+
* events, signups, logins, errors). We drop those from the Axiom mirror only.
9+
*
10+
* `$snapshot` (session replay) alone is the bulk of ingest. Autocapture,
11+
* heatmaps and web-vitals are similar: useful in PostHog's product UI, noise in
12+
* APL. Everything else — `$pageview`, `$identify`, `$exception`, `$rageclick`,
13+
* and all non-`$` named events — is kept.
14+
*/
15+
export const AXIOM_MIRROR_DENYLIST: ReadonlySet<string> = new Set([
16+
'$snapshot',
17+
'$autocapture',
18+
'$heatmap',
19+
'$$heatmap',
20+
'$web_vitals',
21+
'$pageleave',
22+
])
23+
24+
/** True if this analytics event should be copied into the Axiom logs dataset. */
25+
export function shouldMirrorAnalyticsEvent(
26+
eventName: string | null | undefined,
27+
): boolean {
28+
if (!eventName) return true
29+
return !AXIOM_MIRROR_DENYLIST.has(eventName)
30+
}

common/src/util/rate-limit.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Minimal in-memory fixed-window rate limiter, shared by the unauthenticated
3+
* `/api/logs` ingest endpoints (browser + anonymous CLI). Per-instance and
4+
* best-effort — good enough to blunt abuse/cost on a single Render instance,
5+
* not a distributed guarantee. Kept dependency-free and `now`-injectable so the
6+
* window logic is unit-testable.
7+
*/
8+
export interface FixedWindowRateLimiter {
9+
/** Returns true if `key` has exceeded the window's request budget. */
10+
limited(key: string, now: number): boolean
11+
}
12+
13+
export function createFixedWindowRateLimiter(opts: {
14+
windowMs: number
15+
max: number
16+
/** Prune expired entries once the map grows past this. Defaults to 10k. */
17+
maxKeys?: number
18+
}): FixedWindowRateLimiter {
19+
const { windowMs, max, maxKeys = 10_000 } = opts
20+
const hits = new Map<string, { count: number; resetAt: number }>()
21+
let lastPruneAt = 0
22+
23+
return {
24+
limited(key: string, now: number): boolean {
25+
const entry = hits.get(key)
26+
if (!entry || now >= entry.resetAt) {
27+
hits.set(key, { count: 1, resetAt: now + windowMs })
28+
// Bound map growth: prune expired entries, but at most once per window
29+
// so a steady stream of live keys can't trigger an O(n) scan per call.
30+
if (hits.size > maxKeys && now - lastPruneAt >= windowMs) {
31+
lastPruneAt = now
32+
for (const [k, v] of hits) if (now >= v.resetAt) hits.delete(k)
33+
}
34+
return false
35+
}
36+
entry.count++
37+
return entry.count > max
38+
},
39+
}
40+
}
41+
42+
/**
43+
* Best-effort client IP for per-IP rate limiting on the unauthenticated ingest
44+
* endpoints. Prefers the proxy-set `x-real-ip` (harder to spoof than the
45+
* left-most `x-forwarded-for` token). Accepts any Headers-like object so it
46+
* works with `NextRequest.headers` without a Next dependency here.
47+
*/
48+
export function extractClientIp(headers: {
49+
get(name: string): string | null
50+
}): string {
51+
return (
52+
headers.get('x-real-ip')?.trim() ||
53+
headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
54+
'unknown'
55+
)
56+
}

0 commit comments

Comments
 (0)