diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index 6767fbb..a391f66 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -115,7 +115,8 @@ export interface AnalyticsEvent { // ============================================================================= // Ad event status - matches ContextResponseStatus in types/api.ts -export type AdEventStatus = "filled" | "no_fill" | "premium_user" | "gravity_error" | "timeout" | "error"; +// "forwarding_failed" is for impression events where Gravity URL forwarding failed +export type AdEventStatus = "filled" | "no_fill" | "premium_user" | "gravity_error" | "timeout" | "error" | "forwarding_failed"; // Event type for tracking funnel: request -> impression -> click/dismiss export type AdEventType = "request" | "impression" | "click" | "dismiss"; diff --git a/lib/hooks/use-gravity-ad.ts b/lib/hooks/use-gravity-ad.ts index 6c0e001..f184e43 100644 --- a/lib/hooks/use-gravity-ad.ts +++ b/lib/hooks/use-gravity-ad.ts @@ -292,10 +292,11 @@ export function useGravityAd({ placeholderData: (prev) => prev, }); - // Stable tracking function - memoized to prevent re-renders - const sendTrackingEvent = useCallback( - (type: "impression" | "click" | "dismiss", ad: ContextAd | null) => { - if (!ad || !sessionId) return; + // Shared helper to send ad events to /api/px + // All ad tracking goes through this single endpoint + const sendAdEvent = useCallback( + (type: "impression" | "click" | "dismiss", ad: ContextAd) => { + if (!sessionId) return; let hostname = ""; try { @@ -304,6 +305,11 @@ export function useGravityAd({ // Ignore invalid URLs } + // For impressions, include the Gravity URL for billing; clicks/dismisses don't need it + const trackUrl = type === "impression" + ? getApiUrl(`/api/px?url=${encodeURIComponent(ad.impUrl)}`) + : getApiUrl("/api/px"); + const payload = JSON.stringify({ type, sessionId, @@ -320,8 +326,6 @@ export function useGravityAd({ browser: deviceInfo?.browser, }); - const trackUrl = getApiUrl("/api/adtrack"); - // Use sendBeacon for reliable non-blocking tracking if (typeof navigator !== "undefined" && navigator.sendBeacon) { navigator.sendBeacon(trackUrl, new Blob([payload], { type: "application/json" })); @@ -341,36 +345,25 @@ export function useGravityAd({ const fireImpression = useCallback( (targetAd?: ContextAd) => { const ad = targetAd ?? query.data?.[0]; - if (!ad) return; - - // Fire impression to Gravity via proxy (for billing) - const proxyUrl = getApiUrl(`/api/px?url=${encodeURIComponent(ad.impUrl)}`); - if (typeof navigator !== "undefined" && navigator.sendBeacon) { - navigator.sendBeacon(proxyUrl); - } else { - fetch(proxyUrl, { method: "POST", keepalive: true }).catch(() => {}); - } - - // Track locally for analytics - sendTrackingEvent("impression", ad); + if (ad) sendAdEvent("impression", ad); }, - [query.data, sendTrackingEvent] + [query.data, sendAdEvent] ); const fireClick = useCallback( (targetAd?: ContextAd) => { const ad = targetAd ?? query.data?.[0]; - sendTrackingEvent("click", ad ?? null); + if (ad) sendAdEvent("click", ad); }, - [query.data, sendTrackingEvent] + [query.data, sendAdEvent] ); const fireDismiss = useCallback( (targetAd?: ContextAd) => { const ad = targetAd ?? query.data?.[0]; - sendTrackingEvent("dismiss", ad ?? null); + if (ad) sendAdEvent("dismiss", ad); }, - [query.data, sendTrackingEvent] + [query.data, sendAdEvent] ); // Memoize the result to prevent object recreation diff --git a/server/index.ts b/server/index.ts index c4d6fed..cc7177e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -13,7 +13,6 @@ import { chatHistoryRoutes } from "./routes/chat-history"; import { webhookRoutes } from "./routes/webhooks"; import { bypassDetectionRoutes } from "./routes/bypass-detection"; import { gravityRoutes } from "./routes/gravity"; -import { adtrackRoutes } from "./routes/adtrack"; import { startMemoryMonitor, getCurrentMemory } from "../lib/memory-monitor"; import { checkErrorRateAndAlert } from "../lib/alerting"; import { env } from "./env"; @@ -74,7 +73,6 @@ const app = new Elysia({ adapter: node() }) .use(webhookRoutes) .use(bypassDetectionRoutes) .use(gravityRoutes) - .use(adtrackRoutes) .onError(({ code, error, set, request }) => { // Don't log 404s for common browser requests (favicon, etc) if (code === "NOT_FOUND") { diff --git a/server/routes/adtrack.ts b/server/routes/adtrack.ts deleted file mode 100644 index 6faac83..0000000 --- a/server/routes/adtrack.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Ad Tracking Routes - POST /api/adtrack - * - * Lightweight endpoint for client-side ad event tracking (impressions, clicks, dismissals). - * Uses sendBeacon on client for reliable tracking during navigation. - * All tracking is non-blocking and fire-and-forget. - */ - -import { Elysia, t } from "elysia"; -import { trackAdEvent, type AdEventType } from "../../lib/clickhouse"; -import { createLogger } from "../../lib/logger"; - -const logger = createLogger("api:adtrack"); - -export const adtrackRoutes = new Elysia({ prefix: "/api" }).post( - "/adtrack", - async ({ body, set }) => { - const { type, sessionId, hostname, brandName, adTitle, adText, clickUrl, impUrl, cta, favicon, deviceType, os, browser } = body; - - // Fire-and-forget tracking - don't block the response - try { - trackAdEvent({ - event_type: type as AdEventType, - session_id: sessionId, - hostname, - brand_name: brandName, - ad_title: adTitle, - ad_text: adText, - click_url: clickUrl, - imp_url: impUrl, - cta, - favicon, - device_type: deviceType, - os, - browser, - status: "filled", // Client-side events only fire for filled ads - }); - - logger.debug({ type, hostname, brandName }, "Ad event tracked"); - } catch (error) { - // Log but don't fail the request - tracking is best-effort - logger.warn({ error: String(error), type }, "Failed to track ad event"); - } - - // Return 204 No Content immediately - client doesn't need a response body - set.status = 204; - return; - }, - { - body: t.Object({ - type: t.Union([ - t.Literal("impression"), - t.Literal("click"), - t.Literal("dismiss"), - ]), - sessionId: t.String(), - hostname: t.String(), - brandName: t.Optional(t.String()), - adTitle: t.Optional(t.String()), - adText: t.Optional(t.String()), - clickUrl: t.Optional(t.String()), - impUrl: t.Optional(t.String()), - cta: t.Optional(t.String()), - favicon: t.Optional(t.String()), - deviceType: t.Optional(t.String()), - os: t.Optional(t.String()), - browser: t.Optional(t.String()), - }), - } -); diff --git a/server/routes/gravity.ts b/server/routes/gravity.ts index a45915f..8742c0e 100644 --- a/server/routes/gravity.ts +++ b/server/routes/gravity.ts @@ -1,8 +1,11 @@ /** - * Gravity Context Routes - POST /api/context, POST /api/px + * Gravity Routes - POST /api/context, POST /api/px + * + * /api/context - Fetches contextual ads from Gravity AI for free users + * /api/px - Unified ad event tracking (impression, click, dismiss) + * - For impressions: forwards to Gravity for billing + tracks locally + * - For clicks/dismisses: tracks locally only * - * Fetches contextual content from Gravity AI for free users. - * Passes device/user info for better targeting. * Endpoint names are neutral to avoid content blockers. */ @@ -76,50 +79,134 @@ export interface GravityAdResponse { export const gravityRoutes = new Elysia({ prefix: "/api" }) .post( "/px", - async ({ query, set }) => { - const url = query.url; - - if (!url) { - set.status = 400; - return { error: "Missing url parameter" }; - } + async ({ query, body, set }) => { + // Prefer query.url, fall back to body.impUrl for impressions + const impUrl = query.url || body.impUrl; + const eventType = body.type; + + // Track validation/forwarding status for analytics + let gravityStatusCode = 0; + let validationError = ""; + let forwardingSuccess = false; + + // For impressions, validate and forward to Gravity for billing + if (eventType === "impression") { + if (!impUrl) { + // Flag missing impression URL - this is a client bug + validationError = "Missing impression URL"; + logger.warn({ sessionId: body.sessionId, hostname: body.hostname }, validationError); + } else { + // Validate URL is from Gravity + let isValidUrl = false; + try { + const parsedUrl = new URL(impUrl); + if (parsedUrl.hostname.endsWith("trygravity.ai")) { + isValidUrl = true; + } else { + validationError = "Invalid impression URL: not from trygravity.ai"; + logger.warn({ impUrl }, validationError); + } + } catch { + validationError = "Invalid URL format"; + logger.warn({ impUrl }, validationError); + } - // Validate URL is from Gravity - try { - const parsedUrl = new URL(url); - if (!parsedUrl.hostname.endsWith("trygravity.ai")) { - set.status = 400; - return { error: "Invalid impression URL" }; + // Forward to Gravity only if URL is valid + if (isValidUrl) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), GRAVITY_TIMEOUT_MS); + + try { + logger.info({ impUrl }, "Forwarding impression to Gravity"); + const response = await fetch(impUrl, { + method: "GET", + headers: { + "User-Agent": "13ft-impression-proxy/1.0", + }, + signal: controller.signal, + }); + clearTimeout(timeoutId); + await response.text().catch(() => {}); + gravityStatusCode = response.status; + + if (response.ok) { + forwardingSuccess = true; + logger.info({ impUrl, status: response.status }, "Impression forwarded successfully"); + } else { + validationError = `Gravity returned ${response.status}`; + logger.warn({ impUrl, status: response.status }, "Gravity returned non-2xx status"); + } + } catch (error) { + clearTimeout(timeoutId); + const errorMsg = String(error); + const isTimeout = errorMsg.includes("abort"); + validationError = isTimeout ? "Timeout forwarding to Gravity" : `Failed to forward: ${errorMsg}`; + logger.warn({ impUrl, error: errorMsg, isTimeout }, "Failed to forward impression"); + } + } } - } catch { - set.status = 400; - return { error: "Invalid URL format" }; } - // Forward the impression request to Gravity - try { - logger.info({ impUrl: url }, "Forwarding impression to Gravity"); - const response = await fetch(url, { - method: "GET", - headers: { - "User-Agent": "13ft-impression-proxy/1.0", - }, - }); - // Consume body to release connection resources - await response.text().catch(() => {}); - logger.info({ impUrl: url, status: response.status }, "Impression forwarded successfully"); - } catch (error) { - logger.warn({ impUrl: url, error: String(error) }, "Failed to forward impression"); - // Silently fail - impression tracking is best-effort + let trackingStatus: "filled" | "forwarding_failed"; + if (eventType === "impression") { + trackingStatus = forwardingSuccess ? "filled" : "forwarding_failed"; + } else { + trackingStatus = "filled"; } - // Return 204 No Content - the client doesn't need a response + // Always track event locally for analytics (even if Gravity forwarding failed) + trackAdEvent({ + event_type: eventType as "impression" | "click" | "dismiss", + session_id: body.sessionId, + hostname: body.hostname || "", + brand_name: body.brandName || "", + ad_title: body.adTitle || "", + ad_text: body.adText || "", + click_url: body.clickUrl || "", + imp_url: impUrl || "", + cta: body.cta || "", + favicon: body.favicon || "", + device_type: body.deviceType || "", + os: body.os || "", + browser: body.browser || "", + status: trackingStatus, + gravity_status_code: gravityStatusCode, + error_message: validationError, + }); + logger.debug({ + type: eventType, + hostname: body.hostname, + brandName: body.brandName, + status: trackingStatus, + gravityStatusCode, + validationError: validationError || undefined, + }, "Ad event tracked"); + set.status = 204; return; }, { query: t.Object({ - url: t.String(), + url: t.Optional(t.String()), + }), + body: t.Object({ + type: t.Union([ + t.Literal("impression"), + t.Literal("click"), + t.Literal("dismiss"), + ]), + sessionId: t.String(), + hostname: t.Optional(t.String()), + brandName: t.Optional(t.String()), + adTitle: t.Optional(t.String()), + adText: t.Optional(t.String()), + clickUrl: t.Optional(t.String()), + impUrl: t.Optional(t.String()), + cta: t.Optional(t.String()), + favicon: t.Optional(t.String()), + deviceType: t.Optional(t.String()), + os: t.Optional(t.String()), + browser: t.Optional(t.String()), }), } )