@gurulu/node 0.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/README.md +49 -0
- package/dist/node-sdk/src/business-events.d.ts +73 -0
- package/dist/node-sdk/src/business-events.js +113 -0
- package/dist/node-sdk/src/client.d.ts +70 -0
- package/dist/node-sdk/src/client.js +255 -0
- package/dist/node-sdk/src/index.d.ts +12 -0
- package/dist/node-sdk/src/index.js +19 -0
- package/dist/node-sdk/src/types.d.ts +46 -0
- package/dist/node-sdk/src/types.js +30 -0
- package/dist/shared-core/src/canonical-events.d.ts +35 -0
- package/dist/shared-core/src/canonical-events.js +225 -0
- package/dist/shared-core/src/envelope.d.ts +91 -0
- package/dist/shared-core/src/envelope.js +341 -0
- package/dist/shared-core/src/index.d.ts +6 -0
- package/dist/shared-core/src/index.js +22 -0
- package/dist/shared-core/src/server-event.d.ts +37 -0
- package/dist/shared-core/src/server-event.js +6 -0
- package/package.json +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @gurulu/node
|
|
2
|
+
|
|
3
|
+
Server-side analytics SDK for Gurulu.io. Tracks events, identifies users, and batches requests with automatic retries.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @gurulu/node
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { Gurulu } from '@gurulu/node';
|
|
15
|
+
|
|
16
|
+
const gurulu = new Gurulu({
|
|
17
|
+
siteId: 'your-site-id',
|
|
18
|
+
apiKey: 'your-api-key',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Track an event
|
|
22
|
+
gurulu.track('purchase_completed', { amount: 49.99, currency: 'USD' }, {
|
|
23
|
+
userId: 'user-123',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Identify a user
|
|
27
|
+
gurulu.identify('user-123', {
|
|
28
|
+
email: 'user@example.com',
|
|
29
|
+
plan: 'pro',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Manually flush the event queue
|
|
33
|
+
await gurulu.flush();
|
|
34
|
+
|
|
35
|
+
// Graceful shutdown (flushes remaining events and stops the timer)
|
|
36
|
+
await gurulu.shutdown();
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
| Option | Type | Default | Description |
|
|
42
|
+
|---|---|---|---|
|
|
43
|
+
| `siteId` | `string` | **required** | Your Gurulu site ID |
|
|
44
|
+
| `apiKey` | `string` | **required** | Your Gurulu API key |
|
|
45
|
+
| `endpoint` | `string` | `https://app.gurulu.io/api/ingest/v1/server` | Custom ingest endpoint |
|
|
46
|
+
| `flushInterval` | `number` | `10000` | Auto-flush interval in ms |
|
|
47
|
+
| `maxBatchSize` | `number` | `50` | Max events per batch |
|
|
48
|
+
| `maxRetries` | `number` | `3` | Retry count for failed requests |
|
|
49
|
+
| `debug` | `boolean` | `false` | Enable debug logging |
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Business event emitters — Phase 10 W4.2.
|
|
3
|
+
*
|
|
4
|
+
* These are tiny factories that return envelope-shaped `ServerEvent` objects
|
|
5
|
+
* for common server-authored events (user signup, payment, subscription,
|
|
6
|
+
* order, lead capture). They stamp the canonical `event_tier = 'verified'`
|
|
7
|
+
* and `event_source = 'server_sdk'` fields so downstream readers can trust
|
|
8
|
+
* the provenance without guessing.
|
|
9
|
+
*
|
|
10
|
+
* The shape intentionally satisfies BOTH:
|
|
11
|
+
* - the simple `ServerEvent` contract the /api/ingest/v1/server route
|
|
12
|
+
* consumes (event_name, user_id, properties, timestamp,
|
|
13
|
+
* idempotency_key?, correlation_id?), AND
|
|
14
|
+
* - the richer canonical Envelope fields consumers may want to read
|
|
15
|
+
* (event_tier, event_source) — we attach these at the top level so they
|
|
16
|
+
* travel alongside the event without collapsing into properties.
|
|
17
|
+
*
|
|
18
|
+
* `createEnvelope` from @gurulu/shared-core is used to validate/normalize the
|
|
19
|
+
* shared envelope defaults where practical (event_type, timestamp,
|
|
20
|
+
* consent_level, etc.), but the ServerEvent-specific keys (user_id) remain at
|
|
21
|
+
* the root.
|
|
22
|
+
*
|
|
23
|
+
* Related docs: PHASE-10-ROADMAP.md §W4.2
|
|
24
|
+
*/
|
|
25
|
+
import { type Envelope, type ServerEvent } from '@gurulu/shared-core';
|
|
26
|
+
/**
|
|
27
|
+
* Concrete shape returned by every business-event factory below:
|
|
28
|
+
* a ServerEvent with the canonical envelope provenance fields attached.
|
|
29
|
+
*/
|
|
30
|
+
export type BusinessEvent = ServerEvent & {
|
|
31
|
+
event_tier: Envelope['event_tier'];
|
|
32
|
+
event_source: Envelope['event_source'];
|
|
33
|
+
event_type: Envelope['event_type'];
|
|
34
|
+
};
|
|
35
|
+
export interface UserCreatedArgs {
|
|
36
|
+
userId: string;
|
|
37
|
+
email?: string;
|
|
38
|
+
traits?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
export declare function userCreated(args: UserCreatedArgs): BusinessEvent;
|
|
41
|
+
export interface PaymentSucceededArgs {
|
|
42
|
+
userId: string;
|
|
43
|
+
amount: number;
|
|
44
|
+
currency: string;
|
|
45
|
+
orderId: string;
|
|
46
|
+
properties?: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
export declare function paymentSucceeded(args: PaymentSucceededArgs): BusinessEvent;
|
|
49
|
+
export interface SubscriptionStartedArgs {
|
|
50
|
+
userId: string;
|
|
51
|
+
plan: string;
|
|
52
|
+
amount?: number;
|
|
53
|
+
currency?: string;
|
|
54
|
+
}
|
|
55
|
+
export declare function subscriptionStarted(args: SubscriptionStartedArgs): BusinessEvent;
|
|
56
|
+
export interface OrderPlacedArgs {
|
|
57
|
+
userId: string;
|
|
58
|
+
orderId: string;
|
|
59
|
+
total: number;
|
|
60
|
+
currency: string;
|
|
61
|
+
items?: Array<{
|
|
62
|
+
id: string;
|
|
63
|
+
qty: number;
|
|
64
|
+
price: number;
|
|
65
|
+
}>;
|
|
66
|
+
}
|
|
67
|
+
export declare function orderPlaced(args: OrderPlacedArgs): BusinessEvent;
|
|
68
|
+
export interface LeadCapturedArgs {
|
|
69
|
+
anonymousId: string;
|
|
70
|
+
email?: string;
|
|
71
|
+
source?: string;
|
|
72
|
+
}
|
|
73
|
+
export declare function leadCaptured(args: LeadCapturedArgs): BusinessEvent;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Business event emitters — Phase 10 W4.2.
|
|
4
|
+
*
|
|
5
|
+
* These are tiny factories that return envelope-shaped `ServerEvent` objects
|
|
6
|
+
* for common server-authored events (user signup, payment, subscription,
|
|
7
|
+
* order, lead capture). They stamp the canonical `event_tier = 'verified'`
|
|
8
|
+
* and `event_source = 'server_sdk'` fields so downstream readers can trust
|
|
9
|
+
* the provenance without guessing.
|
|
10
|
+
*
|
|
11
|
+
* The shape intentionally satisfies BOTH:
|
|
12
|
+
* - the simple `ServerEvent` contract the /api/ingest/v1/server route
|
|
13
|
+
* consumes (event_name, user_id, properties, timestamp,
|
|
14
|
+
* idempotency_key?, correlation_id?), AND
|
|
15
|
+
* - the richer canonical Envelope fields consumers may want to read
|
|
16
|
+
* (event_tier, event_source) — we attach these at the top level so they
|
|
17
|
+
* travel alongside the event without collapsing into properties.
|
|
18
|
+
*
|
|
19
|
+
* `createEnvelope` from @gurulu/shared-core is used to validate/normalize the
|
|
20
|
+
* shared envelope defaults where practical (event_type, timestamp,
|
|
21
|
+
* consent_level, etc.), but the ServerEvent-specific keys (user_id) remain at
|
|
22
|
+
* the root.
|
|
23
|
+
*
|
|
24
|
+
* Related docs: PHASE-10-ROADMAP.md §W4.2
|
|
25
|
+
*/
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
exports.userCreated = userCreated;
|
|
28
|
+
exports.paymentSucceeded = paymentSucceeded;
|
|
29
|
+
exports.subscriptionStarted = subscriptionStarted;
|
|
30
|
+
exports.orderPlaced = orderPlaced;
|
|
31
|
+
exports.leadCaptured = leadCaptured;
|
|
32
|
+
const shared_core_1 = require("@gurulu/shared-core");
|
|
33
|
+
const SDK_VERSION = 'node@0.1.0';
|
|
34
|
+
function baseEnvelope(eventName, anonymousId, properties) {
|
|
35
|
+
return (0, shared_core_1.createEnvelope)({
|
|
36
|
+
site_id: '', // supplied at transport time by the client
|
|
37
|
+
anonymous_id: anonymousId,
|
|
38
|
+
session_id: '', // server SDK has no browser session
|
|
39
|
+
event_name: eventName,
|
|
40
|
+
event_type: 'server',
|
|
41
|
+
event_tier: 'verified',
|
|
42
|
+
event_source: 'server_sdk',
|
|
43
|
+
consent_level: 'accepted',
|
|
44
|
+
sdk_version: SDK_VERSION,
|
|
45
|
+
timestamp: new Date(Date.now()).toISOString(),
|
|
46
|
+
properties,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function toBusinessEvent(envelope, userId, extras = {}) {
|
|
50
|
+
return {
|
|
51
|
+
event_name: envelope.event_name,
|
|
52
|
+
user_id: userId,
|
|
53
|
+
properties: envelope.properties,
|
|
54
|
+
timestamp: envelope.timestamp,
|
|
55
|
+
event_type: envelope.event_type,
|
|
56
|
+
event_tier: envelope.event_tier,
|
|
57
|
+
event_source: envelope.event_source,
|
|
58
|
+
...extras,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function userCreated(args) {
|
|
62
|
+
const props = {
|
|
63
|
+
...(args.traits ?? {}),
|
|
64
|
+
};
|
|
65
|
+
if (args.email !== undefined)
|
|
66
|
+
props.email = args.email;
|
|
67
|
+
const env = baseEnvelope('user_created', args.userId, props);
|
|
68
|
+
return toBusinessEvent(env, args.userId);
|
|
69
|
+
}
|
|
70
|
+
function paymentSucceeded(args) {
|
|
71
|
+
const props = {
|
|
72
|
+
...(args.properties ?? {}),
|
|
73
|
+
amount: args.amount,
|
|
74
|
+
currency: args.currency,
|
|
75
|
+
order_id: args.orderId,
|
|
76
|
+
};
|
|
77
|
+
const env = baseEnvelope('payment_succeeded', args.userId, props);
|
|
78
|
+
return toBusinessEvent(env, args.userId);
|
|
79
|
+
}
|
|
80
|
+
function subscriptionStarted(args) {
|
|
81
|
+
const props = {
|
|
82
|
+
plan: args.plan,
|
|
83
|
+
};
|
|
84
|
+
if (args.amount !== undefined)
|
|
85
|
+
props.amount = args.amount;
|
|
86
|
+
if (args.currency !== undefined)
|
|
87
|
+
props.currency = args.currency;
|
|
88
|
+
const env = baseEnvelope('subscription_started', args.userId, props);
|
|
89
|
+
return toBusinessEvent(env, args.userId);
|
|
90
|
+
}
|
|
91
|
+
function orderPlaced(args) {
|
|
92
|
+
const props = {
|
|
93
|
+
order_id: args.orderId,
|
|
94
|
+
total: args.total,
|
|
95
|
+
currency: args.currency,
|
|
96
|
+
};
|
|
97
|
+
if (args.items !== undefined)
|
|
98
|
+
props.items = args.items;
|
|
99
|
+
const env = baseEnvelope('order_placed', args.userId, props);
|
|
100
|
+
return toBusinessEvent(env, args.userId);
|
|
101
|
+
}
|
|
102
|
+
function leadCaptured(args) {
|
|
103
|
+
const props = {};
|
|
104
|
+
if (args.email !== undefined)
|
|
105
|
+
props.email = args.email;
|
|
106
|
+
if (args.source !== undefined)
|
|
107
|
+
props.source = args.source;
|
|
108
|
+
const env = baseEnvelope('lead_captured', args.anonymousId, props);
|
|
109
|
+
// Lead capture uses anonymous_id as the user_id placeholder until the lead
|
|
110
|
+
// is resolved to a real account; the server route accepts any non-empty
|
|
111
|
+
// user_id string and will look up the canonical profile from it.
|
|
112
|
+
return toBusinessEvent(env, args.anonymousId);
|
|
113
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gurulu/node — server-side SDK client.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities (Phase 10 W4.2):
|
|
5
|
+
* - Queue + batch server events.
|
|
6
|
+
* - Auto-flush at batchSize boundary and on a periodic timer.
|
|
7
|
+
* - Exponential-backoff retry (200ms, 600ms, 1800ms — 3 attempts total) on
|
|
8
|
+
* 5xx / network errors. No retry on 4xx.
|
|
9
|
+
* - Attach deterministic Idempotency-Key headers so the server can dedupe
|
|
10
|
+
* replayed batches across retries.
|
|
11
|
+
* - Dead-letter callback for batches that exhaust retries or are rejected
|
|
12
|
+
* with a client error.
|
|
13
|
+
* - Flush on process.beforeExit + SIGTERM.
|
|
14
|
+
*
|
|
15
|
+
* Event shapes are pulled from @gurulu/shared-core (W1.2). Only the transport
|
|
16
|
+
* config (`GuruluClientConfig`) is local.
|
|
17
|
+
*
|
|
18
|
+
* Related docs: PHASE-10-ROADMAP.md §W4.2
|
|
19
|
+
*/
|
|
20
|
+
import type { ServerEvent } from '@gurulu/shared-core';
|
|
21
|
+
import type { GuruluClientConfig } from './types';
|
|
22
|
+
export interface GuruluClient {
|
|
23
|
+
track(event: ServerEvent): void;
|
|
24
|
+
flush(): Promise<void>;
|
|
25
|
+
shutdown(): Promise<void>;
|
|
26
|
+
/** Current queue length — exposed for tests and observability. */
|
|
27
|
+
readonly queueSize: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Deterministic idempotency key for a server event. Hashes the tuple
|
|
31
|
+
* (site_id, event_name, timestamp, anonymous_id/user_id) so replayed events
|
|
32
|
+
* across retries produce the same key and the server can dedupe.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createIdempotencyKey(event: ServerEvent, siteId: string): string;
|
|
35
|
+
export declare class Gurulu implements GuruluClient {
|
|
36
|
+
private readonly siteId;
|
|
37
|
+
private readonly apiKey;
|
|
38
|
+
private readonly endpoint;
|
|
39
|
+
private readonly flushInterval;
|
|
40
|
+
private readonly batchSize;
|
|
41
|
+
private readonly timeout;
|
|
42
|
+
private readonly onError?;
|
|
43
|
+
private readonly onDeadLetter?;
|
|
44
|
+
private readonly fetchImpl;
|
|
45
|
+
private readonly debug;
|
|
46
|
+
private queue;
|
|
47
|
+
private flushTimer;
|
|
48
|
+
private inFlight;
|
|
49
|
+
private shutdownHandlers;
|
|
50
|
+
constructor(config: GuruluClientConfig);
|
|
51
|
+
get queueSize(): number;
|
|
52
|
+
/**
|
|
53
|
+
* Enqueue a server event. Auto-flushes synchronously when the queue reaches
|
|
54
|
+
* `batchSize`. Each event is stamped with a timestamp if missing so the
|
|
55
|
+
* idempotency key is stable across retries.
|
|
56
|
+
*/
|
|
57
|
+
track(event: ServerEvent): void;
|
|
58
|
+
/**
|
|
59
|
+
* POST all queued events in one batch. On 5xx / network error retries with
|
|
60
|
+
* exponential backoff (200ms, 600ms, 1800ms). On 4xx drops the batch and
|
|
61
|
+
* fires the dead-letter callback. Concurrent flush calls are serialized.
|
|
62
|
+
*/
|
|
63
|
+
flush(): Promise<void>;
|
|
64
|
+
private sendBatch;
|
|
65
|
+
/**
|
|
66
|
+
* Stop the periodic flush timer, detach process listeners, and perform a
|
|
67
|
+
* final flush. Safe to call multiple times.
|
|
68
|
+
*/
|
|
69
|
+
shutdown(): Promise<void>;
|
|
70
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @gurulu/node — server-side SDK client.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities (Phase 10 W4.2):
|
|
6
|
+
* - Queue + batch server events.
|
|
7
|
+
* - Auto-flush at batchSize boundary and on a periodic timer.
|
|
8
|
+
* - Exponential-backoff retry (200ms, 600ms, 1800ms — 3 attempts total) on
|
|
9
|
+
* 5xx / network errors. No retry on 4xx.
|
|
10
|
+
* - Attach deterministic Idempotency-Key headers so the server can dedupe
|
|
11
|
+
* replayed batches across retries.
|
|
12
|
+
* - Dead-letter callback for batches that exhaust retries or are rejected
|
|
13
|
+
* with a client error.
|
|
14
|
+
* - Flush on process.beforeExit + SIGTERM.
|
|
15
|
+
*
|
|
16
|
+
* Event shapes are pulled from @gurulu/shared-core (W1.2). Only the transport
|
|
17
|
+
* config (`GuruluClientConfig`) is local.
|
|
18
|
+
*
|
|
19
|
+
* Related docs: PHASE-10-ROADMAP.md §W4.2
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.Gurulu = void 0;
|
|
23
|
+
exports.createIdempotencyKey = createIdempotencyKey;
|
|
24
|
+
const crypto_1 = require("crypto");
|
|
25
|
+
const DEFAULT_ENDPOINT = 'https://app.gurulu.io/api/ingest/v1/server';
|
|
26
|
+
const DEFAULT_FLUSH_INTERVAL = 5000;
|
|
27
|
+
const DEFAULT_BATCH_SIZE = 50;
|
|
28
|
+
const DEFAULT_TIMEOUT = 10000;
|
|
29
|
+
const RETRY_DELAYS_MS = [200, 600, 1800];
|
|
30
|
+
/**
|
|
31
|
+
* Deterministic idempotency key for a server event. Hashes the tuple
|
|
32
|
+
* (site_id, event_name, timestamp, anonymous_id/user_id) so replayed events
|
|
33
|
+
* across retries produce the same key and the server can dedupe.
|
|
34
|
+
*/
|
|
35
|
+
function createIdempotencyKey(event, siteId) {
|
|
36
|
+
const parts = [
|
|
37
|
+
siteId,
|
|
38
|
+
event.event_name ?? '',
|
|
39
|
+
event.timestamp ?? '',
|
|
40
|
+
event.user_id ?? '',
|
|
41
|
+
];
|
|
42
|
+
return (0, crypto_1.createHash)('sha256').update(parts.join('|')).digest('hex');
|
|
43
|
+
}
|
|
44
|
+
class Gurulu {
|
|
45
|
+
siteId;
|
|
46
|
+
apiKey;
|
|
47
|
+
endpoint;
|
|
48
|
+
flushInterval;
|
|
49
|
+
batchSize;
|
|
50
|
+
timeout;
|
|
51
|
+
onError;
|
|
52
|
+
onDeadLetter;
|
|
53
|
+
fetchImpl;
|
|
54
|
+
debug;
|
|
55
|
+
queue = [];
|
|
56
|
+
flushTimer = null;
|
|
57
|
+
inFlight = null;
|
|
58
|
+
shutdownHandlers = [];
|
|
59
|
+
constructor(config) {
|
|
60
|
+
if (!config.siteId)
|
|
61
|
+
throw new Error('siteId is required');
|
|
62
|
+
if (!config.apiKey)
|
|
63
|
+
throw new Error('apiKey is required');
|
|
64
|
+
this.siteId = config.siteId;
|
|
65
|
+
this.apiKey = config.apiKey;
|
|
66
|
+
this.endpoint = config.endpoint || DEFAULT_ENDPOINT;
|
|
67
|
+
this.flushInterval = config.flushInterval ?? DEFAULT_FLUSH_INTERVAL;
|
|
68
|
+
this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
69
|
+
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
|
70
|
+
this.onError = config.onError;
|
|
71
|
+
this.onDeadLetter = config.onDeadLetter;
|
|
72
|
+
this.debug = config.debug ?? false;
|
|
73
|
+
const injected = config.fetchImpl;
|
|
74
|
+
if (injected) {
|
|
75
|
+
this.fetchImpl = injected;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const g = globalThis;
|
|
79
|
+
if (typeof g.fetch !== 'function') {
|
|
80
|
+
throw new Error('global fetch is not available; pass fetchImpl in config (Node 18+ required)');
|
|
81
|
+
}
|
|
82
|
+
this.fetchImpl = g.fetch.bind(globalThis);
|
|
83
|
+
}
|
|
84
|
+
if (this.flushInterval > 0) {
|
|
85
|
+
this.flushTimer = setInterval(() => {
|
|
86
|
+
// Fire and forget; errors surface via onError.
|
|
87
|
+
void this.flush();
|
|
88
|
+
}, this.flushInterval);
|
|
89
|
+
// Let the process exit even if the timer is pending.
|
|
90
|
+
const t = this.flushTimer;
|
|
91
|
+
if (typeof t.unref === 'function')
|
|
92
|
+
t.unref();
|
|
93
|
+
}
|
|
94
|
+
if (!config.disableAutoShutdown && typeof process !== 'undefined' && typeof process.on === 'function') {
|
|
95
|
+
const beforeExit = () => {
|
|
96
|
+
void this.flush();
|
|
97
|
+
};
|
|
98
|
+
const onSigterm = () => {
|
|
99
|
+
void this.shutdown();
|
|
100
|
+
};
|
|
101
|
+
process.on('beforeExit', beforeExit);
|
|
102
|
+
process.on('SIGTERM', onSigterm);
|
|
103
|
+
this.shutdownHandlers.push(() => {
|
|
104
|
+
process.off('beforeExit', beforeExit);
|
|
105
|
+
process.off('SIGTERM', onSigterm);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
get queueSize() {
|
|
110
|
+
return this.queue.length;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Enqueue a server event. Auto-flushes synchronously when the queue reaches
|
|
114
|
+
* `batchSize`. Each event is stamped with a timestamp if missing so the
|
|
115
|
+
* idempotency key is stable across retries.
|
|
116
|
+
*/
|
|
117
|
+
track(event) {
|
|
118
|
+
if (!event || !event.event_name) {
|
|
119
|
+
if (this.debug)
|
|
120
|
+
console.warn('[gurulu] event_name is required');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!event.user_id) {
|
|
124
|
+
if (this.debug)
|
|
125
|
+
console.warn('[gurulu] user_id is required for server events');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const stamped = {
|
|
129
|
+
...event,
|
|
130
|
+
timestamp: event.timestamp ?? new Date().toISOString(),
|
|
131
|
+
};
|
|
132
|
+
this.queue.push(stamped);
|
|
133
|
+
if (this.debug) {
|
|
134
|
+
console.log(`[gurulu] queued: ${stamped.event_name} (${this.queue.length}/${this.batchSize})`);
|
|
135
|
+
}
|
|
136
|
+
if (this.queue.length >= this.batchSize) {
|
|
137
|
+
void this.flush();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* POST all queued events in one batch. On 5xx / network error retries with
|
|
142
|
+
* exponential backoff (200ms, 600ms, 1800ms). On 4xx drops the batch and
|
|
143
|
+
* fires the dead-letter callback. Concurrent flush calls are serialized.
|
|
144
|
+
*/
|
|
145
|
+
async flush() {
|
|
146
|
+
// Serialize concurrent flushes so retries don't interleave.
|
|
147
|
+
if (this.inFlight) {
|
|
148
|
+
await this.inFlight;
|
|
149
|
+
}
|
|
150
|
+
if (this.queue.length === 0)
|
|
151
|
+
return;
|
|
152
|
+
const batch = this.queue.splice(0, this.queue.length);
|
|
153
|
+
const run = this.sendBatch(batch);
|
|
154
|
+
this.inFlight = run.finally(() => {
|
|
155
|
+
this.inFlight = null;
|
|
156
|
+
});
|
|
157
|
+
await this.inFlight;
|
|
158
|
+
}
|
|
159
|
+
async sendBatch(batch) {
|
|
160
|
+
// Idempotency key for the whole batch — hashed over individual event keys.
|
|
161
|
+
const perEventKeys = batch.map((e) => createIdempotencyKey(e, this.siteId));
|
|
162
|
+
const batchKey = (0, crypto_1.createHash)('sha256').update(perEventKeys.join('|')).digest('hex');
|
|
163
|
+
const body = JSON.stringify({
|
|
164
|
+
site_id: this.siteId,
|
|
165
|
+
events: batch,
|
|
166
|
+
});
|
|
167
|
+
const headers = {
|
|
168
|
+
'Content-Type': 'application/json',
|
|
169
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
170
|
+
'Idempotency-Key': batchKey,
|
|
171
|
+
};
|
|
172
|
+
let lastError = null;
|
|
173
|
+
for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) {
|
|
174
|
+
try {
|
|
175
|
+
const res = await this.fetchImpl(this.endpoint, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers,
|
|
178
|
+
body,
|
|
179
|
+
});
|
|
180
|
+
if (res.ok) {
|
|
181
|
+
if (this.debug)
|
|
182
|
+
console.log(`[gurulu] flushed ${batch.length} events`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (res.status >= 400 && res.status < 500) {
|
|
186
|
+
// Client error — don't retry. Drop + dead-letter.
|
|
187
|
+
const text = await safeText(res);
|
|
188
|
+
const err = new Error(`client_error ${res.status}: ${text}`);
|
|
189
|
+
if (this.debug)
|
|
190
|
+
console.error(`[gurulu] ${err.message}`);
|
|
191
|
+
this.onError?.(err);
|
|
192
|
+
this.onDeadLetter?.(batch, {
|
|
193
|
+
kind: 'client_error',
|
|
194
|
+
status: res.status,
|
|
195
|
+
message: text,
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
lastError = new Error(`server_error ${res.status}`);
|
|
200
|
+
this.onError?.(lastError);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
204
|
+
this.onError?.(lastError);
|
|
205
|
+
}
|
|
206
|
+
// Not the last attempt — sleep with the fixed exponential schedule.
|
|
207
|
+
if (attempt < RETRY_DELAYS_MS.length - 1) {
|
|
208
|
+
await sleep(RETRY_DELAYS_MS[attempt + 1 - 1]);
|
|
209
|
+
// ^ Index arithmetic: attempt=0 -> use RETRY_DELAYS_MS[0]=200, etc.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// All 3 attempts failed.
|
|
213
|
+
if (this.debug) {
|
|
214
|
+
console.error(`[gurulu] dropping batch of ${batch.length} after retries: ${lastError?.message}`);
|
|
215
|
+
}
|
|
216
|
+
if (this.onDeadLetter) {
|
|
217
|
+
this.onDeadLetter(batch, {
|
|
218
|
+
kind: lastError?.message.startsWith('server_error') ? 'server_error' : 'network_error',
|
|
219
|
+
message: lastError?.message ?? 'unknown error',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
else if (this.debug) {
|
|
223
|
+
console.error('[gurulu] no dead-letter callback configured; events lost');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Stop the periodic flush timer, detach process listeners, and perform a
|
|
228
|
+
* final flush. Safe to call multiple times.
|
|
229
|
+
*/
|
|
230
|
+
async shutdown() {
|
|
231
|
+
if (this.flushTimer) {
|
|
232
|
+
clearInterval(this.flushTimer);
|
|
233
|
+
this.flushTimer = null;
|
|
234
|
+
}
|
|
235
|
+
for (const detach of this.shutdownHandlers.splice(0)) {
|
|
236
|
+
try {
|
|
237
|
+
detach();
|
|
238
|
+
}
|
|
239
|
+
catch { /* best-effort */ }
|
|
240
|
+
}
|
|
241
|
+
await this.flush();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
exports.Gurulu = Gurulu;
|
|
245
|
+
async function safeText(res) {
|
|
246
|
+
try {
|
|
247
|
+
return await res.text();
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return '';
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function sleep(ms) {
|
|
254
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
255
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gurulu/node — public entry point.
|
|
3
|
+
*
|
|
4
|
+
* Phase 10 W4.2: server SDK hardening adds batch+retry, deterministic
|
|
5
|
+
* idempotency keys, dead-letter callbacks, and business event emitters.
|
|
6
|
+
*/
|
|
7
|
+
export { Gurulu, createIdempotencyKey } from './client';
|
|
8
|
+
export type { GuruluClient } from './client';
|
|
9
|
+
export type { GuruluClientConfig, DeadLetterCallback, ErrorCallback, } from './types';
|
|
10
|
+
export type { Envelope, ServerEvent, EventTier, EventSource, ConsentLevel, } from '@gurulu/shared-core';
|
|
11
|
+
export { userCreated, paymentSucceeded, subscriptionStarted, orderPlaced, leadCaptured, } from './business-events';
|
|
12
|
+
export type { BusinessEvent, UserCreatedArgs, PaymentSucceededArgs, SubscriptionStartedArgs, OrderPlacedArgs, LeadCapturedArgs, } from './business-events';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @gurulu/node — public entry point.
|
|
4
|
+
*
|
|
5
|
+
* Phase 10 W4.2: server SDK hardening adds batch+retry, deterministic
|
|
6
|
+
* idempotency keys, dead-letter callbacks, and business event emitters.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.leadCaptured = exports.orderPlaced = exports.subscriptionStarted = exports.paymentSucceeded = exports.userCreated = exports.createIdempotencyKey = exports.Gurulu = void 0;
|
|
10
|
+
var client_1 = require("./client");
|
|
11
|
+
Object.defineProperty(exports, "Gurulu", { enumerable: true, get: function () { return client_1.Gurulu; } });
|
|
12
|
+
Object.defineProperty(exports, "createIdempotencyKey", { enumerable: true, get: function () { return client_1.createIdempotencyKey; } });
|
|
13
|
+
// Business event emitters (W4.2).
|
|
14
|
+
var business_events_1 = require("./business-events");
|
|
15
|
+
Object.defineProperty(exports, "userCreated", { enumerable: true, get: function () { return business_events_1.userCreated; } });
|
|
16
|
+
Object.defineProperty(exports, "paymentSucceeded", { enumerable: true, get: function () { return business_events_1.paymentSucceeded; } });
|
|
17
|
+
Object.defineProperty(exports, "subscriptionStarted", { enumerable: true, get: function () { return business_events_1.subscriptionStarted; } });
|
|
18
|
+
Object.defineProperty(exports, "orderPlaced", { enumerable: true, get: function () { return business_events_1.orderPlaced; } });
|
|
19
|
+
Object.defineProperty(exports, "leadCaptured", { enumerable: true, get: function () { return business_events_1.leadCaptured; } });
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node SDK public types.
|
|
3
|
+
*
|
|
4
|
+
* Canonical event shapes (Envelope, ServerEvent, EventTier, EventSource,
|
|
5
|
+
* ConsentLevel, createEnvelope, normalizeLegacy, parseEnvelope) are re-exported
|
|
6
|
+
* from @gurulu/shared-core — single source of truth per W1.2.
|
|
7
|
+
*
|
|
8
|
+
* The only node-sdk-specific addition is the transport config
|
|
9
|
+
* (GuruluClientConfig) which wraps the shared ServerEvent with the knobs
|
|
10
|
+
* needed to actually ship events to the ingest endpoint.
|
|
11
|
+
*
|
|
12
|
+
* Related docs: PHASE-10-ROADMAP.md §W1.2, §W4.2
|
|
13
|
+
*/
|
|
14
|
+
import type { ServerEvent } from '@gurulu/shared-core';
|
|
15
|
+
export * from '@gurulu/shared-core';
|
|
16
|
+
/**
|
|
17
|
+
* Dead-letter callback fired when a batch is permanently dropped (either
|
|
18
|
+
* because all retries failed against a 5xx/network error, or because the
|
|
19
|
+
* server replied 4xx).
|
|
20
|
+
*/
|
|
21
|
+
export type DeadLetterCallback = (batch: ServerEvent[], reason: {
|
|
22
|
+
kind: 'client_error' | 'network_error' | 'server_error';
|
|
23
|
+
status?: number;
|
|
24
|
+
message: string;
|
|
25
|
+
}) => void;
|
|
26
|
+
export type ErrorCallback = (err: Error) => void;
|
|
27
|
+
export interface GuruluClientConfig {
|
|
28
|
+
siteId: string;
|
|
29
|
+
apiKey: string;
|
|
30
|
+
endpoint?: string;
|
|
31
|
+
/** Periodic flush interval in ms. Default 5000. */
|
|
32
|
+
flushInterval?: number;
|
|
33
|
+
/** Max events per batch. Auto-flushes when queue reaches this size. Default 50. */
|
|
34
|
+
batchSize?: number;
|
|
35
|
+
/** Per-request HTTP timeout in ms. Default 10000. */
|
|
36
|
+
timeout?: number;
|
|
37
|
+
/** Fired on every transport error (retryable or not). */
|
|
38
|
+
onError?: ErrorCallback;
|
|
39
|
+
/** Fired when a batch is permanently dropped. */
|
|
40
|
+
onDeadLetter?: DeadLetterCallback;
|
|
41
|
+
/** Inject fetch for testing (defaults to globalThis.fetch). */
|
|
42
|
+
fetchImpl?: typeof fetch;
|
|
43
|
+
/** Suppress automatic process.on('beforeExit'/'SIGTERM') hooks. */
|
|
44
|
+
disableAutoShutdown?: boolean;
|
|
45
|
+
debug?: boolean;
|
|
46
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Node SDK public types.
|
|
4
|
+
*
|
|
5
|
+
* Canonical event shapes (Envelope, ServerEvent, EventTier, EventSource,
|
|
6
|
+
* ConsentLevel, createEnvelope, normalizeLegacy, parseEnvelope) are re-exported
|
|
7
|
+
* from @gurulu/shared-core — single source of truth per W1.2.
|
|
8
|
+
*
|
|
9
|
+
* The only node-sdk-specific addition is the transport config
|
|
10
|
+
* (GuruluClientConfig) which wraps the shared ServerEvent with the knobs
|
|
11
|
+
* needed to actually ship events to the ingest endpoint.
|
|
12
|
+
*
|
|
13
|
+
* Related docs: PHASE-10-ROADMAP.md §W1.2, §W4.2
|
|
14
|
+
*/
|
|
15
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
18
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
19
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
20
|
+
}
|
|
21
|
+
Object.defineProperty(o, k2, desc);
|
|
22
|
+
}) : (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
o[k2] = m[k];
|
|
25
|
+
}));
|
|
26
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
27
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
28
|
+
};
|
|
29
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
__exportStar(require("@gurulu/shared-core"), exports);
|