Skip to content

Commit 5f6bcd1

Browse files
committed
feat(analytics): add Profound web traffic tracking
1 parent 30377d7 commit 5f6bcd1

File tree

3 files changed

+140
-11
lines changed

3 files changed

+140
-11
lines changed

apps/sim/lib/analytics/profound.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Profound Analytics - Custom log integration
3+
*
4+
* Buffers HTTP request logs in memory and flushes them in batches to Profound's API.
5+
* Runs in Node.js (proxy.ts on ECS), so module-level state persists across requests.
6+
* @see https://docs.tryprofound.com/agent-analytics/custom
7+
*/
8+
import { createLogger } from '@sim/logger'
9+
import { env } from '@/lib/core/config/env'
10+
import { isHosted } from '@/lib/core/config/feature-flags'
11+
12+
const logger = createLogger('ProfoundAnalytics')
13+
14+
const FLUSH_INTERVAL_MS = 10_000
15+
const MAX_BATCH_SIZE = 500
16+
17+
interface ProfoundLogEntry {
18+
timestamp: string
19+
method: string
20+
host: string
21+
path: string
22+
status_code: number
23+
ip: string
24+
user_agent: string
25+
query_params?: Record<string, string>
26+
referer?: string
27+
}
28+
29+
let buffer: ProfoundLogEntry[] = []
30+
let flushTimer: NodeJS.Timeout | null = null
31+
32+
/**
33+
* Returns true if Profound analytics is configured.
34+
*/
35+
export function isProfoundEnabled(): boolean {
36+
return isHosted && Boolean(env.PROFOUND_API_KEY)
37+
}
38+
39+
/**
40+
* Flushes buffered log entries to Profound's API.
41+
*/
42+
async function flush(): Promise<void> {
43+
if (buffer.length === 0) return
44+
45+
const apiKey = env.PROFOUND_API_KEY
46+
if (!apiKey) {
47+
buffer = []
48+
return
49+
}
50+
51+
const endpoint = env.PROFOUND_ENDPOINT
52+
if (!endpoint) {
53+
buffer = []
54+
return
55+
}
56+
const entries = buffer.splice(0, MAX_BATCH_SIZE)
57+
58+
try {
59+
const response = await fetch(endpoint, {
60+
method: 'POST',
61+
headers: {
62+
'x-api-key': apiKey,
63+
'Content-Type': 'application/json',
64+
},
65+
body: JSON.stringify(entries),
66+
})
67+
68+
if (!response.ok) {
69+
logger.error(`Profound API returned ${response.status}`)
70+
}
71+
} catch (error) {
72+
logger.error('Failed to flush logs to Profound', error)
73+
}
74+
}
75+
76+
function ensureFlushTimer(): void {
77+
if (flushTimer) return
78+
flushTimer = setInterval(() => {
79+
flush().catch(() => {})
80+
}, FLUSH_INTERVAL_MS)
81+
flushTimer.unref()
82+
}
83+
84+
/**
85+
* Queues a request log entry for the next batch flush to Profound.
86+
*/
87+
export function sendToProfound(request: Request, statusCode: number): void {
88+
if (!isHosted || !env.PROFOUND_API_KEY) return
89+
90+
const url = new URL(request.url)
91+
const queryParams: Record<string, string> = {}
92+
url.searchParams.forEach((value, key) => {
93+
queryParams[key] = value
94+
})
95+
96+
buffer.push({
97+
timestamp: new Date().toISOString(),
98+
method: request.method,
99+
host: url.hostname,
100+
path: url.pathname,
101+
status_code: statusCode,
102+
ip:
103+
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
104+
request.headers.get('x-real-ip') ||
105+
'0.0.0.0',
106+
user_agent: request.headers.get('user-agent') || '',
107+
...(Object.keys(queryParams).length > 0 && { query_params: queryParams }),
108+
...(request.headers.get('referer') && { referer: request.headers.get('referer')! }),
109+
})
110+
111+
ensureFlushTimer()
112+
113+
if (buffer.length >= MAX_BATCH_SIZE) {
114+
flush().catch(() => {})
115+
}
116+
}

