@suprsend/node-sdk 0.1.1 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suprsend/node-sdk",
3
- "version": "0.1.1",
3
+ "version": "1.1.0",
4
4
  "description": "Suprsend Node SDK to trigger workflow from backend",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -26,6 +26,7 @@
26
26
  "@babel/runtime": "^7.16.3",
27
27
  "axios": "^0.27.2",
28
28
  "jsonschema": "^1.4.0",
29
+ "lodash": "^4.17.21",
29
30
  "mime-types": "^2.1.34",
30
31
  "uuid": "^8.3.2"
31
32
  },
@@ -0,0 +1,44 @@
1
+ import path from "path";
2
+ import mime from "mime-types";
3
+ import { base64Encode, resolveTilde } from "./utils";
4
+
5
+ function check_is_web_url(file_path = "") {
6
+ return file_path.startsWith("http://") || file_path.startsWith("https://");
7
+ }
8
+
9
+ function get_attachment_json_for_file(file_path, file_name, ignore_if_error) {
10
+ const abs_path = path.resolve(resolveTilde(file_path));
11
+ let final_file_name = path.basename(abs_path);
12
+ if (file_name && file_name.trim()) {
13
+ final_file_name = file_name.trim();
14
+ }
15
+ return {
16
+ filename: final_file_name,
17
+ contentType: mime.lookup(abs_path),
18
+ data: base64Encode(abs_path),
19
+ url: null,
20
+ ignore_if_error: ignore_if_error,
21
+ };
22
+ }
23
+
24
+ function get_attachment_json_for_url(file_url, file_name, ignore_if_error) {
25
+ return {
26
+ filename: file_name,
27
+ contentType: null,
28
+ data: null,
29
+ url: file_url,
30
+ ignore_if_error: ignore_if_error,
31
+ };
32
+ }
33
+
34
+ export default function get_attachment_json(
35
+ file_path,
36
+ file_name,
37
+ ignore_if_error = false
38
+ ) {
39
+ if (check_is_web_url(file_path)) {
40
+ return get_attachment_json_for_url(file_path, file_name, ignore_if_error);
41
+ } else {
42
+ return get_attachment_json_for_file(file_path, file_name, ignore_if_error);
43
+ }
44
+ }
@@ -0,0 +1,35 @@
1
+ export default class BulkResponse {
2
+ constructor() {
3
+ this.status;
4
+ this.failed_records = [];
5
+ this.total = 0;
6
+ this.success = 0;
7
+ this.failure = 0;
8
+ this.warnings = [];
9
+ }
10
+
11
+ merge_chunk_response(ch_resp) {
12
+ if (!ch_resp) {
13
+ return;
14
+ }
15
+ // possible status: success/partial/fail
16
+ if (!this.status) {
17
+ this.status = ch_resp["status"];
18
+ } else {
19
+ if (this.status === "success") {
20
+ if (ch_resp.status === "fail") {
21
+ this.status = "partial";
22
+ }
23
+ } else if (this.status === "fail") {
24
+ if (ch_resp.status === "success") {
25
+ this.status = "partial";
26
+ }
27
+ }
28
+ }
29
+ this.total += ch_resp.total || 0;
30
+ this.success += ch_resp.success || 0;
31
+ this.failure += ch_resp.failure || 0;
32
+ const failed_recs = ch_resp.failed_records || [];
33
+ this.failed_records = [...this.failed_records, ...failed_recs];
34
+ }
35
+ }
@@ -0,0 +1,32 @@
1
+ // Default urls
2
+ export const DEFAULT_URL = "https://hub.suprsend.com/";
3
+ export const DEFAULT_UAT_URL =
4
+ "https://collector-staging.suprsend.workers.dev/";
5
+
6
+ // an Event should not have apparent body size of more than 100KB
7
+ export const SINGLE_EVENT_MAX_APPARENT_SIZE_IN_BYTES = 100 * 1024; // 100 * 1024
8
+ export const SINGLE_EVENT_MAX_APPARENT_SIZE_IN_BYTES_READABLE = "100KB";
9
+
10
+ // a API call should not have apparent body size of more than 800KB
11
+ export const BODY_MAX_APPARENT_SIZE_IN_BYTES = 800 * 1024; // 800 * 1024
12
+ export const BODY_MAX_APPARENT_SIZE_IN_BYTES_READABLE = "800KB";
13
+
14
+ // in general url-size wont exceed 2048 chars or 2048 utf-8 bytes
15
+ export const ATTACHMENT_URL_POTENTIAL_SIZE_IN_BYTES = 2100;
16
+
17
+ // few keys added in-flight, amounting to almost 200 bytes increase per workflow-body
18
+ export const WORKFLOW_RUNTIME_KEYS_POTENTIAL_SIZE_IN_BYTES = 200;
19
+
20
+ // max workflow-records in one bulk api call.
21
+ export const MAX_WORKFLOWS_IN_BULK_API = 100;
22
+ // max event-records in one bulk api call
23
+ export const MAX_EVENTS_IN_BULK_API = 100;
24
+
25
+ export const ALLOW_ATTACHMENTS_IN_BULK_API = true;
26
+ export const ATTACHMENT_UPLOAD_ENABLED = false;
27
+
28
+ // -- single Identity event limit
29
+ export const IDENTITY_SINGLE_EVENT_MAX_APPARENT_SIZE_IN_BYTES = 2 * 1024;
30
+ export const IDENTITY_SINGLE_EVENT_MAX_APPARENT_SIZE_IN_BYTES_READABLE = "2KB";
31
+
32
+ export const MAX_IDENTITY_EVENTS_IN_BULK_API = 400;
package/src/event.js CHANGED
@@ -5,12 +5,16 @@ import {
5
5
  has_special_char,
6
6
  uuid,
7
7
  epoch_milliseconds,
8
+ validate_track_event_schema,
9
+ get_apparent_event_size,
8
10
  } from "./utils";
