@nextlytics/core 0.3.0 → 0.3.1-canary.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ import { NextRequest } from 'next/server';
2
+ import { NextlyticsEvent, RequestContext, PageViewDelivery, DispatchResult, UserContext } from './types.js';
3
+ import { NextlyticsConfigWithDefaults } from './config-helpers.js';
4
+ import 'next/dist/server/web/spec-extension/cookies';
5
+
6
+ type DispatchEvent = (event: NextlyticsEvent, ctx: RequestContext, policyFilter?: PageViewDelivery | "client-actions") => DispatchResult;
7
+ type UpdateEvent = (eventId: string, patch: Partial<NextlyticsEvent>, ctx: RequestContext) => Promise<void>;
8
+ declare function getUserContext(config: NextlyticsConfigWithDefaults, ctx: RequestContext): Promise<UserContext | undefined>;
9
+ declare function handleEventPost(request: NextRequest, config: NextlyticsConfigWithDefaults, dispatchEvent: DispatchEvent, updateEvent: UpdateEvent): Promise<Response>;
10
+
11
+ export { type DispatchEvent, type UpdateEvent, getUserContext, handleEventPost };
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var api_handler_exports = {};
20
+ __export(api_handler_exports, {
21
+ getUserContext: () => getUserContext,
22
+ handleEventPost: () => handleEventPost
23
+ });
24
+ module.exports = __toCommonJS(api_handler_exports);
25
+ var import_server = require("next/server");
26
+ var import_server_component_context = require("./server-component-context");
27
+ var import_uitils = require("./uitils");
28
+ var import_anonymous_user = require("./anonymous-user");
29
+ function createRequestContext(request) {
30
+ return {
31
+ headers: request.headers,
32
+ cookies: request.cookies
33
+ };
34
+ }
35
+ async function getUserContext(config, ctx) {
36
+ if (!config.callbacks.getUser) return void 0;
37
+ try {
38
+ return await config.callbacks.getUser(ctx) || void 0;
39
+ } catch {
40
+ return void 0;
41
+ }
42
+ }
43
+ function reconstructServerContext(apiCallContext, clientContext) {
44
+ const searchParams = {};
45
+ if (clientContext.search) {
46
+ const params = new URLSearchParams(clientContext.search);
47
+ params.forEach((value, key) => {
48
+ if (!searchParams[key]) searchParams[key] = [];
49
+ searchParams[key].push(value);
50
+ });
51
+ }
52
+ return {
53
+ ...apiCallContext,
54
+ host: clientContext.host || apiCallContext.host,
55
+ path: clientContext.path || apiCallContext.path,
56
+ search: Object.keys(searchParams).length > 0 ? searchParams : apiCallContext.search,
57
+ method: "GET"
58
+ };
59
+ }
60
+ function filterScripts(actions) {
61
+ const scripts = actions.items.filter((i) => i.type === "script-template");
62
+ return scripts.length > 0 ? scripts : void 0;
63
+ }
64
+ async function handleClientInit(request, hctx) {
65
+ const {
66
+ pageRenderId,
67
+ ctx,
68
+ apiCallServerContext,
69
+ userContext,
70
+ config,
71
+ dispatchEvent,
72
+ updateEvent
73
+ } = hctx;
74
+ const { clientContext } = request;
75
+ const serverContext = reconstructServerContext(apiCallServerContext, clientContext);
76
+ const { anonId: anonymousUserId } = await (0, import_anonymous_user.resolveAnonymousUser)({
77
+ ctx,
78
+ serverContext,
79
+ config
80
+ });
81
+ const isSoftNavigation = hctx.isSoftNavigation;
82
+ const eventId = isSoftNavigation ? (0, import_uitils.generateId)() : pageRenderId;
83
+ const event = {
84
+ origin: "client",
85
+ eventId,
86
+ parentEventId: isSoftNavigation ? pageRenderId : void 0,
87
+ type: "pageView",
88
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString(),
89
+ anonymousUserId,
90
+ serverContext,
91
+ clientContext,
92
+ userContext,
93
+ properties: {}
94
+ };
95
+ if (isSoftNavigation) {
96
+ const { clientActions, completion: completion2 } = dispatchEvent(event, ctx);
97
+ const actions = await clientActions;
98
+ (0, import_server.after)(() => completion2);
99
+ return Response.json({
100
+ ok: true,
101
+ items: filterScripts(actions)
102
+ });
103
+ }
104
+ const { completion } = dispatchEvent(event, ctx, "client-actions");
105
+ (0, import_server.after)(() => completion);
106
+ (0, import_server.after)(() => updateEvent(pageRenderId, { clientContext, userContext, anonymousUserId }, ctx));
107
+ return Response.json({ ok: true });
108
+ }
109
+ async function handleClientEvent(request, hctx) {
110
+ const { pageRenderId, ctx, apiCallServerContext, userContext, config, dispatchEvent } = hctx;
111
+ const { clientContext, name, props, collectedAt } = request;
112
+ const serverContext = clientContext ? reconstructServerContext(apiCallServerContext, clientContext) : apiCallServerContext;
113
+ const { anonId: anonymousUserId } = await (0, import_anonymous_user.resolveAnonymousUser)({
114
+ ctx,
115
+ serverContext,
116
+ config
117
+ });
118
+ const event = {
119
+ origin: "client",
120
+ eventId: (0, import_uitils.generateId)(),
121
+ parentEventId: pageRenderId,
122
+ type: name,
123
+ collectedAt: collectedAt || (/* @__PURE__ */ new Date()).toISOString(),
124
+ anonymousUserId,
125
+ serverContext,
126
+ clientContext,
127
+ userContext,
128
+ properties: props || {}
129
+ };
130
+ const { clientActions, completion } = dispatchEvent(event, ctx);
131
+ const actions = await clientActions;
132
+ (0, import_server.after)(() => completion);
133
+ return Response.json({ ok: true, items: filterScripts(actions) });
134
+ }
135
+ async function handleEventPost(request, config, dispatchEvent, updateEvent) {
136
+ const softNavHeader = request.headers.get(import_server_component_context.headerNames.isSoftNavigation);
137
+ const isSoftNavigation = softNavHeader === "1";
138
+ const pageRenderIdHeader = request.headers.get(import_server_component_context.headerNames.pageRenderId);
139
+ if (!pageRenderIdHeader) {
140
+ return Response.json({ error: "Missing page render ID" }, { status: 400 });
141
+ }
142
+ let body;
143
+ try {
144
+ body = await request.json();
145
+ } catch {
146
+ return Response.json({ error: "Invalid JSON" }, { status: 400 });
147
+ }
148
+ const ctx = createRequestContext(request);
149
+ const apiCallServerContext = (0, import_uitils.createServerContext)(request);
150
+ const userContext = await getUserContext(config, ctx);
151
+ const cookiePageRenderId = request.cookies.get(import_server_component_context.LAST_PAGE_RENDER_ID_COOKIE)?.value;
152
+ const pageRenderId = isSoftNavigation ? cookiePageRenderId ?? (0, import_uitils.generateId)() : pageRenderIdHeader;
153
+ if (isSoftNavigation && !cookiePageRenderId && config.debug) {
154
+ console.warn(
155
+ "[Nextlytics] Missing last-page-render-id cookie on soft navigation; using a new id."
156
+ );
157
+ }
158
+ const hctx = {
159
+ pageRenderId,
160
+ isSoftNavigation,
161
+ ctx,
162
+ apiCallServerContext,
163
+ userContext,
164
+ config,
165
+ dispatchEvent,
166
+ updateEvent
167
+ };
168
+ const bodyType = body.type;
169
+ switch (bodyType) {
170
+ case "page-view":
171
+ return handleClientInit(body, hctx);
172
+ case "custom-event":
173
+ return handleClientEvent(body, hctx);
174
+ default:
175
+ return Response.json({ ok: false, error: `Unknown body type ${bodyType}` }, { status: 400 });
176
+ }
177
+ }
178
+ // Annotate the CommonJS export names for ESM import in node:
179
+ 0 && (module.exports = {
180
+ getUserContext,
181
+ handleEventPost
182
+ });
@@ -15,6 +15,11 @@ type GoogleAnalyticsBackendOptions = {
15
15
  * - "anonymousUserId": Always use Nextlytics anonymousUserId
16
16
  */
17
17
  clientIdSource?: "gaCookie" | "anonymousUserId";
18
+ /**
19
+ * Prefer sending client-origin events from the browser (gtag) instead of Measurement Protocol.
20
+ * Default: true. Set to false to force Measurement Protocol when apiSecret is provided.
21
+ */
22
+ preferClientSideForClientEvents?: boolean;
18
23
  };
