@chirpier/chirpier-js 0.1.2 → 0.1.4

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/src/index.ts CHANGED
@@ -1,30 +1,36 @@
1
1
  // Import necessary dependencies
2
2
  import axios, { AxiosInstance } from "axios";
3
3
  import axiosRetry from "axios-retry";
4
- import { v4 as uuidv4 } from "@lukeed/uuid";
5
4
  import { Base64 } from "js-base64";
6
5
  import {
7
6
  DEFAULT_API_ENDPOINT,
8
7
  DEFAULT_RETRIES,
9
8
  DEFAULT_TIMEOUT,
9
+ DEFAULT_BATCH_SIZE,
10
+ DEFAULT_FLUSH_DELAY,
11
+ MAX_QUEUE_SIZE,
10
12
  } from "./constants";
13
+ import AsyncLock from "async-lock";
14
+
15
+ // Define logging levels
16
+ export enum LogLevel {
17
+ None = 0,
18
+ Error = 1,
19
+ Info = 2,
20
+ Debug = 3,
21
+ }
11
22
 
12
23
  // Define the options interface for Chirpier initialization
13
24
  interface Options {
14
25
  key: string;
15
- apiEndpoint?: string;
16
- retries?: number;
17
- timeout?: number;
18
- batchSize?: number;
19
- flushInterval?: number;
26
+ logLevel?: LogLevel;
20
27
  }
21
28
 
22
29
  // Define the Event interface for monitoring
23
30
  export interface Event {
24
31
  group_id: string;
25
- stream: string;
32
+ stream_name: string;
26
33
  value: number;
27
- event_id?: string;
28
34
  }
29
35
 
30
36
  // Custom error class for Chirpier-specific errors
@@ -36,41 +42,48 @@ export class ChirpierError extends Error {
36
42
  }
37
43
  }
38
44
 
45
+ interface QueuedEvent {
46
+ event: Event;
47
+ timestamp: number;
48
+ retryCount: number;
49
+ }
50
+
39
51
  /**
40
52
  * Main Chirpier class for monitoring events.
41
53
  */
