@klime/node 1.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 +229 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +419 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.js +2 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# @klime/node
|
|
2
|
+
|
|
3
|
+
Klime SDK for Node.js.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @klime/node
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```javascript
|
|
14
|
+
const { KlimeClient } = require("@klime/node");
|
|
15
|
+
|
|
16
|
+
const client = new KlimeClient({
|
|
17
|
+
writeKey: "your-write-key",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Identify a user
|
|
21
|
+
client.identify("user_123", {
|
|
22
|
+
email: "user@example.com",
|
|
23
|
+
name: "Stefan",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Track an event
|
|
27
|
+
client.track(
|
|
28
|
+
"Button Clicked",
|
|
29
|
+
{
|
|
30
|
+
buttonName: "Sign up",
|
|
31
|
+
plan: "pro",
|
|
32
|
+
},
|
|
33
|
+
{ userId: "user_123" }
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Associate user with a group and set group traits
|
|
37
|
+
client.group(
|
|
38
|
+
"org_456",
|
|
39
|
+
{ name: "Acme Inc", plan: "enterprise" },
|
|
40
|
+
{ userId: "user_123" }
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Or just link the user to a group (if traits are already set)
|
|
44
|
+
client.group("org_456", null, { userId: "user_123" });
|
|
45
|
+
|
|
46
|
+
// Shutdown gracefully
|
|
47
|
+
process.on("SIGTERM", async () => {
|
|
48
|
+
await client.shutdown();
|
|
49
|
+
process.exit(0);
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## API Reference
|
|
54
|
+
|
|
55
|
+
### Constructor
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
new KlimeClient(config: {
|
|
59
|
+
writeKey: string; // Required: Your Klime write key
|
|
60
|
+
endpoint?: string; // Optional: API endpoint (default: https://i.klime.com)
|
|
61
|
+
flushInterval?: number; // Optional: Milliseconds between flushes (default: 2000)
|
|
62
|
+
maxBatchSize?: number; // Optional: Max events per batch (default: 20, max: 100)
|
|
63
|
+
maxQueueSize?: number; // Optional: Max queued events (default: 1000)
|
|
64
|
+
retryMaxAttempts?: number; // Optional: Max retry attempts (default: 5)
|
|
65
|
+
retryInitialDelay?: number; // Optional: Initial retry delay in ms (default: 1000)
|
|
66
|
+
flushOnShutdown?: boolean; // Optional: Auto-flush on SIGTERM/SIGINT (default: true)
|
|
67
|
+
})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Methods
|
|
71
|
+
|
|
72
|
+
#### `track(event: string, properties?: object, options?: { userId?, groupId?, ip? })`
|
|
73
|
+
|
|
74
|
+
Track a user event. A `userId` is required for events to be useful in Klime.
|
|
75
|
+
|
|
76
|
+
```javascript
|
|
77
|
+
client.track(
|
|
78
|
+
"Button Clicked",
|
|
79
|
+
{
|
|
80
|
+
buttonName: "Sign up",
|
|
81
|
+
plan: "pro",
|
|
82
|
+
},
|
|
83
|
+
{ userId: "user_123" }
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// With IP address (for geolocation)
|
|
87
|
+
client.track(
|
|
88
|
+
"Button Clicked",
|
|
89
|
+
{
|
|
90
|
+
buttonName: "Sign up",
|
|
91
|
+
plan: "pro",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
userId: "user_123",
|
|
95
|
+
ip: "192.168.1.1",
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
> **Advanced**: The `groupId` option is available for multi-tenant scenarios where a user belongs to multiple organizations and you need to specify which organization context the event occurred in.
|
|
101
|
+
|
|
102
|
+
#### `identify(userId: string, traits?: object, options?: { ip? })`
|
|
103
|
+
|
|
104
|
+
Identify a user with traits.
|
|
105
|
+
|
|
106
|
+
```javascript
|
|
107
|
+
client.identify(
|
|
108
|
+
"user_123",
|
|
109
|
+
{
|
|
110
|
+
email: "user@example.com",
|
|
111
|
+
name: "Stefan",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
ip: "192.168.1.1",
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
#### `group(groupId: string, traits?: object, options?: { userId?, ip? })`
|
|
120
|
+
|
|
121
|
+
Associate a user with a group and/or set group traits.
|
|
122
|
+
|
|
123
|
+
```javascript
|
|
124
|
+
// Associate user with a group and set group traits (most common)
|
|
125
|
+
client.group(
|
|
126
|
+
"org_456",
|
|
127
|
+
{ name: "Acme Inc", plan: "enterprise" },
|
|
128
|
+
{ userId: "user_123" }
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Just link a user to a group (traits already set or not needed)
|
|
132
|
+
client.group("org_456", null, { userId: "user_123" });
|
|
133
|
+
|
|
134
|
+
// Just update group traits (e.g., from a webhook or background job)
|
|
135
|
+
client.group("org_456", { plan: "enterprise", employeeCount: 50 });
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
#### `flush(): Promise<void>`
|
|
139
|
+
|
|
140
|
+
Manually flush queued events immediately.
|
|
141
|
+
|
|
142
|
+
```javascript
|
|
143
|
+
await client.flush();
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
#### `shutdown(): Promise<void>`
|
|
147
|
+
|
|
148
|
+
Gracefully shutdown the client, flushing remaining events.
|
|
149
|
+
|
|
150
|
+
```javascript
|
|
151
|
+
await client.shutdown();
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Features
|
|
155
|
+
|
|
156
|
+
- **Automatic Batching**: Events are automatically batched and sent every 2 seconds or when the batch size reaches 20 events
|
|
157
|
+
- **Automatic Retries**: Failed requests are automatically retried with exponential backoff
|
|
158
|
+
- **Process Exit Handling**: Automatically flushes events on SIGTERM/SIGINT
|
|
159
|
+
- **Zero Dependencies**: Uses only Node.js standard library (fetch for Node 18+, https/http for older versions)
|
|
160
|
+
|
|
161
|
+
## Configuration
|
|
162
|
+
|
|
163
|
+
### Default Values
|
|
164
|
+
|
|
165
|
+
- `flushInterval`: 2000ms
|
|
166
|
+
- `maxBatchSize`: 20 events
|
|
167
|
+
- `maxQueueSize`: 1000 events
|
|
168
|
+
- `retryMaxAttempts`: 5 attempts
|
|
169
|
+
- `retryInitialDelay`: 1000ms
|
|
170
|
+
- `flushOnShutdown`: true
|
|
171
|
+
|
|
172
|
+
## Error Handling
|
|
173
|
+
|
|
174
|
+
The SDK automatically handles:
|
|
175
|
+
|
|
176
|
+
- **Transient errors** (429, 503, network failures): Retries with exponential backoff
|
|
177
|
+
- **Permanent errors** (400, 401): Logs error and drops event
|
|
178
|
+
- **Rate limiting**: Respects `Retry-After` header
|
|
179
|
+
|
|
180
|
+
## Size Limits
|
|
181
|
+
|
|
182
|
+
- Maximum event size: 200KB
|
|
183
|
+
- Maximum batch size: 10MB
|
|
184
|
+
- Maximum events per batch: 100
|
|
185
|
+
|
|
186
|
+
Events exceeding these limits are rejected and logged.
|
|
187
|
+
|
|
188
|
+
## Express.js Example
|
|
189
|
+
|
|
190
|
+
```javascript
|
|
191
|
+
const express = require("express");
|
|
192
|
+
const { KlimeClient } = require("@klime/node");
|
|
193
|
+
|
|
194
|
+
const app = express();
|
|
195
|
+
const client = new KlimeClient({
|
|
196
|
+
writeKey: process.env.KLIME_WRITE_KEY,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
app.post("/api/button-clicked", (req, res) => {
|
|
200
|
+
client.track(
|
|
201
|
+
"Button Clicked",
|
|
202
|
+
{
|
|
203
|
+
buttonName: req.body.buttonName,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
userId: req.user.id,
|
|
207
|
+
ip: req.ip,
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
res.json({ success: true });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Graceful shutdown
|
|
215
|
+
process.on("SIGTERM", async () => {
|
|
216
|
+
await client.shutdown();
|
|
217
|
+
process.exit(0);
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Requirements
|
|
222
|
+
|
|
223
|
+
- Node.js 14.0.0 or higher
|
|
224
|
+
- For Node.js 18+, native `fetch` is used
|
|
225
|
+
- For Node.js 14-17, `https`/`http` modules are used
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { KlimeConfig, TrackOptions, IdentifyOptions, GroupOptions } from "./types";
|
|
2
|
+
export declare class KlimeClient {
|
|
3
|
+
private config;
|
|
4
|
+
private queue;
|
|
5
|
+
private flushTimer;
|
|
6
|
+
private isShutdown;
|
|
7
|
+
private flushPromise;
|
|
8
|
+
private shutdownHandlers;
|
|
9
|
+
constructor(config: KlimeConfig);
|
|
10
|
+
track(event: string, properties?: Record<string, any>, options?: TrackOptions): void;
|
|
11
|
+
identify(userId: string, traits?: Record<string, any>, options?: IdentifyOptions): void;
|
|
12
|
+
group(groupId: string, traits?: Record<string, any>, options?: GroupOptions): void;
|
|
13
|
+
flush(): Promise<void>;
|
|
14
|
+
shutdown(): Promise<void>;
|
|
15
|
+
private enqueue;
|
|
16
|
+
private doFlush;
|
|
17
|
+
private extractBatch;
|
|
18
|
+
private sendBatch;
|
|
19
|
+
private sendBatchWithFetch;
|
|
20
|
+
private sendBatchWithHttps;
|
|
21
|
+
private makeRequest;
|
|
22
|
+
private scheduleFlush;
|
|
23
|
+
private generateUUID;
|
|
24
|
+
private generateTimestamp;
|
|
25
|
+
private getContext;
|
|
26
|
+
private estimateEventSize;
|
|
27
|
+
private sleep;
|
|
28
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.KlimeClient = void 0;
|
|
37
|
+
const https = __importStar(require("https"));
|
|
38
|
+
const http = __importStar(require("http"));
|
|
39
|
+
const url = __importStar(require("url"));
|
|
40
|
+
const DEFAULT_ENDPOINT = "https://i.klime.com";
|
|
41
|
+
const DEFAULT_FLUSH_INTERVAL = 2000;
|
|
42
|
+
const DEFAULT_MAX_BATCH_SIZE = 20;
|
|
43
|
+
const DEFAULT_MAX_QUEUE_SIZE = 1000;
|
|
44
|
+
const DEFAULT_RETRY_MAX_ATTEMPTS = 5;
|
|
45
|
+
const DEFAULT_RETRY_INITIAL_DELAY = 1000;
|
|
46
|
+
const MAX_BATCH_SIZE = 100;
|
|
47
|
+
const MAX_EVENT_SIZE_BYTES = 200 * 1024; // 200KB
|
|
48
|
+
const MAX_BATCH_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
|
|
49
|
+
const SDK_VERSION = "1.0.1";
|
|
50
|
+
// Check if fetch is available (Node 18+)
|
|
51
|
+
const hasNativeFetch = typeof fetch !== "undefined";
|
|
52
|
+
class KlimeClient {
|
|
53
|
+
constructor(config) {
|
|
54
|
+
this.queue = [];
|
|
55
|
+
this.flushTimer = null;
|
|
56
|
+
this.isShutdown = false;
|
|
57
|
+
this.flushPromise = null;
|
|
58
|
+
this.shutdownHandlers = [];
|
|
59
|
+
if (!config.writeKey) {
|
|
60
|
+
throw new Error("writeKey is required");
|
|
61
|
+
}
|
|
62
|
+
this.config = {
|
|
63
|
+
writeKey: config.writeKey,
|
|
64
|
+
endpoint: config.endpoint || DEFAULT_ENDPOINT,
|
|
65
|
+
flushInterval: config.flushInterval ?? DEFAULT_FLUSH_INTERVAL,
|
|
66
|
+
maxBatchSize: Math.min(config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE, MAX_BATCH_SIZE),
|
|
67
|
+
maxQueueSize: config.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
|
|
68
|
+
retryMaxAttempts: config.retryMaxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS,
|
|
69
|
+
retryInitialDelay: config.retryInitialDelay ?? DEFAULT_RETRY_INITIAL_DELAY,
|
|
70
|
+
flushOnShutdown: config.flushOnShutdown ?? true,
|
|
71
|
+
};
|
|
72
|
+
if (this.config.flushOnShutdown) {
|
|
73
|
+
const shutdownHandler = async () => {
|
|
74
|
+
await this.shutdown();
|
|
75
|
+
};
|
|
76
|
+
process.on("SIGTERM", shutdownHandler);
|
|
77
|
+
process.on("SIGINT", shutdownHandler);
|
|
78
|
+
this.shutdownHandlers.push(() => {
|
|
79
|
+
process.removeListener("SIGTERM", shutdownHandler);
|
|
80
|
+
process.removeListener("SIGINT", shutdownHandler);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
this.scheduleFlush();
|
|
84
|
+
}
|
|
85
|
+
track(event, properties, options) {
|
|
86
|
+
if (this.isShutdown) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const eventObj = {
|
|
90
|
+
type: "track",
|
|
91
|
+
messageId: this.generateUUID(),
|
|
92
|
+
event,
|
|
93
|
+
timestamp: this.generateTimestamp(),
|
|
94
|
+
properties: properties || {},
|
|
95
|
+
context: this.getContext(options?.ip),
|
|
96
|
+
};
|
|
97
|
+
if (options?.userId) {
|
|
98
|
+
eventObj.userId = options.userId;
|
|
99
|
+
}
|
|
100
|
+
if (options?.groupId) {
|
|
101
|
+
eventObj.groupId = options.groupId;
|
|
102
|
+
}
|
|
103
|
+
this.enqueue(eventObj);
|
|
104
|
+
}
|
|
105
|
+
identify(userId, traits, options) {
|
|
106
|
+
if (this.isShutdown) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const eventObj = {
|
|
110
|
+
type: "identify",
|
|
111
|
+
messageId: this.generateUUID(),
|
|
112
|
+
userId,
|
|
113
|
+
timestamp: this.generateTimestamp(),
|
|
114
|
+
traits: traits || {},
|
|
115
|
+
context: this.getContext(options?.ip),
|
|
116
|
+
};
|
|
117
|
+
this.enqueue(eventObj);
|
|
118
|
+
}
|
|
119
|
+
group(groupId, traits, options) {
|
|
120
|
+
if (this.isShutdown) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const eventObj = {
|
|
124
|
+
type: "group",
|
|
125
|
+
messageId: this.generateUUID(),
|
|
126
|
+
groupId,
|
|
127
|
+
timestamp: this.generateTimestamp(),
|
|
128
|
+
traits: traits || {},
|
|
129
|
+
context: this.getContext(options?.ip),
|
|
130
|
+
};
|
|
131
|
+
if (options?.userId) {
|
|
132
|
+
eventObj.userId = options.userId;
|
|
133
|
+
}
|
|
134
|
+
this.enqueue(eventObj);
|
|
135
|
+
}
|
|
136
|
+
async flush() {
|
|
137
|
+
if (this.flushPromise) {
|
|
138
|
+
return this.flushPromise;
|
|
139
|
+
}
|
|
140
|
+
this.flushPromise = this.doFlush();
|
|
141
|
+
try {
|
|
142
|
+
await this.flushPromise;
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
this.flushPromise = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async shutdown() {
|
|
149
|
+
if (this.isShutdown) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.isShutdown = true;
|
|
153
|
+
// Remove shutdown handlers
|
|
154
|
+
this.shutdownHandlers.forEach((handler) => handler());
|
|
155
|
+
this.shutdownHandlers = [];
|
|
156
|
+
if (this.flushTimer) {
|
|
157
|
+
clearTimeout(this.flushTimer);
|
|
158
|
+
this.flushTimer = null;
|
|
159
|
+
}
|
|
160
|
+
await this.flush();
|
|
161
|
+
}
|
|
162
|
+
enqueue(event) {
|
|
163
|
+
// Check event size
|
|
164
|
+
const eventSize = this.estimateEventSize(event);
|
|
165
|
+
if (eventSize > MAX_EVENT_SIZE_BYTES) {
|
|
166
|
+
console.error(`Klime: Event size (${eventSize} bytes) exceeds ${MAX_EVENT_SIZE_BYTES} bytes limit`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Drop oldest if queue is full
|
|
170
|
+
if (this.queue.length >= this.config.maxQueueSize) {
|
|
171
|
+
this.queue.shift();
|
|
172
|
+
}
|
|
173
|
+
this.queue.push(event);
|
|
174
|
+
// Check if we should flush immediately
|
|
175
|
+
if (this.queue.length >= this.config.maxBatchSize) {
|
|
176
|
+
this.flush();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async doFlush() {
|
|
180
|
+
if (this.queue.length === 0) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Clear the flush timer
|
|
184
|
+
if (this.flushTimer) {
|
|
185
|
+
clearTimeout(this.flushTimer);
|
|
186
|
+
this.flushTimer = null;
|
|
187
|
+
}
|
|
188
|
+
// Process batches
|
|
189
|
+
while (this.queue.length > 0) {
|
|
190
|
+
const batch = this.extractBatch();
|
|
191
|
+
if (batch.length === 0) {
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
await this.sendBatch(batch);
|
|
195
|
+
}
|
|
196
|
+
// Schedule next flush
|
|
197
|
+
this.scheduleFlush();
|
|
198
|
+
}
|
|
199
|
+
extractBatch() {
|
|
200
|
+
const batch = [];
|
|
201
|
+
let batchSize = 0;
|
|
202
|
+
while (this.queue.length > 0 && batch.length < MAX_BATCH_SIZE) {
|
|
203
|
+
const event = this.queue[0];
|
|
204
|
+
const eventSize = this.estimateEventSize(event);
|
|
205
|
+
// Check if adding this event would exceed batch size limit
|
|
206
|
+
if (batchSize + eventSize > MAX_BATCH_SIZE_BYTES) {
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
batch.push(this.queue.shift());
|
|
210
|
+
batchSize += eventSize;
|
|
211
|
+
}
|
|
212
|
+
return batch;
|
|
213
|
+
}
|
|
214
|
+
async sendBatch(batch) {
|
|
215
|
+
if (batch.length === 0) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const request = { batch };
|
|
219
|
+
const requestBody = JSON.stringify(request);
|
|
220
|
+
if (hasNativeFetch) {
|
|
221
|
+
await this.sendBatchWithFetch(batch, requestBody);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
await this.sendBatchWithHttps(batch, requestBody);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async sendBatchWithFetch(batch, requestBody) {
|
|
228
|
+
const requestUrl = `${this.config.endpoint}/v1/batch`;
|
|
229
|
+
let attempt = 0;
|
|
230
|
+
let delay = this.config.retryInitialDelay;
|
|
231
|
+
while (attempt < this.config.retryMaxAttempts) {
|
|
232
|
+
try {
|
|
233
|
+
const response = await fetch(requestUrl, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: {
|
|
236
|
+
"Content-Type": "application/json",
|
|
237
|
+
Authorization: `Bearer ${this.config.writeKey}`,
|
|
238
|
+
},
|
|
239
|
+
body: requestBody,
|
|
240
|
+
});
|
|
241
|
+
const data = (await response.json());
|
|
242
|
+
if (response.ok) {
|
|
243
|
+
if (data.failed > 0 && data.errors) {
|
|
244
|
+
console.warn(`Klime: Batch partially failed. Accepted: ${data.accepted}, Failed: ${data.failed}`, data.errors);
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (response.status === 400 || response.status === 401) {
|
|
249
|
+
console.error(`Klime: Permanent error (${response.status}):`, data);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (response.status === 429 || response.status === 503) {
|
|
253
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
254
|
+
if (retryAfter) {
|
|
255
|
+
delay = parseInt(retryAfter, 10) * 1000;
|
|
256
|
+
}
|
|
257
|
+
attempt++;
|
|
258
|
+
if (attempt < this.config.retryMaxAttempts) {
|
|
259
|
+
await this.sleep(delay);
|
|
260
|
+
delay = Math.min(delay * 2, 16000);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
attempt++;
|
|
265
|
+
if (attempt < this.config.retryMaxAttempts) {
|
|
266
|
+
await this.sleep(delay);
|
|
267
|
+
delay = Math.min(delay * 2, 16000);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
attempt++;
|
|
272
|
+
if (attempt < this.config.retryMaxAttempts) {
|
|
273
|
+
await this.sleep(delay);
|
|
274
|
+
delay = Math.min(delay * 2, 16000);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
console.error("Klime: Failed to send batch after retries:", error);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async sendBatchWithHttps(batch, requestBody) {
|
|
283
|
+
const parsedUrl = url.parse(this.config.endpoint);
|
|
284
|
+
const isHttps = parsedUrl.protocol === "https:";
|
|
285
|
+
const client = isHttps ? https : http;
|
|
286
|
+
const port = parsedUrl.port
|
|
287
|
+
? parseInt(parsedUrl.port, 10)
|
|
288
|
+
: isHttps
|
|
289
|
+
? 443
|
|
290
|
+
: 80;
|
|
291
|
+
const hostname = parsedUrl.hostname || "";
|
|
292
|
+
let attempt = 0;
|
|
293
|
+
let delay = this.config.retryInitialDelay;
|
|
294
|
+
while (attempt < this.config.retryMaxAttempts) {
|
|
295
|
+
try {
|
|
296
|
+
const result = await this.makeRequest(client, hostname, port, "/v1/batch", requestBody);
|
|
297
|
+
if (result.statusCode === 200) {
|
|
298
|
+
const data = JSON.parse(result.body);
|
|
299
|
+
if (data.failed > 0 && data.errors) {
|
|
300
|
+
console.warn(`Klime: Batch partially failed. Accepted: ${data.accepted}, Failed: ${data.failed}`, data.errors);
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (result.statusCode === 400 || result.statusCode === 401) {
|
|
305
|
+
const data = JSON.parse(result.body);
|
|
306
|
+
console.error(`Klime: Permanent error (${result.statusCode}):`, data);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (result.statusCode === 429 || result.statusCode === 503) {
|
|
310
|
+
const retryAfter = result.headers["retry-after"];
|
|
311
|
+
if (retryAfter) {
|
|
312
|
+
delay =
|
|
313
|
+
parseInt(Array.isArray(retryAfter) ? retryAfter[0] : retryAfter, 10) * 1000;
|
|
314
|
+
}
|
|
315
|
+
attempt++;
|
|
316
|
+
if (attempt < this.config.retryMaxAttempts) {
|
|
317
|
+
await this.sleep(delay);
|
|
318
|
+
delay = Math.min(delay * 2, 16000);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
attempt++;
|
|
323
|
+
if (attempt < this.config.retryMaxAttempts) {
|
|
324
|
+
await this.sleep(delay);
|
|
325
|
+
delay = Math.min(delay * 2, 16000);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
attempt++;
|
|
330
|
+
if (attempt < this.config.retryMaxAttempts) {
|
|
331
|
+
await this.sleep(delay);
|
|
332
|
+
delay = Math.min(delay * 2, 16000);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
console.error("Klime: Failed to send batch after retries:", error);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
makeRequest(client, hostname, port, path, body) {
|
|
341
|
+
return new Promise((resolve, reject) => {
|
|
342
|
+
const options = {
|
|
343
|
+
hostname,
|
|
344
|
+
port,
|
|
345
|
+
path,
|
|
346
|
+
method: "POST",
|
|
347
|
+
headers: {
|
|
348
|
+
"Content-Type": "application/json",
|
|
349
|
+
Authorization: `Bearer ${this.config.writeKey}`,
|
|
350
|
+
"Content-Length": Buffer.byteLength(body),
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
const req = client.request(options, (res) => {
|
|
354
|
+
let data = "";
|
|
355
|
+
res.on("data", (chunk) => {
|
|
356
|
+
data += chunk;
|
|
357
|
+
});
|
|
358
|
+
res.on("end", () => {
|
|
359
|
+
resolve({
|
|
360
|
+
statusCode: res.statusCode || 500,
|
|
361
|
+
body: data,
|
|
362
|
+
headers: res.headers,
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
req.on("error", (error) => {
|
|
367
|
+
reject(error);
|
|
368
|
+
});
|
|
369
|
+
req.write(body);
|
|
370
|
+
req.end();
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
scheduleFlush() {
|
|
374
|
+
if (this.isShutdown || this.flushTimer) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
this.flushTimer = setTimeout(() => {
|
|
378
|
+
this.flush();
|
|
379
|
+
}, this.config.flushInterval);
|
|
380
|
+
}
|
|
381
|
+
generateUUID() {
|
|
382
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
383
|
+
return crypto.randomUUID();
|
|
384
|
+
}
|
|
385
|
+
// Fallback for Node < 15
|
|
386
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
387
|
+
const r = (Math.random() * 16) | 0;
|
|
388
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
389
|
+
return v.toString(16);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
generateTimestamp() {
|
|
393
|
+
return new Date().toISOString();
|
|
394
|
+
}
|
|
395
|
+
getContext(ip) {
|
|
396
|
+
const context = {
|
|
397
|
+
library: {
|
|
398
|
+
name: "node-sdk",
|
|
399
|
+
version: SDK_VERSION,
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
if (ip) {
|
|
403
|
+
context.ip = ip;
|
|
404
|
+
}
|
|
405
|
+
return context;
|
|
406
|
+
}
|
|
407
|
+
estimateEventSize(event) {
|
|
408
|
+
try {
|
|
409
|
+
return JSON.stringify(event).length;
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return 500;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
sleep(ms) {
|
|
416
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
exports.KlimeClient = KlimeClient;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface KlimeConfig {
|
|
2
|
+
writeKey: string;
|
|
3
|
+
endpoint?: string;
|
|
4
|
+
flushInterval?: number;
|
|
5
|
+
maxBatchSize?: number;
|
|
6
|
+
maxQueueSize?: number;
|
|
7
|
+
retryMaxAttempts?: number;
|
|
8
|
+
retryInitialDelay?: number;
|
|
9
|
+
flushOnShutdown?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface TrackOptions {
|
|
12
|
+
userId?: string;
|
|
13
|
+
groupId?: string;
|
|
14
|
+
ip?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface IdentifyOptions {
|
|
17
|
+
ip?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface GroupOptions {
|
|
20
|
+
userId?: string;
|
|
21
|
+
ip?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface Event {
|
|
24
|
+
type: 'track' | 'identify' | 'group';
|
|
25
|
+
messageId: string;
|
|
26
|
+
event?: string;
|
|
27
|
+
userId?: string;
|
|
28
|
+
groupId?: string;
|
|
29
|
+
timestamp: string;
|
|
30
|
+
properties?: Record<string, any>;
|
|
31
|
+
traits?: Record<string, any>;
|
|
32
|
+
context?: EventContext;
|
|
33
|
+
}
|
|
34
|
+
export interface EventContext {
|
|
35
|
+
library?: {
|
|
36
|
+
name: string;
|
|
37
|
+
version: string;
|
|
38
|
+
};
|
|
39
|
+
ip?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface BatchRequest {
|
|
42
|
+
batch: Event[];
|
|
43
|
+
}
|
|
44
|
+
export interface BatchResponse {
|
|
45
|
+
status: string;
|
|
46
|
+
accepted: number;
|
|
47
|
+
failed: number;
|
|
48
|
+
errors?: ValidationError[];
|
|
49
|
+
}
|
|
50
|
+
export interface ValidationError {
|
|
51
|
+
index: number;
|
|
52
|
+
message: string;
|
|
53
|
+
code: string;
|
|
54
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@klime/node",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "Klime SDK for Node.js",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"clean": "rm -rf dist",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:watch": "vitest",
|
|
12
|
+
"prepublishOnly": "npm run build"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"analytics",
|
|
16
|
+
"klime",
|
|
17
|
+
"tracking",
|
|
18
|
+
"events",
|
|
19
|
+
"node"
|
|
20
|
+
],
|
|
21
|
+
"author": "Klime",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^20.0.0",
|
|
25
|
+
"typescript": "^5.0.0",
|
|
26
|
+
"vitest": "^2.0.0"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=14.0.0"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/klimeapp/klime-js.git",
|
|
37
|
+
"directory": "packages/node"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/klimeapp/klime-js/tree/main/packages/node#readme"
|
|
40
|
+
}
|