@ginger-ai/ginger-js 0.0.2 → 0.0.3
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/README.md +8 -2
- package/package.json +1 -1
- package/src/behaviour/computeFieldInputMetrics.ts +106 -0
- package/src/behaviour/helpers.ts +60 -0
- package/src/behaviour/index.ts +202 -182
- package/src/client/index.ts +19 -11
- package/src/core/constants.ts +1 -1
- package/src/core/dto/metrics.dto.ts +8 -2
- package/src/core/helpers.ts +43 -3
- package/src/core/http/request.ts +2 -2
package/README.md
CHANGED
|
@@ -24,6 +24,12 @@ Install via NPM:
|
|
|
24
24
|
npm install @ginger-ai/ginger-js
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
Install via Yarn:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
yarn add @ginger-ai/ginger-js
|
|
31
|
+
```
|
|
32
|
+
|
|
27
33
|
## Implementation Guide
|
|
28
34
|
|
|
29
35
|
### NPM Implementation
|
|
@@ -33,7 +39,7 @@ import { GingerJsClient } from "@ginger-ai/ginger-js";
|
|
|
33
39
|
|
|
34
40
|
async function initializeGinger() {
|
|
35
41
|
const gingerjs = new GingerJsClient({
|
|
36
|
-
apikey: "
|
|
42
|
+
apikey: "Public-Key"
|
|
37
43
|
});
|
|
38
44
|
const response = await gingerjs.initialize();
|
|
39
45
|
|
|
@@ -60,7 +66,7 @@ The library operates through a request session-based architecture:
|
|
|
60
66
|
2. **Behavior Tracking** (Optional):
|
|
61
67
|
- Requires: `event_type`, `request_id`, and `track_fields`
|
|
62
68
|
- Monitors specified input elements for user interaction
|
|
63
|
-
3. **Data Submission**: Returns a structured
|
|
69
|
+
3. **Data Submission**: Returns a structured response:
|
|
64
70
|
```javascript
|
|
65
71
|
{
|
|
66
72
|
event_id: number,
|
package/package.json
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { FieldMetrics } from "../core";
|
|
2
|
+
|
|
3
|
+
interface computeFieldParams {
|
|
4
|
+
id: string;
|
|
5
|
+
event: any;
|
|
6
|
+
currentField: any;
|
|
7
|
+
focusFields: any;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const computeFieldInputMetrics = ({
|
|
11
|
+
id,
|
|
12
|
+
event,
|
|
13
|
+
currentField,
|
|
14
|
+
focusFields,
|
|
15
|
+
}: computeFieldParams) => {
|
|
16
|
+
const now = performance.now();
|
|
17
|
+
const lastKeyStrokeTime = currentField.ended_at;
|
|
18
|
+
|
|
19
|
+
const isCorrected = isCorrectionAction(event.inputType);
|
|
20
|
+
const isPaste = isPasteAction(event.inputType);
|
|
21
|
+
const isPause = isPauseAction(now, lastKeyStrokeTime);
|
|
22
|
+
const isAutoFill = isAutofillAction(event, currentField);
|
|
23
|
+
const focusTime = focusFields?.[id];
|
|
24
|
+
|
|
25
|
+
const started_at = currentField.started_at || now;
|
|
26
|
+
const paste_count = currentField.paste_count + (isPaste ? 1 : 0);
|
|
27
|
+
const autofill_count = currentField.autofill_count + (isAutoFill ? 1 : 0);
|
|
28
|
+
const pauses = currentField.pauses + (isPause ? 1 : 0);
|
|
29
|
+
const corrections_count =
|
|
30
|
+
currentField.corrections_count + (isCorrected ? 1 : 0);
|
|
31
|
+
const interaction_count = currentField.interaction_count + 1;
|
|
32
|
+
const characters_count = event.target.value.length;
|
|
33
|
+
const pause_durations = calculatePauseDurations(currentField, isPause, now);
|
|
34
|
+
const hesitation_time = calculateHesitationTime(started_at, focusTime);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
...currentField,
|
|
38
|
+
field_name: id,
|
|
39
|
+
started_at,
|
|
40
|
+
ended_at: now,
|
|
41
|
+
interaction_count,
|
|
42
|
+
characters_count,
|
|
43
|
+
paste_count,
|
|
44
|
+
autofill_count,
|
|
45
|
+
corrections_count,
|
|
46
|
+
pauses,
|
|
47
|
+
hesitation_time,
|
|
48
|
+
pause_durations,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const isCorrectionAction = (inputType: string) =>
|
|
53
|
+
inputType === "deleteContentBackward" ||
|
|
54
|
+
inputType == "deleteContentForward" ||
|
|
55
|
+
inputType == "deleteSoftLineBackward";
|
|
56
|
+
|
|
57
|
+
const isPasteAction = (inputType: string) =>
|
|
58
|
+
inputType == "insertFromPaste" || inputType == "insertFromDrop";
|
|
59
|
+
|
|
60
|
+
const isPauseAction = (
|
|
61
|
+
now: number,
|
|
62
|
+
lastKeyStrokeTime: number,
|
|
63
|
+
waitTime: number = 2500
|
|
64
|
+
) => {
|
|
65
|
+
const timeSinceLastKeystroke = now - lastKeyStrokeTime;
|
|
66
|
+
return lastKeyStrokeTime !== 0 && timeSinceLastKeystroke > waitTime;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const isAutofillAction = (event: any, currentField: any) => {
|
|
70
|
+
const { inputType, isComposing } = event;
|
|
71
|
+
const isPaste = isPasteAction(inputType);
|
|
72
|
+
const isRedo = isRedoAction(inputType);
|
|
73
|
+
const isUndo = isUndoAction(inputType);
|
|
74
|
+
|
|
75
|
+
if (isPaste || isRedo || isUndo || isComposing) return false;
|
|
76
|
+
|
|
77
|
+
const prevCharacterCount = currentField.characters_count;
|
|
78
|
+
const currentCharacterCount = event.target.value.length;
|
|
79
|
+
const diff = currentCharacterCount - prevCharacterCount;
|
|
80
|
+
|
|
81
|
+
return diff > 1;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const isRedoAction = (inputType: string) => inputType === "historyRedo";
|
|
85
|
+
const isUndoAction = (inputType: string) => inputType === "historyUndo";
|
|
86
|
+
|
|
87
|
+
const calculatePauseDurations = (
|
|
88
|
+
currentField: FieldMetrics,
|
|
89
|
+
isPause: boolean,
|
|
90
|
+
now: number
|
|
91
|
+
) => {
|
|
92
|
+
if (!isPause) return currentField.pause_durations;
|
|
93
|
+
|
|
94
|
+
const duration = now - currentField.ended_at;
|
|
95
|
+
const pauseDurations = [...currentField.pause_durations, duration];
|
|
96
|
+
return pauseDurations;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const calculateHesitationTime = (
|
|
100
|
+
typeStartTime: number,
|
|
101
|
+
focusTime: number | null | undefined
|
|
102
|
+
) => {
|
|
103
|
+
if (!focusTime) return 0;
|
|
104
|
+
const hesitationTime = typeStartTime - focusTime;
|
|
105
|
+
return Math.max(0, hesitationTime);
|
|
106
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { FieldMetrics } from "../core";
|
|
2
|
+
|
|
3
|
+
export function createFieldMetric(id: string, ltm: boolean): FieldMetrics {
|
|
4
|
+
return {
|
|
5
|
+
field_name: id,
|
|
6
|
+
ltm: ltm,
|
|
7
|
+
started_at: 0,
|
|
8
|
+
ended_at: 0,
|
|
9
|
+
interaction_count: 0,
|
|
10
|
+
characters_count: 0,
|
|
11
|
+
paste_count: 0,
|
|
12
|
+
autofill_count: 0,
|
|
13
|
+
corrections_count: 0,
|
|
14
|
+
modifier_key_count: 0,
|
|
15
|
+
pauses: 0,
|
|
16
|
+
hesitation_time: 0,
|
|
17
|
+
pause_durations: [],
|
|
18
|
+
key_hold_times: [],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const MODIFIER_KEYS = [
|
|
23
|
+
"Meta",
|
|
24
|
+
"Alt",
|
|
25
|
+
"Control",
|
|
26
|
+
"Shift",
|
|
27
|
+
"CapsLock",
|
|
28
|
+
"Tab",
|
|
29
|
+
"Backspace",
|
|
30
|
+
"Enter",
|
|
31
|
+
"Escape",
|
|
32
|
+
"ArrowUp",
|
|
33
|
+
"ArrowDown",
|
|
34
|
+
"ArrowLeft",
|
|
35
|
+
"ArrowRight",
|
|
36
|
+
"Home",
|
|
37
|
+
"End",
|
|
38
|
+
"PageUp",
|
|
39
|
+
"PageDown",
|
|
40
|
+
"Insert",
|
|
41
|
+
"Delete",
|
|
42
|
+
"ContextMenu",
|
|
43
|
+
"NumLock",
|
|
44
|
+
"ScrollLock",
|
|
45
|
+
"Pause",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export const computeFlightTimeMetric = (flights: any) => {
|
|
49
|
+
return flights.reduce((acc: any, curr: any, index: number, arr: any) => {
|
|
50
|
+
if (index === 0) return acc;
|
|
51
|
+
|
|
52
|
+
const prev = arr[index - 1];
|
|
53
|
+
|
|
54
|
+
if (curr.downTime != null && prev.upTime != null) {
|
|
55
|
+
const flightTime = curr.downTime - prev.upTime;
|
|
56
|
+
acc.push(flightTime);
|
|
57
|
+
}
|
|
58
|
+
return acc;
|
|
59
|
+
}, []);
|
|
60
|
+
};
|
package/src/behaviour/index.ts
CHANGED
|
@@ -1,77 +1,121 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import {
|
|
2
|
+
GingerClientError,
|
|
3
|
+
FieldMetrics,
|
|
4
|
+
pageVisibility,
|
|
5
|
+
Field,
|
|
6
|
+
FocusMetrics,
|
|
7
|
+
} from "../core";
|
|
8
|
+
import { computeFieldInputMetrics } from "./computeFieldInputMetrics";
|
|
9
|
+
import {
|
|
10
|
+
computeFlightTimeMetric,
|
|
11
|
+
createFieldMetric,
|
|
12
|
+
MODIFIER_KEYS,
|
|
13
|
+
} from "./helpers";
|
|
14
|
+
|
|
15
|
+
type KeyTiming = { downTime: number };
|
|
16
|
+
type ActiveField = Map<string, KeyTiming>;
|
|
17
|
+
type ActiveKeys = Map<string, ActiveField>;
|
|
18
|
+
type HoldTime = Record<string, number[]>;
|
|
19
|
+
type FullKeyTiming = {
|
|
20
|
+
upTime: number | null;
|
|
21
|
+
downTime: number | null;
|
|
22
|
+
key: string;
|
|
23
|
+
};
|
|
24
|
+
type FlightTime = Record<string, FullKeyTiming[]>;
|
|
25
|
+
type ModifierKey = Record<string, number>;
|
|
10
26
|
|
|
11
|
-
const { getCount,
|
|
27
|
+
const { getCount, addPageListener, removePageListener } = pageVisibility();
|
|
12
28
|
|
|
13
29
|
class EventTracker {
|
|
14
|
-
private
|
|
15
|
-
|
|
16
|
-
};
|
|
30
|
+
private trackedFieldsMap: Record<string, FieldMetrics> = {};
|
|
31
|
+
private focusFieldsMap: FocusMetrics = {};
|
|
17
32
|
|
|
18
|
-
private
|
|
19
|
-
private
|
|
20
|
-
private
|
|
21
|
-
private
|
|
33
|
+
private activeKeyMap: ActiveKeys = new Map();
|
|
34
|
+
private holdTimeMap: HoldTime = {};
|
|
35
|
+
private flightTimeMap: FlightTime = {};
|
|
36
|
+
private modifierKeysMap: ModifierKey = {};
|
|
22
37
|
|
|
23
|
-
constructor(
|
|
24
|
-
|
|
25
|
-
addListener();
|
|
38
|
+
constructor() {
|
|
39
|
+
addPageListener();
|
|
26
40
|
}
|
|
27
41
|
|
|
28
42
|
/**
|
|
29
|
-
*
|
|
43
|
+
* Field that were previously tracked and needs to be removed
|
|
30
44
|
*/
|
|
31
|
-
public
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
);
|
|
45
|
+
public removeUntrackedFieldsMap(fields: Field[]) {
|
|
46
|
+
const trackedIds = Object.keys(this.trackedFieldsMap);
|
|
47
|
+
const newIds = fields.map((field) => field.id);
|
|
48
|
+
const IdsToUntrack = trackedIds.filter((id) => !newIds.includes(id));
|
|
49
|
+
|
|
50
|
+
IdsToUntrack.forEach((id) => {
|
|
51
|
+
this.removeFromTrackedMap(id);
|
|
52
|
+
this.removeFromFocusMap(id);
|
|
53
|
+
this.removeFromHoldTimeMap(id);
|
|
54
|
+
this.removeFromFlightTimeMap(id);
|
|
55
|
+
this.removeFromModifierKeysMap(id);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
35
58
|
|
|
36
|
-
|
|
37
|
-
|
|
59
|
+
private removeFromTrackedMap(id: string) {
|
|
60
|
+
const map = this.trackedFieldsMap;
|
|
61
|
+
const field = map[id];
|
|
62
|
+
if (!field) return;
|
|
63
|
+
delete map[id];
|
|
38
64
|
}
|
|
39
65
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
66
|
+
private removeFromFocusMap(id: string) {
|
|
67
|
+
const map = this.focusFieldsMap;
|
|
68
|
+
const field = map[id];
|
|
69
|
+
if (!field) return;
|
|
70
|
+
delete map[id];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private removeFromHoldTimeMap(id: string) {
|
|
74
|
+
const map = this.holdTimeMap;
|
|
75
|
+
const field = map[id];
|
|
76
|
+
if (!field) return;
|
|
77
|
+
delete map[id];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private removeFromFlightTimeMap(id: string) {
|
|
81
|
+
const map = this.flightTimeMap;
|
|
82
|
+
const field = map[id];
|
|
83
|
+
if (!field) return;
|
|
84
|
+
delete map[id];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private removeFromModifierKeysMap(id: string) {
|
|
88
|
+
const map = this.modifierKeysMap;
|
|
89
|
+
const field = map[id];
|
|
90
|
+
if (!field) return;
|
|
91
|
+
delete map[id];
|
|
46
92
|
}
|
|
47
93
|
|
|
48
94
|
/**
|
|
49
|
-
*
|
|
95
|
+
* Initialize tracking for multiple form fields
|
|
50
96
|
*/
|
|
51
|
-
public
|
|
52
|
-
|
|
53
|
-
this.removeFieldTracking(fieldId);
|
|
54
|
-
} else {
|
|
55
|
-
this.removeAllTracking();
|
|
56
|
-
}
|
|
97
|
+
public startTracking(fields: readonly Field[]): void {
|
|
98
|
+
fields.forEach((field) => this.trackField(field));
|
|
57
99
|
}
|
|
58
100
|
|
|
59
101
|
// Private implementation methods
|
|
60
102
|
|
|
61
103
|
private trackField(field: Field): void {
|
|
62
104
|
const { id, ltm } = field;
|
|
63
|
-
const element = this.
|
|
64
|
-
const fieldMetric = this.createFieldMetric(id, element, ltm);
|
|
105
|
+
const element = this.validateElement(id);
|
|
65
106
|
|
|
66
|
-
|
|
67
|
-
this.
|
|
107
|
+
const metrics = createFieldMetric(id, ltm ?? false);
|
|
108
|
+
this.trackedFieldsMap[id] = metrics;
|
|
68
109
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
element.addEventListener("
|
|
110
|
+
element.addEventListener("input", (e) => this.computeChangeMetrics(id, e));
|
|
111
|
+
element.addEventListener("focus", (e) => this.computeFocusMetrics(id, e));
|
|
112
|
+
element.addEventListener("keydown", (e) =>
|
|
113
|
+
this.computeKeydownMetrics(id, e)
|
|
114
|
+
);
|
|
115
|
+
element.addEventListener("keyup", (e) => this.computeKeyupMetrics(id, e));
|
|
72
116
|
}
|
|
73
117
|
|
|
74
|
-
private
|
|
118
|
+
private validateElement(id: string): HTMLInputElement | HTMLTextAreaElement {
|
|
75
119
|
const element = document.getElementById(id);
|
|
76
120
|
|
|
77
121
|
if (!element) {
|
|
@@ -80,7 +124,7 @@ class EventTracker {
|
|
|
80
124
|
);
|
|
81
125
|
}
|
|
82
126
|
|
|
83
|
-
if (!this.
|
|
127
|
+
if (!this.validateType(element)) {
|
|
84
128
|
throw new GingerClientError(
|
|
85
129
|
`Element with ID "${id}" must be an HTMLInputElement or HTMLTextAreaElement.`
|
|
86
130
|
);
|
|
@@ -89,7 +133,7 @@ class EventTracker {
|
|
|
89
133
|
return element;
|
|
90
134
|
}
|
|
91
135
|
|
|
92
|
-
private
|
|
136
|
+
private validateType(
|
|
93
137
|
element: Element
|
|
94
138
|
): element is HTMLInputElement | HTMLTextAreaElement {
|
|
95
139
|
return (
|
|
@@ -98,159 +142,136 @@ class EventTracker {
|
|
|
98
142
|
);
|
|
99
143
|
}
|
|
100
144
|
|
|
101
|
-
private
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
): TrackedFieldMetrics {
|
|
106
|
-
return {
|
|
107
|
-
field_name: id,
|
|
108
|
-
started_at: 0,
|
|
109
|
-
ended_at: 0,
|
|
110
|
-
interaction_count: 0,
|
|
111
|
-
fill_method: null,
|
|
112
|
-
paste_count: 0,
|
|
113
|
-
ltm: ltm ?? false,
|
|
114
|
-
corrections_count: 0,
|
|
115
|
-
pauses: 0,
|
|
116
|
-
pauseDurations: [],
|
|
117
|
-
element,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
145
|
+
private computeChangeMetrics(id: string, event: Event): void {
|
|
146
|
+
const map = this.trackedFieldsMap;
|
|
147
|
+
const currentField = map[id];
|
|
148
|
+
if (!currentField) return;
|
|
120
149
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
150
|
+
const focusFieldsMap = this.focusFieldsMap;
|
|
151
|
+
|
|
152
|
+
const computedMetrics = computeFieldInputMetrics({
|
|
153
|
+
id,
|
|
154
|
+
event,
|
|
155
|
+
currentField,
|
|
156
|
+
focusFields: focusFieldsMap,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
map[id] = computedMetrics;
|
|
125
160
|
}
|
|
126
161
|
|
|
127
|
-
private
|
|
128
|
-
|
|
129
|
-
event: InputEvent
|
|
130
|
-
): void {
|
|
131
|
-
const now = performance.now();
|
|
132
|
-
const lastInteractionTime = currentField.ended_at || 0;
|
|
162
|
+
private computeFocusMetrics(id: string, event: Event) {
|
|
163
|
+
const map = this.focusFieldsMap;
|
|
133
164
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
);
|
|
165
|
+
if (map && !map?.[id]) {
|
|
166
|
+
const now = performance.now();
|
|
167
|
+
map[id] = now;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
140
170
|
|
|
141
|
-
|
|
171
|
+
private computeKeydownMetrics(id: string, event: any) {
|
|
172
|
+
const { key, timeStamp } = event;
|
|
173
|
+
this.addToActiveKeyStore(id, key, timeStamp);
|
|
174
|
+
this.addToFlightKeyStore(id, key, timeStamp);
|
|
175
|
+
this.computeModifierKeysCount(id, event);
|
|
142
176
|
}
|
|
143
177
|
|
|
144
|
-
private
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
fillMethod,
|
|
154
|
-
isCorrection: this.isCorrection(event),
|
|
155
|
-
isPaste: this.isPaste(fillMethod, event),
|
|
156
|
-
isPause: this.isPause(now, lastInteractionTime),
|
|
157
|
-
pauseDuration: now - lastInteractionTime,
|
|
158
|
-
};
|
|
178
|
+
private addToActiveKeyStore(id: string, key: string, timestamp: number) {
|
|
179
|
+
const map = this.activeKeyMap;
|
|
180
|
+
const field = map.get(id) ?? new Map<string, KeyTiming>();
|
|
181
|
+
const downTime = timestamp;
|
|
182
|
+
|
|
183
|
+
if (field.has(key)) return;
|
|
184
|
+
|
|
185
|
+
field.set(key, { downTime });
|
|
186
|
+
map.set(id, field);
|
|
159
187
|
}
|
|
160
188
|
|
|
161
|
-
private
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
now: number
|
|
165
|
-
): void {
|
|
166
|
-
// Initialize start time on first interaction
|
|
167
|
-
if (!field.started_at) {
|
|
168
|
-
field.started_at = now;
|
|
169
|
-
}
|
|
189
|
+
private addToFlightKeyStore(id: string, key: string, timestamp: number) {
|
|
190
|
+
const map = this.flightTimeMap;
|
|
191
|
+
const field = map[id] ?? [];
|
|
170
192
|
|
|
171
|
-
|
|
172
|
-
field.
|
|
173
|
-
field.interaction_count += 1;
|
|
193
|
+
const keyTime = { key, downTime: timestamp, upTime: null };
|
|
194
|
+
field.push(keyTime);
|
|
174
195
|
|
|
175
|
-
|
|
176
|
-
if (interactions.isPaste) field.paste_count += 1;
|
|
177
|
-
if (interactions.isPause) {
|
|
178
|
-
field.pauses += 1;
|
|
179
|
-
field.pauseDurations.push(interactions.pauseDuration);
|
|
180
|
-
}
|
|
196
|
+
map[id] = field;
|
|
181
197
|
}
|
|
182
198
|
|
|
183
|
-
private
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const currentMethod: FillMethod =
|
|
191
|
-
inputType === "insertText"
|
|
192
|
-
? FillEnum.typed
|
|
193
|
-
: inputType === "insertFromPaste"
|
|
194
|
-
? FillEnum.paste
|
|
195
|
-
: FillEnum.mixed;
|
|
196
|
-
|
|
197
|
-
// If methods have been mixed, maintain "mixed" state
|
|
198
|
-
if (field.fill_method && field.fill_method !== currentMethod) {
|
|
199
|
-
return FillEnum.mixed;
|
|
199
|
+
private computeModifierKeysCount = (id: string, e: any) => {
|
|
200
|
+
const key = e.key;
|
|
201
|
+
const modifierKeys = this.modifierKeysMap;
|
|
202
|
+
|
|
203
|
+
if (MODIFIER_KEYS.includes(key)) {
|
|
204
|
+
modifierKeys[id] = (modifierKeys[id] || 0) + 1;
|
|
200
205
|
}
|
|
206
|
+
};
|
|
201
207
|
|
|
202
|
-
|
|
203
|
-
|
|
208
|
+
private computeKeyupMetrics(id: string, e: any) {
|
|
209
|
+
const { key, timeStamp } = e;
|
|
204
210
|
|
|
205
|
-
|
|
206
|
-
|
|
211
|
+
this.computeKeyHoldTime(id, key, timeStamp);
|
|
212
|
+
this.computeFlightTime(id, key, timeStamp);
|
|
207
213
|
}
|
|
208
214
|
|
|
209
|
-
private
|
|
210
|
-
|
|
211
|
-
fillMethod === FillEnum.paste || event.inputType === "insertFromPaste"
|
|
212
|
-
);
|
|
213
|
-
}
|
|
215
|
+
private computeKeyHoldTime = (id: string, key: string, timestamp: number) => {
|
|
216
|
+
const map = this.activeKeyMap;
|
|
214
217
|
|
|
215
|
-
|
|
216
|
-
if (
|
|
218
|
+
const field = map.get(id);
|
|
219
|
+
if (!field) return;
|
|
217
220
|
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
+
const fieldObject = field.get(key);
|
|
222
|
+
if (!fieldObject) return;
|
|
221
223
|
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
);
|
|
224
|
+
const { downTime } = fieldObject;
|
|
225
|
+
const upTime = timestamp;
|
|
226
|
+
const holdTime = upTime - downTime;
|
|
226
227
|
|
|
227
|
-
|
|
228
|
+
const holdTimes = this.holdTimeMap;
|
|
229
|
+
const holdTimeArray = (holdTimes[id] ||= []);
|
|
228
230
|
|
|
229
|
-
|
|
230
|
-
this.cleanupFieldTracking(field);
|
|
231
|
+
holdTimeArray.push(holdTime);
|
|
231
232
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
field.delete(key);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
private computeFlightTime = (id: string, key: string, timestamp: number) => {
|
|
237
|
+
const map = this.flightTimeMap;
|
|
238
|
+
const fieldArray = map[id];
|
|
239
|
+
if (!fieldArray) return;
|
|
235
240
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
241
|
+
const lastEntry = fieldArray.findLast((f) => f.key === key);
|
|
242
|
+
if (!lastEntry) return;
|
|
243
|
+
|
|
244
|
+
lastEntry.upTime = timestamp;
|
|
245
|
+
|
|
246
|
+
const index = fieldArray.lastIndexOf(lastEntry);
|
|
247
|
+
if (index !== -1) {
|
|
248
|
+
fieldArray[index] = lastEntry;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
private getFlightTime(id: string) {
|
|
253
|
+
const flights = this.flightTimeMap[id] || [];
|
|
254
|
+
return computeFlightTimeMetric(flights);
|
|
240
255
|
}
|
|
241
256
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
257
|
+
public reset() {
|
|
258
|
+
this.trackedFieldsMap = {};
|
|
259
|
+
this.focusFieldsMap = {};
|
|
260
|
+
this.activeKeyMap = new Map();
|
|
261
|
+
this.holdTimeMap = {};
|
|
262
|
+
this.flightTimeMap = {};
|
|
263
|
+
this.modifierKeysMap = {};
|
|
246
264
|
}
|
|
247
265
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
266
|
+
public serialize() {
|
|
267
|
+
const metrics = this.trackedFieldsMap;
|
|
268
|
+
return Object.values(metrics).map((metric) => {
|
|
269
|
+
const id = metric.field_name;
|
|
270
|
+
const key_hold_times = this.holdTimeMap[id] || [];
|
|
271
|
+
const flight_time = this.getFlightTime(id);
|
|
272
|
+
const modifier_key_count = this.modifierKeysMap[id];
|
|
273
|
+
return { ...metric, key_hold_times, flight_time, modifier_key_count };
|
|
274
|
+
});
|
|
254
275
|
}
|
|
255
276
|
}
|
|
256
277
|
|
|
@@ -258,22 +279,21 @@ class EventTracker {
|
|
|
258
279
|
const eventTracker = new EventTracker();
|
|
259
280
|
|
|
260
281
|
// Public API exports
|
|
261
|
-
|
|
262
|
-
eventTracker.
|
|
282
|
+
const trackInputs = (fields: Field[]): void => {
|
|
283
|
+
eventTracker.removeUntrackedFieldsMap(fields);
|
|
284
|
+
eventTracker.startTracking(fields);
|
|
263
285
|
};
|
|
264
286
|
|
|
265
|
-
|
|
287
|
+
const trackingData = (): {
|
|
266
288
|
distractions_count: number;
|
|
267
289
|
fields: FieldMetrics[];
|
|
268
290
|
} => {
|
|
269
291
|
return {
|
|
270
292
|
distractions_count: getCount(),
|
|
271
|
-
fields: eventTracker.
|
|
293
|
+
fields: eventTracker.serialize(),
|
|
272
294
|
};
|
|
273
295
|
};
|
|
274
296
|
|
|
275
|
-
|
|
276
|
-
eventTracker.removeTracking(fieldId);
|
|
277
|
-
};
|
|
297
|
+
const reset = () => eventTracker.reset();
|
|
278
298
|
|
|
279
|
-
export { EventTracker,
|
|
299
|
+
export { EventTracker, removePageListener, trackInputs, trackingData, reset };
|
package/src/client/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
trackInputs,
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
trackingData,
|
|
4
|
+
removePageListener,
|
|
5
|
+
reset,
|
|
5
6
|
} from "../behaviour";
|
|
6
7
|
|
|
7
8
|
import { buildInitialPayload } from "../device";
|
|
@@ -23,10 +24,14 @@ import {
|
|
|
23
24
|
BehaviourPayloadResponseData,
|
|
24
25
|
} from "../core";
|
|
25
26
|
|
|
27
|
+
type EventPayload = {
|
|
28
|
+
event: BehaviourPayload;
|
|
29
|
+
};
|
|
30
|
+
|
|
26
31
|
interface GingerClient {
|
|
27
32
|
initialize(configs: Configurations): Promise<PayloadResponse | undefined>;
|
|
28
33
|
trackEvent(params: BehaviourParams): void;
|
|
29
|
-
getTrackedData():
|
|
34
|
+
getTrackedData(): EventPayload;
|
|
30
35
|
submitEvent(): Promise<BehaviourPayloadResponseData>;
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -43,7 +48,7 @@ export class GingerJsClient implements GingerClient {
|
|
|
43
48
|
|
|
44
49
|
async initialize() {
|
|
45
50
|
try {
|
|
46
|
-
const sdkInfo = { name:
|
|
51
|
+
const sdkInfo = { name: "@ginger-ai/ginger-js", version: "0.0.3" };
|
|
47
52
|
const payload = await buildInitialPayload(this.requestId, sdkInfo);
|
|
48
53
|
const response = await this.httpClient.post<Payload, PayloadResponse>({
|
|
49
54
|
url: `/api/v1/devices`,
|
|
@@ -99,13 +104,15 @@ export class GingerJsClient implements GingerClient {
|
|
|
99
104
|
);
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
const trackData =
|
|
107
|
+
const trackData = trackingData();
|
|
103
108
|
|
|
104
109
|
return {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
event: {
|
|
111
|
+
event_type: this.trackDetails.event_type,
|
|
112
|
+
request_id: this.trackDetails.request_id,
|
|
113
|
+
fingerprint_id: this.trackDetails.fingerprint_id,
|
|
114
|
+
data: { ...trackData },
|
|
115
|
+
},
|
|
109
116
|
};
|
|
110
117
|
};
|
|
111
118
|
|
|
@@ -117,11 +124,12 @@ export class GingerJsClient implements GingerClient {
|
|
|
117
124
|
|
|
118
125
|
try {
|
|
119
126
|
const response = await this.httpClient.post<
|
|
120
|
-
|
|
127
|
+
EventPayload,
|
|
121
128
|
BehaviourPayloadResponse
|
|
122
129
|
>({ url: `/api/v1/events`, payload });
|
|
123
130
|
|
|
124
|
-
|
|
131
|
+
removePageListener();
|
|
132
|
+
reset();
|
|
125
133
|
return response.data;
|
|
126
134
|
} catch (error) {
|
|
127
135
|
throw new GingerClientError(
|
package/src/core/constants.ts
CHANGED
|
@@ -12,9 +12,15 @@ export interface FieldMetrics {
|
|
|
12
12
|
started_at: number;
|
|
13
13
|
ended_at: number;
|
|
14
14
|
interaction_count: number;
|
|
15
|
-
|
|
15
|
+
characters_count: number;
|
|
16
16
|
paste_count: number;
|
|
17
|
+
autofill_count: number;
|
|
17
18
|
corrections_count: number;
|
|
18
19
|
pauses: number;
|
|
19
|
-
|
|
20
|
+
hesitation_time: number;
|
|
21
|
+
pause_durations: number[];
|
|
22
|
+
key_hold_times: number[];
|
|
23
|
+
modifier_key_count: number;
|
|
20
24
|
}
|
|
25
|
+
|
|
26
|
+
export type FocusMetrics = Record<string, number | null>
|
package/src/core/helpers.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { GingerClientError } from "./util/error";
|
|
|
4
4
|
export default function getBasePath(apikey: string) {
|
|
5
5
|
if (apikey.includes("sk_live") || apikey.includes("pk_live"))
|
|
6
6
|
return CONSTANTS.LIVE_URL;
|
|
7
|
-
|
|
7
|
+
|
|
8
8
|
return CONSTANTS.TEST_URL;
|
|
9
9
|
}
|
|
10
10
|
|
|
@@ -21,9 +21,9 @@ export const pageVisibility = () => {
|
|
|
21
21
|
|
|
22
22
|
return {
|
|
23
23
|
getCount: () => leaveCount,
|
|
24
|
-
|
|
24
|
+
addPageListener: () =>
|
|
25
25
|
document.addEventListener("visibilitychange", visibilityHandler),
|
|
26
|
-
|
|
26
|
+
removePageListener: () =>
|
|
27
27
|
document.removeEventListener("visibilitychange", visibilityHandler),
|
|
28
28
|
};
|
|
29
29
|
};
|
|
@@ -31,3 +31,43 @@ export const pageVisibility = () => {
|
|
|
31
31
|
export const validatePresence = (key: string) => {
|
|
32
32
|
if (!key) throw new GingerClientError("'apikey' must be provided.");
|
|
33
33
|
};
|
|
34
|
+
|
|
35
|
+
const getEncoderKey = (apikey: string): string => {
|
|
36
|
+
const last6 = apikey.slice(-6);
|
|
37
|
+
const secondCharacterAfterPrefix = apikey.split("_")[2]?.[1];
|
|
38
|
+
return last6 + secondCharacterAfterPrefix;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const encode = (apikey: string, payload: unknown): string => {
|
|
42
|
+
try {
|
|
43
|
+
const key = getEncoderKey(apikey);
|
|
44
|
+
const data = JSON.stringify(payload);
|
|
45
|
+
|
|
46
|
+
let encoded = "";
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < data.length; i++) {
|
|
49
|
+
const keyChar = key.charCodeAt(i % key.length);
|
|
50
|
+
// eslint-disable-next-line no-bitwise
|
|
51
|
+
encoded += String.fromCharCode(data.charCodeAt(i) ^ keyChar);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return btoa(encoded);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new Error("Encoding failed");
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const decode = (apikey: string, encoded: string): unknown => {
|
|
61
|
+
const key = getEncoderKey(apikey);
|
|
62
|
+
const decoded = atob(encoded);
|
|
63
|
+
|
|
64
|
+
let result = "";
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < decoded.length; i++) {
|
|
67
|
+
const keyChar = key.charCodeAt(i % key.length);
|
|
68
|
+
// eslint-disable-next-line no-bitwise
|
|
69
|
+
result += String.fromCharCode(decoded.charCodeAt(i) ^ keyChar);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return JSON.parse(result);
|
|
73
|
+
};
|
package/src/core/http/request.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { encode } from "../helpers";
|
|
1
2
|
import { handleException } from "../util/error";
|
|
2
3
|
|
|
3
4
|
type Method = "POST" | "GET" | "PUT" | "DELETE";
|
|
@@ -8,7 +9,6 @@ export const makeRequest = async <Request, Response>(
|
|
|
8
9
|
method: Method,
|
|
9
10
|
apikey: string
|
|
10
11
|
): Promise<Response> => {
|
|
11
|
-
|
|
12
12
|
try {
|
|
13
13
|
const response = await fetch(url, {
|
|
14
14
|
method,
|
|
@@ -17,7 +17,7 @@ export const makeRequest = async <Request, Response>(
|
|
|
17
17
|
Accept: "application/json",
|
|
18
18
|
Authorization: `Bearer ${apikey}`,
|
|
19
19
|
},
|
|
20
|
-
body: JSON.stringify(payload),
|
|
20
|
+
body: JSON.stringify(encode(apikey, payload)),
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
if (!response.ok) {
|