Skip to content

Commit b77e9f9

Browse files
committed
feat(core): add ingestPolicy for per-backend event timing control
- Add BackendWithConfig type with optional ingestPolicy field - Support "immediate" (default) and "on-client-event" policies - "immediate" backends receive events in middleware - "on-client-event" backends wait for client-init with full context - Enhance Segment backend with proper context.page and properties - Collect additional client context: title, hash, url, search, host - Remove deprecated pageViewMode config option
1 parent d420a1c commit b77e9f9

File tree

13 files changed

+399
-287
lines changed

13 files changed

+399
-287
lines changed

e2e/test-app/src/nextlytics.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
import { Nextlytics } from "@nextlytics/core/server";
2+
import type { BackendConfigEntry } from "@nextlytics/core";
23
import { postgrestBackend } from "@nextlytics/core/backends/postgrest";
34
import { auth } from "./auth";
45

5-
const backends = [
6+
const backends: BackendConfigEntry[] = [
7+
// Immediate backend - receives events in middleware (no client context initially)
68
postgrestBackend({
79
url: process.env.POSTGREST_URL || "http://localhost:3001",
810
tableName: "analytics",
911
}),
12+
// Delayed backend - receives events on client-init (has full client context)
13+
{
14+
backend: postgrestBackend({
15+
url: process.env.POSTGREST_URL || "http://localhost:3001",
16+
tableName: "analytics_delayed",
17+
}),
18+
ingestPolicy: "on-client-event",
19+
},
1020
];
1121

1222
export const { middleware, handlers, analytics } = Nextlytics({

e2e/tests/analytics.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,5 +139,50 @@ describe.each(versions)("%s", (version) => {
139139

140140
await page.close();
141141
});
142+
143+
it("on-client-event backend receives events with full client context", async () => {
144+
const page = await testApp.newPage();
145+
146+
await testApp.visitHome(page);
147+
148+
// Wait for events in both backends
149+
const [immediateEvents, delayedEvents] = await Promise.all([
150+
testApp.waitForEvents((e) =>
151+
e.some((ev) => ev.type === "pageView" && ev.path === testApp.homePath)
152+
),
153+
testApp.waitForDelayedEvents((e) =>
154+
e.some((ev) => ev.type === "pageView" && ev.path === testApp.homePath)
155+
),
156+
]);
157+
158+
const immediatePageView = immediateEvents.find(
159+
(e) => e.type === "pageView" && e.path === testApp.homePath
160+
);
161+
const delayedPageView = delayedEvents.find(
162+
(e) => e.type === "pageView" && e.path === testApp.homePath
163+
);
164+
165+
expect(immediatePageView).toBeDefined();
166+
expect(delayedPageView).toBeDefined();
167+
168+
// Both should have the same event_id
169+
expect(delayedPageView!.event_id).toBe(immediatePageView!.event_id);
170+
171+
// Delayed backend should have client_context with screen info
172+
const delayedClientCtx = delayedPageView!.client_context as Record<string, unknown>;
173+
expect(delayedClientCtx).toBeDefined();
174+
expect(delayedClientCtx.screen).toBeDefined();
175+
176+
const screen = delayedClientCtx.screen as Record<string, unknown>;
177+
expect(screen.width).toBeGreaterThan(0);
178+
expect(screen.height).toBeGreaterThan(0);
179+
expect(screen.innerWidth).toBeGreaterThan(0);
180+
expect(screen.innerHeight).toBeGreaterThan(0);
181+
182+
// Delayed backend should have title (from document.title)
183+
expect(delayedClientCtx.title).toBeDefined();
184+
185+
await page.close();
186+
});
142187
});
143188
});

e2e/tests/test-app.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,25 @@ export class TestApp {
8484
throw new Error(`Timed out waiting for analytics events after ${timeout}ms`);
8585
}
8686

