@contractspec/lib.analytics 1.57.0 → 1.58.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,138 @@
|
|
|
1
|
+
// src/posthog/event-source.ts
|
|
2
|
+
class PosthogAnalyticsEventSource {
|
|
3
|
+
reader;
|
|
4
|
+
limitPerEvent;
|
|
5
|
+
tenantPropertyKey;
|
|
6
|
+
defaultRangeDays;
|
|
7
|
+
constructor(reader, options = {}) {
|
|
8
|
+
this.reader = reader;
|
|
9
|
+
this.limitPerEvent = options.limitPerEvent ?? 1000;
|
|
10
|
+
this.tenantPropertyKey = options.tenantPropertyKey ?? "tenantId";
|
|
11
|
+
this.defaultRangeDays = options.defaultRangeDays ?? 30;
|
|
12
|
+
}
|
|
13
|
+
async getEventsForFunnel(definition, dateRange) {
|
|
14
|
+
const eventNames = definition.steps.map((step) => step.eventName);
|
|
15
|
+
return this.getEventsByNames(eventNames, dateRange);
|
|
16
|
+
}
|
|
17
|
+
async getEventsForCohort(definition, dateRange, eventNames) {
|
|
18
|
+
const events = eventNames && eventNames.length > 0 ? eventNames : ["*"];
|
|
19
|
+
if (events[0] === "*") {
|
|
20
|
+
return this.getEventsByNames([], dateRange, definition.periods * 500);
|
|
21
|
+
}
|
|
22
|
+
return this.getEventsByNames(events, dateRange, definition.periods * 500);
|
|
23
|
+
}
|
|
24
|
+
async getUserActivity(userId, dateRange, limit = 1000) {
|
|
25
|
+
if (!this.reader.getEvents) {
|
|
26
|
+
throw new Error("Analytics reader does not support event queries.");
|
|
27
|
+
}
|
|
28
|
+
const response = await this.reader.getEvents({
|
|
29
|
+
distinctId: userId,
|
|
30
|
+
dateRange,
|
|
31
|
+
limit
|
|
32
|
+
});
|
|
33
|
+
return response.results.map((event) => toAnalyticsEvent(event, this.tenantPropertyKey));
|
|
34
|
+
}
|
|
35
|
+
async getGrowthMetrics(metricNames, dateRange) {
|
|
36
|
+
const range = resolveRange(dateRange, this.defaultRangeDays);
|
|
37
|
+
const previous = shiftRange(range);
|
|
38
|
+
const results = [];
|
|
39
|
+
for (const metricName of metricNames) {
|
|
40
|
+
const current = await this.countEvents(metricName, range);
|
|
41
|
+
const previousCount = await this.countEvents(metricName, previous);
|
|
42
|
+
results.push({
|
|
43
|
+
name: metricName,
|
|
44
|
+
current,
|
|
45
|
+
previous: previousCount
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return results;
|
|
49
|
+
}
|
|
50
|
+
async getEventsByNames(eventNames, dateRange, limitPerEvent = this.limitPerEvent) {
|
|
51
|
+
if (!this.reader.getEvents) {
|
|
52
|
+
throw new Error("Analytics reader does not support event queries.");
|
|
53
|
+
}
|
|
54
|
+
if (eventNames.length === 0) {
|
|
55
|
+
const response = await this.reader.getEvents({
|
|
56
|
+
dateRange,
|
|
57
|
+
limit: limitPerEvent
|
|
58
|
+
});
|
|
59
|
+
return response.results.map((event) => toAnalyticsEvent(event, this.tenantPropertyKey));
|
|
60
|
+
}
|
|
61
|
+
const events = [];
|
|
62
|
+
for (const eventName of eventNames) {
|
|
63
|
+
const response = await this.reader.getEvents({
|
|
64
|
+
event: eventName,
|
|
65
|
+
dateRange,
|
|
66
|
+
limit: limitPerEvent
|
|
67
|
+
});
|
|
68
|
+
response.results.forEach((event) => {
|
|
69
|
+
events.push(toAnalyticsEvent(event, this.tenantPropertyKey));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return events;
|
|
73
|
+
}
|
|
74
|
+
async countEvents(eventName, range) {
|
|
75
|
+
if (!this.reader.queryHogQL) {
|
|
76
|
+
throw new Error("Analytics reader does not support HogQL queries.");
|
|
77
|
+
}
|
|
78
|
+
const result = await this.reader.queryHogQL({
|
|
79
|
+
query: [
|
|
80
|
+
"select",
|
|
81
|
+
" count() as total",
|
|
82
|
+
"from events",
|
|
83
|
+
"where event = {eventName}",
|
|
84
|
+
"and timestamp >= {dateFrom}",
|
|
85
|
+
"and timestamp < {dateTo}"
|
|
86
|
+
].join(`
|
|
87
|
+
`),
|
|
88
|
+
values: {
|
|
89
|
+
eventName,
|
|
90
|
+
dateFrom: range.from.toISOString(),
|
|
91
|
+
dateTo: range.to.toISOString()
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return readSingleNumber(result) ?? 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function toAnalyticsEvent(event, tenantPropertyKey) {
|
|
98
|
+
const tenantIdValue = event.properties?.[tenantPropertyKey] ?? event.properties?.tenantId;
|
|
99
|
+
return {
|
|
100
|
+
name: event.event,
|
|
101
|
+
userId: event.distinctId,
|
|
102
|
+
tenantId: typeof tenantIdValue === "string" ? tenantIdValue : undefined,
|
|
103
|
+
timestamp: event.timestamp,
|
|
104
|
+
properties: event.properties
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function resolveRange(dateRange, defaultDays) {
|
|
108
|
+
const to = dateRange?.to instanceof Date ? dateRange.to : dateRange?.to ? new Date(dateRange.to) : new Date;
|
|
109
|
+
const from = dateRange?.from instanceof Date ? dateRange.from : dateRange?.from ? new Date(dateRange.from) : new Date(to.getTime() - defaultDays * 24 * 60 * 60 * 1000);
|
|
110
|
+
return { from, to };
|
|
111
|
+
}
|
|
112
|
+
function shiftRange(range) {
|
|
113
|
+
const duration = range.to.getTime() - range.from.getTime();
|
|
114
|
+
return {
|
|
115
|
+
from: new Date(range.from.getTime() - duration),
|
|
116
|
+
to: new Date(range.to.getTime() - duration)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function readSingleNumber(result) {
|
|
120
|
+
if (!Array.isArray(result.results) || result.results.length === 0) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const firstRow = result.results[0];
|
|
124
|
+
if (Array.isArray(firstRow) && firstRow.length > 0) {
|
|
125
|
+
const value = firstRow[0];
|
|
126
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
127
|
+
return value;
|
|
128
|
+
if (typeof value === "string" && value.trim()) {
|
|
129
|
+
const parsed = Number(value);
|
|
130
|
+
if (Number.isFinite(parsed))
|
|
131
|
+
return parsed;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
export {
|
|
137
|
+
PosthogAnalyticsEventSource
|
|
138
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// src/posthog/event-source.ts
|
|
2
|
+
class PosthogAnalyticsEventSource {
|
|
3
|
+
reader;
|
|
4
|
+
limitPerEvent;
|
|
5
|
+
tenantPropertyKey;
|
|
6
|
+
defaultRangeDays;
|
|
7
|
+
constructor(reader, options = {}) {
|
|
8
|
+
this.reader = reader;
|
|
9
|
+
this.limitPerEvent = options.limitPerEvent ?? 1000;
|
|
10
|
+
this.tenantPropertyKey = options.tenantPropertyKey ?? "tenantId";
|
|
11
|
+
this.defaultRangeDays = options.defaultRangeDays ?? 30;
|
|
12
|
+
}
|
|
13
|
+
async getEventsForFunnel(definition, dateRange) {
|
|
14
|
+
const eventNames = definition.steps.map((step) => step.eventName);
|
|
15
|
+
return this.getEventsByNames(eventNames, dateRange);
|
|
16
|
+
}
|
|
17
|
+
async getEventsForCohort(definition, dateRange, eventNames) {
|
|
18
|
+
const events = eventNames && eventNames.length > 0 ? eventNames : ["*"];
|
|
19
|
+
if (events[0] === "*") {
|
|
20
|
+
return this.getEventsByNames([], dateRange, definition.periods * 500);
|
|
21
|
+
}
|
|
22
|
+
return this.getEventsByNames(events, dateRange, definition.periods * 500);
|
|
23
|
+
}
|
|
24
|
+
async getUserActivity(userId, dateRange, limit = 1000) {
|
|
25
|
+
if (!this.reader.getEvents) {
|
|
26
|
+
throw new Error("Analytics reader does not support event queries.");
|
|
27
|
+
}
|
|
28
|
+
const response = await this.reader.getEvents({
|
|
29
|
+
distinctId: userId,
|
|
30
|
+
dateRange,
|
|
31
|
+
limit
|
|
32
|
+
});
|
|
33
|
+
return response.results.map((event) => toAnalyticsEvent(event, this.tenantPropertyKey));
|
|
34
|
+
}
|
|
35
|
+
async getGrowthMetrics(metricNames, dateRange) {
|
|
36
|
+
const range = resolveRange(dateRange, this.defaultRangeDays);
|
|
37
|
+
const previous = shiftRange(range);
|
|
38
|
+
const results = [];
|
|
39
|
+
for (const metricName of metricNames) {
|
|
40
|
+
const current = await this.countEvents(metricName, range);
|
|
41
|
+
const previousCount = await this.countEvents(metricName, previous);
|
|
42
|
+
results.push({
|
|
43
|
+
name: metricName,
|
|
44
|
+
current,
|
|
45
|
+
previous: previousCount
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return results;
|
|
49
|
+
}
|
|
50
|
+
async getEventsByNames(eventNames, dateRange, limitPerEvent = this.limitPerEvent) {
|
|
51
|
+
if (!this.reader.getEvents) {
|
|
52
|
+
throw new Error("Analytics reader does not support event queries.");
|
|
53
|
+
}
|
|
54
|
+
if (eventNames.length === 0) {
|
|
55
|
+
const response = await this.reader.getEvents({
|
|
56
|
+
dateRange,
|
|
57
|
+
limit: limitPerEvent
|
|
58
|
+
});
|
|
59
|
+
return response.results.map((event) => toAnalyticsEvent(event, this.tenantPropertyKey));
|
|
60
|
+
}
|
|
61
|
+
const events = [];
|
|
62
|
+
for (const eventName of eventNames) {
|
|
63
|
+
const response = await this.reader.getEvents({
|
|
64
|
+
event: eventName,
|
|
65
|
+
dateRange,
|
|
66
|
+
limit: limitPerEvent
|
|
67
|
+
});
|
|
68
|
+
response.results.forEach((event) => {
|
|
69
|
+
events.push(toAnalyticsEvent(event, this.tenantPropertyKey));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return events;
|
|
73
|
+
}
|
|
74
|
+
async countEvents(eventName, range) {
|
|
75
|
+
if (!this.reader.queryHogQL) {
|
|
76
|
+
throw new Error("Analytics reader does not support HogQL queries.");
|
|
77
|
+
}
|
|
78
|
+
const result = await this.reader.queryHogQL({
|
|
79
|
+
query: [
|
|
80
|
+
"select",
|
|
81
|
+
" count() as total",
|
|
82
|
+
"from events",
|
|
83
|
+
"where event = {eventName}",
|
|
84
|
+
"and timestamp >= {dateFrom}",
|
|
85
|
+
"and timestamp < {dateTo}"
|
|
86
|
+
].join(`
|
|
87
|
+
`),
|
|
88
|
+
values: {
|
|
89
|
+
eventName,
|
|
90
|
+
dateFrom: range.from.toISOString(),
|
|
91
|
+
dateTo: range.to.toISOString()
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return readSingleNumber(result) ?? 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function toAnalyticsEvent(event, tenantPropertyKey) {
|
|
98
|
+
const tenantIdValue = event.properties?.[tenantPropertyKey] ?? event.properties?.tenantId;
|
|
99
|
+
return {
|
|
100
|
+
name: event.event,
|
|
101
|
+
userId: event.distinctId,
|
|
102
|
+
tenantId: typeof tenantIdValue === "string" ? tenantIdValue : undefined,
|
|
103
|
+
timestamp: event.timestamp,
|
|
104
|
+
properties: event.properties
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function resolveRange(dateRange, defaultDays) {
|
|
108
|
+
const to = dateRange?.to instanceof Date ? dateRange.to : dateRange?.to ? new Date(dateRange.to) : new Date;
|
|
109
|
+
const from = dateRange?.from instanceof Date ? dateRange.from : dateRange?.from ? new Date(dateRange.from) : new Date(to.getTime() - defaultDays * 24 * 60 * 60 * 1000);
|
|
110
|
+
return { from, to };
|
|
111
|
+
}
|
|
112
|
+
function shiftRange(range) {
|
|
113
|
+
const duration = range.to.getTime() - range.from.getTime();
|
|
114
|
+
return {
|
|
115
|
+
from: new Date(range.from.getTime() - duration),
|
|
116
|
+
to: new Date(range.to.getTime() - duration)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function readSingleNumber(result) {
|
|
120
|
+
if (!Array.isArray(result.results) || result.results.length === 0) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const firstRow = result.results[0];
|
|
124
|
+
if (Array.isArray(firstRow) && firstRow.length > 0) {
|
|
125
|
+
const value = firstRow[0];
|
|
126
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
127
|
+
return value;
|
|
128
|
+
if (typeof value === "string" && value.trim()) {
|
|
129
|
+
const parsed = Number(value);
|
|
130
|
+
if (Number.isFinite(parsed))
|
|
131
|
+
return parsed;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
export {
|
|
137
|
+
PosthogAnalyticsEventSource
|
|
138
|
+
};
|
|
File without changes
|
package/dist/churn/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export * from './predictor';
|
|
2
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/churn/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC"}
|
package/dist/churn/index.js
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
-
|
|
1
|
+
// @bun
|
|
2
|
+
// src/churn/predictor.ts
|
|
3
|
+
import dayjs from "dayjs";
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
class ChurnPredictor {
|
|
6
|
+
recencyWeight;
|
|
7
|
+
frequencyWeight;
|
|
8
|
+
errorWeight;
|
|
9
|
+
decayDays;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.recencyWeight = options?.recencyWeight ?? 0.5;
|
|
12
|
+
this.frequencyWeight = options?.frequencyWeight ?? 0.3;
|
|
13
|
+
this.errorWeight = options?.errorWeight ?? 0.2;
|
|
14
|
+
this.decayDays = options?.decayDays ?? 14;
|
|
15
|
+
}
|
|
16
|
+
score(events) {
|
|
17
|
+
const grouped = groupBy(events, (event) => event.userId);
|
|
18
|
+
const signals = [];
|
|
19
|
+
for (const [userId, userEvents] of grouped.entries()) {
|
|
20
|
+
const score = this.computeScore(userEvents);
|
|
21
|
+
signals.push({
|
|
22
|
+
userId,
|
|
23
|
+
score,
|
|
24
|
+
bucket: score >= 0.7 ? "high" : score >= 0.4 ? "medium" : "low",
|
|
25
|
+
drivers: this.drivers(userEvents)
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return signals.sort((a, b) => b.score - a.score);
|
|
29
|
+
}
|
|
30
|
+
computeScore(events) {
|
|
31
|
+
if (!events.length)
|
|
32
|
+
return 0;
|
|
33
|
+
const sorted = events.sort((a, b) => dateMs(a) - dateMs(b));
|
|
34
|
+
const lastEvent = sorted[sorted.length - 1];
|
|
35
|
+
if (!lastEvent)
|
|
36
|
+
return 0;
|
|
37
|
+
const daysSinceLast = dayjs().diff(dayjs(lastEvent.timestamp), "day");
|
|
38
|
+
const recencyScore = Math.max(0, 1 - daysSinceLast / this.decayDays);
|
|
39
|
+
const windowStart = dayjs().subtract(this.decayDays, "day");
|
|
40
|
+
const recentEvents = sorted.filter((event) => dayjs(event.timestamp).isAfter(windowStart));
|
|
41
|
+
const averagePerDay = recentEvents.length / Math.max(this.decayDays, 1);
|
|
42
|
+
const frequencyScore = Math.min(1, averagePerDay * 5);
|
|
43
|
+
const errorEvents = recentEvents.filter((event) => typeof event.properties?.error !== "undefined" || /error|failed/i.test(event.name)).length;
|
|
44
|
+
const errorScore = Math.min(1, errorEvents / 3);
|
|
45
|
+
const score = recencyScore * this.recencyWeight + frequencyScore * this.frequencyWeight + (1 - errorScore) * this.errorWeight;
|
|
46
|
+
return Number(score.toFixed(3));
|
|
47
|
+
}
|
|
48
|
+
drivers(events) {
|
|
49
|
+
const drivers = [];
|
|
50
|
+
const sorted = events.sort((a, b) => dateMs(a) - dateMs(b));
|
|
51
|
+
const lastEvent = sorted[sorted.length - 1];
|
|
52
|
+
if (lastEvent) {
|
|
53
|
+
const days = dayjs().diff(dayjs(lastEvent.timestamp), "day");
|
|
54
|
+
if (days > this.decayDays)
|
|
55
|
+
drivers.push(`Inactive for ${days} days`);
|
|
56
|
+
}
|
|
57
|
+
const errorEvents = events.filter((event) => typeof event.properties?.error !== "undefined" || /error|failed/i.test(event.name));
|
|
58
|
+
if (errorEvents.length)
|
|
59
|
+
drivers.push(`${errorEvents.length} errors logged`);
|
|
60
|
+
return drivers;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function groupBy(items, selector) {
|
|
64
|
+
const map = new Map;
|
|
65
|
+
for (const item of items) {
|
|
66
|
+
const key = selector(item);
|
|
67
|
+
const list = map.get(key) ?? [];
|
|
68
|
+
list.push(item);
|
|
69
|
+
map.set(key, list);
|
|
70
|
+
}
|
|
71
|
+
return map;
|
|
72
|
+
}
|
|
73
|
+
function dateMs(event) {
|
|
74
|
+
return new Date(event.timestamp).getTime();
|
|
75
|
+
}
|
|
76
|
+
export {
|
|
77
|
+
ChurnPredictor
|
|
78
|
+
};
|
|
@@ -1,22 +1,18 @@
|
|
|
1
|
-
import { AnalyticsEvent, ChurnSignal } from
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
errorWeight?: number;
|
|
8
|
-
decayDays?: number;
|
|
1
|
+
import type { AnalyticsEvent, ChurnSignal } from '../types';
|
|
2
|
+
export interface ChurnPredictorOptions {
|
|
3
|
+
recencyWeight?: number;
|
|
4
|
+
frequencyWeight?: number;
|
|
5
|
+
errorWeight?: number;
|
|
6
|
+
decayDays?: number;
|
|
9
7
|
}
|
|
10
|
-
declare class ChurnPredictor {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
export declare class ChurnPredictor {
|
|
9
|
+
private readonly recencyWeight;
|
|
10
|
+
private readonly frequencyWeight;
|
|
11
|
+
private readonly errorWeight;
|
|
12
|
+
private readonly decayDays;
|
|
13
|
+
constructor(options?: ChurnPredictorOptions);
|
|
14
|
+
score(events: AnalyticsEvent[]): ChurnSignal[];
|
|
15
|
+
private computeScore;
|
|
16
|
+
private drivers;
|
|
19
17
|
}
|
|
20
|
-
//#endregion
|
|
21
|
-
export { ChurnPredictor, ChurnPredictorOptions };
|
|
22
18
|
//# sourceMappingURL=predictor.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"predictor.d.ts","
|
|
1
|
+
{"version":3,"file":"predictor.d.ts","sourceRoot":"","sources":["../../src/churn/predictor.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE5D,MAAM,WAAW,qBAAqB;IACpC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;gBAEvB,OAAO,CAAC,EAAE,qBAAqB;IAO3C,KAAK,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,WAAW,EAAE;IAe9C,OAAO,CAAC,YAAY;IA6BpB,OAAO,CAAC,OAAO;CAgBhB"}
|
package/dist/churn/predictor.js
CHANGED
|
@@ -1,74 +1,78 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/churn/predictor.ts
|
|
1
3
|
import dayjs from "dayjs";
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
};
|
|
5
|
+
class ChurnPredictor {
|
|
6
|
+
recencyWeight;
|
|
7
|
+
frequencyWeight;
|
|
8
|
+
errorWeight;
|
|
9
|
+
decayDays;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.recencyWeight = options?.recencyWeight ?? 0.5;
|
|
12
|
+
this.frequencyWeight = options?.frequencyWeight ?? 0.3;
|
|
13
|
+
this.errorWeight = options?.errorWeight ?? 0.2;
|
|
14
|
+
this.decayDays = options?.decayDays ?? 14;
|
|
15
|
+
}
|
|
16
|
+
score(events) {
|
|
17
|
+
const grouped = groupBy(events, (event) => event.userId);
|
|
18
|
+
const signals = [];
|
|
19
|
+
for (const [userId, userEvents] of grouped.entries()) {
|
|
20
|
+
const score = this.computeScore(userEvents);
|
|
21
|
+
signals.push({
|
|
22
|
+
userId,
|
|
23
|
+
score,
|
|
24
|
+
bucket: score >= 0.7 ? "high" : score >= 0.4 ? "medium" : "low",
|
|
25
|
+
drivers: this.drivers(userEvents)
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return signals.sort((a, b) => b.score - a.score);
|
|
29
|
+
}
|
|
30
|
+
computeScore(events) {
|
|
31
|
+
if (!events.length)
|
|
32
|
+
return 0;
|
|
33
|
+
const sorted = events.sort((a, b) => dateMs(a) - dateMs(b));
|
|
34
|
+
const lastEvent = sorted[sorted.length - 1];
|
|
35
|
+
if (!lastEvent)
|
|
36
|
+
return 0;
|
|
37
|
+
const daysSinceLast = dayjs().diff(dayjs(lastEvent.timestamp), "day");
|
|
38
|
+
const recencyScore = Math.max(0, 1 - daysSinceLast / this.decayDays);
|
|
39
|
+
const windowStart = dayjs().subtract(this.decayDays, "day");
|
|
40
|
+
const recentEvents = sorted.filter((event) => dayjs(event.timestamp).isAfter(windowStart));
|
|
41
|
+
const averagePerDay = recentEvents.length / Math.max(this.decayDays, 1);
|
|
42
|
+
const frequencyScore = Math.min(1, averagePerDay * 5);
|
|
43
|
+
const errorEvents = recentEvents.filter((event) => typeof event.properties?.error !== "undefined" || /error|failed/i.test(event.name)).length;
|
|
44
|
+
const errorScore = Math.min(1, errorEvents / 3);
|
|
45
|
+
const score = recencyScore * this.recencyWeight + frequencyScore * this.frequencyWeight + (1 - errorScore) * this.errorWeight;
|
|
46
|
+
return Number(score.toFixed(3));
|
|
47
|
+
}
|
|
48
|
+
drivers(events) {
|
|
49
|
+
const drivers = [];
|
|
50
|
+
const sorted = events.sort((a, b) => dateMs(a) - dateMs(b));
|
|
51
|
+
const lastEvent = sorted[sorted.length - 1];
|
|
52
|
+
if (lastEvent) {
|
|
53
|
+
const days = dayjs().diff(dayjs(lastEvent.timestamp), "day");
|
|
54
|
+
if (days > this.decayDays)
|
|
55
|
+
drivers.push(`Inactive for ${days} days`);
|
|
56
|
+
}
|
|
57
|
+
const errorEvents = events.filter((event) => typeof event.properties?.error !== "undefined" || /error|failed/i.test(event.name));
|
|
58
|
+
if (errorEvents.length)
|
|
59
|
+
drivers.push(`${errorEvents.length} errors logged`);
|
|
60
|
+
return drivers;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
58
63
|
function groupBy(items, selector) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
const map = new Map;
|
|
65
|
+
for (const item of items) {
|
|
66
|
+
const key = selector(item);
|
|
67
|
+
const list = map.get(key) ?? [];
|
|
68
|
+
list.push(item);
|
|
69
|
+
map.set(key, list);
|
|
70
|
+
}
|
|
71
|
+
return map;
|
|
67
72
|
}
|
|
68
73
|
function dateMs(event) {
|
|
69
|
-
|
|
74
|
+
return new Date(event.timestamp).getTime();
|
|
70
75
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
//# sourceMappingURL=predictor.js.map
|
|
76
|
+
export {
|
|
77
|
+
ChurnPredictor
|
|
78
|
+
};
|
package/dist/cohort/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export * from './tracker';
|
|
2
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cohort/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC"}
|