mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(analytics): add Profound web traffic tracking (#3835)
* feat(analytics): add Profound web traffic tracking * fix(analytics): address PR review — add endpoint check and document trade-offs * chore(analytics): remove implementation comments * fix(analytics): guard sendToProfound with try-catch and align check with isProfoundEnabled * fix(analytics): strip sensitive query params and remove redundant guard * chore(analytics): remove unnecessary query param filtering
This commit is contained in:
120
apps/sim/lib/analytics/profound.ts
Normal file
120
apps/sim/lib/analytics/profound.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Profound Analytics - Custom log integration
|
||||
*
|
||||
* Buffers HTTP request logs in memory and flushes them in batches to Profound's API.
|
||||
* Runs in Node.js (proxy.ts on ECS), so module-level state persists across requests.
|
||||
* @see https://docs.tryprofound.com/agent-analytics/custom
|
||||
*/
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
|
||||
const logger = createLogger('ProfoundAnalytics')
|
||||
|
||||
const FLUSH_INTERVAL_MS = 10_000
|
||||
const MAX_BATCH_SIZE = 500
|
||||
|
||||
interface ProfoundLogEntry {
|
||||
timestamp: string
|
||||
method: string
|
||||
host: string
|
||||
path: string
|
||||
status_code: number
|
||||
ip: string
|
||||
user_agent: string
|
||||
query_params?: Record<string, string>
|
||||
referer?: string
|
||||
}
|
||||
|
||||
let buffer: ProfoundLogEntry[] = []
|
||||
let flushTimer: NodeJS.Timeout | null = null
|
||||
|
||||
/**
|
||||
* Returns true if Profound analytics is configured.
|
||||
*/
|
||||
export function isProfoundEnabled(): boolean {
|
||||
return isHosted && Boolean(env.PROFOUND_API_KEY) && Boolean(env.PROFOUND_ENDPOINT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes buffered log entries to Profound's API.
|
||||
*/
|
||||
async function flush(): Promise<void> {
|
||||
if (buffer.length === 0) return
|
||||
|
||||
const apiKey = env.PROFOUND_API_KEY
|
||||
if (!apiKey) {
|
||||
buffer = []
|
||||
return
|
||||
}
|
||||
|
||||
const endpoint = env.PROFOUND_ENDPOINT
|
||||
if (!endpoint) {
|
||||
buffer = []
|
||||
return
|
||||
}
|
||||
const entries = buffer.splice(0, MAX_BATCH_SIZE)
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(entries),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Profound API returned ${response.status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to flush logs to Profound', error)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureFlushTimer(): void {
|
||||
if (flushTimer) return
|
||||
flushTimer = setInterval(() => {
|
||||
flush().catch(() => {})
|
||||
}, FLUSH_INTERVAL_MS)
|
||||
flushTimer.unref()
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a request log entry for the next batch flush to Profound.
|
||||
*/
|
||||
export function sendToProfound(request: Request, statusCode: number): void {
|
||||
if (!isProfoundEnabled()) return
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const queryParams: Record<string, string> = {}
|
||||
url.searchParams.forEach((value, key) => {
|
||||
queryParams[key] = value
|
||||
})
|
||||
|
||||
buffer.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
method: request.method,
|
||||
host: url.hostname,
|
||||
path: url.pathname,
|
||||
status_code: statusCode,
|
||||
ip:
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'0.0.0.0',
|
||||
user_agent: request.headers.get('user-agent') || '',
|
||||
...(Object.keys(queryParams).length > 0 && { query_params: queryParams }),
|
||||
...(request.headers.get('referer') && { referer: request.headers.get('referer')! }),
|
||||
})
|
||||
|
||||
ensureFlushTimer()
|
||||
|
||||
if (buffer.length >= MAX_BATCH_SIZE) {
|
||||
flush().catch(() => {})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to enqueue log entry', error)
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,8 @@ export const env = createEnv({
|
||||
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
|
||||
LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development)
|
||||
DRIZZLE_ODS_API_KEY: z.string().min(1).optional(), // OneDollarStats API key for analytics tracking
|
||||
PROFOUND_API_KEY: z.string().min(1).optional(), // Profound analytics API key
|
||||
PROFOUND_ENDPOINT: z.string().url().optional(), // Profound analytics endpoint
|
||||
|
||||
// External Services
|
||||
BROWSERBASE_API_KEY: z.string().min(1).optional(), // Browserbase API key for browser automation
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getSessionCookie } from 'better-auth/cookies'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { sendToProfound } from './lib/analytics/profound'
|
||||
import { isAuthDisabled, isHosted } from './lib/core/config/feature-flags'
|
||||
import { generateRuntimeCSP } from './lib/core/security/csp'
|
||||
|
||||
@@ -144,47 +145,47 @@ export async function proxy(request: NextRequest) {
|
||||
const hasActiveSession = isAuthDisabled || !!sessionCookie
|
||||
|
||||
const redirect = handleRootPathRedirects(request, hasActiveSession)
|
||||
if (redirect) return redirect
|
||||
if (redirect) return track(request, redirect)
|
||||
|
||||
if (url.pathname === '/login' || url.pathname === '/signup') {
|
||||
if (hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
return track(request, NextResponse.redirect(new URL('/workspace', request.url)))
|
||||
}
|
||||
const response = NextResponse.next()
|
||||
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
|
||||
return response
|
||||
return track(request, response)
|
||||
}
|
||||
|
||||
// Chat pages are publicly accessible embeds — CSP is set in next.config.ts headers
|
||||
if (url.pathname.startsWith('/chat/')) {
|
||||
return NextResponse.next()
|
||||
return track(request, NextResponse.next())
|
||||
}
|
||||
|
||||
// Allow public access to template pages for SEO
|
||||
if (url.pathname.startsWith('/templates')) {
|
||||
return NextResponse.next()
|
||||
return track(request, NextResponse.next())
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/workspace')) {
|
||||
// Allow public access to workspace template pages - they handle their own redirects
|
||||
if (url.pathname.match(/^\/workspace\/[^/]+\/templates/)) {
|
||||
return NextResponse.next()
|
||||
return track(request, NextResponse.next())
|
||||
}
|
||||
|
||||
if (!hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
return track(request, NextResponse.redirect(new URL('/login', request.url)))
|
||||
}
|
||||
return NextResponse.next()
|
||||
return track(request, NextResponse.next())
|
||||
}
|
||||
|
||||
const invitationRedirect = handleInvitationRedirects(request, hasActiveSession)
|
||||
if (invitationRedirect) return invitationRedirect
|
||||
if (invitationRedirect) return track(request, invitationRedirect)
|
||||
|
||||
const workspaceInvitationRedirect = handleWorkspaceInvitationAPI(request, hasActiveSession)
|
||||
if (workspaceInvitationRedirect) return workspaceInvitationRedirect
|
||||
if (workspaceInvitationRedirect) return track(request, workspaceInvitationRedirect)
|
||||
|
||||
const securityBlock = handleSecurityFiltering(request)
|
||||
if (securityBlock) return securityBlock
|
||||
if (securityBlock) return track(request, securityBlock)
|
||||
|
||||
const response = NextResponse.next()
|
||||
response.headers.set('Vary', 'User-Agent')
|
||||
@@ -193,6 +194,14 @@ export async function proxy(request: NextRequest) {
|
||||
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
|
||||
}
|
||||
|
||||
return track(request, response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends request data to Profound analytics (fire-and-forget) and returns the response.
|
||||
*/
|
||||
function track(request: NextRequest, response: NextResponse): NextResponse {
|
||||
sendToProfound(request, response.status)
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user