Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
39 changes: 16 additions & 23 deletions lib/hooks/use-gravity-ad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)}`)
Comment on lines +309 to +310
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For impressions, impUrl is now sent both in query string and request body - this creates redundancy that could lead to sync issues if they differ

: getApiUrl("/api/px");

const payload = JSON.stringify({
type,
sessionId,
Expand All @@ -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" }));
Expand All @@ -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
Expand Down
2 changes: 0 additions & 2 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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") {
Expand Down
70 changes: 0 additions & 70 deletions server/routes/adtrack.ts

This file was deleted.

159 changes: 123 additions & 36 deletions server/routes/gravity.ts
Original file line number Diff line number Diff line change
@@ -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.
*/

Expand Down Expand Up @@ -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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eventType is cast without validation - if body.type is undefined, line 85 defaults to undefined, causing this cast to be unsafe

Suggested change
event_type: eventType as "impression" | "click" | "dismiss",
event_type: eventType || "impression",

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()),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

url field is marked optional but should be required for consistency

Suggested change
url: t.Optional(t.String()),
url: t.String(),

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}),
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()),
}),
}
)
Expand Down