19
24
  declare function googleAnalyticsBackend(opts: GoogleAnalyticsBackendOptions): NextlyticsBackendFactory;
20
25
 
@@ -21,7 +21,9 @@ __export(ga_exports, {
21
21
  googleAnalyticsBackend: () => googleAnalyticsBackend
22
22
  });
23
23
  module.exports = __toCommonJS(ga_exports);
24
- const GA_TEMPLATE_ID = "ga-gtag";
24
+ const GA_INIT_TEMPLATE = "ga-gtag";
25
+ const GA_PROPERTIES_TEMPLATE = "ga-properties";
26
+ const GA_EVENT_TEMPLATE = "ga-event";
25
27
  function parseGaCookie(cookieValue) {
26
28
  const match = cookieValue.match(/^GA\d+\.\d+\.(.+)$/);
27
29
  return match ? match[1] : null;
@@ -118,27 +120,51 @@ function googleAnalyticsBackend(opts) {
118
120
  return (ctx) => {
119
121
  const gaCookie = ctx.cookies.get("_ga");
120
122
  const gaCookieClientId = gaCookie ? parseGaCookie(gaCookie.value) : null;
123
+ const preferClientSideForClientEvents = opts.preferClientSideForClientEvents ?? true;
121
124
  return {
122
125
  name: "google-analytics",
123
126
  returnsClientActions: true,
124
127
  supportsUpdates: false,
125
128
  getClientSideTemplates() {
126
129
  return {
127
- [GA_TEMPLATE_ID]: {
130
+ [GA_EVENT_TEMPLATE]: {
131
+ deps: "{{eventId}}",
128
132
  items: [
133
+ // Update user properties for this event (if provided)
134
+ {
135
+ body: [
136
+ "gtag('set', {{json(properties)}});",
137
+ "gtag('event', '{{eventName}}', {{json(eventParams)}});"
138
+ ]
139
+ }
140
+ ]
141
+ },
142
+ [GA_INIT_TEMPLATE]: {
143
+ deps: "{{measurementId}}{{json(initial_config)}}",
144
+ items: [
145
+ // External gtag.js - load once
129
146
  {
130
- async: "true",
131
147
  src: "https://www.googletagmanager.com/gtag/js?id={{measurementId}}",
132
- singleton: true
148
+ async: true
133
149
  },
150
+ // gtag definition and initialization - run once
134
151
  {
135
152
  body: [
136
153
  "window.dataLayer = window.dataLayer || [];",
137
- "function gtag(){dataLayer.push(arguments);}",
154
+ opts.debugMode ? "function gtag(){ console.log('[gtag() call]', arguments); dataLayer.push(arguments); }" : "function gtag(){dataLayer.push(arguments);}",
155
+ "window.gtag = gtag;",
138
156
  "gtag('js', new Date());",
139
- "gtag('config', '{{measurementId}}', {{json(config)}});",
140
- "gtag('event', 'page_view');"
141
- ].join("\n")
157
+ "gtag('config', '{{measurementId}}', {{json(initial_config)}});"
158
+ ]
159
+ }
160
+ ]
161
+ },
162
+ [GA_PROPERTIES_TEMPLATE]: {
163
+ deps: "{{json(properties)}}",
164
+ items: [
165
+ // Updates that should NOT trigger page_view (e.g., user_id, user_properties)
166
+ {
167
+ body: "gtag('set', {{json(properties)}});"
142
168
  }
143
169
  ]
144
170
  }
@@ -155,33 +181,66 @@ function googleAnalyticsBackend(opts) {
155
181
  } = event.userContext?.traits ?? {};
156
182
  const userProperties = Object.keys(customTraits).length > 0 ? customTraits : void 0;
157
183
  if (event.type === "pageView") {
158
- const config = {
159
- send_page_view: false,
184
+ const initial_config = {
185
+ // Rely on GA auto page_view (including SPA history changes).
186
+ send_page_view: true,
160
187
  client_id: clientId
161
188
  };
162
189
  if (debugMode) {
163
- config.debug_mode = true;
190
+ initial_config.debug_mode = true;
164
191
  }
192
+ const properties2 = {};
165
193
  if (userId) {
166
- config.user_id = userId;
194
+ properties2.user_id = userId;
167
195
  }
168
196
  if (userProperties) {
169
- config.user_properties = userProperties;
197
+ properties2.user_properties = userProperties;
170
198
  }
171
199
  return {
172
200
  items: [
173
201
  {
174
202
  type: "script-template",
175
- templateId: GA_TEMPLATE_ID,
203
+ templateId: GA_INIT_TEMPLATE,
176
204
  params: {
177
205
  measurementId,
178
- config
206
+ initial_config
207
+ }
208
+ },
209
+ {
210
+ type: "script-template",
211
+ templateId: GA_PROPERTIES_TEMPLATE,
212
+ params: {
213
+ properties: properties2
179
214
  }
180
215
  }
181
216
  ]
182
217
  };
183
218
  }
184
- if (apiSecret) {
219
+ const eventParams = buildEventParams(event);
220
+ const properties = {};
221
+ if (userId) {
222
+ properties.user_id = userId;
223
+ }
224
+ if (userProperties) {
225
+ properties.user_properties = userProperties;
226
+ }
227
+ if (event.origin === "client") {
228
+ if (preferClientSideForClientEvents || !apiSecret) {
229
+ return {
230
+ items: [
231
+ {
232
+ type: "script-template",
233
+ templateId: GA_EVENT_TEMPLATE,
234
+ params: {
235
+ eventId: event.eventId,
236
+ eventName: toGA4EventName(event.type),
237
+ eventParams,
238
+ properties
239
+ }
240
+ }
241
+ ]
242
+ };
243
+ }
185
244
  await sendToMeasurementProtocol({
186
245
  measurementId,
187
246
  apiSecret,
@@ -189,12 +248,29 @@ function googleAnalyticsBackend(opts) {
189
248
  userId,
190
249
  userProperties,
191
250
  eventName: toGA4EventName(event.type),
192
- eventParams: buildEventParams(event),
251
+ eventParams,
193
252
  userAgent: getUserAgent(event),
194
253
  clientIp: getClientIp(event),
195
254
  debugMode
196
255
  });
256
+ return void 0;
257
+ }
258
+ if (!apiSecret) {
259
+ return void 0;
197
260
  }
261
+ await sendToMeasurementProtocol({
262
+ measurementId,
263
+ apiSecret,
264
+ clientId,
265
+ userId,
266
+ userProperties,
267
+ eventName: toGA4EventName(event.type),
268
+ eventParams,
269
+ userAgent: getUserAgent(event),
270
+ clientIp: getClientIp(event),
271
+ debugMode
272
+ });
273
+ return void 0;
198
274
  },
199
275
  updateEvent() {
200
276
  }
@@ -22,6 +22,7 @@ __export(gtm_exports, {
22
22
  });
23
23
  module.exports = __toCommonJS(gtm_exports);
24
24
  const GTM_INIT_TEMPLATE_ID = "gtm-init";
25
+ const GTM_INIT_DATA_TEMPLATE_ID = "gtm-init-data";
25
26
  const GTM_PAGEVIEW_TEMPLATE_ID = "gtm-pageview";
26
27
  const GTM_EVENT_TEMPLATE_ID = "gtm-event";
27
28
  function toSnakeCase(str) {
@@ -37,21 +38,27 @@ function googleTagManagerBackend(opts) {
37
38
  return {
38
39
  [GTM_INIT_TEMPLATE_ID]: {
39
40
  items: [
41
+ // GTM script loader - run once
40
42
  {
41
43
  body: [
42
44
  "window.dataLayer = window.dataLayer || [];",
43
- "dataLayer.push({{json(initialData)}});",
44
- "if (!window.google_tag_manager || !window.google_tag_manager['{{containerId}}']) {",
45
- " (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':",
46
- " new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],",
47
- " j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=",
48
- " 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);",
49
- " })(window,document,'script','dataLayer','{{containerId}}');",
50
- "}"
45
+ "(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':",
46
+ "new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],",
47
+ "j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=",
48
+ "'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);",
49
+ "})(window,document,'script','dataLayer','{{containerId}}');"
51
50
  ].join("\n")
52
51
  }
53
52
  ]
54
53
  },
54
+ [GTM_INIT_DATA_TEMPLATE_ID]: {
55
+ items: [
56
+ // Initial data push - run when params change (e.g., user logs in)
57
+ {
58
+ body: "dataLayer.push({{json(initialData)}});"
59
+ }
60
+ ]
61
+ },
55
62
  [GTM_PAGEVIEW_TEMPLATE_ID]: {
56
63
  items: [
57
64
  {
@@ -122,6 +129,11 @@ function googleTagManagerBackend(opts) {
122
129
  if (event.clientContext) {
123
130
  return {
124
131
  items: [
132
+ {
133
+ type: "script-template",
134
+ templateId: GTM_INIT_DATA_TEMPLATE_ID,
135
+ params: { initialData }
136
+ },
125
137
  {
126
138
  type: "script-template",
127
139
  templateId: GTM_PAGEVIEW_TEMPLATE_ID,
@@ -137,6 +149,11 @@ function googleTagManagerBackend(opts) {
137
149
  templateId: GTM_INIT_TEMPLATE_ID,
138
150
  params: { containerId, initialData }
139
151
  },
152
+ {
153
+ type: "script-template",
154
+ templateId: GTM_INIT_DATA_TEMPLATE_ID,
155
+ params: { initialData }
156
+ },
140
157
  {
141
158
  type: "script-template",
142
159
  templateId: GTM_PAGEVIEW_TEMPLATE_ID,
@@ -3,49 +3,41 @@ import 'next/dist/server/web/spec-extension/cookies';
3
3
  import 'next/server';
4
4
 
5
5
  declare const tableColumns: readonly [{
6
- readonly name: "event_id";
7
- readonly pgType: "TEXT PRIMARY KEY";
8
- readonly chType: "String";
9
- }, {
10
- readonly name: "parent_event_id";
11
- readonly pgType: "TEXT";
12
- readonly chType: "Nullable(String)";
13
- }, {
14
6
  readonly name: "timestamp";
15
- readonly pgType: "TIMESTAMPTZ";
7
+ readonly pgType: "TIMESTAMPTZ NOT NULL";
16
8
  readonly chType: "DateTime64(3)";
17
9
  }, {
18
10
  readonly name: "type";
19
- readonly pgType: "TEXT";
11
+ readonly pgType: "TEXT NOT NULL";
20
12
  readonly chType: "LowCardinality(String)";
21
13
  }, {
22
- readonly name: "anonymous_user_id";
14
+ readonly name: "host";
23
15
  readonly pgType: "TEXT";
24
- readonly chType: "Nullable(String)";
16
+ readonly chType: "LowCardinality(String)";
25
17
  }, {
26
- readonly name: "user_id";
18
+ readonly name: "path";
27
19
  readonly pgType: "TEXT";
28
- readonly chType: "Nullable(String)";
20
+ readonly chType: "String";
29
21
  }, {
30
- readonly name: "user_email";
22
+ readonly name: "method";
31
23
  readonly pgType: "TEXT";
32
- readonly chType: "Nullable(String)";
24
+ readonly chType: "LowCardinality(String)";
33
25
  }, {
34
- readonly name: "user_name";
26
+ readonly name: "user_id";
35
27
  readonly pgType: "TEXT";
36
28
  readonly chType: "Nullable(String)";
37
29
  }, {
38
- readonly name: "host";
30
+ readonly name: "anonymous_user_id";
39
31
  readonly pgType: "TEXT";
40
- readonly chType: "LowCardinality(String)";
32
+ readonly chType: "Nullable(String)";
41
33
  }, {
42
- readonly name: "method";
34
+ readonly name: "user_email";
43
35
  readonly pgType: "TEXT";
44
- readonly chType: "LowCardinality(String)";
36
+ readonly chType: "Nullable(String)";
45
37
  }, {
46
- readonly name: "path";
38
+ readonly name: "user_name";
47
39
  readonly pgType: "TEXT";
48
- readonly chType: "String";
40
+ readonly chType: "Nullable(String)";
49
41
  }, {
50
42
  readonly name: "ip";
51
43
  readonly pgType: "INET";
@@ -62,6 +54,14 @@ declare const tableColumns: readonly [{
62
54
  readonly name: "locale";
63
55
  readonly pgType: "TEXT";
64
56
  readonly chType: "LowCardinality(Nullable(String))";
57
+ }, {
58
+ readonly name: "event_id";
59
+ readonly pgType: "TEXT PRIMARY KEY";
60
+ readonly chType: "String";
61
+ }, {
62
+ readonly name: "parent_event_id";
63
+ readonly pgType: "TEXT";
64
+ readonly chType: "Nullable(String)";
65
65
  }, {
66
66
  readonly name: "server_context";
67
67
  readonly pgType: "JSONB";
@@ -97,21 +97,21 @@ declare function generateChCreateTableSQL(database: string, tableName: string):
97
97
  declare function isChTableNotFoundError(text: string): boolean;
98
98
  /** Row type returned from analytics table queries */
99
99
  interface AnalyticsEventRow {
100
- event_id: string;
101
- parent_event_id: string | null;
102
100
  timestamp: string;
103
101
  type: string;
104
- anonymous_user_id: string | null;
102
+ host: string;
103
+ path: string;
104
+ method: string;
105
105
  user_id: string | null;
106
+ anonymous_user_id: string | null;
106
107
  user_email: string | null;
107
108
  user_name: string | null;
108
- host: string;
109
- method: string;
110
- path: string;
111
109
  ip: string | null;
112
110
  referer: string | null;
113
111
  user_agent: string | null;
114
112
  locale: string | null;
113
+ event_id: string;
114
+ parent_event_id: string | null;
115
115
  server_context: Record<string, unknown>;
116
116
  client_context: Record<string, unknown>;
117
117
  user_traits: Record<string, unknown>;
@@ -29,21 +29,21 @@ __export(db_exports, {
29
29
  });
30
30
  module.exports = __toCommonJS(db_exports);
31
31
  const tableColumns = [
32
- { name: "event_id", pgType: "TEXT PRIMARY KEY", chType: "String" },
33
- { name: "parent_event_id", pgType: "TEXT", chType: "Nullable(String)" },
34
- { name: "timestamp", pgType: "TIMESTAMPTZ", chType: "DateTime64(3)" },
35
- { name: "type", pgType: "TEXT", chType: "LowCardinality(String)" },
36
- { name: "anonymous_user_id", pgType: "TEXT", chType: "Nullable(String)" },
32
+ { name: "timestamp", pgType: "TIMESTAMPTZ NOT NULL", chType: "DateTime64(3)" },
33
+ { name: "type", pgType: "TEXT NOT NULL", chType: "LowCardinality(String)" },
34
+ { name: "host", pgType: "TEXT", chType: "LowCardinality(String)" },
35
+ { name: "path", pgType: "TEXT", chType: "String" },
36
+ { name: "method", pgType: "TEXT", chType: "LowCardinality(String)" },
37
37
  { name: "user_id", pgType: "TEXT", chType: "Nullable(String)" },
38
+ { name: "anonymous_user_id", pgType: "TEXT", chType: "Nullable(String)" },
38
39
  { name: "user_email", pgType: "TEXT", chType: "Nullable(String)" },
39
40
  { name: "user_name", pgType: "TEXT", chType: "Nullable(String)" },
40
- { name: "host", pgType: "TEXT", chType: "LowCardinality(String)" },
41
- { name: "method", pgType: "TEXT", chType: "LowCardinality(String)" },
42
- { name: "path", pgType: "TEXT", chType: "String" },
43
41
  { name: "ip", pgType: "INET", chType: "Nullable(IPv6)" },
44
42
  { name: "referer", pgType: "TEXT", chType: "Nullable(String)" },
45
43
  { name: "user_agent", pgType: "TEXT", chType: "Nullable(String)" },
46
44
  { name: "locale", pgType: "TEXT", chType: "LowCardinality(Nullable(String))" },
45
+ { name: "event_id", pgType: "TEXT PRIMARY KEY", chType: "String" },
46
+ { name: "parent_event_id", pgType: "TEXT", chType: "Nullable(String)" },
47
47
  { name: "server_context", pgType: "JSONB", chType: "JSON" },
48
48
  { name: "client_context", pgType: "JSONB", chType: "JSON" },
49
49
  { name: "user_traits", pgType: "JSONB", chType: "JSON" },
@@ -77,21 +77,21 @@ function extractCommonFields(event) {
77
77
  return Object.keys(rest).length > 0 ? rest : null;
78
78
  })() : null;
79
79
  return {
80
- event_id: event.eventId,
81
- parent_event_id: event.parentEventId ?? null,
82
80
  timestamp: event.collectedAt,
83
81
  type: event.type,
84
- anonymous_user_id: event.anonymousUserId ?? null,
82
+ host,
83
+ path,
84
+ method,
85
85
  user_id: event.userContext?.userId ?? null,
86
+ anonymous_user_id: event.anonymousUserId ?? null,
86
87
  user_email: event.userContext?.traits?.email ?? null,
87
88
  user_name: event.userContext?.traits?.name ?? null,
88
- host,
89
- method,
90
- path,
91
89
  ip: ip || null,
92
90
  referer: clientCtx.referer ?? null,
93
91
  user_agent: clientCtx.user_agent ?? null,
94
92
  locale: clientCtx.locale ?? null,
93
+ event_id: event.eventId,
94
+ parent_event_id: event.parentEventId ?? null,
95
95
  serverContextRest,
96
96
  clientContextRest: clientCtx.rest,
97
97
  userTraitsRest,
@@ -118,8 +118,8 @@ function eventToJsonRow(event) {
118
118
  };
119
119
  }
120
120
  function generatePgCreateTableSQL(tableName) {
121
- const pk = tableColumns[0];
122
- const alters = tableColumns.slice(1).map((col) => `ALTER TABLE ${tableName} ADD COLUMN IF NOT EXISTS ${col.name} ${col.pgType};`).join("\n");
121
+ const pk = tableColumns.find((c) => c.pgType.includes("PRIMARY KEY"));
122
+ const alters = tableColumns.filter((c) => c !== pk).map((col) => `ALTER TABLE ${tableName} ADD COLUMN IF NOT EXISTS ${col.name} ${col.pgType};`).join("\n");
123
123
  return `CREATE TABLE IF NOT EXISTS ${tableName} (${pk.name} ${pk.pgType});
124
124
  ${alters}`;
125
125
  }
@@ -129,7 +129,7 @@ function isPgTableNotFoundError(err) {
129
129
  function generateChCreateTableSQL(database, tableName) {
130
130
  const fullTable = `${database}.${tableName}`;
131
131
  const createCols = tableColumns.filter((c) => c.name === "event_id" || c.name === "timestamp").map((c) => `${c.name} ${c.chType}`).join(", ");
132
- const create = `CREATE TABLE IF NOT EXISTS ${fullTable} (${createCols}) ENGINE = ReplacingMergeTree() PARTITION BY toYYYYMM(timestamp) ORDER BY event_id;`;
132
+ const create = `CREATE TABLE IF NOT EXISTS ${fullTable} (${createCols}) ENGINE = ReplacingMergeTree() PARTITION BY toYYYYMM(timestamp) ORDER BY (timestamp, event_id);`;
133
133
  const alters = tableColumns.filter((c) => c.name !== "event_id" && c.name !== "timestamp").map((c) => `ALTER TABLE ${fullTable} ADD COLUMN IF NOT EXISTS ${c.name} ${c.chType};`).join("\n");
134
134
  return `${create}
135
135
  ${alters}`;