@nodewrite-labs/analytics-sdk 0.0.0-stage → 0.2.0
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.
- package/LICENSE +21 -0
- package/README.md +119 -2
- package/dist/chunk-F7BY2JQ3.js +9 -0
- package/dist/chunk-GVOESWHZ.js +270 -0
- package/dist/chunk-GWOVVKP3.js +179 -0
- package/dist/chunk-Q4JRXZ6M.js +132 -0
- package/dist/client/index.d.ts +8 -0
- package/dist/client/index.js +219 -0
- package/dist/event-body-fpV2mYpY.d.ts +18 -0
- package/dist/event-store-BH-ZR3yD.d.ts +72 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +49 -0
- package/dist/options-Dpx2vs4R.d.ts +29 -0
- package/dist/payload/admin-view.d.ts +6 -0
- package/dist/payload/admin-view.js +290 -0
- package/dist/payload/index.d.ts +77 -0
- package/dist/payload/index.js +114 -0
- package/dist/payload/nav-link.d.ts +10 -0
- package/dist/payload/nav-link.js +8 -0
- package/dist/postgres/index.d.ts +17 -0
- package/dist/postgres/index.js +156 -0
- package/dist/server/index.d.ts +41 -0
- package/dist/server/index.js +9 -0
- package/package.json +90 -6
- package/src/postgres/schema.sql +12 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PayloadAnalyticsEventStore,
|
|
3
|
+
adminSections,
|
|
4
|
+
analyticsModulesFromConfig
|
|
5
|
+
} from "../chunk-GVOESWHZ.js";
|
|
6
|
+
import "../chunk-Q4JRXZ6M.js";
|
|
7
|
+
import "../chunk-F7BY2JQ3.js";
|
|
8
|
+
|
|
9
|
+
// src/payload/admin/view.tsx
|
|
10
|
+
import { DefaultTemplate } from "@payloadcms/next/templates";
|
|
11
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
12
|
+
var WEEK_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
13
|
+
var reportStyle = `
|
|
14
|
+
.analytics-report {
|
|
15
|
+
box-sizing: border-box;
|
|
16
|
+
display: grid;
|
|
17
|
+
gap: 1.5rem;
|
|
18
|
+
max-width: 72rem;
|
|
19
|
+
padding: 1.5rem 1rem 3rem;
|
|
20
|
+
color: inherit;
|
|
21
|
+
}
|
|
22
|
+
.analytics-report h1,
|
|
23
|
+
.analytics-report h2 {
|
|
24
|
+
margin: 0;
|
|
25
|
+
}
|
|
26
|
+
.analytics-report a {
|
|
27
|
+
color: inherit;
|
|
28
|
+
}
|
|
29
|
+
.analytics-filters {
|
|
30
|
+
display: grid;
|
|
31
|
+
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
|
32
|
+
gap: 0.75rem;
|
|
33
|
+
align-items: end;
|
|
34
|
+
}
|
|
35
|
+
.analytics-filters label {
|
|
36
|
+
display: grid;
|
|
37
|
+
gap: 0.25rem;
|
|
38
|
+
}
|
|
39
|
+
.analytics-filters input,
|
|
40
|
+
.analytics-filters button {
|
|
41
|
+
font: inherit;
|
|
42
|
+
min-height: 2.5rem;
|
|
43
|
+
padding: 0.4rem 0.6rem;
|
|
44
|
+
}
|
|
45
|
+
.analytics-totals {
|
|
46
|
+
display: grid;
|
|
47
|
+
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
|
48
|
+
gap: 0.75rem;
|
|
49
|
+
margin: 0.75rem 0 0;
|
|
50
|
+
}
|
|
51
|
+
.analytics-scroll {
|
|
52
|
+
overflow-x: auto;
|
|
53
|
+
margin-top: 0.75rem;
|
|
54
|
+
}
|
|
55
|
+
.analytics-report table {
|
|
56
|
+
width: 100%;
|
|
57
|
+
border-collapse: collapse;
|
|
58
|
+
}
|
|
59
|
+
.analytics-report th,
|
|
60
|
+
.analytics-report td {
|
|
61
|
+
text-align: start;
|
|
62
|
+
padding: 0.5rem 0.75rem;
|
|
63
|
+
border-bottom: 1px solid currentColor;
|
|
64
|
+
vertical-align: top;
|
|
65
|
+
}
|
|
66
|
+
.analytics-back {
|
|
67
|
+
justify-self: start;
|
|
68
|
+
}
|
|
69
|
+
@media (max-width: 40rem) {
|
|
70
|
+
.analytics-report {
|
|
71
|
+
padding: 1rem 0.75rem 2rem;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
`;
|
|
75
|
+
async function AnalyticsAdminView(props) {
|
|
76
|
+
if (!props.initPageResult.req.user) return null;
|
|
77
|
+
const modules = analyticsModulesFromConfig(props.payload.config);
|
|
78
|
+
const range = resolveRange(props.searchParams);
|
|
79
|
+
const country = modules.country ? firstParam(props.searchParams?.country) : null;
|
|
80
|
+
const path = firstParam(props.searchParams?.path);
|
|
81
|
+
const session = modules.sessions ? firstParam(props.searchParams?.session) : null;
|
|
82
|
+
const store = new PayloadAnalyticsEventStore(props.payload, modules);
|
|
83
|
+
const summary = await store.summarizeAnalytics(
|
|
84
|
+
{
|
|
85
|
+
from: range.from,
|
|
86
|
+
to: range.to,
|
|
87
|
+
country,
|
|
88
|
+
path,
|
|
89
|
+
sessionId: session
|
|
90
|
+
},
|
|
91
|
+
modules
|
|
92
|
+
);
|
|
93
|
+
const sections = adminSections(summary, modules, { session });
|
|
94
|
+
const visibleEntities = props.visibleEntities ?? props.initPageResult.visibleEntities;
|
|
95
|
+
return /* @__PURE__ */ jsx(
|
|
96
|
+
DefaultTemplate,
|
|
97
|
+
{
|
|
98
|
+
i18n: props.i18n,
|
|
99
|
+
payload: props.payload,
|
|
100
|
+
req: props.initPageResult.req,
|
|
101
|
+
viewType: props.viewType,
|
|
102
|
+
visibleEntities,
|
|
103
|
+
children: /* @__PURE__ */ jsxs("div", { className: "analytics-report", children: [
|
|
104
|
+
/* @__PURE__ */ jsx("style", { children: reportStyle }),
|
|
105
|
+
/* @__PURE__ */ jsx("h1", { children: "Analytics" }),
|
|
106
|
+
/* @__PURE__ */ jsx(
|
|
107
|
+
FilterForm,
|
|
108
|
+
{
|
|
109
|
+
from: range.from,
|
|
110
|
+
to: range.to,
|
|
111
|
+
country,
|
|
112
|
+
path,
|
|
113
|
+
session,
|
|
114
|
+
modules
|
|
115
|
+
}
|
|
116
|
+
),
|
|
117
|
+
session ? /* @__PURE__ */ jsx("a", { className: "analytics-back", href: reportHref({ from: range.from, to: range.to, country, path }), children: "All sessions" }) : null,
|
|
118
|
+
sections.map((section) => /* @__PURE__ */ jsx(
|
|
119
|
+
SectionBlock,
|
|
120
|
+
{
|
|
121
|
+
section,
|
|
122
|
+
hrefForSession: (id) => reportHref({ from: range.from, to: range.to, country, path, session: id })
|
|
123
|
+
},
|
|
124
|
+
section.kind
|
|
125
|
+
))
|
|
126
|
+
] })
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
function FilterForm({
|
|
131
|
+
from,
|
|
132
|
+
to,
|
|
133
|
+
country,
|
|
134
|
+
path,
|
|
135
|
+
session,
|
|
136
|
+
modules
|
|
137
|
+
}) {
|
|
138
|
+
return /* @__PURE__ */ jsxs("form", { className: "analytics-filters", method: "get", action: "/admin/analytics", children: [
|
|
139
|
+
/* @__PURE__ */ jsxs("label", { children: [
|
|
140
|
+
"From",
|
|
141
|
+
/* @__PURE__ */ jsx("input", { name: "from", type: "text", defaultValue: from, autoComplete: "off" })
|
|
142
|
+
] }),
|
|
143
|
+
/* @__PURE__ */ jsxs("label", { children: [
|
|
144
|
+
"To",
|
|
145
|
+
/* @__PURE__ */ jsx("input", { name: "to", type: "text", defaultValue: to, autoComplete: "off" })
|
|
146
|
+
] }),
|
|
147
|
+
modules.country ? /* @__PURE__ */ jsxs("label", { children: [
|
|
148
|
+
"Country",
|
|
149
|
+
/* @__PURE__ */ jsx("input", { name: "country", type: "text", defaultValue: country ?? "", maxLength: 2, autoComplete: "off" })
|
|
150
|
+
] }) : null,
|
|
151
|
+
/* @__PURE__ */ jsxs("label", { children: [
|
|
152
|
+
"Path",
|
|
153
|
+
/* @__PURE__ */ jsx("input", { name: "path", type: "text", defaultValue: path ?? "", maxLength: 2048, autoComplete: "off" })
|
|
154
|
+
] }),
|
|
155
|
+
session ? /* @__PURE__ */ jsx("input", { type: "hidden", name: "session", value: session }) : null,
|
|
156
|
+
/* @__PURE__ */ jsx("button", { type: "submit", children: "Apply" })
|
|
157
|
+
] });
|
|
158
|
+
}
|
|
159
|
+
function SectionBlock({
|
|
160
|
+
section,
|
|
161
|
+
hrefForSession
|
|
162
|
+
}) {
|
|
163
|
+
switch (section.kind) {
|
|
164
|
+
case "totals":
|
|
165
|
+
return /* @__PURE__ */ jsxs("section", { children: [
|
|
166
|
+
/* @__PURE__ */ jsx("h2", { children: "Totals" }),
|
|
167
|
+
/* @__PURE__ */ jsxs("dl", { className: "analytics-totals", children: [
|
|
168
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
169
|
+
/* @__PURE__ */ jsx("dt", { children: "Total views" }),
|
|
170
|
+
/* @__PURE__ */ jsx("dd", { children: section.totalViews })
|
|
171
|
+
] }),
|
|
172
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
173
|
+
/* @__PURE__ */ jsx("dt", { children: "Average duration" }),
|
|
174
|
+
/* @__PURE__ */ jsx("dd", { children: formatSeconds(section.averageDurationSeconds) })
|
|
175
|
+
] })
|
|
176
|
+
] })
|
|
177
|
+
] });
|
|
178
|
+
case "paths":
|
|
179
|
+
return /* @__PURE__ */ jsx(
|
|
180
|
+
DataTable,
|
|
181
|
+
{
|
|
182
|
+
title: "Paths",
|
|
183
|
+
columns: ["Path", "Views", "Average duration"],
|
|
184
|
+
rows: section.rows.map((row) => [row.path, String(row.views), formatSeconds(row.averageDurationSeconds)])
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
case "countries":
|
|
188
|
+
return /* @__PURE__ */ jsx(
|
|
189
|
+
DataTable,
|
|
190
|
+
{
|
|
191
|
+
title: "Countries",
|
|
192
|
+
columns: ["Country", "Views"],
|
|
193
|
+
rows: section.rows.map((row) => [row.country, String(row.views)])
|
|
194
|
+
}
|
|
195
|
+
);
|
|
196
|
+
case "cities":
|
|
197
|
+
return /* @__PURE__ */ jsx(
|
|
198
|
+
DataTable,
|
|
199
|
+
{
|
|
200
|
+
title: "Cities",
|
|
201
|
+
columns: ["City", "Views"],
|
|
202
|
+
rows: section.rows.map((row) => [row.city, String(row.views)])
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
case "namedClicks":
|
|
206
|
+
return /* @__PURE__ */ jsx(
|
|
207
|
+
DataTable,
|
|
208
|
+
{
|
|
209
|
+
title: "Named clicks",
|
|
210
|
+
columns: ["Event", "Count"],
|
|
211
|
+
rows: section.rows.map((row) => [row.eventName, String(row.count)])
|
|
212
|
+
}
|
|
213
|
+
);
|
|
214
|
+
case "sessions":
|
|
215
|
+
return /* @__PURE__ */ jsxs("section", { children: [
|
|
216
|
+
/* @__PURE__ */ jsx("h2", { children: "Sessions" }),
|
|
217
|
+
/* @__PURE__ */ jsx("div", { className: "analytics-scroll", children: /* @__PURE__ */ jsxs("table", { children: [
|
|
218
|
+
/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
|
|
219
|
+
/* @__PURE__ */ jsx("th", { scope: "col", children: "Session" }),
|
|
220
|
+
/* @__PURE__ */ jsx("th", { scope: "col", children: "Started" }),
|
|
221
|
+
/* @__PURE__ */ jsx("th", { scope: "col", children: "Pages" })
|
|
222
|
+
] }) }),
|
|
223
|
+
/* @__PURE__ */ jsx("tbody", { children: section.rows.length === 0 ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", { colSpan: 3, children: "None in this range." }) }) : section.rows.map((row) => /* @__PURE__ */ jsxs("tr", { children: [
|
|
224
|
+
/* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx("a", { href: hrefForSession(row.id), children: row.id }) }),
|
|
225
|
+
/* @__PURE__ */ jsx("td", { children: row.startedAt }),
|
|
226
|
+
/* @__PURE__ */ jsx("td", { children: row.pageCount })
|
|
227
|
+
] }, row.id)) })
|
|
228
|
+
] }) })
|
|
229
|
+
] });
|
|
230
|
+
case "trail":
|
|
231
|
+
return /* @__PURE__ */ jsx(
|
|
232
|
+
DataTable,
|
|
233
|
+
{
|
|
234
|
+
title: "Session events",
|
|
235
|
+
columns: ["Time", "Kind", "Path", "Event"],
|
|
236
|
+
rows: section.events.map((event) => [
|
|
237
|
+
event.occurredAt,
|
|
238
|
+
event.kind === "click" ? "Click" : "View",
|
|
239
|
+
event.path,
|
|
240
|
+
event.eventName ?? ""
|
|
241
|
+
])
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function DataTable({ title, columns, rows }) {
|
|
247
|
+
return /* @__PURE__ */ jsxs("section", { children: [
|
|
248
|
+
/* @__PURE__ */ jsx("h2", { children: title }),
|
|
249
|
+
/* @__PURE__ */ jsx("div", { className: "analytics-scroll", children: /* @__PURE__ */ jsxs("table", { children: [
|
|
250
|
+
/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { children: columns.map((column) => /* @__PURE__ */ jsx("th", { scope: "col", children: column }, column)) }) }),
|
|
251
|
+
/* @__PURE__ */ jsx("tbody", { children: rows.length === 0 ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", { colSpan: columns.length, children: "None in this range." }) }) : rows.map((row, index) => /* @__PURE__ */ jsx("tr", { children: row.map((cell, cellIndex) => /* @__PURE__ */ jsx("td", { children: cell }, `${columns[cellIndex] ?? cellIndex}`)) }, `${title}-${index}`)) })
|
|
252
|
+
] }) })
|
|
253
|
+
] });
|
|
254
|
+
}
|
|
255
|
+
function reportHref(query) {
|
|
256
|
+
const params = new URLSearchParams();
|
|
257
|
+
params.set("from", query.from);
|
|
258
|
+
params.set("to", query.to);
|
|
259
|
+
if (query.country) params.set("country", query.country);
|
|
260
|
+
if (query.path) params.set("path", query.path);
|
|
261
|
+
if (query.session) params.set("session", query.session);
|
|
262
|
+
return `/admin/analytics?${params.toString()}`;
|
|
263
|
+
}
|
|
264
|
+
function resolveRange(searchParams) {
|
|
265
|
+
const now = /* @__PURE__ */ new Date();
|
|
266
|
+
const to = parseTime(firstParam(searchParams?.to), now);
|
|
267
|
+
const from = parseTime(firstParam(searchParams?.from), new Date(to.getTime() - WEEK_MS));
|
|
268
|
+
return { from: from.toISOString(), to: to.toISOString() };
|
|
269
|
+
}
|
|
270
|
+
function parseTime(value, fallback) {
|
|
271
|
+
if (value === null) return fallback;
|
|
272
|
+
const parsed = new Date(value);
|
|
273
|
+
if (Number.isNaN(parsed.getTime())) return fallback;
|
|
274
|
+
return parsed;
|
|
275
|
+
}
|
|
276
|
+
function firstParam(value) {
|
|
277
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
278
|
+
if (typeof first !== "string") return null;
|
|
279
|
+
const trimmed = first.trim();
|
|
280
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
281
|
+
}
|
|
282
|
+
function formatSeconds(value) {
|
|
283
|
+
if (!Number.isFinite(value)) return "0 seconds";
|
|
284
|
+
const rounded = Math.round(value * 10) / 10;
|
|
285
|
+
const text = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
|
286
|
+
return `${text} ${rounded === 1 ? "second" : "seconds"}`;
|
|
287
|
+
}
|
|
288
|
+
export {
|
|
289
|
+
AnalyticsAdminView
|
|
290
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Access, Config, Payload } from 'payload';
|
|
2
|
+
import { AnalyticsModules, AnalyticsServerOptions, ReadGeoFromHeaders } from '../index.js';
|
|
3
|
+
import { AnalyticsSummary, AnalyticsEventStore, NewAnalyticsEvent, StoredAnalyticsEvent, AnalyticsSummaryQuery } from '../index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read is allowed for a signed-in user, or by the function the plugin was given.
|
|
7
|
+
* Create, update, and delete stay closed. The ingest endpoint writes with overrideAccess.
|
|
8
|
+
*/
|
|
9
|
+
declare function analyticsEventsAccess(read?: Access): {
|
|
10
|
+
read: Access;
|
|
11
|
+
create: Access;
|
|
12
|
+
update: Access;
|
|
13
|
+
delete: Access;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
interface AdminSectionsQuery {
|
|
17
|
+
session?: string | null;
|
|
18
|
+
}
|
|
19
|
+
type AdminSection = {
|
|
20
|
+
kind: "totals";
|
|
21
|
+
totalViews: number;
|
|
22
|
+
averageDurationSeconds: number;
|
|
23
|
+
} | {
|
|
24
|
+
kind: "paths";
|
|
25
|
+
rows: AnalyticsSummary["byPath"];
|
|
26
|
+
} | {
|
|
27
|
+
kind: "countries";
|
|
28
|
+
rows: NonNullable<AnalyticsSummary["byCountry"]>;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "cities";
|
|
31
|
+
rows: NonNullable<AnalyticsSummary["byCity"]>;
|
|
32
|
+
} | {
|
|
33
|
+
kind: "namedClicks";
|
|
34
|
+
rows: NonNullable<AnalyticsSummary["byEventName"]>;
|
|
35
|
+
} | {
|
|
36
|
+
kind: "sessions";
|
|
37
|
+
rows: NonNullable<AnalyticsSummary["sessions"]>;
|
|
38
|
+
} | {
|
|
39
|
+
kind: "trail";
|
|
40
|
+
events: NonNullable<AnalyticsSummary["events"]>;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Blocks for the admin screen, in display order.
|
|
44
|
+
* A module that is off contributes no block.
|
|
45
|
+
* A session query replaces the session list with one trail of that session's events, in time order.
|
|
46
|
+
*/
|
|
47
|
+
declare function adminSections(summary: AnalyticsSummary, modules: AnalyticsModules, query: AdminSectionsQuery): AdminSection[];
|
|
48
|
+
|
|
49
|
+
interface AnalyticsEndpointOptions extends AnalyticsServerOptions {
|
|
50
|
+
/** Without it, the country and city modules store null. */
|
|
51
|
+
geo?: ReadGeoFromHeaders;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface AnalyticsPluginOptions extends AnalyticsEndpointOptions {
|
|
55
|
+
read?: Access;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Adds the analytics collection, the ingest endpoint, and the admin screen.
|
|
59
|
+
* Existing collections, endpoints, and views stay in place.
|
|
60
|
+
*/
|
|
61
|
+
declare function analyticsPlugin(options: AnalyticsPluginOptions): (config: Config) => Config;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* AnalyticsEventStore on Payload's Local API.
|
|
65
|
+
* Rows in the requested range are grouped by summarizeAnalyticsEvents.
|
|
66
|
+
*/
|
|
67
|
+
declare class PayloadAnalyticsEventStore implements AnalyticsEventStore {
|
|
68
|
+
private readonly payload;
|
|
69
|
+
private readonly modules;
|
|
70
|
+
constructor(payload: Payload, modules: AnalyticsModules);
|
|
71
|
+
insertAnalyticsEvent(event: NewAnalyticsEvent, occurredAt: string): Promise<StoredAnalyticsEvent>;
|
|
72
|
+
recordAnalyticsDuration(id: string, durationSeconds: number): Promise<void>;
|
|
73
|
+
summarizeAnalytics(query: AnalyticsSummaryQuery, modules: AnalyticsModules): Promise<AnalyticsSummary>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { PayloadAnalyticsEventStore, adminSections, analyticsEventsAccess, analyticsPlugin };
|
|
77
|
+
export type { AnalyticsEndpointOptions, AnalyticsPluginOptions };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
recordAnalyticsEvent
|
|
3
|
+
} from "../chunk-GWOVVKP3.js";
|
|
4
|
+
import {
|
|
5
|
+
ANALYTICS_CUSTOM_KEY,
|
|
6
|
+
PayloadAnalyticsEventStore,
|
|
7
|
+
adminSections,
|
|
8
|
+
analyticsEventsAccess,
|
|
9
|
+
analyticsEventsCollection
|
|
10
|
+
} from "../chunk-GVOESWHZ.js";
|
|
11
|
+
import "../chunk-Q4JRXZ6M.js";
|
|
12
|
+
import "../chunk-F7BY2JQ3.js";
|
|
13
|
+
|
|
14
|
+
// src/payload/endpoint.ts
|
|
15
|
+
var NO_GEO = { country: null, city: null };
|
|
16
|
+
async function handleAnalyticsEvent(request, deps) {
|
|
17
|
+
const result = await recordAnalyticsEvent(
|
|
18
|
+
{
|
|
19
|
+
body: request.body,
|
|
20
|
+
headers: headerRecord(request.headers),
|
|
21
|
+
country: request.country ?? null,
|
|
22
|
+
city: request.city ?? null
|
|
23
|
+
},
|
|
24
|
+
deps
|
|
25
|
+
);
|
|
26
|
+
if (result.status === 204 || result.body === null) {
|
|
27
|
+
return new Response(null, { status: result.status });
|
|
28
|
+
}
|
|
29
|
+
return Response.json(result.body, { status: result.status });
|
|
30
|
+
}
|
|
31
|
+
function analyticsEventsEndpoint(options) {
|
|
32
|
+
return {
|
|
33
|
+
path: "/analytics/events",
|
|
34
|
+
method: "post",
|
|
35
|
+
handler: (req) => handlePayloadAnalyticsEvent(req, options)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async function handlePayloadAnalyticsEvent(req, options) {
|
|
39
|
+
const store = new PayloadAnalyticsEventStore(req.payload, options.modules);
|
|
40
|
+
let body;
|
|
41
|
+
try {
|
|
42
|
+
body = typeof req.json === "function" ? await req.json() : req.data;
|
|
43
|
+
} catch {
|
|
44
|
+
body = void 0;
|
|
45
|
+
}
|
|
46
|
+
const geo = options.geo ? options.geo(req.headers) : NO_GEO;
|
|
47
|
+
return handleAnalyticsEvent(
|
|
48
|
+
{ body, headers: req.headers, country: geo.country, city: geo.city },
|
|
49
|
+
{ store, options }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
function headerRecord(headers) {
|
|
53
|
+
if (headers instanceof Headers) {
|
|
54
|
+
const record = {};
|
|
55
|
+
headers.forEach((value, key) => {
|
|
56
|
+
record[key] = value;
|
|
57
|
+
});
|
|
58
|
+
return record;
|
|
59
|
+
}
|
|
60
|
+
return headers;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/payload/plugin.ts
|
|
64
|
+
var DEFAULT_ADMIN_ROUTE = "/admin";
|
|
65
|
+
var ANALYTICS_VIEW_PATH = "/analytics";
|
|
66
|
+
function analyticsPlugin(options) {
|
|
67
|
+
return (config) => {
|
|
68
|
+
const adminRoute = config.routes?.admin ?? DEFAULT_ADMIN_ROUTE;
|
|
69
|
+
const admin = config.admin ?? {};
|
|
70
|
+
const components = admin.components ?? {};
|
|
71
|
+
return {
|
|
72
|
+
...config,
|
|
73
|
+
collections: [...config.collections ?? [], analyticsEventsCollection(options.read)],
|
|
74
|
+
endpoints: [...config.endpoints ?? [], analyticsEventsEndpoint(options)],
|
|
75
|
+
admin: {
|
|
76
|
+
...admin,
|
|
77
|
+
components: {
|
|
78
|
+
...components,
|
|
79
|
+
views: {
|
|
80
|
+
...components.views,
|
|
81
|
+
analytics: {
|
|
82
|
+
Component: {
|
|
83
|
+
path: "@nodewrite-labs/analytics-sdk/payload/admin-view",
|
|
84
|
+
exportName: "AnalyticsAdminView"
|
|
85
|
+
},
|
|
86
|
+
path: ANALYTICS_VIEW_PATH
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
afterNavLinks: [
|
|
90
|
+
...components.afterNavLinks ?? [],
|
|
91
|
+
{
|
|
92
|
+
path: "@nodewrite-labs/analytics-sdk/payload/nav-link",
|
|
93
|
+
exportName: "AnalyticsNavLink",
|
|
94
|
+
clientProps: { href: `${adminRoute}${ANALYTICS_VIEW_PATH}` }
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
custom: {
|
|
100
|
+
...config.custom,
|
|
101
|
+
[ANALYTICS_CUSTOM_KEY]: {
|
|
102
|
+
enabled: options.enabled,
|
|
103
|
+
modules: options.modules
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export {
|
|
110
|
+
PayloadAnalyticsEventStore,
|
|
111
|
+
adminSections,
|
|
112
|
+
analyticsEventsAccess,
|
|
113
|
+
analyticsPlugin
|
|
114
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
interface AnalyticsNavLinkProps {
|
|
4
|
+
/** The analytics view under the app's admin route; the plugin sets it. */
|
|
5
|
+
href: string;
|
|
6
|
+
}
|
|
7
|
+
declare function AnalyticsNavLink({ href }: AnalyticsNavLinkProps): react_jsx_runtime.JSX.Element;
|
|
8
|
+
|
|
9
|
+
export { AnalyticsNavLink };
|
|
10
|
+
export type { AnalyticsNavLinkProps };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Pool } from 'pg';
|
|
2
|
+
import { AnalyticsEventStore, NewAnalyticsEvent, StoredAnalyticsEvent, AnalyticsSummaryQuery, AnalyticsSummary } from '../index.js';
|
|
3
|
+
import { AnalyticsModules } from '../index.js';
|
|
4
|
+
|
|
5
|
+
declare class PostgresAnalyticsEventStore implements AnalyticsEventStore {
|
|
6
|
+
private readonly pool;
|
|
7
|
+
private readonly modules;
|
|
8
|
+
constructor(pool: Pool, modules: AnalyticsModules);
|
|
9
|
+
insertAnalyticsEvent(event: NewAnalyticsEvent, occurredAt: string): Promise<StoredAnalyticsEvent>;
|
|
10
|
+
recordAnalyticsDuration(id: string, durationSeconds: number): Promise<void>;
|
|
11
|
+
summarizeAnalytics(query: AnalyticsSummaryQuery, modules: AnalyticsModules): Promise<AnalyticsSummary>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare const analyticsEventSchemaSql: string;
|
|
15
|
+
declare function applyAnalyticsSchema(pool: Pool): Promise<void>;
|
|
16
|
+
|
|
17
|
+
export { PostgresAnalyticsEventStore, analyticsEventSchemaSql, applyAnalyticsSchema };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
acceptDurationSeconds,
|
|
3
|
+
summarizeAnalyticsEvents
|
|
4
|
+
} from "../chunk-Q4JRXZ6M.js";
|
|
5
|
+
import {
|
|
6
|
+
AnalyticsEventKind
|
|
7
|
+
} from "../chunk-F7BY2JQ3.js";
|
|
8
|
+
|
|
9
|
+
// src/postgres/store.ts
|
|
10
|
+
import { randomUUID } from "crypto";
|
|
11
|
+
var INSERT_EVENT = `
|
|
12
|
+
insert into analytics_event (
|
|
13
|
+
id,
|
|
14
|
+
kind,
|
|
15
|
+
path,
|
|
16
|
+
locale,
|
|
17
|
+
occurred_at,
|
|
18
|
+
duration_seconds,
|
|
19
|
+
country,
|
|
20
|
+
city,
|
|
21
|
+
session_id,
|
|
22
|
+
event_name
|
|
23
|
+
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
24
|
+
returning
|
|
25
|
+
id,
|
|
26
|
+
kind,
|
|
27
|
+
path,
|
|
28
|
+
locale,
|
|
29
|
+
occurred_at,
|
|
30
|
+
duration_seconds,
|
|
31
|
+
country,
|
|
32
|
+
city,
|
|
33
|
+
session_id,
|
|
34
|
+
event_name
|
|
35
|
+
`;
|
|
36
|
+
var SELECT_IN_RANGE = `
|
|
37
|
+
select
|
|
38
|
+
id,
|
|
39
|
+
kind,
|
|
40
|
+
path,
|
|
41
|
+
locale,
|
|
42
|
+
occurred_at,
|
|
43
|
+
duration_seconds,
|
|
44
|
+
country,
|
|
45
|
+
city,
|
|
46
|
+
session_id,
|
|
47
|
+
event_name
|
|
48
|
+
from analytics_event
|
|
49
|
+
where occurred_at >= $1 and occurred_at <= $2
|
|
50
|
+
`;
|
|
51
|
+
var PostgresAnalyticsEventStore = class {
|
|
52
|
+
constructor(pool, modules) {
|
|
53
|
+
this.pool = pool;
|
|
54
|
+
this.modules = modules;
|
|
55
|
+
}
|
|
56
|
+
pool;
|
|
57
|
+
modules;
|
|
58
|
+
async insertAnalyticsEvent(event, occurredAt) {
|
|
59
|
+
const id = randomUUID();
|
|
60
|
+
const result = await this.pool.query(INSERT_EVENT, [
|
|
61
|
+
id,
|
|
62
|
+
event.kind,
|
|
63
|
+
event.path,
|
|
64
|
+
event.locale,
|
|
65
|
+
occurredAt,
|
|
66
|
+
durationColumn(event.durationSeconds),
|
|
67
|
+
this.modules.country ? event.country : null,
|
|
68
|
+
this.modules.city ? event.city : null,
|
|
69
|
+
this.modules.sessions ? event.sessionId : null,
|
|
70
|
+
this.modules.clicks ? event.eventName : null
|
|
71
|
+
]);
|
|
72
|
+
return requiredRow(result.rows[0]);
|
|
73
|
+
}
|
|
74
|
+
async recordAnalyticsDuration(id, durationSeconds) {
|
|
75
|
+
const accepted = acceptDurationSeconds(durationSeconds);
|
|
76
|
+
if (accepted === null) return;
|
|
77
|
+
await this.pool.query(
|
|
78
|
+
"update analytics_event set duration_seconds = $1 where id = $2 and kind = $3",
|
|
79
|
+
[Math.round(accepted), id, AnalyticsEventKind.VIEW]
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
async summarizeAnalytics(query, modules) {
|
|
83
|
+
const result = await this.pool.query(SELECT_IN_RANGE, [query.from, query.to]);
|
|
84
|
+
const rows = [];
|
|
85
|
+
for (const row of result.rows) {
|
|
86
|
+
const mapped = mapRow(row);
|
|
87
|
+
if (mapped) rows.push(mapped);
|
|
88
|
+
}
|
|
89
|
+
return summarizeAnalyticsEvents(rows, query, modules);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
function durationColumn(value) {
|
|
93
|
+
if (value === null) return null;
|
|
94
|
+
const accepted = acceptDurationSeconds(value);
|
|
95
|
+
if (accepted === null) return null;
|
|
96
|
+
return Math.round(accepted);
|
|
97
|
+
}
|
|
98
|
+
function requiredRow(row) {
|
|
99
|
+
const mapped = row ? mapRow(row) : null;
|
|
100
|
+
if (!mapped) throw new Error("analytics_event insert did not return a row");
|
|
101
|
+
return mapped;
|
|
102
|
+
}
|
|
103
|
+
function mapRow(row) {
|
|
104
|
+
if (row.kind !== AnalyticsEventKind.VIEW && row.kind !== AnalyticsEventKind.CLICK) return null;
|
|
105
|
+
return {
|
|
106
|
+
id: String(row.id),
|
|
107
|
+
kind: row.kind,
|
|
108
|
+
path: row.path,
|
|
109
|
+
locale: row.locale,
|
|
110
|
+
occurredAt: toIso(row.occurred_at),
|
|
111
|
+
durationSeconds: durationFromColumn(row.duration_seconds),
|
|
112
|
+
country: row.country,
|
|
113
|
+
city: row.city,
|
|
114
|
+
sessionId: row.session_id,
|
|
115
|
+
eventName: row.event_name
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function durationFromColumn(value) {
|
|
119
|
+
if (value === null) return null;
|
|
120
|
+
const numeric = typeof value === "number" ? value : Number(value);
|
|
121
|
+
if (!Number.isFinite(numeric)) return null;
|
|
122
|
+
return numeric;
|
|
123
|
+
}
|
|
124
|
+
function toIso(value) {
|
|
125
|
+
if (value instanceof Date) return value.toISOString();
|
|
126
|
+
return new Date(value).toISOString();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/postgres/schema.ts
|
|
130
|
+
import { readFileSync } from "fs";
|
|
131
|
+
import { dirname, join } from "path";
|
|
132
|
+
import { fileURLToPath } from "url";
|
|
133
|
+
var here = dirname(fileURLToPath(import.meta.url));
|
|
134
|
+
function readSchema() {
|
|
135
|
+
const candidates = [join(here, "schema.sql"), join(here, "..", "..", "src", "postgres", "schema.sql")];
|
|
136
|
+
for (const candidate of candidates) {
|
|
137
|
+
try {
|
|
138
|
+
return readFileSync(candidate, "utf8");
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!isMissingFile(error)) throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
throw new Error("analytics schema.sql was not found");
|
|
144
|
+
}
|
|
145
|
+
function isMissingFile(error) {
|
|
146
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
147
|
+
}
|
|
148
|
+
var analyticsEventSchemaSql = readSchema();
|
|
149
|
+
async function applyAnalyticsSchema(pool) {
|
|
150
|
+
await pool.query(analyticsEventSchemaSql);
|
|
151
|
+
}
|
|
152
|
+
export {
|
|
153
|
+
PostgresAnalyticsEventStore,
|
|
154
|
+
analyticsEventSchemaSql,
|
|
155
|
+
applyAnalyticsSchema
|
|
156
|
+
};
|