87+
/** Get events from the delayed (on-client-event) backend */
88+
async getDelayedAnalyticsEvents(): Promise<AnalyticsEventRow[]> {
89+
return this.services.getDelayedAnalyticsEvents();
90+
}
91+
92+
/** Poll until delayed events match the predicate */
93+
async waitForDelayedEvents(
94+
predicate: (events: AnalyticsEventRow[]) => boolean,
95+
{ timeout = 5000, interval = 100 } = {}
96+
): Promise<AnalyticsEventRow[]> {
97+
const start = Date.now();
98+
while (Date.now() - start < timeout) {
99+
const events = await this.getDelayedAnalyticsEvents();
100+
if (predicate(events)) return events;
101+
await new Promise((r) => setTimeout(r, interval));
102+
}
103+
throw new Error(`Timed out waiting for delayed analytics events after ${timeout}ms`);
104+
}
105+
87106
// Test action helpers
88107
async login(page: Page): Promise<void> {
89108
await page.goto(`${this.baseUrl}${this.loginPath}`);

e2e/tests/thirdparty-services.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import {
1414

1515
export type { AnalyticsEventRow };
1616

17-
const INIT_SQL = generatePgCreateTableSQL("analytics");
17+
const INIT_SQL = [
18+
generatePgCreateTableSQL("analytics"),
19+
generatePgCreateTableSQL("analytics_delayed"),
20+
].join("\n");
1821

1922
const PG_ALIAS = "postgres";
2023

@@ -109,6 +112,7 @@ export class ThirdpartyServices {
109112

110113
async clearAnalytics(): Promise<void> {
111114
await this.getPool().query("DELETE FROM analytics");
115+
await this.getPool().query("DELETE FROM analytics_delayed");
112116
}
113117

114118
async getAnalyticsEvents(): Promise<AnalyticsEventRow[]> {
@@ -125,4 +129,12 @@ export class ThirdpartyServices {
125129
);
126130
return result.rows;
127131
}
132+
133+
/** Get events from the delayed (on-client-event) backend */
134+
async getDelayedAnalyticsEvents(): Promise<AnalyticsEventRow[]> {
135+
const result = await this.getPool().query<AnalyticsEventRow>(
136+
"SELECT * FROM analytics_delayed ORDER BY timestamp ASC"
137+
);
138+
return result.rows;
139+
}
128140
}

packages/core/src/backends/segment.ts

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,36 @@ export function segmentBackend(config: SegmentBackendConfig): NextlyticsBackend
5454
}
5555
}
5656

