From 5f6bcd121eaa0e0b5222037c43431fcdbddd546a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 28 Mar 2026 21:26:48 -0700 Subject: [PATCH 1/6] feat(analytics): add Profound web traffic tracking --- apps/sim/lib/analytics/profound.ts | 116 +++++++++++++++++++++++++++++ apps/sim/lib/core/config/env.ts | 2 + apps/sim/proxy.ts | 33 +++++--- 3 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 apps/sim/lib/analytics/profound.ts diff --git a/apps/sim/lib/analytics/profound.ts b/apps/sim/lib/analytics/profound.ts new file mode 100644 index 00000000000..44da2c8380d --- /dev/null +++ b/apps/sim/lib/analytics/profound.ts @@ -0,0 +1,116 @@ +/** + * 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) +} + +/** + * 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 (!isHosted || !env.PROFOUND_API_KEY) return + + 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(() => {}) + } +} 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..07013dfb26e 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 { isProfoundEnabled, 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,16 @@ 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 { + if (isProfoundEnabled()) { + sendToProfound(request, response.status) + } return response } From 627b61ea2d98d6e8e3bd33e9a409f6d705a5d9bd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 28 Mar 2026 21:30:46 -0700 Subject: [PATCH 2/6] =?UTF-8?q?fix(analytics):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20add=20endpoint=20check=20and=20document=20trade-off?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sim/lib/analytics/profound.ts | 4 +++- apps/sim/proxy.ts | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/analytics/profound.ts b/apps/sim/lib/analytics/profound.ts index 44da2c8380d..efe6bae00b6 100644 --- a/apps/sim/lib/analytics/profound.ts +++ b/apps/sim/lib/analytics/profound.ts @@ -33,7 +33,7 @@ let flushTimer: NodeJS.Timeout | null = null * Returns true if Profound analytics is configured. */ export function isProfoundEnabled(): boolean { - return isHosted && Boolean(env.PROFOUND_API_KEY) + return isHosted && Boolean(env.PROFOUND_API_KEY) && Boolean(env.PROFOUND_ENDPOINT) } /** @@ -69,6 +69,8 @@ async function flush(): Promise { logger.error(`Profound API returned ${response.status}`) } } catch (error) { + // Entries are intentionally not re-queued on failure to prevent unbounded memory growth. + // Under a Profound outage, analytics data is lost — acceptable for non-critical telemetry. logger.error('Failed to flush logs to Profound', error) } } diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 07013dfb26e..c294515332c 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -199,6 +199,9 @@ export async function proxy(request: NextRequest) { /** * Sends request data to Profound analytics (fire-and-forget) and returns the response. + * Note: `NextResponse.next()` always carries status 200 — it signals "continue to route handler", + * not the final HTTP status. Only redirects (307/308) and explicit blocks (403) have accurate codes. + * This matches the Vercel log drain behavior where proxy-level status reflects middleware outcome. */ function track(request: NextRequest, response: NextResponse): NextResponse { if (isProfoundEnabled()) { From a902d60f298dd3b5b2a621e8edea5c90372adbb1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 28 Mar 2026 21:33:07 -0700 Subject: [PATCH 3/6] chore(analytics): remove implementation comments --- apps/sim/lib/analytics/profound.ts | 2 -- apps/sim/proxy.ts | 3 --- 2 files changed, 5 deletions(-) diff --git a/apps/sim/lib/analytics/profound.ts b/apps/sim/lib/analytics/profound.ts index efe6bae00b6..4842684325d 100644 --- a/apps/sim/lib/analytics/profound.ts +++ b/apps/sim/lib/analytics/profound.ts @@ -69,8 +69,6 @@ async function flush(): Promise { logger.error(`Profound API returned ${response.status}`) } } catch (error) { - // Entries are intentionally not re-queued on failure to prevent unbounded memory growth. - // Under a Profound outage, analytics data is lost — acceptable for non-critical telemetry. logger.error('Failed to flush logs to Profound', error) } } diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index c294515332c..07013dfb26e 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -199,9 +199,6 @@ export async function proxy(request: NextRequest) { /** * Sends request data to Profound analytics (fire-and-forget) and returns the response. - * Note: `NextResponse.next()` always carries status 200 — it signals "continue to route handler", - * not the final HTTP status. Only redirects (307/308) and explicit blocks (403) have accurate codes. - * This matches the Vercel log drain behavior where proxy-level status reflects middleware outcome. */ function track(request: NextRequest, response: NextResponse): NextResponse { if (isProfoundEnabled()) { From e407ddc063259fc3556b1bcbce8a8fef0594c366 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 28 Mar 2026 21:41:01 -0700 Subject: [PATCH 4/6] fix(analytics): guard sendToProfound with try-catch and align check with isProfoundEnabled --- apps/sim/lib/analytics/profound.ts | 58 ++++++++++++++++-------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/apps/sim/lib/analytics/profound.ts b/apps/sim/lib/analytics/profound.ts index 4842684325d..6ad00743fbb 100644 --- a/apps/sim/lib/analytics/profound.ts +++ b/apps/sim/lib/analytics/profound.ts @@ -85,32 +85,36 @@ function ensureFlushTimer(): void { * Queues a request log entry for the next batch flush to Profound. */ export function sendToProfound(request: Request, statusCode: number): void { - if (!isHosted || !env.PROFOUND_API_KEY) return - - 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(() => {}) + 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) } } From 86a85a30263c83763f59e73a15deab6a3220315a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 28 Mar 2026 21:50:43 -0700 Subject: [PATCH 5/6] fix(analytics): strip sensitive query params and remove redundant guard --- apps/sim/lib/analytics/profound.ts | 5 ++++- apps/sim/proxy.ts | 6 ++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/analytics/profound.ts b/apps/sim/lib/analytics/profound.ts index 6ad00743fbb..37006373e96 100644 --- a/apps/sim/lib/analytics/profound.ts +++ b/apps/sim/lib/analytics/profound.ts @@ -13,6 +13,7 @@ const logger = createLogger('ProfoundAnalytics') const FLUSH_INTERVAL_MS = 10_000 const MAX_BATCH_SIZE = 500 +const SENSITIVE_PARAMS = new Set(['token', 'callbackUrl', 'code', 'state', 'secret']) interface ProfoundLogEntry { timestamp: string @@ -91,7 +92,9 @@ export function sendToProfound(request: Request, statusCode: number): void { const url = new URL(request.url) const queryParams: Record = {} url.searchParams.forEach((value, key) => { - queryParams[key] = value + if (!SENSITIVE_PARAMS.has(key)) { + queryParams[key] = value + } }) buffer.push({ diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 07013dfb26e..20f0da49159 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getSessionCookie } from 'better-auth/cookies' import { type NextRequest, NextResponse } from 'next/server' -import { isProfoundEnabled, sendToProfound } from './lib/analytics/profound' +import { sendToProfound } from './lib/analytics/profound' import { isAuthDisabled, isHosted } from './lib/core/config/feature-flags' import { generateRuntimeCSP } from './lib/core/security/csp' @@ -201,9 +201,7 @@ export async function proxy(request: NextRequest) { * Sends request data to Profound analytics (fire-and-forget) and returns the response. */ function track(request: NextRequest, response: NextResponse): NextResponse { - if (isProfoundEnabled()) { - sendToProfound(request, response.status) - } + sendToProfound(request, response.status) return response } From acfbf94a149f32df8e8e1aa04b7622eb19f34dda Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 28 Mar 2026 21:53:31 -0700 Subject: [PATCH 6/6] chore(analytics): remove unnecessary query param filtering --- apps/sim/lib/analytics/profound.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/sim/lib/analytics/profound.ts b/apps/sim/lib/analytics/profound.ts index 37006373e96..6ad00743fbb 100644 --- a/apps/sim/lib/analytics/profound.ts +++ b/apps/sim/lib/analytics/profound.ts @@ -13,7 +13,6 @@ const logger = createLogger('ProfoundAnalytics') const FLUSH_INTERVAL_MS = 10_000 const MAX_BATCH_SIZE = 500 -const SENSITIVE_PARAMS = new Set(['token', 'callbackUrl', 'code', 'state', 'secret']) interface ProfoundLogEntry { timestamp: string @@ -92,9 +91,7 @@ export function sendToProfound(request: Request, statusCode: number): void { const url = new URL(request.url) const queryParams: Record = {} url.searchParams.forEach((value, key) => { - if (!SENSITIVE_PARAMS.has(key)) { - queryParams[key] = value - } + queryParams[key] = value }) buffer.push({