42
54
  export class Chirpier {
55
+ private static instance: Chirpier | null = null;
43
56
  private readonly apiKey: string;
44
57
  private readonly apiEndpoint: string;
45
58
  private readonly retries: number;
46
59
  private readonly timeout: number;
47
60
  private readonly axiosInstance: AxiosInstance;
48
- private eventQueue: Event[] = [];
49
- private flushTimeout: NodeJS.Timeout | null = null;
61
+ private eventQueue: QueuedEvent[] = [];
50
62
  private readonly batchSize: number;
51
- private readonly flushInterval: number;
63
+ private readonly flushDelay: number;
64
+ private flushTimeoutId: NodeJS.Timeout | null = null;
65
+ private readonly queueLock = new AsyncLock();
66
+ private readonly flushLock = new AsyncLock();
67
+ private readonly logLevel: LogLevel;
52
68
 
53
69
  /**
54
70
  * Initializes a new instance of the Chirpier class.
55
71
  * @param options - Configuration options for the SDK.
56
72
  */
57
- constructor({
58
- key,
59
- apiEndpoint = DEFAULT_API_ENDPOINT,
60
- retries = DEFAULT_RETRIES,
61
- timeout = DEFAULT_TIMEOUT,
62
- batchSize = 100,
63
- flushInterval = 500,
64
- }: Options) {
73
+ private constructor(options: Options) {
74
+ const { key, logLevel = LogLevel.None } = options;
75
+
65
76
  if (!key || typeof key !== "string") {
66
77
  throw new ChirpierError("API key is required and must be a string");
67
78
  }
79
+
68
80
  this.apiKey = key;
69
- this.apiEndpoint = apiEndpoint;
70
- this.retries = retries;
71
- this.timeout = timeout;
72
- this.batchSize = batchSize;
73
- this.flushInterval = flushInterval;
81
+ this.apiEndpoint = DEFAULT_API_ENDPOINT;
82
+ this.retries = DEFAULT_RETRIES;
83
+ this.timeout = DEFAULT_TIMEOUT;
84
+ this.batchSize = DEFAULT_BATCH_SIZE;
85
+ this.flushDelay = DEFAULT_FLUSH_DELAY;
86
+ this.logLevel = logLevel;
74
87
 
75
88
  // Create axios instance with authorization header
76
89
  this.axiosInstance = axios.create({
@@ -102,6 +115,18 @@ export class Chirpier {
102
115
  });
103
116
  }
104
117
 
118
+ /**
119
+ * Gets the singleton instance of Chirpier, creating it if it doesn't exist.
120
+ * @param options - Configuration options for the SDK.
121
+ * @returns The Chirpier instance.
122
+ */
123
+ public static getInstance(options: Options): Chirpier | null {
124
+ if (!Chirpier.instance && options.key) {
125
+ Chirpier.instance = new Chirpier(options);
126
+ }
127
+ return Chirpier.instance;
128
+ }
129
+
105
130
  /**
106
131
  * Validates the event structure.
107
132
  * @param event - The event to validate.
@@ -114,8 +139,8 @@ export class Chirpier {
114
139
  event.group_id
115
140
  ) &&
116
141
  event.group_id.trim().length > 0 &&
117
- typeof event.stream === "string" &&
118
- event.stream.trim().length > 0 &&
142
+ typeof event.stream_name === "string" &&
143
+ event.stream_name.trim().length > 0 &&
119
144
  typeof event.value === "number"
120
145
  );
121
146
  }
@@ -125,27 +150,26 @@ export class Chirpier {
125
150
  * @param event - The event to monitor.
126
151
  */
127
152
  public async monitor(event: Event): Promise<void> {
128
- if (!this.apiKey) {
129
- throw new ChirpierError("Chirpier SDK must be initialized before calling monitor()");
130
- }
131
-
132
153
  if (!this.isValidEvent(event)) {
133
154
  throw new ChirpierError(
134
- "Invalid event format. Must include group_id, stream, and numeric value."
155
+ "Invalid event format. Must include group_id, stream_name, and numeric value."
135
156
  );
136
157
  }
137
158
 
138
- // Ensure event_id is only set once
139
- const eventWithID = { ...event, event_id: event.event_id || uuidv4() };
159
+ await this.queueLock.acquire("queue", async () => {
160
+ if (this.eventQueue.length >= MAX_QUEUE_SIZE) {
161
+ throw new ChirpierError("Event queue is full.");
162
+ }
140
163
 
141
- this.eventQueue.push(eventWithID);
164
+ this.eventQueue.push({ event, timestamp: Date.now(), retryCount: 0 });
165
+ });
142
166
 
143
167
  if (this.eventQueue.length >= this.batchSize) {
144
- this.flushQueue();
145
- } else if (!this.flushTimeout) {
146
- this.flushTimeout = setTimeout(
168
+ await this.flushQueue();
169
+ } else if (!this.flushTimeoutId) {
170
+ this.flushTimeoutId = setTimeout(
147
171
  () => this.flushQueue(),
148
- this.flushInterval
172
+ this.flushDelay
149
173
  );
150
174
  }
151
175
  }
@@ -154,32 +178,65 @@ export class Chirpier {
154
178
  * Flushes the event queue by sending all events to the API.
155
179
  */
156
180
  private async flushQueue(): Promise<void> {
157
- if (this.eventQueue.length === 0) {
158
- return;
159
- }
160
-
161
- if (this.flushTimeout) {
162
- clearTimeout(this.flushTimeout);
163
- this.flushTimeout = null;
164
- }
181
+ // Acquire the flush lock
182
+ await this.flushLock.acquire("flush", async () => {
183
+ let eventsToSend: QueuedEvent[] = [];
165
184
 
166
- const eventsToSend = [...this.eventQueue];
167
- this.eventQueue = [];
185
+ // Extract events from the queue under the queue lock
186
+ await this.queueLock.acquire("eventQueue", async () => {
187
+ if (this.eventQueue.length > 0) {
188
+ eventsToSend = [...this.eventQueue];
189
+ this.eventQueue = [];
190
+ }
191
+ });
168
192
 
169
- try {
170
- await this.sendEvents(eventsToSend);
171
- console.info(`Successfully sent ${eventsToSend.length} events`);
172
- } catch (error) {
173
- console.error("Failed to send events:", error);
174
- }
193
+ if (eventsToSend.length === 0) {
194
+ return;
195
+ }
175
196
 
176
- // Schedule next flush if there are more events
177
- if (this.eventQueue.length > 0) {
178
- this.flushTimeout = setTimeout(
179
- () => this.flushQueue(),
180
- this.flushInterval
181
- );
182
- }
197
+ try {
198
+ // Clear any pending flush timeout
199
+ if (this.flushTimeoutId) {
200
+ clearTimeout(this.flushTimeoutId);
201
+ this.flushTimeoutId = null;
202
+ }
203
+
204
+ // Attempt to send events
205
+ await this.sendEvents(eventsToSend.map((qe) => qe.event));
206
+
207
+ if (this.logLevel >= LogLevel.Info) {
208
+ console.info(`Successfully sent ${eventsToSend.length} events`);
209
+ }
210
+ } catch (error) {
211
+ // Log failure
212
+ if (this.logLevel >= LogLevel.Error) {
213
+ console.error("Failed to send events:", error);
214
+ }
215
+
216
+ // Requeue failed events with retry count checks
217
+ const retryableEvents: QueuedEvent[] = [];
218
+ for (const queuedEvent of eventsToSend) {
219
+ if (queuedEvent.retryCount >= this.retries) {
220
+ if (this.logLevel >= LogLevel.Error) {
221
+ console.error(
222
+ `Dropping event after ${this.retries} retries:`,
223
+ queuedEvent.event
224
+ );
225
+ }
226
+ continue; // Skip adding this event back to the queue
227
+ }
228
+
229
+ // Increment retry count and add back to the queue
230
+ queuedEvent.retryCount++;
231
+ retryableEvents.push(queuedEvent);
232
+ }
233
+
234
+ // Requeue remaining retryable events
235
+ await this.queueLock.acquire("eventQueue", async () => {
236
+ this.eventQueue = [...retryableEvents, ...this.eventQueue];
237
+ });
238
+ }
239
+ });
183
240
  }
184
241
 
185
242
  /**
@@ -189,6 +246,21 @@ export class Chirpier {
189
246
  private async sendEvents(events: Event[]): Promise<void> {
190
247
  await this.axiosInstance.post(this.apiEndpoint, events);
191
248
  }
249
+
250
+ // Stop the timeout and uninitialize the Chirpier instance
251
+ public static async stop(): Promise<void> {
252
+ if (!Chirpier.instance) {
253
+ return;
254
+ }
255
+ if (Chirpier.instance.flushTimeoutId) {
256
+ clearTimeout(Chirpier.instance.flushTimeoutId);
257
+ Chirpier.instance.flushTimeoutId = null;
258
+ }
259
+ // Flush any remaining events in the queue
260
+ await Chirpier.instance.flushQueue();
261
+ // Uninitialize the Chirpier instance
262
+ Chirpier.instance = null;
263
+ }
192
264
  }
193
265
 
194
266
  /**
@@ -226,9 +298,6 @@ function isValidJWT(token: string): boolean {
226
298
  }
227
299
  }
228
300
 
229
- // Singleton instance of Chirpier
230
- let chirpierInstance: Chirpier | null = null;
231
-
232
301
  /**
233
302
  * Initializes the Chirpier SDK.
234
303
  * @param options - Configuration options for the SDK.
@@ -239,15 +308,19 @@ export function initialize(options: Options): void {
239
308
  }
240
309
 
241
310
  try {
242
- chirpierInstance = new Chirpier(options);
311
+ Chirpier.getInstance(options);
243
312
  } catch (error) {
244
313
  if (error instanceof ChirpierError) {
245
- console.error("Failed to initialize Chirpier SDK:", error.message);
314
+ if (options.logLevel && options.logLevel >= LogLevel.Error) {
315
+ console.error("Failed to initialize Chirpier SDK:", error.message);
316
+ }
246
317
  } else {
247
- console.error(
248
- "An unexpected error occurred during Chirpier SDK initialization:",
249
- error
250
- );
318
+ if (options.logLevel && options.logLevel >= LogLevel.Error) {
319
+ console.error(
320
+ "An unexpected error occurred during Chirpier SDK initialization:",
321
+ error
322
+ );
323
+ }
251
324
  }
252
325
  throw error;
253
326
  }
@@ -258,12 +331,14 @@ export function initialize(options: Options): void {
258
331
  * @param event - The event to monitor.
259
332
  */
260
333
  export function monitor(event: Event): void {
261
- if (!chirpierInstance) {
334
+ const instance = Chirpier.getInstance({} as Options);
335
+ if (!instance) {
262
336
  throw new ChirpierError(
263
337
  "Chirpier SDK is not initialized. Please call initialize() first."
264
338
  );
265
339
  }
266
- chirpierInstance.monitor(event).catch((error) => {
340
+
341
+ instance.monitor(event).catch((error) => {
267
342
  console.error("Error in monitor function:", error);
268
343
  });
269
344
  }
@@ -1,6 +0,0 @@
1
- import axios from "axios";
2
-
3
- // Cleanup mock server after tests
4
- export function cleanupMockServer() {
5
- axios.defaults.adapter = undefined;
6
- }
@@ -1,222 +0,0 @@
1
- import { Chirpier, ChirpierError, Event, initialize, monitor } from "../index";
2
- import { DEFAULT_API_ENDPOINT, DEFAULT_RETRIES } from "../constants";
3
- import MockAdapter from "axios-mock-adapter";
4
- import axios from "axios";
5
- import { cleanupMockServer } from "./mocks/server";
6
- import { v4 as uuidv4 } from "@lukeed/uuid";
7
-
8
- jest.mock("@lukeed/uuid");
9
-
10
- describe("Chirpier SDK", () => {
11
- let chirpier: Chirpier;
12
-
13
- afterEach(() => {
14
- // Clean up mock server
15
- cleanupMockServer();
16
- });
17
-
18
- describe("Initialization", () => {
19
- test("should initialize with default values", () => {
20
- chirpier = new Chirpier({
21
- key: "api_key",
22
- });
23
-
24
- // Setup mock server
25
- const mock = new MockAdapter(axios);
26
- mock.onPost(DEFAULT_API_ENDPOINT).reply(200, { success: true });
27
-
28
- expect(chirpier["apiEndpoint"]).toBe(DEFAULT_API_ENDPOINT);
29
- expect(chirpier["retries"]).toBe(DEFAULT_RETRIES);
30
- });
31
-
32
- test("should initialize with custom values using mock server", () => {
33
- chirpier = new Chirpier({
34
- key: "api_key",
35
- });
36
-
37
- // Setup mock server
38
- const mock = new MockAdapter(axios);
39
- mock.onPost(DEFAULT_API_ENDPOINT).reply(200, { success: true });
40
-
41
- const customChirpier = new Chirpier({
42
- key: "api_key",
43
- apiEndpoint: DEFAULT_API_ENDPOINT,
44
- retries: 5,
45
- });
46
-
47
- expect(customChirpier["apiEndpoint"]).toBe(DEFAULT_API_ENDPOINT);
48
- expect(customChirpier["retries"]).toBe(5);
49
- });
50
-
51
- test("should throw error if key is not provided", () => {
52
- chirpier = new Chirpier({
53
- key: "api_key",
54
- });
55
-
56
- // Setup mock server
57
- const mock = new MockAdapter(axios);
58
- mock.onPost(DEFAULT_API_ENDPOINT).reply(200, { success: true });
59
-
60
- expect(() => new Chirpier({} as any)).toThrow(ChirpierError);
61
- });
62
-
63
- test("should throw error if key is not a valid JWT", () => {
64
- expect(() => initialize({ key: "invalid_key" })).toThrow(ChirpierError);
65
- });
66
-
67
- test("should initialize successfully with a valid JWT", () => {
68
- const validJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
69
- expect(() => initialize({ key: validJWT })).not.toThrow();
70
- });
71
- });
72
-
73
- describe("monitor", () => {
74
- test("event should be sent", async () => {
75
- chirpier = new Chirpier({
76
- key: "api_key",
77
- });
78
-
79
- // Setup mock server
80
- const mock = new MockAdapter(axios);
81
- mock.onPost(DEFAULT_API_ENDPOINT).reply(200, { success: true });
82
-
83
- const validJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
84
- initialize({ key: validJWT });
85
-
86
- const event: Event = {
87
- group_id: "f3438ee9-b964-48aa-b938-a803df440a3c",
88
- stream: "test-stream",
89
- value: 1,
90
- };
91
-
92
- await chirpier.monitor(event);
93
-
94
- await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for flush
95
-
96
- expect(mock.history.post.length).toBe(1);
97
- expect(mock.history.post[0].url).toBe(DEFAULT_API_ENDPOINT);
98
- expect(JSON.parse(mock.history.post[0].data)).toEqual([
99
- {
100
- group_id: "f3438ee9-b964-48aa-b938-a803df440a3c",
101
- stream: "test-stream",
102
- value: 1,
103
- },
104
- ]);
105
-
106
- // Clean up the mock
107
- mock.reset();
108
- });
109
-
110
- test("should throw error for invalid event", async () => {
111
- chirpier = new Chirpier({
112
- key: "api_key",
113
- });
114
- const invalidEvent = {
115
- group_id: "f3438ee9-b964-48aa-b938-a803df440a3c",
116
- } as any;
117
- await expect(chirpier.monitor(invalidEvent)).rejects.toThrow(
118
- ChirpierError
119
- );
120
-
121
- // Clean up the mock
122
- const mock = new MockAdapter(axios);
123
- mock.reset();
124
- });
125
-
126
- test("should batch events and flush when batch size is reached", async () => {
127
- const mock = new MockAdapter(axios);
128
- mock.onPost(DEFAULT_API_ENDPOINT).reply(200, { success: true });
129
-
130
- const validJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
131
- initialize({ key: validJWT, batchSize: 2 });
132
-
133
- const event: Event = {
134
- group_id: "f3438ee9-b964-48aa-b938-a803df440a3c",
135
- stream: "test-stream",
136
- value: 1,
137
- };
138
-
139
- monitor(event);
140
- monitor(event);
141
-
142
- await new Promise(resolve => setTimeout(resolve, 100)); // Wait for flush
143
-
144
- expect(mock.history.post.length).toBe(1);
145
- expect(JSON.parse(mock.history.post[0].data).length).toBe(2);
146
-
147
- mock.reset();
148
- });
149
-
150
- test("should flush events after interval", async () => {
151
- const mock = new MockAdapter(axios);
152
- mock.onPost(DEFAULT_API_ENDPOINT).reply(200, { success: true });
153
-
154
- const validJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
155
- initialize({ key: validJWT, flushInterval: 100 });
156
-
157
- const event: Event = {
158
- group_id: "f3438ee9-b964-48aa-b938-a803df440a3c",
159
- stream: "test-stream",
160
- value: 1,
161
- };
162
-
163
- monitor(event);
164
-
165
- await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for flush
166
-
167
- expect(mock.history.post.length).toBe(1);
168
- expect(JSON.parse(mock.history.post[0].data).length).toBe(1);
169
-
170
- mock.reset();
171
- });
172
-
173
- test("should use provided event_id if available", async () => {
174
- const mock = new MockAdapter(axios);
175
- mock.onPost(DEFAULT_API_ENDPOINT).reply(200, { success: true });
176
-
177
- const validJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
178
- initialize({ key: validJWT });
179
-
180
- const event: Event = {
181
- group_id: "f3438ee9-b964-48aa-b938-a803df440a3c",
182
- stream: "test-stream",
183
- value: 1,
184
- event_id: "f3438ee9-b964-48aa-b938-a803df440a3c",
185
- };
186
-
187
- monitor(event);
188
-
189
- await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for flush
190
-
191
- expect(mock.history.post.length).toBe(1);
192
- expect(JSON.parse(mock.history.post[0].data)[0].event_id).toBe("f3438ee9-b964-48aa-b938-a803df440a3c");
193
-
194
- mock.reset();
195
- });
196
-
197
- test("should generate event_id if not provided", async () => {
198
- const mock = new MockAdapter(axios);
199
- mock.onPost(DEFAULT_API_ENDPOINT).reply(200, { success: true });
200
-
201
- (uuidv4 as jest.Mock).mockReturnValue("generated-uuid");
202
-
203
- const validJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
204
- initialize({ key: validJWT });
205
-
206
- const event: Event = {
207
- group_id: "f3438ee9-b964-48aa-b938-a803df440a3c",
208
- stream: "test-stream",
209
- value: 1,
210
- };
211
-
212
- monitor(event);
213
-
214
- await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for flush
215
-
216
- expect(mock.history.post.length).toBe(1);
217
- expect(JSON.parse(mock.history.post[0].data)[0].event_id).toBe("generated-uuid");
218
-
219
- mock.reset();
220
- });
221
- });
222
- });