57+
function buildUrl(event: NextlyticsEvent): string {
58+
// Prefer client-provided URL if available
59+
if (event.clientContext?.url) return event.clientContext.url;
60+
61+
const { host, path, search } = event.serverContext;
62+
const protocol = host.includes("localhost") || host.match(/^[\d.:]+$/) ? "http" : "https";
63+
const searchStr = Object.entries(search)
64+
.flatMap(([k, vals]) => vals.map((v) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`))
65+
.join("&");
66+
return `${protocol}://${host}${path}${searchStr ? `?${searchStr}` : ""}`;
67+
}
68+
69+
function getSearchString(event: NextlyticsEvent): string {
70+
// Prefer client-provided search if available
71+
if (event.clientContext?.search) return event.clientContext.search;
72+
73+
return Object.entries(event.serverContext.search)
74+
.flatMap(([k, vals]) => vals.map((v) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`))
75+
.join("&");
76+
}
77+
78+
function getReferringDomain(referer?: string): string | undefined {
79+
if (!referer) return undefined;
80+
try {
81+
return new URL(referer).hostname;
82+
} catch {
83+
return undefined;
84+
}
85+
}
86+
5787
function buildContext(event: NextlyticsEvent) {
5888
const ctx: Record<string, unknown> = {
5989
ip: event.serverContext.ip,
@@ -63,20 +93,29 @@ export function segmentBackend(config: SegmentBackendConfig): NextlyticsBackend
6393
ctx.traits = event.userContext.traits;
6494
}
6595

66-
if (event.clientContext) {
67-
ctx.userAgent = event.clientContext.userAgent;
68-
ctx.locale = event.clientContext.locale;
69-
ctx.page = {
70-
path: event.clientContext.path,
71-
referrer: event.clientContext.referer,
72-
};
73-
if (event.clientContext.screen) {
96+
const cc = event.clientContext;
97+
const sc = event.serverContext;
98+
99+
ctx.page = {
100+
path: cc?.path ?? sc.path,
101+
referrer: cc?.referer,
102+
referring_domain: getReferringDomain(cc?.referer),
103+
host: cc?.host ?? sc.host,
104+
search: getSearchString(event),
105+
title: cc?.title,
106+
url: buildUrl(event),
107+
};
108+
109+
if (cc) {
110+
ctx.userAgent = cc.userAgent;
111+
ctx.locale = cc.locale;
112+
if (cc.screen) {
74113
ctx.screen = {
75-
width: event.clientContext.screen.width,
76-
height: event.clientContext.screen.height,
77-
innerWidth: event.clientContext.screen.innerWidth,
78-
innerHeight: event.clientContext.screen.innerHeight,
79-
density: event.clientContext.screen.density,
114+
width: cc.screen.width,
115+
height: cc.screen.height,
116+
innerWidth: cc.screen.innerWidth,
117+
innerHeight: cc.screen.innerHeight,
118+
density: cc.screen.density,
80119
};
81120
}
82121
}
@@ -85,12 +124,19 @@ export function segmentBackend(config: SegmentBackendConfig): NextlyticsBackend
85124
}
86125

87126
function buildProperties(event: NextlyticsEvent) {
127+
const cc = event.clientContext;
128+
const sc = event.serverContext;
129+
88130
return {
89131
parentEventId: event.parentEventId,
90-
path: event.serverContext.path,
91-
host: event.serverContext.host,
92-
method: event.serverContext.method,
93-
search: event.serverContext.search,
132+
path: cc?.path ?? sc.path,
133+
url: buildUrl(event),
134+
search: getSearchString(event),
135+
hash: cc?.hash,
136+
title: cc?.title,
137+
referrer: cc?.referer,
138+
width: cc?.screen?.innerWidth,
139+
height: cc?.screen?.innerHeight,
94140
...event.properties,
95141
};
96142
}

packages/core/src/client.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ function createClientContext(): ClientContext {
3232
collectedAt: new Date(),
3333
referer: isBrowser ? document.referrer || undefined : undefined,
3434
path: isBrowser ? window.location.pathname : undefined,
35+
url: isBrowser ? window.location.href : undefined,
36+
host: isBrowser ? window.location.host : undefined,
37+
search: isBrowser ? window.location.search : undefined,
38+
hash: isBrowser ? window.location.hash : undefined,
39+
title: isBrowser ? document.title : undefined,
3540
screen: {
3641
width: isBrowser ? window.screen.width : undefined,
3742
height: isBrowser ? window.screen.height : undefined,

packages/core/src/config-helpers.ts

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import type { NextlyticsConfig } from "./types";
22

33
export type NextlyticsConfigWithDefaults = Required<
4-
Pick<
5-
NextlyticsConfig,
6-
"pageViewMode" | "excludeApiCalls" | "eventEndpoint" | "isApiPath" | "backends"
7-
>
4+
Pick<NextlyticsConfig, "excludeApiCalls" | "eventEndpoint" | "isApiPath" | "backends">
85
> &
96
NextlyticsConfig & {
107
anonymousUsers: Required<NonNullable<NextlyticsConfig["anonymousUsers"]>>;
@@ -13,7 +10,6 @@ export type NextlyticsConfigWithDefaults = Required<
1310
export function withDefaults(config: NextlyticsConfig): NextlyticsConfigWithDefaults {
1411
return {
1512
...config,
16-
pageViewMode: config.pageViewMode ?? "server",
1713
excludeApiCalls: config.excludeApiCalls ?? false,
1814
eventEndpoint: config.eventEndpoint ?? "/api/event",
1915
isApiPath: config.isApiPath ?? (() => false),
@@ -33,27 +29,9 @@ export interface ConfigValidationResult {
3329
warnings: string[];
3430
}
3531

36-
export function validateConfig(config: NextlyticsConfig): ConfigValidationResult {
37-
const warnings: string[] = [];
38-
39-
if (config.pageViewMode === "client-init" && config.backends?.length) {
40-
// Only check non-factory backends (factories are resolved at runtime)
41-
const staticBackends = config.backends.filter((b) => typeof b !== "function");
42-
const backendsWithoutUpdates = staticBackends.filter((b) => !b.supportsUpdates);
43-
44-
if (backendsWithoutUpdates.length > 0) {
45-
const backendNames = backendsWithoutUpdates.map((b) => `"${b.name}"`).join(", ");
46-
warnings.push(
47-
`[Nextlytics] pageViewMode="client-init" requires backends that support updates. ` +
48-
`These don't: ${backendNames}`
49-
);
50-
}
51-
}
52-
53-
return {
54-
valid: warnings.length === 0,
55-
warnings,
56-
};
32+
export function validateConfig(_config: NextlyticsConfig): ConfigValidationResult {
33+
// Currently no validations - can add backend-specific checks here
34+
return { valid: true, warnings: [] };
5735
}
5836

5937
export function logConfigWarnings(result: ConfigValidationResult): void {

0 commit comments

Comments
 (0)