diff --git a/apps/sim/lib/analytics/profound.ts b/apps/sim/lib/analytics/profound.ts new file mode 100644 index 00000000000..6ad00743fbb --- /dev/null +++ b/apps/sim/lib/analytics/profound.ts @@ -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 + 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 { + 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 = {} + 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) + } +} diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 4e9bd27feda..be295643075 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -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 diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index e8e1a10cef2..20f0da49159 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -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 }