apps/sim/lib/core/config/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ export const env = createEnv({
135135
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
136136
LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development)
137137
DRIZZLE_ODS_API_KEY: z.string().min(1).optional(), // OneDollarStats API key for analytics tracking
138+
PROFOUND_API_KEY: z.string().min(1).optional(), // Profound analytics API key
139+
PROFOUND_ENDPOINT: z.string().url().optional(), // Profound analytics endpoint
138140

139141
// External Services
140142
BROWSERBASE_API_KEY: z.string().min(1).optional(), // Browserbase API key for browser automation

apps/sim/proxy.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { getSessionCookie } from 'better-auth/cookies'
33
import { type NextRequest, NextResponse } from 'next/server'
4+
import { isProfoundEnabled, sendToProfound } from './lib/analytics/profound'
45
import { isAuthDisabled, isHosted } from './lib/core/config/feature-flags'
56
import { generateRuntimeCSP } from './lib/core/security/csp'
67

@@ -144,47 +145,47 @@ export async function proxy(request: NextRequest) {
144145
const hasActiveSession = isAuthDisabled || !!sessionCookie
145146

146147
const redirect = handleRootPathRedirects(request, hasActiveSession)
147-
if (redirect) return redirect
148+
if (redirect) return track(request, redirect)
148149

149150
if (url.pathname === '/login' || url.pathname === '/signup') {
150151
if (hasActiveSession) {
151-
return NextResponse.redirect(new URL('/workspace', request.url))
152+
return track(request, NextResponse.redirect(new URL('/workspace', request.url)))
152153
}
153154
const response = NextResponse.next()
154155
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
155-
return response
156+
return track(request, response)
156157
}
157158

158159
// Chat pages are publicly accessible embeds — CSP is set in next.config.ts headers
159160
if (url.pathname.startsWith('/chat/')) {
160-
return NextResponse.next()
161+
return track(request, NextResponse.next())
161162
}
162163

163164
// Allow public access to template pages for SEO
164165
if (url.pathname.startsWith('/templates')) {
165-
return NextResponse.next()
166+
return track(request, NextResponse.next())
166167
}
167168

168169
if (url.pathname.startsWith('/workspace')) {
169170
// Allow public access to workspace template pages - they handle their own redirects
170171
if (url.pathname.match(/^\/workspace\/[^/]+\/templates/)) {
171-
return NextResponse.next()
172+
return track(request, NextResponse.next())
172173
}
173174

174175
if (!hasActiveSession) {
175-
return NextResponse.redirect(new URL('/login', request.url))
176+
return track(request, NextResponse.redirect(new URL('/login', request.url)))
176177
}
177-
return NextResponse.next()
178+
return track(request, NextResponse.next())
178179
}
179180

180181
const invitationRedirect = handleInvitationRedirects(request, hasActiveSession)
181-
if (invitationRedirect) return invitationRedirect
182+
if (invitationRedirect) return track(request, invitationRedirect)
182183

183184
const workspaceInvitationRedirect = handleWorkspaceInvitationAPI(request, hasActiveSession)
184-
if (workspaceInvitationRedirect) return workspaceInvitationRedirect
185+
if (workspaceInvitationRedirect) return track(request, workspaceInvitationRedirect)
185186

186187
const securityBlock = handleSecurityFiltering(request)
187-
if (securityBlock) return securityBlock
188+
if (securityBlock) return track(request, securityBlock)
188189

189190
const response = NextResponse.next()
190191
response.headers.set('Vary', 'User-Agent')
@@ -193,6 +194,16 @@ export async function proxy(request: NextRequest) {
193194
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
194195
}
195196

197+
return track(request, response)
198+
}
199+
200+
/**
201+
* Sends request data to Profound analytics (fire-and-forget) and returns the response.
202+
*/
203+
function track(request: NextRequest, response: NextResponse): NextResponse {
204+
if (isProfoundEnabled()) {
205+
sendToProfound(request, response.status)
206+
}
196207
return response
197208
}
198209

0 commit comments

Comments
 (0)