Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(analytics): add Profound web traffic tracking#3835
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
5f6bcd1627b61ea902d60e407ddc86a85a3acfbf94File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| 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')! }), | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| }) | ||
| ensureFlushTimer() | ||
| if (buffer.length >= MAX_BATCH_SIZE) { | ||
| flush().catch(() => {}) | ||
| } | ||
| } catch (error) { | ||
| logger.error('Failed to enqueue log entry', error) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.