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