9
11
  import get_request_signature from "./signature";
10
- import { Validator } from "jsonschema";
11
12
  import axios from "axios";
12
-
13
- const event_schema = require("./request_json/event.json");
13
+ import get_attachment_json from "./attachment";
14
+ import {
15
+ SINGLE_EVENT_MAX_APPARENT_SIZE_IN_BYTES,
16
+ SINGLE_EVENT_MAX_APPARENT_SIZE_IN_BYTES_READABLE,
17
+ } from "./constants";
14
18
 
15
19
  const RESERVED_EVENT_NAMES = [
16
20
  "$identify",
@@ -22,12 +26,103 @@ const RESERVED_EVENT_NAMES = [
22
26
  "$user_logout",
23
27
  ];
24
28
 
25
- class EventCollector {
29
+ export default class Event {
30
+ constructor(distinct_id, event_name, properties, kwargs = {}) {
31
+ this.distinct_id = distinct_id;
32
+ this.event_name = event_name;
33
+ this.properties = properties;
34
+ this.idempotency_key = kwargs?.idempotency_key;
35
+ // --- validate
36
+ this.__validate_distinct_id();
37
+ this.__validate_event_name();
38
+ this.__validate_properties();
39
+ }
40
+
41
+ __validate_distinct_id() {
42
+ if (this.distinct_id instanceof String) {
43
+ throw new SuprsendError(
44
+ "distinct_id must be a string. an Id which uniquely identify a user in your app"
45
+ );
46
+ }
47
+ const distinct_id = this.distinct_id.trim();
48
+ if (!distinct_id) {
49
+ throw new SuprsendError("distinct_id missing");
50
+ }
51
+ this.distinct_id = distinct_id;
52
+ }
53
+
54
+ __validate_properties() {
55
+ if (!this.properties) {
56
+ this.properties = {};
57
+ }
58
+ if (!(this.properties instanceof Object)) {
59
+ throw new SuprsendError("properties must be a dictionary");
60
+ }
61
+ }
62
+
63
+ __check_event_prefix(event_name) {
64
+ if (!RESERVED_EVENT_NAMES.includes(event_name)) {
65
+ if (has_special_char(event_name)) {
66
+ throw new SuprsendError(
67
+ "event_names starting with [$,ss_] are reserved by SuprSend"
68
+ );
69
+ }
70
+ }
71
+ }
72
+
73
+ __validate_event_name() {
74
+ if (!is_string(this.event_name)) {
75
+ throw new SuprsendError("event_name must be a string");
76
+ }
77
+ const event_name = this.event_name.trim();
78
+ this.__check_event_prefix(event_name);
79
+ this.event_name = event_name;
80
+ }
81
+
82
+ add_attachment(file_path, kwargs = {}) {
83
+ const file_name = kwargs?.file_name;
84
+ const ignore_if_error = kwargs?.ignore_if_error ?? false;
85
+ const attachment = get_attachment_json(
86
+ file_path,
87
+ file_name,
88
+ ignore_if_error
89
+ );
90
+ // --- add the attachment to properties->$attachments
91
+ if (!this.properties["$attachments"]) {
92
+ this.properties["$attachments"] = [];
93
+ }
94
+ this.properties["$attachments"].push(attachment);
95
+ }
96
+
97
+ get_final_json(config, is_part_of_bulk = false) {
98
+ const super_props = { $ss_sdk_version: config.user_agent };
99
+ let event_dict = {
100
+ $insert_id: uuid(),
101
+ $time: epoch_milliseconds(),
102
+ event: this.event_name,
103
+ env: config.workspace_key,
104
+ distinct_id: this.distinct_id,
105
+ properties: { ...this.properties, ...super_props },
106
+ };
107
+ if (this.idempotency_key) {
108
+ event_dict["$idempotency_key"] = this.idempotency_key;
109
+ }
110
+ event_dict = validate_track_event_schema(event_dict);
111
+ const apparent_size = get_apparent_event_size(event_dict, is_part_of_bulk);
112
+ if (apparent_size > SINGLE_EVENT_MAX_APPARENT_SIZE_IN_BYTES) {
113
+ throw new SuprsendError(
114
+ `Event size too big - ${apparent_size} Bytes,must not cross ${SINGLE_EVENT_MAX_APPARENT_SIZE_IN_BYTES_READABLE}`
115
+ );
116
+ }
117
+ return [event_dict, apparent_size];
118
+ }
119
+ }
120
+
121
+ export class EventCollector {
26
122
  constructor(config) {
27
123
  this.config = config;
28
124
  this.__url = this.__get_url();
29
125
  this.__headers = this.__common_headers();
30
- this.__supr_props = this.__super_properties();
31
126
  }
32
127
 
33
128
  __get_url() {
@@ -54,63 +149,9 @@ class EventCollector {
54
149
  };
55
150
  }
56
151
 
57
- __super_properties() {
58
- return {
59
- $ss_sdk_version: this.config.user_agent,
60
- };
61
- }
62
-
63
- __check_event_prefix(event_name) {
64
- if (!RESERVED_EVENT_NAMES.includes(event_name)) {
65
- if (has_special_char(event_name)) {
66
- throw new SuprsendError(
67
- "event_names starting with [$,ss_] are reserved"
68
- );
69
- }
70
- }
71
- }
72
-
73
- __validate_event_name(event_name) {
74
- if (!is_string(event_name)) {
75
- throw new SuprsendError("event_name must be a string");
76
- }
77
- event_name = event_name.trim();
78
- this.__check_event_prefix(event_name);
79
- return event_name;
80
- }
81
-
82
- validate_track_event_schema(data) {
83
- if (!data["properties"]) {
84
- data["properties"] = {};
85
- }
86
- const schema = event_schema;
87
- var v = new Validator();
88
- const validated_data = v.validate(data, schema);
89
- if (validated_data.valid) {
90
- return data;
91
- } else {
92
- const error_obj = validated_data.errors[0];
93
- const error_msg = `${error_obj.property} ${error_obj.message}`;
94
- throw new SuprsendError(error_msg);
95
- }
96
- }
97
-
98
- collect(distinct_id, event_name, properties = {}) {
99
- event_name = this.__validate_event_name(event_name);
100
- if (!is_object(properties)) {
101
- throw new SuprsendError("properties must be a dictionary");
102
- }
103
- properties = { ...properties, ...this.__supr_props };
104
- let event = {
105
- $insert_id: uuid(),
106
- $time: epoch_milliseconds(),
107
- event: event_name,
108
- env: this.config.env_key,
109
- distinct_id: distinct_id,
110
- properties: properties,
111
- };
112
- event = this.validate_track_event_schema(event);
113
- return this.send(event);
152
+ collect(event) {
153
+ const [event_dict, event_size] = event.get_final_json(this.config, false);
154
+ return this.send(event_dict);
114
155
  }
115
156
 
116
157
  async send(event) {
@@ -122,27 +163,35 @@ class EventCollector {
122
163
  "POST",
123
164
  content_text,
124
165
  headers,
125
- this.config.env_secret
166
+ this.config.workspace_secret
126
167
  );
127
- headers["Authorization"] = `${this.config.env_key}:${signature}`;
168
+ headers["Authorization"] = `${this.config.workspace_key}:${signature}`;
128
169
  }
129
170
  try {
130
- const response = await axios.post(this.__url, content_text, {
131
- headers,
132
- });
133
- return {
134
- status_code: response.status,
135
- success: true,
136
- message: response.statusText,
137
- };
171
+ const response = await axios.post(this.__url, content_text, { headers });
172
+ const ok_response = Math.floor(response.status / 100) == 2;
173
+ if (ok_response) {
174
+ return {
175
+ success: true,
176
+ status: "success",
177
+ status_code: response.status,
178
+ message: response.statusText,
179
+ };
180
+ } else {
181
+ return {
182
+ success: false,
183
+ status: "fail",
184
+ status_code: response.status,
185
+ message: response.statusText,
186
+ };
187
+ }
138
188
  } catch (err) {
139
189
  return {
140
- status_code: 400,
141
190
  success: false,
191
+ status: "fail",
192
+ status_code: err.status || 500,
142
193
  message: err.message,
143
194
  };
144
195
  }
145
196
  }
146
197
  }
147
-
148
- export default EventCollector;
@@ -0,0 +1,236 @@
1
+ import {
2
+ SINGLE_EVENT_MAX_APPARENT_SIZE_IN_BYTES,
3
+ SINGLE_EVENT_MAX_APPARENT_SIZE_IN_BYTES_READABLE,
4
+ ALLOW_ATTACHMENTS_IN_BULK_API,
5
+ BODY_MAX_APPARENT_SIZE_IN_BYTES,
6
+ BODY_MAX_APPARENT_SIZE_IN_BYTES_READABLE,
7
+ MAX_EVENTS_IN_BULK_API,
8
+ } from "./constants";
9
+ import get_request_signature from "./signature";
10
+ import BulkResponse from "./bulk_response";
11
+ import Event from "./event";
12
+ import { SuprsendError } from "./utils";
13
+ import { cloneDeep } from "lodash";
14
+ import axios from "axios";
15
+
16
+ export class BulkEventsFactory {
17
+ constructor(config) {
18
+ this.config = config;
19
+ }
20
+
21
+ new_instance() {
22
+ return new BulkEvents(this.config);
23
+ }
24
+ }
25
+
26
+ class _BulkEventsChunk {
27
+ constructor(config) {
28
+ this.config = config;
29
+ this.__chunk = [];
30
+ this.__url = this.__get_url();
31
+ this.__headers = this.__common_headers();
32
+
33
+ this.__running_size = 0;
34
+ this.__running_length = 0;
35
+ this.response;
36
+ }
37
+
38
+ __get_url() {
39
+ let url_template = "event/";
40
+ if (this.config.include_signature_param) {
41
+ if (this.config.auth_enabled) {
42
+ url_template = url_template + "?verify=true";
43
+ } else {
44
+ url_template = url_template + "?verify=false";
45
+ }
46
+ }
47
+ const url_formatted = `${this.config.base_url}${url_template}`;
48
+ return url_formatted;
49
+ }
50
+
51
+ __common_headers() {
52
+ return {
53
+ "Content-Type": "application/json; charset=utf-8",
54
+ "User-Agent": this.config.user_agent,
55
+ };
56
+ }
57
+
58
+ __dynamic_headers() {
59
+ return {
60
+ Date: new Date().toUTCString(),
61
+ };
62
+ }
63
+
64
+ __add_event_to_chunk(event, event_size) {
65
+ // First add size, then body to reduce effects of race condition
66
+ this.__running_size += event_size;
67
+ this.__chunk.push(event);
68
+ this.__running_length += 1;
69
+ }
70
+
71
+ __check_limit_reached() {
72
+ if (
73
+ this.__running_length >= MAX_EVENTS_IN_BULK_API ||
74
+ this.__running_size >= BODY_MAX_APPARENT_SIZE_IN_BYTES
75
+ ) {
76
+ return true;
77
+ } else {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ try_to_add_into_chunk(event, event_size) {
83
+ if (!event) {
84
+ return true;
85
+ }
86
+ if (this.__check_limit_reached()) {
87
+ return false;
88
+ }
89
+ if (event_size > SINGLE_EVENT_MAX_APPARENT_SIZE_IN_BYTES) {
90
+ throw new SuprsendError(
91
+ `Event properties too big - ${event_size} Bytes, must not cross ${SINGLE_EVENT_MAX_APPARENT_SIZE_IN_BYTES_READABLE}`
92
+ );
93
+ }
94
+ if (this.__running_size + event_size > BODY_MAX_APPARENT_SIZE_IN_BYTES) {
95
+ return false;
96
+ }
97
+ if (!ALLOW_ATTACHMENTS_IN_BULK_API) {
98
+ delete event.properties["$attachments"];
99
+ }
100
+
101
+ this.__add_event_to_chunk(event, event_size);
102
+ return true;
103
+ }
104
+
105
+ async trigger() {
106
+ const headers = { ...this.__headers, ...this.__dynamic_headers() };
107
+ const content_text = JSON.stringify(this.__chunk);
108
+ // Based on whether signature is required or not, add Authorization header
109
+ if (this.config.auth_enabled) {
110
+ const signature = get_request_signature(
111
+ this.__url,
112
+ "POST",
113
+ content_text,
114
+ headers,
115
+ this.config.workspace_secret
116
+ );
117
+ headers["Authorization"] = `${this.config.workspace_key}:${signature}`;
118
+ }
119
+ try {
120
+ const response = await axios.post(this.__url, content_text, { headers });
121
+ const ok_response = Math.floor(response.status / 100) == 2;
122
+ if (ok_response) {
123
+ this.response = {
124
+ status: "success",
125
+ status_code: response.status,
126
+ total: this.__chunk.length,
127
+ success: this.__chunk.length,
128
+ failure: 0,
129
+ failed_records: [],
130
+ };
131
+ } else {
132
+ this.response = {
133
+ status: "fail",
134
+ status_code: response.status,
135
+ total: this.__chunk.length,
136
+ success: 0,
137
+ failure: this.__chunk.length,
138
+ failed_records: this.__chunk.map((item) => ({
139
+ record: item,
140
+ error: response.statusText,
141
+ code: response.status,
142
+ })),
143
+ };
144
+ }
145
+ } catch (err) {
146
+ const error_status = err.status || 500;
147
+ return {
148
+ status: "fail",
149
+ status_code: error_status,
150
+ message: err.message,
151
+ total: this.__chunk.length,
152
+ success: 0,
153
+ failure: this.__chunk.length,
154
+ failed_records: this.__chunk.map((item) => ({
155
+ record: item,
156
+ error: err.message,
157
+ code: error_status,
158
+ })),
159
+ };
160
+ }
161
+ }
162
+ }
163
+
164
+ class BulkEvents {
165
+ constructor(config) {
166
+ this.config = config;
167
+ this.__events = [];
168
+ this.__pending_records = [];
169
+ this.chunks = [];
170
+ this.response = new BulkResponse();
171
+ }
172
+
173
+ __validate_events() {
174
+ if (!this.__events) {
175
+ throw new SuprsendError("events list is empty in bulk request");
176
+ }
177
+ for (let ev of this.__events) {
178
+ const is_part_of_bulk = true;
179
+ const [ev_json, body_size] = ev.get_final_json(
180
+ this.config,
181
+ is_part_of_bulk
182
+ );
183
+ this.__pending_records.push([ev_json, body_size]);
184
+ }
185
+ }
186
+
187
+ __chunkify(start_idx = 0) {
188
+ const curr_chunk = new _BulkEventsChunk(this.config);
189
+ this.chunks.push(curr_chunk);
190
+ const entries = this.__pending_records.slice(start_idx).entries();
191
+ for (const [rel_idx, rec] of entries) {
192
+ const is_added = curr_chunk.try_to_add_into_chunk(rec[0], rec[1]);
193
+ if (!is_added) {
194
+ // create chunks from remaining records
195
+ this.__chunkify(start_idx + rel_idx);
196
+ // Don't forget to break. As current loop must not continue further
197
+ break;
198
+ }
199
+ }
200
+ }
201
+
202
+ append(...events) {
203
+ if (!events) {
204
+ throw new SuprsendError(
205
+ "events list empty. must pass one or more events"
206
+ );
207
+ }
208
+ for (let ev of events) {
209
+ if (!ev) {
210
+ throw new SuprsendError("null/empty element found in bulk instance");
211
+ }
212
+ if (!(ev instanceof Event)) {
213
+ throw new SuprsendError(
214
+ "element must be an instance of suprsend.Event"
215
+ );
216
+ }
217
+ const ev_copy = cloneDeep(ev);
218
+ this.__events.push(ev_copy);
219
+ }
220
+ }
221
+
222
+ async trigger() {
223
+ this.__validate_events();
224
+ this.__chunkify();
225
+ for (const [c_idx, ch] of this.chunks.entries()) {
226
+ if (this.config.req_log_level > 0) {
227
+ console.log(`DEBUG: triggering api call for chunk: ${c_idx}`);
228
+ }
229
+ // do api call
230
+ await ch.trigger();
231
+ // merge response
232
+ this.response.merge_chunk_response(ch.response);
233
+ }
234
+ return this.response;
235
+ }
236
+ }