@contractspec/lib.analytics 1.57.0 → 1.59.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/dist/browser/churn/index.js +77 -0
- package/dist/browser/churn/predictor.js +77 -0
- package/dist/browser/cohort/index.js +117 -0
- package/dist/browser/cohort/tracker.js +117 -0
- package/dist/browser/funnel/analyzer.js +68 -0
- package/dist/browser/funnel/index.js +68 -0
- package/dist/browser/growth/hypothesis-generator.js +46 -0
- package/dist/browser/growth/index.js +46 -0
- package/dist/browser/index.js +723 -0
- package/dist/browser/lifecycle/index.js +287 -0
- package/dist/browser/lifecycle/metric-collectors.js +58 -0
- package/dist/browser/lifecycle/posthog-bridge.js +79 -0
- package/dist/browser/lifecycle/posthog-metric-source.js +205 -0
- package/dist/browser/posthog/event-source.js +138 -0
- package/dist/browser/posthog/index.js +138 -0
- package/dist/browser/types.js +0 -0
- package/dist/churn/index.d.ts +2 -2
- package/dist/churn/index.d.ts.map +1 -0
- package/dist/churn/index.js +77 -2
- package/dist/churn/predictor.d.ts +15 -19
- package/dist/churn/predictor.d.ts.map +1 -1
- package/dist/churn/predictor.js +72 -68
- package/dist/cohort/index.d.ts +2 -2
- package/dist/cohort/index.d.ts.map +1 -0
- package/dist/cohort/index.js +117 -2
- package/dist/cohort/tracker.d.ts +3 -7
- package/dist/cohort/tracker.d.ts.map +1 -1
- package/dist/cohort/tracker.js +106 -87
- package/dist/funnel/analyzer.d.ts +4 -8
- package/dist/funnel/analyzer.d.ts.map +1 -1
- package/dist/funnel/analyzer.js +67 -62
- package/dist/funnel/index.d.ts +2 -2
- package/dist/funnel/index.d.ts.map +1 -0
- package/dist/funnel/index.js +69 -3
- package/dist/growth/hypothesis-generator.d.ts +11 -15
- package/dist/growth/hypothesis-generator.d.ts.map +1 -1
- package/dist/growth/hypothesis-generator.js +46 -39
- package/dist/growth/index.d.ts +2 -2
- package/dist/growth/index.d.ts.map +1 -0
- package/dist/growth/index.js +47 -3
- package/dist/index.d.ts +8 -10
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +724 -12
- package/dist/lifecycle/index.d.ts +4 -4
- package/dist/lifecycle/index.d.ts.map +1 -0
- package/dist/lifecycle/index.js +287 -4
- package/dist/lifecycle/metric-collectors.d.ts +22 -26
- package/dist/lifecycle/metric-collectors.d.ts.map +1 -1
- package/dist/lifecycle/metric-collectors.js +55 -44
- package/dist/lifecycle/posthog-bridge.d.ts +9 -13
- package/dist/lifecycle/posthog-bridge.d.ts.map +1 -1
- package/dist/lifecycle/posthog-bridge.js +77 -25
- package/dist/lifecycle/posthog-metric-source.d.ts +40 -44
- package/dist/lifecycle/posthog-metric-source.d.ts.map +1 -1
- package/dist/lifecycle/posthog-metric-source.js +200 -180
- package/dist/node/churn/index.js +77 -0
- package/dist/node/churn/predictor.js +77 -0
- package/dist/node/cohort/index.js +117 -0
- package/dist/node/cohort/tracker.js +117 -0
- package/dist/node/funnel/analyzer.js +68 -0
- package/dist/node/funnel/index.js +68 -0
- package/dist/node/growth/hypothesis-generator.js +46 -0
- package/dist/node/growth/index.js +46 -0
- package/dist/node/index.js +723 -0
- package/dist/node/lifecycle/index.js +287 -0
- package/dist/node/lifecycle/metric-collectors.js +58 -0
- package/dist/node/lifecycle/posthog-bridge.js +79 -0
- package/dist/node/lifecycle/posthog-metric-source.js +205 -0
- package/dist/node/posthog/event-source.js +138 -0
- package/dist/node/posthog/index.js +138 -0
- package/dist/node/types.js +0 -0
- package/dist/posthog/event-source.d.ts +18 -22
- package/dist/posthog/event-source.d.ts.map +1 -1
- package/dist/posthog/event-source.js +131 -111
- package/dist/posthog/index.d.ts +2 -2
- package/dist/posthog/index.d.ts.map +1 -0
- package/dist/posthog/index.js +139 -3
- package/dist/types.d.ts +52 -55
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/package.json +189 -46
- package/dist/churn/predictor.js.map +0 -1
- package/dist/cohort/tracker.js.map +0 -1
- package/dist/funnel/analyzer.js.map +0 -1
- package/dist/growth/hypothesis-generator.js.map +0 -1
- package/dist/lifecycle/metric-collectors.js.map +0 -1
- package/dist/lifecycle/posthog-bridge.js.map +0 -1
- package/dist/lifecycle/posthog-metric-source.js.map +0 -1
- package/dist/posthog/event-source.js.map +0 -1
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
// src/churn/predictor.ts
|
|
2
|
+
import dayjs from "dayjs";
|
|
3
|
+
|
|
4
|
+
class ChurnPredictor {
|
|
5
|
+
recencyWeight;
|
|
6
|
+
frequencyWeight;
|
|
7
|
+
errorWeight;
|
|
8
|
+
decayDays;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.recencyWeight = options?.recencyWeight ?? 0.5;
|
|
11
|
+
this.frequencyWeight = options?.frequencyWeight ?? 0.3;
|
|
12
|
+
this.errorWeight = options?.errorWeight ?? 0.2;
|
|
13
|
+
this.decayDays = options?.decayDays ?? 14;
|
|
14
|
+
}
|
|
15
|
+
score(events) {
|
|
16
|
+
const grouped = groupBy(events, (event) => event.userId);
|
|
17
|
+
const signals = [];
|
|
18
|
+
for (const [userId, userEvents] of grouped.entries()) {
|
|
19
|
+
const score = this.computeScore(userEvents);
|
|
20
|
+
signals.push({
|
|
21
|
+
userId,
|
|
22
|
+
score,
|
|
23
|
+
bucket: score >= 0.7 ? "high" : score >= 0.4 ? "medium" : "low",
|
|
24
|
+
drivers: this.drivers(userEvents)
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return signals.sort((a, b) => b.score - a.score);
|
|
28
|
+
}
|
|
29
|
+
computeScore(events) {
|
|
30
|
+
if (!events.length)
|
|
31
|
+
return 0;
|
|
32
|
+
const sorted = events.sort((a, b) => dateMs(a) - dateMs(b));
|
|
33
|
+
const lastEvent = sorted[sorted.length - 1];
|
|
34
|
+
if (!lastEvent)
|
|
35
|
+
return 0;
|
|
36
|
+
const daysSinceLast = dayjs().diff(dayjs(lastEvent.timestamp), "day");
|
|
37
|
+
const recencyScore = Math.max(0, 1 - daysSinceLast / this.decayDays);
|
|
38
|
+
const windowStart = dayjs().subtract(this.decayDays, "day");
|
|
39
|
+
const recentEvents = sorted.filter((event) => dayjs(event.timestamp).isAfter(windowStart));
|
|
40
|
+
const averagePerDay = recentEvents.length / Math.max(this.decayDays, 1);
|
|
41
|
+
const frequencyScore = Math.min(1, averagePerDay * 5);
|
|
42
|
+
const errorEvents = recentEvents.filter((event) => typeof event.properties?.error !== "undefined" || /error|failed/i.test(event.name)).length;
|
|
43
|
+
const errorScore = Math.min(1, errorEvents / 3);
|
|
44
|
+
const score = recencyScore * this.recencyWeight + frequencyScore * this.frequencyWeight + (1 - errorScore) * this.errorWeight;
|
|
45
|
+
return Number(score.toFixed(3));
|
|
46
|
+
}
|
|
47
|
+
drivers(events) {
|
|
48
|
+
const drivers = [];
|
|
49
|
+
const sorted = events.sort((a, b) => dateMs(a) - dateMs(b));
|
|
50
|
+
const lastEvent = sorted[sorted.length - 1];
|
|
51
|
+
if (lastEvent) {
|
|
52
|
+
const days = dayjs().diff(dayjs(lastEvent.timestamp), "day");
|
|
53
|
+
if (days > this.decayDays)
|
|
54
|
+
drivers.push(`Inactive for ${days} days`);
|
|
55
|
+
}
|
|
56
|
+
const errorEvents = events.filter((event) => typeof event.properties?.error !== "undefined" || /error|failed/i.test(event.name));
|
|
57
|
+
if (errorEvents.length)
|
|
58
|
+
drivers.push(`${errorEvents.length} errors logged`);
|
|
59
|
+
return drivers;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function groupBy(items, selector) {
|
|
63
|
+
const map = new Map;
|
|
64
|
+
for (const item of items) {
|
|
65
|
+
const key = selector(item);
|
|
66
|
+
const list = map.get(key) ?? [];
|
|
67
|
+
list.push(item);
|
|
68
|
+
map.set(key, list);
|
|
69
|
+
}
|
|
70
|
+
return map;
|
|
71
|
+
}
|
|
72
|
+
function dateMs(event) {
|
|
73
|
+
return new Date(event.timestamp).getTime();
|
|
74
|
+
}
|
|
75
|
+
// src/cohort/tracker.ts
|
|
76
|
+
import dayjs2 from "dayjs";
|
|
77
|
+
|
|
78
|
+
class CohortTracker {
|
|
79
|
+
analyze(events, definition) {
|
|
80
|
+
const groupedByUser = groupBy2(events, (event) => event.userId);
|
|
81
|
+
const cohorts = new Map;
|
|
82
|
+
for (const [userId, userEvents] of groupedByUser.entries()) {
|
|
83
|
+
userEvents.sort((a, b) => dateMs2(a) - dateMs2(b));
|
|
84
|
+
const signup = userEvents[0];
|
|
85
|
+
if (!signup)
|
|
86
|
+
continue;
|
|
87
|
+
const cohortKey = bucketKey(signup.timestamp, definition.bucket);
|
|
88
|
+
const builder = cohorts.get(cohortKey) ?? new CohortStatsBuilder(cohortKey, definition);
|
|
89
|
+
builder.addUser(userId);
|
|
90
|
+
for (const event of userEvents) {
|
|
91
|
+
builder.addEvent(userId, event);
|
|
92
|
+
}
|
|
93
|
+
cohorts.set(cohortKey, builder);
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
definition,
|
|
97
|
+
cohorts: [...cohorts.values()].map((builder) => builder.build())
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
class CohortStatsBuilder {
|
|
103
|
+
key;
|
|
104
|
+
definition;
|
|
105
|
+
users = new Set;
|
|
106
|
+
retentionMap = new Map;
|
|
107
|
+
ltv = 0;
|
|
108
|
+
constructor(key, definition) {
|
|
109
|
+
this.key = key;
|
|
110
|
+
this.definition = definition;
|
|
111
|
+
}
|
|
112
|
+
addUser(userId) {
|
|
113
|
+
this.users.add(userId);
|
|
114
|
+
}
|
|
115
|
+
addEvent(userId, event) {
|
|
116
|
+
const period = bucketDiff(this.key, event.timestamp, this.definition.bucket);
|
|
117
|
+
if (period < 0 || period >= this.definition.periods)
|
|
118
|
+
return;
|
|
119
|
+
const bucket = this.retentionMap.get(period) ?? new Set;
|
|
120
|
+
bucket.add(userId);
|
|
121
|
+
this.retentionMap.set(period, bucket);
|
|
122
|
+
const amount = typeof event.properties?.amount === "number" ? event.properties.amount : 0;
|
|
123
|
+
this.ltv += amount;
|
|
124
|
+
}
|
|
125
|
+
build() {
|
|
126
|
+
const totalUsers = this.users.size || 1;
|
|
127
|
+
const retention = [];
|
|
128
|
+
for (let period = 0;period < this.definition.periods; period++) {
|
|
129
|
+
const active = this.retentionMap.get(period)?.size ?? 0;
|
|
130
|
+
retention.push(Number((active / totalUsers).toFixed(3)));
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
cohortKey: this.key,
|
|
134
|
+
users: this.users.size,
|
|
135
|
+
retention,
|
|
136
|
+
ltv: Number(this.ltv.toFixed(2))
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function groupBy2(items, selector) {
|
|
141
|
+
const map = new Map;
|
|
142
|
+
for (const item of items) {
|
|
143
|
+
const key = selector(item);
|
|
144
|
+
const list = map.get(key) ?? [];
|
|
145
|
+
list.push(item);
|
|
146
|
+
map.set(key, list);
|
|
147
|
+
}
|
|
148
|
+
return map;
|
|
149
|
+
}
|
|
150
|
+
function bucketKey(timestamp, bucket) {
|
|
151
|
+
const dt = dayjs2(timestamp);
|
|
152
|
+
switch (bucket) {
|
|
153
|
+
case "day":
|
|
154
|
+
return dt.startOf("day").format("YYYY-MM-DD");
|
|
155
|
+
case "week":
|
|
156
|
+
return dt.startOf("week").format("YYYY-[W]WW");
|
|
157
|
+
case "month":
|
|
158
|
+
default:
|
|
159
|
+
return dt.startOf("month").format("YYYY-MM");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function bucketDiff(cohortKey, timestamp, bucket) {
|
|
163
|
+
const start = parseBucketKey(cohortKey, bucket);
|
|
164
|
+
const target = dayjs2(timestamp);
|
|
165
|
+
switch (bucket) {
|
|
166
|
+
case "day":
|
|
167
|
+
return target.diff(start, "day");
|
|
168
|
+
case "week":
|
|
169
|
+
return target.diff(start, "week");
|
|
170
|
+
case "month":
|
|
171
|
+
default:
|
|
172
|
+
return target.diff(start, "month");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function parseBucketKey(key, bucket) {
|
|
176
|
+
switch (bucket) {
|
|
177
|
+
case "day":
|
|
178
|
+
return dayjs2(key, "YYYY-MM-DD");
|
|
179
|
+
case "week":
|
|
180
|
+
return dayjs2(key.replace("W", ""), "YYYY-ww");
|
|
181
|
+
case "month":
|
|
182
|
+
default:
|
|
183
|
+
return dayjs2(key, "YYYY-MM");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function dateMs2(event) {
|
|
187
|
+
return new Date(event.timestamp).getTime();
|
|
188
|
+
}
|
|
189
|
+
// src/funnel/analyzer.ts
|
|
190
|
+
class FunnelAnalyzer {
|
|
191
|
+
analyze(events, definition) {
|
|
192
|
+
const windowMs = (definition.windowHours ?? 72) * 60 * 60 * 1000;
|
|
193
|
+
const eventsByUser = groupByUser(events);
|
|
194
|
+
const stepCounts = definition.steps.map(() => 0);
|
|
195
|
+
for (const userEvents of eventsByUser.values()) {
|
|
196
|
+
const completionIndex = this.evaluateUser(userEvents, definition.steps, windowMs);
|
|
197
|
+
completionIndex.forEach((hit, stepIdx) => {
|
|
198
|
+
if (hit) {
|
|
199
|
+
stepCounts[stepIdx] = (stepCounts[stepIdx] ?? 0) + 1;
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
const totalUsers = eventsByUser.size;
|
|
204
|
+
const steps = definition.steps.map((step, index) => {
|
|
205
|
+
const prevCount = index === 0 ? totalUsers : stepCounts[index - 1] || 1;
|
|
206
|
+
const count = stepCounts[index] ?? 0;
|
|
207
|
+
const conversionRate = prevCount === 0 ? 0 : Number((count / prevCount).toFixed(3));
|
|
208
|
+
const dropOffRate = Number((1 - conversionRate).toFixed(3));
|
|
209
|
+
return { step, count, conversionRate, dropOffRate };
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
definition,
|
|
213
|
+
totalUsers,
|
|
214
|
+
steps
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
evaluateUser(events, steps, windowMs) {
|
|
218
|
+
const sorted = [...events].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
219
|
+
const completion = Array(steps.length).fill(false);
|
|
220
|
+
let cursor = 0;
|
|
221
|
+
let anchorTime;
|
|
222
|
+
for (const event of sorted) {
|
|
223
|
+
const step = steps[cursor];
|
|
224
|
+
if (!step)
|
|
225
|
+
break;
|
|
226
|
+
if (event.name !== step.eventName)
|
|
227
|
+
continue;
|
|
228
|
+
if (step.match && !step.match(event))
|
|
229
|
+
continue;
|
|
230
|
+
const eventTime = new Date(event.timestamp).getTime();
|
|
231
|
+
if (cursor === 0) {
|
|
232
|
+
anchorTime = eventTime;
|
|
233
|
+
completion[cursor] = true;
|
|
234
|
+
cursor += 1;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (anchorTime && eventTime - anchorTime <= windowMs) {
|
|
238
|
+
completion[cursor] = true;
|
|
239
|
+
cursor += 1;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return completion;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function groupByUser(events) {
|
|
246
|
+
const map = new Map;
|
|
247
|
+
for (const event of events) {
|
|
248
|
+
const list = map.get(event.userId) ?? [];
|
|
249
|
+
list.push(event);
|
|
250
|
+
map.set(event.userId, list);
|
|
251
|
+
}
|
|
252
|
+
return map;
|
|
253
|
+
}
|
|
254
|
+
// src/growth/hypothesis-generator.ts
|
|
255
|
+
class GrowthHypothesisGenerator {
|
|
256
|
+
minDelta;
|
|
257
|
+
constructor(options) {
|
|
258
|
+
this.minDelta = options?.minDelta ?? 0.05;
|
|
259
|
+
}
|
|
260
|
+
generate(metrics) {
|
|
261
|
+
return metrics.map((metric) => this.fromMetric(metric)).filter((hypothesis) => Boolean(hypothesis));
|
|
262
|
+
}
|
|
263
|
+
fromMetric(metric) {
|
|
264
|
+
const change = this.delta(metric);
|
|
265
|
+
if (Math.abs(change) < this.minDelta)
|
|
266
|
+
return null;
|
|
267
|
+
const direction = change > 0 ? "rising" : "declining";
|
|
268
|
+
const statement = this.statement(metric, change, direction);
|
|
269
|
+
return {
|
|
270
|
+
statement,
|
|
271
|
+
metric: metric.name,
|
|
272
|
+
confidence: Math.abs(change) > 0.2 ? "high" : "medium",
|
|
273
|
+
impact: this.impact(metric)
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
delta(metric) {
|
|
277
|
+
if (metric.previous == null)
|
|
278
|
+
return 0;
|
|
279
|
+
const prev = metric.previous || 1;
|
|
280
|
+
return (metric.current - prev) / Math.abs(prev);
|
|
281
|
+
}
|
|
282
|
+
impact(metric) {
|
|
283
|
+
if (metric.target && metric.current < metric.target * 0.8)
|
|
284
|
+
return "high";
|
|
285
|
+
if (metric.target && metric.current < metric.target)
|
|
286
|
+
return "medium";
|
|
287
|
+
return "low";
|
|
288
|
+
}
|
|
289
|
+
statement(metric, change, direction) {
|
|
290
|
+
const percent = Math.abs(parseFloat((change * 100).toFixed(1)));
|
|
291
|
+
if (direction === "declining") {
|
|
292
|
+
return `${metric.name} is down ${percent}% vs last period; test new onboarding prompts to recover activation.`;
|
|
293
|
+
}
|
|
294
|
+
return `${metric.name} grew ${percent}% period-over-period; double down with expanded experiment or pricing test.`;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// src/lifecycle/metric-collectors.ts
|
|
298
|
+
var collectLifecycleMetrics = async (source) => {
|
|
299
|
+
const [
|
|
300
|
+
activeUsers,
|
|
301
|
+
weeklyActiveUsers,
|
|
302
|
+
retentionRate,
|
|
303
|
+
monthlyRecurringRevenue,
|
|
304
|
+
customerCount,
|
|
305
|
+
teamSize,
|
|
306
|
+
burnMultiple
|
|
307
|
+
] = await Promise.all([
|
|
308
|
+
source.getActiveUsers(),
|
|
309
|
+
source.getWeeklyActiveUsers?.(),
|
|
310
|
+
source.getRetentionRate?.(),
|
|
311
|
+
source.getMonthlyRecurringRevenue?.(),
|
|
312
|
+
source.getCustomerCount?.(),
|
|
313
|
+
source.getTeamSize?.(),
|
|
314
|
+
source.getBurnMultiple?.()
|
|
315
|
+
]);
|
|
316
|
+
return {
|
|
317
|
+
activeUsers,
|
|
318
|
+
weeklyActiveUsers,
|
|
319
|
+
retentionRate,
|
|
320
|
+
monthlyRecurringRevenue,
|
|
321
|
+
customerCount,
|
|
322
|
+
teamSize,
|
|
323
|
+
burnMultiple
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
var metricsToSignals = (metrics, tenantId) => Object.entries(metrics).filter(([, value]) => value !== undefined && value !== null).map(([metricKey, value]) => ({
|
|
327
|
+
id: `lifecycle-metric:${metricKey}`,
|
|
328
|
+
kind: "metric",
|
|
329
|
+
source: "analytics",
|
|
330
|
+
name: metricKey,
|
|
331
|
+
value,
|
|
332
|
+
weight: 1,
|
|
333
|
+
confidence: 0.8,
|
|
334
|
+
details: tenantId ? { tenantId } : undefined,
|
|
335
|
+
capturedAt: new Date().toISOString()
|
|
336
|
+
}));
|
|
337
|
+
var lifecycleEventNames = {
|
|
338
|
+
assessmentRun: "lifecycle_assessment_run",
|
|
339
|
+
stageChanged: "lifecycle_stage_changed",
|
|
340
|
+
guidanceConsumed: "lifecycle_guidance_consumed"
|
|
341
|
+
};
|
|
342
|
+
var createStageChangeEvent = (payload) => ({
|
|
343
|
+
name: lifecycleEventNames.stageChanged,
|
|
344
|
+
userId: "system",
|
|
345
|
+
tenantId: payload.tenantId,
|
|
346
|
+
timestamp: new Date,
|
|
347
|
+
properties: { ...payload }
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// src/lifecycle/posthog-bridge.ts
|
|
351
|
+
var trackLifecycleAssessment = async (client, tenantId, assessment) => {
|
|
352
|
+
await client.capture({
|
|
353
|
+
distinctId: tenantId,
|
|
354
|
+
event: lifecycleEventNames.assessmentRun,
|
|
355
|
+
properties: {
|
|
356
|
+
stage: assessment.stage,
|
|
357
|
+
confidence: assessment.confidence,
|
|
358
|
+
axes: assessment.axes
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
var trackLifecycleStageChange = async (client, tenantId, previousStage, nextStage) => {
|
|
363
|
+
await client.capture({
|
|
364
|
+
distinctId: tenantId,
|
|
365
|
+
event: lifecycleEventNames.stageChanged,
|
|
366
|
+
properties: {
|
|
367
|
+
previousStage,
|
|
368
|
+
nextStage
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// src/lifecycle/posthog-metric-source.ts
|
|
374
|
+
class PosthogLifecycleMetricSource {
|
|
375
|
+
reader;
|
|
376
|
+
activityEvents;
|
|
377
|
+
retentionWindowDays;
|
|
378
|
+
revenueEvent;
|
|
379
|
+
revenueProperty;
|
|
380
|
+
customerEvent;
|
|
381
|
+
customerProperty;
|
|
382
|
+
teamSizeEvent;
|
|
383
|
+
teamSizeProperty;
|
|
384
|
+
burnMultipleEvent;
|
|
385
|
+
burnMultipleProperty;
|
|
386
|
+
constructor(reader, options = {}) {
|
|
387
|
+
this.reader = reader;
|
|
388
|
+
this.activityEvents = options.activityEvents;
|
|
389
|
+
this.retentionWindowDays = options.retentionWindowDays ?? 7;
|
|
390
|
+
this.revenueEvent = options.revenueEvent;
|
|
391
|
+
this.revenueProperty = options.revenueProperty ?? "amount";
|
|
392
|
+
this.customerEvent = options.customerEvent;
|
|
393
|
+
this.customerProperty = options.customerProperty ?? "is_customer";
|
|
394
|
+
this.teamSizeEvent = options.teamSizeEvent;
|
|
395
|
+
this.teamSizeProperty = options.teamSizeProperty ?? "team_size";
|
|
396
|
+
this.burnMultipleEvent = options.burnMultipleEvent;
|
|
397
|
+
this.burnMultipleProperty = options.burnMultipleProperty ?? "burn_multiple";
|
|
398
|
+
}
|
|
399
|
+
async getActiveUsers() {
|
|
400
|
+
return this.countDistinctUsers(1);
|
|
401
|
+
}
|
|
402
|
+
async getWeeklyActiveUsers() {
|
|
403
|
+
return this.countDistinctUsers(7);
|
|
404
|
+
}
|
|
405
|
+
async getRetentionRate() {
|
|
406
|
+
const windowDays = this.retentionWindowDays;
|
|
407
|
+
const now = new Date;
|
|
408
|
+
const prevEnd = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
|
|
409
|
+
const prevStart = new Date(now.getTime() - windowDays * 2 * 24 * 60 * 60 * 1000);
|
|
410
|
+
const returningUsers = await this.countReturningUsers(prevStart, prevEnd, now);
|
|
411
|
+
const prevUsers = await this.countDistinctUsersBetween(prevStart, prevEnd);
|
|
412
|
+
if (prevUsers === undefined || prevUsers === 0)
|
|
413
|
+
return;
|
|
414
|
+
return returningUsers / prevUsers;
|
|
415
|
+
}
|
|
416
|
+
async getMonthlyRecurringRevenue() {
|
|
417
|
+
if (!this.revenueEvent || !this.revenueProperty)
|
|
418
|
+
return;
|
|
419
|
+
return this.sumMetric(this.revenueEvent, this.revenueProperty);
|
|
420
|
+
}
|
|
421
|
+
async getCustomerCount() {
|
|
422
|
+
if (!this.customerEvent || !this.customerProperty)
|
|
423
|
+
return;
|
|
424
|
+
return this.countDistinctUsersByProperty(this.customerEvent, this.customerProperty);
|
|
425
|
+
}
|
|
426
|
+
async getTeamSize() {
|
|
427
|
+
if (!this.teamSizeEvent || !this.teamSizeProperty)
|
|
428
|
+
return;
|
|
429
|
+
return this.latestMetric(this.teamSizeEvent, this.teamSizeProperty);
|
|
430
|
+
}
|
|
431
|
+
async getBurnMultiple() {
|
|
432
|
+
if (!this.burnMultipleEvent || !this.burnMultipleProperty)
|
|
433
|
+
return;
|
|
434
|
+
return this.latestMetric(this.burnMultipleEvent, this.burnMultipleProperty);
|
|
435
|
+
}
|
|
436
|
+
async countDistinctUsers(days) {
|
|
437
|
+
const range = buildDateRange(days);
|
|
438
|
+
return this.countDistinctUsersBetween(range.from, range.to);
|
|
439
|
+
}
|
|
440
|
+
async countDistinctUsersBetween(from, to) {
|
|
441
|
+
const eventFilter = buildEventFilter(this.activityEvents, "activityEvent");
|
|
442
|
+
const result = await this.queryHogQL({
|
|
443
|
+
query: [
|
|
444
|
+
"select",
|
|
445
|
+
" countDistinct(distinct_id) as total",
|
|
446
|
+
"from events",
|
|
447
|
+
`where timestamp >= {dateFrom} and timestamp < {dateTo}`,
|
|
448
|
+
eventFilter.clause ? `and ${eventFilter.clause}` : ""
|
|
449
|
+
].filter(Boolean).join(`
|
|
450
|
+
`),
|
|
451
|
+
values: {
|
|
452
|
+
dateFrom: from.toISOString(),
|
|
453
|
+
dateTo: to.toISOString(),
|
|
454
|
+
...eventFilter.values
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
return readSingleNumber(result);
|
|
458
|
+
}
|
|
459
|
+
async countReturningUsers(previousStart, previousEnd, currentEnd) {
|
|
460
|
+
const eventFilter = buildEventFilter(this.activityEvents, "activityEvent");
|
|
461
|
+
const result = await this.queryHogQL({
|
|
462
|
+
query: [
|
|
463
|
+
"select",
|
|
464
|
+
" countDistinct(distinct_id) as total",
|
|
465
|
+
"from events",
|
|
466
|
+
`where timestamp >= {currentFrom} and timestamp < {currentTo}`,
|
|
467
|
+
eventFilter.clause ? `and ${eventFilter.clause}` : "",
|
|
468
|
+
"and distinct_id in (",
|
|
469
|
+
" select distinct_id from events",
|
|
470
|
+
" where timestamp >= {previousFrom} and timestamp < {previousTo}",
|
|
471
|
+
eventFilter.clause ? `and ${eventFilter.clause}` : "",
|
|
472
|
+
")"
|
|
473
|
+
].join(`
|
|
474
|
+
`),
|
|
475
|
+
values: {
|
|
476
|
+
currentFrom: previousEnd.toISOString(),
|
|
477
|
+
currentTo: currentEnd.toISOString(),
|
|
478
|
+
previousFrom: previousStart.toISOString(),
|
|
479
|
+
previousTo: previousEnd.toISOString(),
|
|
480
|
+
...eventFilter.values
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
return readSingleNumber(result) ?? 0;
|
|
484
|
+
}
|
|
485
|
+
async sumMetric(eventName, propertyKey) {
|
|
486
|
+
const result = await this.queryHogQL({
|
|
487
|
+
query: [
|
|
488
|
+
"select",
|
|
489
|
+
` sum(properties.${propertyKey}) as total`,
|
|
490
|
+
"from events",
|
|
491
|
+
"where event = {eventName}"
|
|
492
|
+
].join(`
|
|
493
|
+
`),
|
|
494
|
+
values: { eventName }
|
|
495
|
+
});
|
|
496
|
+
return readSingleNumber(result);
|
|
497
|
+
}
|
|
498
|
+
async countDistinctUsersByProperty(eventName, propertyKey) {
|
|
499
|
+
const result = await this.queryHogQL({
|
|
500
|
+
query: [
|
|
501
|
+
"select",
|
|
502
|
+
" countDistinct(distinct_id) as total",
|
|
503
|
+
"from events",
|
|
504
|
+
"where event = {eventName}",
|
|
505
|
+
`and properties.${propertyKey} = {propertyValue}`
|
|
506
|
+
].join(`
|
|
507
|
+
`),
|
|
508
|
+
values: { eventName, propertyValue: true }
|
|
509
|
+
});
|
|
510
|
+
return readSingleNumber(result);
|
|
511
|
+
}
|
|
512
|
+
async latestMetric(eventName, propertyKey) {
|
|
513
|
+
const result = await this.queryHogQL({
|
|
514
|
+
query: [
|
|
515
|
+
"select",
|
|
516
|
+
` properties.${propertyKey} as value`,
|
|
517
|
+
"from events",
|
|
518
|
+
"where event = {eventName}",
|
|
519
|
+
"order by timestamp desc",
|
|
520
|
+
"limit 1"
|
|
521
|
+
].join(`
|
|
522
|
+
`),
|
|
523
|
+
values: { eventName }
|
|
524
|
+
});
|
|
525
|
+
return readSingleNumber(result);
|
|
526
|
+
}
|
|
527
|
+
async queryHogQL(input) {
|
|
528
|
+
if (!this.reader.queryHogQL) {
|
|
529
|
+
throw new Error("Analytics reader does not support HogQL queries.");
|
|
530
|
+
}
|
|
531
|
+
return this.reader.queryHogQL(input);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function buildDateRange(days) {
|
|
535
|
+
const to = new Date;
|
|
536
|
+
const from = new Date(to.getTime() - days * 24 * 60 * 60 * 1000);
|
|
537
|
+
return { from, to };
|
|
538
|
+
}
|
|
539
|
+
function buildEventFilter(events, prefix) {
|
|
540
|
+
if (!events || events.length === 0)
|
|
541
|
+
return {};
|
|
542
|
+
if (events.length === 1) {
|
|
543
|
+
return {
|
|
544
|
+
clause: `event = {${prefix}0}`,
|
|
545
|
+
values: { [`${prefix}0`]: events[0] }
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const clauses = events.map((_event, index) => `event = {${prefix}${index}}`);
|
|
549
|
+
const values = {};
|
|
550
|
+
events.forEach((event, index) => {
|
|
551
|
+
values[`${prefix}${index}`] = event;
|
|
552
|
+
});
|
|
553
|
+
return {
|
|
554
|
+
clause: `(${clauses.join(" or ")})`,
|
|
555
|
+
values
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function readSingleNumber(result) {
|
|
559
|
+
if (!Array.isArray(result.results) || result.results.length === 0) {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const firstRow = result.results[0];
|
|
563
|
+
if (Array.isArray(firstRow) && firstRow.length > 0) {
|
|
564
|
+
const value = firstRow[0];
|
|
565
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
566
|
+
return value;
|
|
567
|
+
if (typeof value === "string" && value.trim()) {
|
|
568
|
+
const parsed = Number(value);
|
|
569
|
+
if (Number.isFinite(parsed))
|
|
570
|
+
return parsed;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
// src/posthog/event-source.ts
|
|
576
|
+
class PosthogAnalyticsEventSource {
|
|
577
|
+
reader;
|
|
578
|
+
limitPerEvent;
|
|
579
|
+
tenantPropertyKey;
|
|
580
|
+
defaultRangeDays;
|
|
581
|
+
constructor(reader, options = {}) {
|
|
582
|
+
this.reader = reader;
|
|
583
|
+
this.limitPerEvent = options.limitPerEvent ?? 1000;
|
|
584
|
+
this.tenantPropertyKey = options.tenantPropertyKey ?? "tenantId";
|
|
585
|
+
this.defaultRangeDays = options.defaultRangeDays ?? 30;
|
|
586
|
+
}
|
|
587
|
+
async getEventsForFunnel(definition, dateRange) {
|
|
588
|
+
const eventNames = definition.steps.map((step) => step.eventName);
|
|
589
|
+
return this.getEventsByNames(eventNames, dateRange);
|
|
590
|
+
}
|
|
591
|
+
async getEventsForCohort(definition, dateRange, eventNames) {
|
|
592
|
+
const events = eventNames && eventNames.length > 0 ? eventNames : ["*"];
|
|
593
|
+
if (events[0] === "*") {
|
|
594
|
+
return this.getEventsByNames([], dateRange, definition.periods * 500);
|
|
595
|
+
}
|
|
596
|
+
return this.getEventsByNames(events, dateRange, definition.periods * 500);
|
|
597
|
+
}
|
|
598
|
+
async getUserActivity(userId, dateRange, limit = 1000) {
|
|
599
|
+
if (!this.reader.getEvents) {
|
|
600
|
+
throw new Error("Analytics reader does not support event queries.");
|
|
601
|
+
}
|
|
602
|
+
const response = await this.reader.getEvents({
|
|
603
|
+
distinctId: userId,
|
|
604
|
+
dateRange,
|
|
605
|
+
limit
|
|
606
|
+
});
|
|
607
|
+
return response.results.map((event) => toAnalyticsEvent(event, this.tenantPropertyKey));
|
|
608
|
+
}
|
|
609
|
+
async getGrowthMetrics(metricNames, dateRange) {
|
|
610
|
+
const range = resolveRange(dateRange, this.defaultRangeDays);
|
|
611
|
+
const previous = shiftRange(range);
|
|
612
|
+
const results = [];
|
|
613
|
+
for (const metricName of metricNames) {
|
|
614
|
+
const current = await this.countEvents(metricName, range);
|
|
615
|
+
const previousCount = await this.countEvents(metricName, previous);
|
|
616
|
+
results.push({
|
|
617
|
+
name: metricName,
|
|
618
|
+
current,
|
|
619
|
+
previous: previousCount
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
return results;
|
|
623
|
+
}
|
|
624
|
+
async getEventsByNames(eventNames, dateRange, limitPerEvent = this.limitPerEvent) {
|
|
625
|
+
if (!this.reader.getEvents) {
|
|
626
|
+
throw new Error("Analytics reader does not support event queries.");
|
|
627
|
+
}
|
|
628
|
+
if (eventNames.length === 0) {
|
|
629
|
+
const response = await this.reader.getEvents({
|
|
630
|
+
dateRange,
|
|
631
|
+
limit: limitPerEvent
|
|
632
|
+
});
|
|
633
|
+
return response.results.map((event) => toAnalyticsEvent(event, this.tenantPropertyKey));
|
|
634
|
+
}
|
|
635
|
+
const events = [];
|
|
636
|
+
for (const eventName of eventNames) {
|
|
637
|
+
const response = await this.reader.getEvents({
|
|
638
|
+
event: eventName,
|
|
639
|
+
dateRange,
|
|
640
|
+
limit: limitPerEvent
|
|
641
|
+
});
|
|
642
|
+
response.results.forEach((event) => {
|
|
643
|
+
events.push(toAnalyticsEvent(event, this.tenantPropertyKey));
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
return events;
|
|
647
|
+
}
|
|
648
|
+
async countEvents(eventName, range) {
|
|
649
|
+
if (!this.reader.queryHogQL) {
|
|
650
|
+
throw new Error("Analytics reader does not support HogQL queries.");
|
|
651
|
+
}
|
|
652
|
+
const result = await this.reader.queryHogQL({
|
|
653
|
+
query: [
|
|
654
|
+
"select",
|
|
655
|
+
" count() as total",
|
|
656
|
+
"from events",
|
|
657
|
+
"where event = {eventName}",
|
|
658
|
+
"and timestamp >= {dateFrom}",
|
|
659
|
+
"and timestamp < {dateTo}"
|
|
660
|
+
].join(`
|
|
661
|
+
`),
|
|
662
|
+
values: {
|
|
663
|
+
eventName,
|
|
664
|
+
dateFrom: range.from.toISOString(),
|
|
665
|
+
dateTo: range.to.toISOString()
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
return readSingleNumber2(result) ?? 0;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function toAnalyticsEvent(event, tenantPropertyKey) {
|
|
672
|
+
const tenantIdValue = event.properties?.[tenantPropertyKey] ?? event.properties?.tenantId;
|
|
673
|
+
return {
|
|
674
|
+
name: event.event,
|
|
675
|
+
userId: event.distinctId,
|
|
676
|
+
tenantId: typeof tenantIdValue === "string" ? tenantIdValue : undefined,
|
|
677
|
+
timestamp: event.timestamp,
|
|
678
|
+
properties: event.properties
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
function resolveRange(dateRange, defaultDays) {
|
|
682
|
+
const to = dateRange?.to instanceof Date ? dateRange.to : dateRange?.to ? new Date(dateRange.to) : new Date;
|
|
683
|
+
const from = dateRange?.from instanceof Date ? dateRange.from : dateRange?.from ? new Date(dateRange.from) : new Date(to.getTime() - defaultDays * 24 * 60 * 60 * 1000);
|
|
684
|
+
return { from, to };
|
|
685
|
+
}
|
|
686
|
+
function shiftRange(range) {
|
|
687
|
+
const duration = range.to.getTime() - range.from.getTime();
|
|
688
|
+
return {
|
|
689
|
+
from: new Date(range.from.getTime() - duration),
|
|
690
|
+
to: new Date(range.to.getTime() - duration)
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
function readSingleNumber2(result) {
|
|
694
|
+
if (!Array.isArray(result.results) || result.results.length === 0) {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const firstRow = result.results[0];
|
|
698
|
+
if (Array.isArray(firstRow) && firstRow.length > 0) {
|
|
699
|
+
const value = firstRow[0];
|
|
700
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
701
|
+
return value;
|
|
702
|
+
if (typeof value === "string" && value.trim()) {
|
|
703
|
+
const parsed = Number(value);
|
|
704
|
+
if (Number.isFinite(parsed))
|
|
705
|
+
return parsed;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
export {
|
|
711
|
+
trackLifecycleStageChange,
|
|
712
|
+
trackLifecycleAssessment,
|
|
713
|
+
metricsToSignals,
|
|
714
|
+
lifecycleEventNames,
|
|
715
|
+
createStageChangeEvent,
|
|
716
|
+
collectLifecycleMetrics,
|
|
717
|
+
PosthogLifecycleMetricSource,
|
|
718
|
+
PosthogAnalyticsEventSource,
|
|
719
|
+
GrowthHypothesisGenerator,
|
|
720
|
+
FunnelAnalyzer,
|
|
721
|
+
CohortTracker,
|
|
722
|
+
ChurnPredictor
|
|
723
|
+
};
|