@doow/track 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/CHANGELOG.md +33 -0
- package/README.md +222 -0
- package/dist/cjs/debug.js +19 -0
- package/dist/cjs/debug.js.map +1 -0
- package/dist/cjs/event-processor.js +147 -0
- package/dist/cjs/event-processor.js.map +1 -0
- package/dist/cjs/exporter.js +477 -0
- package/dist/cjs/exporter.js.map +1 -0
- package/dist/cjs/file-offline-store.js +101 -0
- package/dist/cjs/file-offline-store.js.map +1 -0
- package/dist/cjs/index.js +10 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/tracker.js +292 -0
- package/dist/cjs/tracker.js.map +1 -0
- package/dist/cjs/uuid.js +13 -0
- package/dist/cjs/uuid.js.map +1 -0
- package/dist/cli.cjs +1491 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/esm/debug.js +17 -0
- package/dist/esm/debug.js.map +1 -0
- package/dist/esm/event-processor.js +145 -0
- package/dist/esm/event-processor.js.map +1 -0
- package/dist/esm/exporter.js +475 -0
- package/dist/esm/exporter.js.map +1 -0
- package/dist/esm/file-offline-store.js +99 -0
- package/dist/esm/file-offline-store.js.map +1 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/tracker.js +290 -0
- package/dist/esm/tracker.js.map +1 -0
- package/dist/esm/uuid.js +11 -0
- package/dist/esm/uuid.js.map +1 -0
- package/dist/sidecar.cjs +1285 -0
- package/dist/sidecar.cjs.map +1 -0
- package/dist/types/index.d.ts +224 -0
- package/package.json +71 -0
package/dist/sidecar.cjs
ADDED
|
@@ -0,0 +1,1285 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var crypto = require('crypto');
|
|
5
|
+
var zlib = require('zlib');
|
|
6
|
+
var http = require('http');
|
|
7
|
+
var fs = require('fs');
|
|
8
|
+
var net = require('net');
|
|
9
|
+
|
|
10
|
+
function _interopNamespaceDefault(e) {
|
|
11
|
+
var n = Object.create(null);
|
|
12
|
+
if (e) {
|
|
13
|
+
Object.keys(e).forEach(function (k) {
|
|
14
|
+
if (k !== 'default') {
|
|
15
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
16
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
17
|
+
enumerable: true,
|
|
18
|
+
get: function () { return e[k]; }
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
n.default = e;
|
|
24
|
+
return Object.freeze(n);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
var http__namespace = /*#__PURE__*/_interopNamespaceDefault(http);
|
|
28
|
+
var net__namespace = /*#__PURE__*/_interopNamespaceDefault(net);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* UUID v4 generation — stdlib only, no dependencies.
|
|
32
|
+
*/
|
|
33
|
+
function generateUUID() {
|
|
34
|
+
return crypto.randomUUID();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createDebugLogger(enabled) {
|
|
38
|
+
if (!enabled) {
|
|
39
|
+
return { log: () => undefined, warn: () => undefined };
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
log: (msg, ...args) => {
|
|
43
|
+
// eslint-disable-next-line no-console
|
|
44
|
+
console.warn(`[doow/track] ${msg}`, ...args);
|
|
45
|
+
},
|
|
46
|
+
warn: (msg, ...args) => {
|
|
47
|
+
console.warn(`[doow/track:warn] ${msg}`, ...args);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* S78: Exporter — HTTP transport + compression + retry
|
|
54
|
+
*
|
|
55
|
+
* Handles the *how* of delivery:
|
|
56
|
+
* - POST to {endpoint}/telemetry/events with gzip
|
|
57
|
+
* - Exponential backoff with ±20% jitter, max retryCount retries
|
|
58
|
+
* - 429 respects Retry-After header
|
|
59
|
+
* - 413 adaptive batch halving
|
|
60
|
+
* - Serialized flushes (one in-flight at a time)
|
|
61
|
+
* - Custom transport for testing/mTLS
|
|
62
|
+
* - onError callback — SDK never throws
|
|
63
|
+
*/
|
|
64
|
+
async function gzipBuffer(data) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const chunks = [];
|
|
67
|
+
const gz = zlib.createGzip();
|
|
68
|
+
gz.on('data', (chunk) => chunks.push(chunk));
|
|
69
|
+
gz.on('end', () => resolve(Buffer.concat(chunks)));
|
|
70
|
+
gz.on('error', reject);
|
|
71
|
+
gz.end(data);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const SDK_VERSION = "0.1.0";
|
|
75
|
+
function toWireEvent(event) {
|
|
76
|
+
var _a;
|
|
77
|
+
const sourceSystem = ((_a = event.source_system) === null || _a === void 0 ? void 0 : _a.trim()) ? event.source_system : 'sdk';
|
|
78
|
+
const metricTupleHint = event.metric_tuple_hint;
|
|
79
|
+
return {
|
|
80
|
+
...event,
|
|
81
|
+
occurred_at: event.timestamp,
|
|
82
|
+
source_system: sourceSystem,
|
|
83
|
+
...(metricTupleHint ? { metric_tuple_hint: metricTupleHint } : {}),
|
|
84
|
+
measurements: [
|
|
85
|
+
{
|
|
86
|
+
metric_name: event.metric,
|
|
87
|
+
quantity: event.quantity,
|
|
88
|
+
...(metricTupleHint ? { metric_tuple_hint: metricTupleHint } : {}),
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function toBatchPayload(batchId, events) {
|
|
94
|
+
return {
|
|
95
|
+
batch_id: batchId,
|
|
96
|
+
sdk_version: SDK_VERSION,
|
|
97
|
+
events: events.map(toWireEvent),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
class Exporter {
|
|
101
|
+
constructor(config) {
|
|
102
|
+
this._pendingFlush = null;
|
|
103
|
+
this._adaptiveBatchSize = null;
|
|
104
|
+
this._stopped = false;
|
|
105
|
+
this._concurrentCount = 0;
|
|
106
|
+
this._concurrentQueue = [];
|
|
107
|
+
/** S79: Per-category rate limit state. Key = category name, value = expiry timestamp in ms */
|
|
108
|
+
this._rateLimits = new Map();
|
|
109
|
+
this.config = config;
|
|
110
|
+
}
|
|
111
|
+
/** Serialized flush: one HTTP request in-flight at a time */
|
|
112
|
+
async flush(events) {
|
|
113
|
+
var _a;
|
|
114
|
+
this._evictExpiredLimits();
|
|
115
|
+
// S79: If any rate limit is currently active, hold events (don't drop)
|
|
116
|
+
if (this._isRateLimited()) {
|
|
117
|
+
this.config.debug.log('Rate limited — holding events, skipping flush');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Chain onto any pending flush
|
|
121
|
+
const chain = (_a = this._pendingFlush) !== null && _a !== void 0 ? _a : Promise.resolve();
|
|
122
|
+
let resolve;
|
|
123
|
+
const next = new Promise((r) => {
|
|
124
|
+
resolve = r;
|
|
125
|
+
});
|
|
126
|
+
this._pendingFlush = next;
|
|
127
|
+
await chain;
|
|
128
|
+
try {
|
|
129
|
+
// S80: Drain offline store FIFO before new events
|
|
130
|
+
await this._drainOfflineStore();
|
|
131
|
+
await this._doFlush(events);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
resolve();
|
|
135
|
+
if (this._pendingFlush === next) {
|
|
136
|
+
this._pendingFlush = null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Mark stopped — 401 auth failure */
|
|
141
|
+
stop() {
|
|
142
|
+
this._stopped = true;
|
|
143
|
+
}
|
|
144
|
+
get stopped() {
|
|
145
|
+
return this._stopped;
|
|
146
|
+
}
|
|
147
|
+
/** S79: True if any active rate limit has not yet expired */
|
|
148
|
+
get rateLimited() {
|
|
149
|
+
return this._isRateLimited();
|
|
150
|
+
}
|
|
151
|
+
/** Wait for any in-flight flush to complete */
|
|
152
|
+
async drain() {
|
|
153
|
+
if (this._pendingFlush) {
|
|
154
|
+
await this._pendingFlush;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// ─── S79: Rate limit helpers ──────────────────────────────────────────────
|
|
158
|
+
_isRateLimited() {
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
for (const [, entry] of this._rateLimits) {
|
|
161
|
+
if (now < entry.expiresAt)
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
_evictExpiredLimits() {
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
for (const [category, entry] of this._rateLimits) {
|
|
169
|
+
if (now >= entry.expiresAt) {
|
|
170
|
+
this._rateLimits.delete(category);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Parse and apply X-Doow-Rate-Limits header from a 429 response */
|
|
175
|
+
_applyRateLimitHeader(headers) {
|
|
176
|
+
var _a;
|
|
177
|
+
const raw = (_a = headers['x-doow-rate-limits']) !== null && _a !== void 0 ? _a : headers['X-Doow-Rate-Limits'];
|
|
178
|
+
if (!raw)
|
|
179
|
+
return;
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(raw);
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
for (const [category, entry] of Object.entries(parsed)) {
|
|
184
|
+
if (typeof entry === 'object' &&
|
|
185
|
+
entry !== null &&
|
|
186
|
+
typeof entry.retry_after === 'number' &&
|
|
187
|
+
entry.retry_after > 0) {
|
|
188
|
+
const expiresAt = now + entry.retry_after * 1000;
|
|
189
|
+
this._rateLimits.set(category, { expiresAt });
|
|
190
|
+
this.config.debug.log(`Rate limit set: ${category} expires in ${entry.retry_after}s`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Malformed header — ignore gracefully
|
|
196
|
+
this.config.debug.warn('Malformed X-Doow-Rate-Limits header — ignored');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// ─── S80: Offline store helpers ───────────────────────────────────────────
|
|
200
|
+
/** Drain the offline store FIFO before sending new events */
|
|
201
|
+
async _drainOfflineStore() {
|
|
202
|
+
const store = this.config.offlineStore;
|
|
203
|
+
if (!store)
|
|
204
|
+
return;
|
|
205
|
+
try {
|
|
206
|
+
const len = await store.length();
|
|
207
|
+
if (len === 0)
|
|
208
|
+
return;
|
|
209
|
+
this.config.debug.log(`Draining ${len} batches from offline store`);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// Drain FIFO — shift one batch at a time
|
|
215
|
+
let batch;
|
|
216
|
+
while ((batch = await store.shift()) !== undefined) {
|
|
217
|
+
try {
|
|
218
|
+
const payload = JSON.parse(batch.payload);
|
|
219
|
+
if (payload.events && payload.events.length > 0) {
|
|
220
|
+
await this._sendWithRetry(payload.events, this.config.retryCount, payload.batch_id);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
this.config.debug.warn(`Failed to replay offline batch ${batch.batch_id}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async _doFlush(events) {
|
|
229
|
+
var _a;
|
|
230
|
+
if (this._stopped || events.length === 0)
|
|
231
|
+
return;
|
|
232
|
+
const maxBatch = (_a = this._adaptiveBatchSize) !== null && _a !== void 0 ? _a : 500;
|
|
233
|
+
// Split into ≤500 event chunks
|
|
234
|
+
const chunks = [];
|
|
235
|
+
for (let i = 0; i < events.length; i += maxBatch) {
|
|
236
|
+
chunks.push(events.slice(i, i + maxBatch));
|
|
237
|
+
}
|
|
238
|
+
for (const chunk of chunks) {
|
|
239
|
+
await this._sendWithRetry(chunk, this.config.retryCount);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async _sendWithRetry(events, retriesLeft, batchId) {
|
|
243
|
+
var _a, _b;
|
|
244
|
+
if (this._stopped)
|
|
245
|
+
return;
|
|
246
|
+
const resolvedBatchId = batchId !== null && batchId !== void 0 ? batchId : generateUUID();
|
|
247
|
+
try {
|
|
248
|
+
await this._withConcurrencyLimit(() => this._sendOnce(events, resolvedBatchId));
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
const sdkErr = err;
|
|
252
|
+
if (sdkErr.statusCode === 401) {
|
|
253
|
+
// Auth failure — stop permanently
|
|
254
|
+
this._stopped = true;
|
|
255
|
+
this.config.onError({
|
|
256
|
+
kind: 'AUTH_FAILURE',
|
|
257
|
+
message: 'API key rejected (401). Stopping SDK.',
|
|
258
|
+
statusCode: 401,
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (sdkErr.statusCode === 413) {
|
|
263
|
+
// Adaptive recovery — halve batch
|
|
264
|
+
const half = Math.max(1, Math.floor(events.length / 2));
|
|
265
|
+
this._adaptiveBatchSize = half;
|
|
266
|
+
this.config.debug.warn(`413 received — adaptive batch size reduced to ${half}`);
|
|
267
|
+
const first = events.slice(0, half);
|
|
268
|
+
const second = events.slice(half);
|
|
269
|
+
// Retry each half with full retry budget
|
|
270
|
+
await this._sendWithRetry(first, this.config.retryCount, resolvedBatchId);
|
|
271
|
+
if (second.length > 0) {
|
|
272
|
+
await this._sendWithRetry(second, this.config.retryCount);
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (sdkErr.statusCode === 429) {
|
|
277
|
+
// S79: Apply rate limit header from the response if present
|
|
278
|
+
if (sdkErr.rateLimitHeaders) {
|
|
279
|
+
this._applyRateLimitHeader(sdkErr.rateLimitHeaders);
|
|
280
|
+
}
|
|
281
|
+
const retryAfterMs = (_a = sdkErr.retryAfterMs) !== null && _a !== void 0 ? _a : this._backoff(this.config.retryCount - retriesLeft);
|
|
282
|
+
this.config.debug.log(`429 rate limited — waiting ${retryAfterMs}ms`);
|
|
283
|
+
this.config.onError({
|
|
284
|
+
kind: 'RATE_LIMITED',
|
|
285
|
+
message: `Rate limited (429). Retry after ${retryAfterMs}ms.`,
|
|
286
|
+
statusCode: 429,
|
|
287
|
+
retryAfterMs,
|
|
288
|
+
});
|
|
289
|
+
if (retriesLeft <= 0)
|
|
290
|
+
return;
|
|
291
|
+
await this._sleep(retryAfterMs);
|
|
292
|
+
return await this._sendWithRetry(events, retriesLeft - 1, resolvedBatchId);
|
|
293
|
+
}
|
|
294
|
+
if (retriesLeft <= 0) {
|
|
295
|
+
const errPayload = {
|
|
296
|
+
kind: (_b = sdkErr.kind) !== null && _b !== void 0 ? _b : 'NETWORK_ERROR',
|
|
297
|
+
message: sdkErr.message,
|
|
298
|
+
rejectedEventIds: events.map((e) => e.event_id),
|
|
299
|
+
};
|
|
300
|
+
if (sdkErr.statusCode !== undefined)
|
|
301
|
+
errPayload.statusCode = sdkErr.statusCode;
|
|
302
|
+
if (sdkErr.cause instanceof Error)
|
|
303
|
+
errPayload.error = sdkErr.cause;
|
|
304
|
+
this.config.onError(errPayload);
|
|
305
|
+
// S80: Persist to offline store if configured
|
|
306
|
+
await this._persistToOfflineStore(resolvedBatchId, events);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Exponential backoff for non-429 retryable errors
|
|
310
|
+
const delay = this._backoff(this.config.retryCount - retriesLeft);
|
|
311
|
+
this.config.debug.log(`Retry in ${delay}ms, ${retriesLeft - 1} retries left`);
|
|
312
|
+
await this._sleep(delay);
|
|
313
|
+
await this._sendWithRetry(events, retriesLeft - 1, resolvedBatchId);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** S80: Persist a failed batch to the offline store */
|
|
317
|
+
async _persistToOfflineStore(batchId, events) {
|
|
318
|
+
const store = this.config.offlineStore;
|
|
319
|
+
if (!store)
|
|
320
|
+
return;
|
|
321
|
+
try {
|
|
322
|
+
const payload = toBatchPayload(batchId, events);
|
|
323
|
+
const serialized = {
|
|
324
|
+
batch_id: batchId,
|
|
325
|
+
payload: JSON.stringify(payload),
|
|
326
|
+
timestamp: new Date().toISOString(),
|
|
327
|
+
};
|
|
328
|
+
await store.push(serialized);
|
|
329
|
+
this.config.debug.log(`Persisted failed batch ${batchId} to offline store`);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
this.config.debug.warn(`Failed to persist batch ${batchId} to offline store`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async _sendOnce(events, batchId) {
|
|
336
|
+
var _a, _b, _c;
|
|
337
|
+
const payload = toBatchPayload(batchId, events);
|
|
338
|
+
const jsonBuffer = Buffer.from(JSON.stringify(payload));
|
|
339
|
+
let body;
|
|
340
|
+
const headers = {
|
|
341
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
342
|
+
'Content-Type': 'application/json',
|
|
343
|
+
'X-Doow-SDK-Version': SDK_VERSION,
|
|
344
|
+
};
|
|
345
|
+
if (this.config.disableCompression) {
|
|
346
|
+
body = jsonBuffer;
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
body = await gzipBuffer(jsonBuffer);
|
|
350
|
+
headers['Content-Encoding'] = 'gzip';
|
|
351
|
+
}
|
|
352
|
+
headers['Content-Length'] = String(body.length);
|
|
353
|
+
const url = `${this.config.endpoint}/telemetry/events`;
|
|
354
|
+
const transportPayload = { body, headers, url };
|
|
355
|
+
this.config.debug.log(`POST ${url} batch_id=${batchId} events=${events.length} bytes=${body.length}`);
|
|
356
|
+
let response;
|
|
357
|
+
try {
|
|
358
|
+
if (this.config.transport) {
|
|
359
|
+
response = await this._withTimeout(this.config.transport.send(transportPayload), this.config.timeout);
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
response = await this._withTimeout(this._defaultTransport(transportPayload), this.config.timeout);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
const e = err;
|
|
367
|
+
if (e.name === 'AbortError' || ((_a = e.message) === null || _a === void 0 ? void 0 : _a.includes('timeout'))) {
|
|
368
|
+
throw new SdkHttpError('TIMEOUT', `Request timed out after ${this.config.timeout}ms`, undefined, undefined, e);
|
|
369
|
+
}
|
|
370
|
+
throw new SdkHttpError('NETWORK_ERROR', `Network error: ${e.message}`, undefined, undefined, e);
|
|
371
|
+
}
|
|
372
|
+
this.config.debug.log(`Response ${response.status} batch_id=${batchId}`);
|
|
373
|
+
if (response.status === 202) {
|
|
374
|
+
// Success — reset adaptive batch size
|
|
375
|
+
this._adaptiveBatchSize = null;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (response.status === 207) {
|
|
379
|
+
// Partial accept
|
|
380
|
+
try {
|
|
381
|
+
const parsed = JSON.parse(response.body);
|
|
382
|
+
if (((_b = parsed.rejected) === null || _b === void 0 ? void 0 : _b.length) > 0) {
|
|
383
|
+
this.config.onError({
|
|
384
|
+
kind: 'PARTIAL_ACCEPT',
|
|
385
|
+
message: `Batch partially accepted — ${parsed.rejected.length} events rejected`,
|
|
386
|
+
statusCode: 207,
|
|
387
|
+
rejectedEventIds: parsed.rejected.map((r) => r.event_id),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// Non-JSON 207 — treat as success
|
|
393
|
+
}
|
|
394
|
+
this._adaptiveBatchSize = null;
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (response.status === 401) {
|
|
398
|
+
throw new SdkHttpError('AUTH_FAILURE', 'Unauthorized (401)', 401);
|
|
399
|
+
}
|
|
400
|
+
if (response.status === 413) {
|
|
401
|
+
throw new SdkHttpError('TRANSPORT_ERROR', 'Payload too large (413)', 413);
|
|
402
|
+
}
|
|
403
|
+
if (response.status === 429) {
|
|
404
|
+
const retryAfterMs = this._parseRetryAfter((_c = response.headers['retry-after']) !== null && _c !== void 0 ? _c : response.headers['Retry-After']);
|
|
405
|
+
throw new SdkHttpError('RATE_LIMITED', `Rate limited (429)`, 429, retryAfterMs, undefined, response.headers);
|
|
406
|
+
}
|
|
407
|
+
if (response.status >= 500) {
|
|
408
|
+
throw new SdkHttpError('TRANSPORT_ERROR', `Server error ${response.status}`, response.status);
|
|
409
|
+
}
|
|
410
|
+
// Other 4xx — don't retry
|
|
411
|
+
throw new SdkHttpError('TRANSPORT_ERROR', `HTTP ${response.status}`, response.status);
|
|
412
|
+
}
|
|
413
|
+
async _defaultTransport(payload) {
|
|
414
|
+
const { default: https } = await import('https');
|
|
415
|
+
const { default: http } = await import('http');
|
|
416
|
+
return new Promise((resolve, reject) => {
|
|
417
|
+
const url = new URL(payload.url);
|
|
418
|
+
const isHttps = url.protocol === 'https:';
|
|
419
|
+
const client = isHttps ? https : http;
|
|
420
|
+
const req = client.request({
|
|
421
|
+
hostname: url.hostname,
|
|
422
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
423
|
+
path: url.pathname + url.search,
|
|
424
|
+
method: 'POST',
|
|
425
|
+
headers: payload.headers,
|
|
426
|
+
}, (res) => {
|
|
427
|
+
const chunks = [];
|
|
428
|
+
let totalBytes = 0;
|
|
429
|
+
const maxBodyBytes = 1024 * 1024; // 1 MB cap
|
|
430
|
+
res.on('data', (chunk) => {
|
|
431
|
+
totalBytes += chunk.length;
|
|
432
|
+
if (totalBytes <= maxBodyBytes)
|
|
433
|
+
chunks.push(chunk);
|
|
434
|
+
});
|
|
435
|
+
res.on('end', () => {
|
|
436
|
+
var _a, _b;
|
|
437
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
438
|
+
const headers = {};
|
|
439
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
440
|
+
if (typeof v === 'string')
|
|
441
|
+
headers[k] = v;
|
|
442
|
+
else if (Array.isArray(v))
|
|
443
|
+
headers[k] = (_a = v[0]) !== null && _a !== void 0 ? _a : '';
|
|
444
|
+
}
|
|
445
|
+
resolve({ status: (_b = res.statusCode) !== null && _b !== void 0 ? _b : 0, headers, body });
|
|
446
|
+
});
|
|
447
|
+
res.on('error', reject);
|
|
448
|
+
});
|
|
449
|
+
req.on('error', reject);
|
|
450
|
+
req.write(payload.body);
|
|
451
|
+
req.end();
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
async _withTimeout(promise, timeoutMs) {
|
|
455
|
+
return new Promise((resolve, reject) => {
|
|
456
|
+
const timer = setTimeout(() => {
|
|
457
|
+
const err = new Error(`Timeout after ${timeoutMs}ms`);
|
|
458
|
+
err.name = 'AbortError';
|
|
459
|
+
reject(err);
|
|
460
|
+
}, timeoutMs);
|
|
461
|
+
promise.then((v) => {
|
|
462
|
+
clearTimeout(timer);
|
|
463
|
+
resolve(v);
|
|
464
|
+
}, (e) => {
|
|
465
|
+
clearTimeout(timer);
|
|
466
|
+
reject(e);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
async _withConcurrencyLimit(fn) {
|
|
471
|
+
const max = this.config.maxConcurrentFlushes;
|
|
472
|
+
if (this._concurrentCount >= max) {
|
|
473
|
+
// Queue and wait
|
|
474
|
+
await new Promise((resolve) => this._concurrentQueue.push(resolve));
|
|
475
|
+
}
|
|
476
|
+
this._concurrentCount++;
|
|
477
|
+
try {
|
|
478
|
+
return await fn();
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
this._concurrentCount--;
|
|
482
|
+
const next = this._concurrentQueue.shift();
|
|
483
|
+
if (next)
|
|
484
|
+
next();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/** Exponential backoff with ±20% jitter */
|
|
488
|
+
_backoff(attempt) {
|
|
489
|
+
const base = Math.min(1000 * Math.pow(2, attempt), 16000);
|
|
490
|
+
const jitter = base * 0.2 * (Math.random() * 2 - 1);
|
|
491
|
+
return Math.round(base + jitter);
|
|
492
|
+
}
|
|
493
|
+
_parseRetryAfter(header) {
|
|
494
|
+
if (!header)
|
|
495
|
+
return undefined;
|
|
496
|
+
const seconds = parseFloat(header);
|
|
497
|
+
if (!isNaN(seconds))
|
|
498
|
+
return Math.round(seconds * 1000);
|
|
499
|
+
const date = new Date(header).getTime();
|
|
500
|
+
if (!isNaN(date))
|
|
501
|
+
return Math.max(0, date - Date.now());
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
_sleep(ms) {
|
|
505
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
class SdkHttpError extends Error {
|
|
509
|
+
constructor(kind, message, statusCode, retryAfterMs, cause,
|
|
510
|
+
/** S79: Response headers from the 429 response — used to parse X-Doow-Rate-Limits */
|
|
511
|
+
rateLimitHeaders) {
|
|
512
|
+
super(message);
|
|
513
|
+
this.kind = kind;
|
|
514
|
+
this.statusCode = statusCode;
|
|
515
|
+
this.retryAfterMs = retryAfterMs;
|
|
516
|
+
this.cause = cause;
|
|
517
|
+
this.rateLimitHeaders = rateLimitHeaders;
|
|
518
|
+
this.name = 'SdkHttpError';
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
class EventProcessor {
|
|
523
|
+
constructor(config, exporter) {
|
|
524
|
+
this._queue = [];
|
|
525
|
+
this._timer = null;
|
|
526
|
+
this._hasFlushed = false;
|
|
527
|
+
this._currentBytes = 0;
|
|
528
|
+
/** Track pending async enqueue promises so shutdown can wait for them */
|
|
529
|
+
this._pendingEnqueues = new Set();
|
|
530
|
+
this._config = config;
|
|
531
|
+
this._exporter = exporter;
|
|
532
|
+
}
|
|
533
|
+
/** Enqueue an event. Applies beforeSend hook, ring buffer eviction, flush triggers. */
|
|
534
|
+
enqueue(event) {
|
|
535
|
+
const p = this._enqueueAsync(event);
|
|
536
|
+
this._pendingEnqueues.add(p);
|
|
537
|
+
void p.finally(() => this._pendingEnqueues.delete(p));
|
|
538
|
+
return p;
|
|
539
|
+
}
|
|
540
|
+
async _enqueueAsync(event) {
|
|
541
|
+
// Apply beforeSend hook
|
|
542
|
+
const processed = await this._applyBeforeSend(event);
|
|
543
|
+
if (processed === null) {
|
|
544
|
+
this._config.debug.log(`beforeSend dropped event ${event.event_id}`);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
// Ring buffer: evict oldest if at capacity
|
|
548
|
+
if (this._queue.length >= this._config.maxQueueSize) {
|
|
549
|
+
const evicted = this._queue.shift();
|
|
550
|
+
if (evicted) {
|
|
551
|
+
this._currentBytes -= this._eventBytes(evicted);
|
|
552
|
+
this._config.debug.warn(`Ring buffer full — evicted oldest event ${evicted.event_id}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
this._queue.push(processed);
|
|
556
|
+
this._currentBytes += this._eventBytes(processed);
|
|
557
|
+
// First-event fast path
|
|
558
|
+
if (!this._hasFlushed) {
|
|
559
|
+
this._config.debug.log(`First event — immediate flush`);
|
|
560
|
+
await this._triggerFlush();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// Count threshold
|
|
564
|
+
if (this._queue.length >= this._config.flushAt) {
|
|
565
|
+
this._config.debug.log(`Count threshold (${this._config.flushAt}) reached — flushing`);
|
|
566
|
+
await this._triggerFlush();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
// Byte threshold
|
|
570
|
+
if (this._currentBytes >= this._config.maxPayloadBytes) {
|
|
571
|
+
this._config.debug.log(`Byte threshold (${this._config.maxPayloadBytes}) reached — flushing`);
|
|
572
|
+
await this._triggerFlush();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
// Ensure timer is running
|
|
576
|
+
this._ensureTimer();
|
|
577
|
+
}
|
|
578
|
+
/** Manual flush — waits for pending enqueues, then flushes buffer */
|
|
579
|
+
async flush() {
|
|
580
|
+
if (this._pendingEnqueues.size > 0) {
|
|
581
|
+
await Promise.all([...this._pendingEnqueues]);
|
|
582
|
+
}
|
|
583
|
+
await this._triggerFlush();
|
|
584
|
+
}
|
|
585
|
+
/** Flush and stop timer */
|
|
586
|
+
async shutdown() {
|
|
587
|
+
this._stopTimer();
|
|
588
|
+
if (this._pendingEnqueues.size > 0) {
|
|
589
|
+
await Promise.all([...this._pendingEnqueues]);
|
|
590
|
+
}
|
|
591
|
+
await this._triggerFlush();
|
|
592
|
+
await this._exporter.drain();
|
|
593
|
+
}
|
|
594
|
+
/** Stop the interval timer */
|
|
595
|
+
_stopTimer() {
|
|
596
|
+
if (this._timer !== null) {
|
|
597
|
+
clearTimeout(this._timer);
|
|
598
|
+
this._timer = null;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
_ensureTimer() {
|
|
602
|
+
if (this._timer !== null)
|
|
603
|
+
return;
|
|
604
|
+
this._timer = setTimeout(() => {
|
|
605
|
+
this._timer = null;
|
|
606
|
+
this._config.debug.log(`Timer flush after ${this._config.flushInterval}ms`);
|
|
607
|
+
void this._triggerFlush();
|
|
608
|
+
}, this._config.flushInterval);
|
|
609
|
+
}
|
|
610
|
+
async _triggerFlush() {
|
|
611
|
+
this._stopTimer();
|
|
612
|
+
if (this._queue.length === 0)
|
|
613
|
+
return;
|
|
614
|
+
// Drain the queue
|
|
615
|
+
const batch = this._queue.splice(0, this._queue.length);
|
|
616
|
+
this._currentBytes = 0;
|
|
617
|
+
this._hasFlushed = true;
|
|
618
|
+
// Apply beforeFlush hook
|
|
619
|
+
const finalBatch = await this._applyBeforeFlush(batch);
|
|
620
|
+
if (finalBatch === null || finalBatch.length === 0) {
|
|
621
|
+
this._config.debug.log(`beforeFlush dropped batch of ${batch.length} events`);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
await this._exporter.flush(finalBatch);
|
|
625
|
+
}
|
|
626
|
+
async _applyBeforeSend(event) {
|
|
627
|
+
if (!this._config.beforeSend)
|
|
628
|
+
return event;
|
|
629
|
+
try {
|
|
630
|
+
return await this._config.beforeSend(event);
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
this._config.debug.warn(`beforeSend threw: ${String(err)}`);
|
|
634
|
+
return event; // fail-open
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
async _applyBeforeFlush(batch) {
|
|
638
|
+
if (!this._config.beforeFlush)
|
|
639
|
+
return batch;
|
|
640
|
+
try {
|
|
641
|
+
return await this._config.beforeFlush(batch);
|
|
642
|
+
}
|
|
643
|
+
catch (err) {
|
|
644
|
+
this._config.debug.warn(`beforeFlush threw: ${String(err)}`);
|
|
645
|
+
return batch; // fail-open
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
_eventBytes(event) {
|
|
649
|
+
return Buffer.byteLength(JSON.stringify(event), 'utf8');
|
|
650
|
+
}
|
|
651
|
+
/** Expose queue length for testing */
|
|
652
|
+
get queueLength() {
|
|
653
|
+
return this._queue.length;
|
|
654
|
+
}
|
|
655
|
+
/** Expose hasFlushed for testing */
|
|
656
|
+
get hasFlushed() {
|
|
657
|
+
return this._hasFlushed;
|
|
658
|
+
}
|
|
659
|
+
/** Override flushAt threshold (used by serverless wrappers to force flushAt=1) */
|
|
660
|
+
setFlushAt(n) {
|
|
661
|
+
this._config.flushAt = n;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* S76: DoowTracker — public API surface
|
|
667
|
+
*
|
|
668
|
+
* The customer-facing class. Four lines to start emitting:
|
|
669
|
+
*
|
|
670
|
+
* import { DoowTracker } from '@doow/track';
|
|
671
|
+
* const meter = new DoowTracker('dk_...');
|
|
672
|
+
* meter.track({ metric: 'api_calls', quantity: 1, license_id: '...' });
|
|
673
|
+
* await meter.shutdown();
|
|
674
|
+
*/
|
|
675
|
+
// ─── Defaults ──────────────────────────────────────────────────────────────
|
|
676
|
+
const DEFAULTS = {
|
|
677
|
+
endpoint: 'https://api.doow.co',
|
|
678
|
+
enabled: true,
|
|
679
|
+
debug: false,
|
|
680
|
+
flushAt: 20,
|
|
681
|
+
flushInterval: 10000,
|
|
682
|
+
maxPayloadBytes: 450 * 1024,
|
|
683
|
+
maxQueueSize: 10000,
|
|
684
|
+
timeout: 10000,
|
|
685
|
+
retryCount: 3,
|
|
686
|
+
disableCompression: false,
|
|
687
|
+
maxConcurrentFlushes: 30,
|
|
688
|
+
shutdownTimeout: 5000,
|
|
689
|
+
};
|
|
690
|
+
// ─── Env var parsing ───────────────────────────────────────────────────────
|
|
691
|
+
function resolveEnvOverrides(opts) {
|
|
692
|
+
const env = typeof process !== 'undefined' ? process.env : undefined;
|
|
693
|
+
// Spread opts without creating explicit undefined keys that would override DEFAULTS
|
|
694
|
+
const result = { ...opts };
|
|
695
|
+
if (env === null || env === void 0 ? void 0 : env.DOOW_TRACK_ENDPOINT)
|
|
696
|
+
result.endpoint = env.DOOW_TRACK_ENDPOINT;
|
|
697
|
+
if ((env === null || env === void 0 ? void 0 : env.DOOW_TRACK_DISABLED) === 'true')
|
|
698
|
+
result.enabled = false;
|
|
699
|
+
if ((env === null || env === void 0 ? void 0 : env.DOOW_TRACK_DEBUG) === 'true')
|
|
700
|
+
result.debug = true;
|
|
701
|
+
if ((env === null || env === void 0 ? void 0 : env.DOOW_TRACK_FLUSH_AT) !== undefined) {
|
|
702
|
+
const n = parseInt(env.DOOW_TRACK_FLUSH_AT, 10);
|
|
703
|
+
if (!isNaN(n) && n > 0)
|
|
704
|
+
result.flushAt = n;
|
|
705
|
+
}
|
|
706
|
+
if ((env === null || env === void 0 ? void 0 : env.DOOW_TRACK_FLUSH_INTERVAL) !== undefined) {
|
|
707
|
+
const n = parseInt(env.DOOW_TRACK_FLUSH_INTERVAL, 10);
|
|
708
|
+
if (!isNaN(n) && n > 0)
|
|
709
|
+
result.flushInterval = n;
|
|
710
|
+
}
|
|
711
|
+
if ((env === null || env === void 0 ? void 0 : env.DOOW_TRACK_ATTRIBUTION) !== undefined) {
|
|
712
|
+
try {
|
|
713
|
+
result.attribution = JSON.parse(env.DOOW_TRACK_ATTRIBUTION);
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
/* keep opts.attribution */
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return result;
|
|
720
|
+
}
|
|
721
|
+
// ─── DoowTracker ───────────────────────────────────────────────────────────
|
|
722
|
+
class DoowTracker {
|
|
723
|
+
constructor(apiKey, options = {}) {
|
|
724
|
+
var _a, _b, _c;
|
|
725
|
+
this._shutdownCalled = false;
|
|
726
|
+
this._sigHandlers = null;
|
|
727
|
+
// Validate API key prefix
|
|
728
|
+
const envApiKey = typeof process !== 'undefined' ? ((_a = process.env['DOOW_TRACK_API_KEY']) !== null && _a !== void 0 ? _a : apiKey) : apiKey;
|
|
729
|
+
if (!envApiKey.startsWith('dk_')) {
|
|
730
|
+
console.warn(`[doow/track] API key must start with "dk_". Got: "${envApiKey.slice(0, 10)}..."`);
|
|
731
|
+
}
|
|
732
|
+
this._apiKey = envApiKey;
|
|
733
|
+
// Merge options with env var overrides (env takes precedence)
|
|
734
|
+
const merged = resolveEnvOverrides(options);
|
|
735
|
+
// Log unknown options in debug mode
|
|
736
|
+
const knownKeys = new Set([
|
|
737
|
+
'endpoint',
|
|
738
|
+
'enabled',
|
|
739
|
+
'attribution',
|
|
740
|
+
'debug',
|
|
741
|
+
'flushAt',
|
|
742
|
+
'flushInterval',
|
|
743
|
+
'maxPayloadBytes',
|
|
744
|
+
'maxQueueSize',
|
|
745
|
+
'timeout',
|
|
746
|
+
'retryCount',
|
|
747
|
+
'disableCompression',
|
|
748
|
+
'onError',
|
|
749
|
+
'beforeSend',
|
|
750
|
+
'beforeFlush',
|
|
751
|
+
'transport',
|
|
752
|
+
'offlineStore',
|
|
753
|
+
'maxConcurrentFlushes',
|
|
754
|
+
'shutdownTimeout',
|
|
755
|
+
]);
|
|
756
|
+
this._options = {
|
|
757
|
+
...DEFAULTS,
|
|
758
|
+
...merged,
|
|
759
|
+
};
|
|
760
|
+
const unknownKeys = Object.keys(options).filter((k) => !knownKeys.has(k));
|
|
761
|
+
const debugLogger = createDebugLogger((_b = this._options.debug) !== null && _b !== void 0 ? _b : false);
|
|
762
|
+
if (unknownKeys.length > 0) {
|
|
763
|
+
debugLogger.warn(`Unknown init options ignored: ${unknownKeys.join(', ')}`);
|
|
764
|
+
}
|
|
765
|
+
// If disabled — complete no-op, no buffers allocated
|
|
766
|
+
if (!this._options.enabled) {
|
|
767
|
+
debugLogger.log('SDK disabled — all operations are no-ops');
|
|
768
|
+
this._processor = null;
|
|
769
|
+
this._exporter = null;
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const onError = (_c = this._options.onError) !== null && _c !== void 0 ? _c : ((e) => console.warn(`[doow/track] ${e.kind}: ${e.message}`));
|
|
773
|
+
const exporterConfig = {
|
|
774
|
+
endpoint: this._options.endpoint,
|
|
775
|
+
apiKey: this._apiKey,
|
|
776
|
+
timeout: this._options.timeout,
|
|
777
|
+
retryCount: this._options.retryCount,
|
|
778
|
+
disableCompression: this._options.disableCompression,
|
|
779
|
+
maxConcurrentFlushes: this._options.maxConcurrentFlushes,
|
|
780
|
+
onError,
|
|
781
|
+
debug: debugLogger,
|
|
782
|
+
};
|
|
783
|
+
if (this._options.transport)
|
|
784
|
+
exporterConfig.transport = this._options.transport;
|
|
785
|
+
if (this._options.offlineStore)
|
|
786
|
+
exporterConfig.offlineStore = this._options.offlineStore;
|
|
787
|
+
this._exporter = new Exporter(exporterConfig);
|
|
788
|
+
const processorConfig = {
|
|
789
|
+
flushAt: this._options.flushAt,
|
|
790
|
+
flushInterval: this._options.flushInterval,
|
|
791
|
+
maxPayloadBytes: this._options.maxPayloadBytes,
|
|
792
|
+
maxQueueSize: this._options.maxQueueSize,
|
|
793
|
+
debug: debugLogger,
|
|
794
|
+
};
|
|
795
|
+
if (this._options.beforeSend)
|
|
796
|
+
processorConfig.beforeSend = this._options.beforeSend;
|
|
797
|
+
if (this._options.beforeFlush)
|
|
798
|
+
processorConfig.beforeFlush = this._options.beforeFlush;
|
|
799
|
+
this._processor = new EventProcessor(processorConfig, this._exporter);
|
|
800
|
+
if (typeof process !== 'undefined') {
|
|
801
|
+
const sigterm = () => {
|
|
802
|
+
void this.shutdown();
|
|
803
|
+
};
|
|
804
|
+
const beforeExit = () => {
|
|
805
|
+
void this.shutdown();
|
|
806
|
+
};
|
|
807
|
+
process.setMaxListeners(process.getMaxListeners() + 2);
|
|
808
|
+
process.on('SIGTERM', sigterm);
|
|
809
|
+
process.on('beforeExit', beforeExit);
|
|
810
|
+
this._sigHandlers = { sigterm, beforeExit };
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Track a usage event.
|
|
815
|
+
* - Generates UUID v4 event_id at call time
|
|
816
|
+
* - Merges SDK-level attribution defaults
|
|
817
|
+
* - Enqueues to EventProcessor
|
|
818
|
+
* - No-op if SDK is disabled or stopped
|
|
819
|
+
*/
|
|
820
|
+
track(event) {
|
|
821
|
+
var _a, _b, _c, _d;
|
|
822
|
+
if (!this._processor || !this._exporter)
|
|
823
|
+
return; // disabled
|
|
824
|
+
if (this._exporter.stopped)
|
|
825
|
+
return; // auth failure
|
|
826
|
+
if (this._shutdownCalled)
|
|
827
|
+
return;
|
|
828
|
+
const serialized = {
|
|
829
|
+
...event,
|
|
830
|
+
event_id: generateUUID(),
|
|
831
|
+
timestamp: (_a = event.timestamp) !== null && _a !== void 0 ? _a : new Date().toISOString(),
|
|
832
|
+
attribution: {
|
|
833
|
+
...((_b = this._options.attribution) !== null && _b !== void 0 ? _b : {}),
|
|
834
|
+
...((_c = event.attribution) !== null && _c !== void 0 ? _c : {}),
|
|
835
|
+
},
|
|
836
|
+
kind: (_d = event.kind) !== null && _d !== void 0 ? _d : 'USAGE',
|
|
837
|
+
};
|
|
838
|
+
// Fire-and-forget enqueue — errors surface via onError
|
|
839
|
+
void this._processor.enqueue(serialized);
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Manually flush the current buffer.
|
|
843
|
+
* Returns a Promise that resolves when the flush completes.
|
|
844
|
+
*/
|
|
845
|
+
async flush() {
|
|
846
|
+
if (!this._processor)
|
|
847
|
+
return;
|
|
848
|
+
await this._processor.flush();
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Drain all pending events and shut down.
|
|
852
|
+
* Registers SIGTERM + beforeExit handlers automatically.
|
|
853
|
+
* Best-effort — hard kill loses buffered events.
|
|
854
|
+
*/
|
|
855
|
+
async shutdown(timeout) {
|
|
856
|
+
if (this._shutdownCalled)
|
|
857
|
+
return;
|
|
858
|
+
this._shutdownCalled = true;
|
|
859
|
+
// Remove signal handlers
|
|
860
|
+
if (this._sigHandlers && typeof process !== 'undefined') {
|
|
861
|
+
process.removeListener('SIGTERM', this._sigHandlers.sigterm);
|
|
862
|
+
process.removeListener('beforeExit', this._sigHandlers.beforeExit);
|
|
863
|
+
process.setMaxListeners(Math.max(0, process.getMaxListeners() - 2));
|
|
864
|
+
this._sigHandlers = null;
|
|
865
|
+
}
|
|
866
|
+
if (!this._processor)
|
|
867
|
+
return;
|
|
868
|
+
const timeoutMs = timeout !== null && timeout !== void 0 ? timeout : this._options.shutdownTimeout;
|
|
869
|
+
const shutdownPromise = this._processor.shutdown();
|
|
870
|
+
const timeoutPromise = new Promise((resolve) => setTimeout(resolve, timeoutMs));
|
|
871
|
+
await Promise.race([shutdownPromise, timeoutPromise]);
|
|
872
|
+
}
|
|
873
|
+
/** True if the SDK is enabled */
|
|
874
|
+
get enabled() {
|
|
875
|
+
return !!this._processor;
|
|
876
|
+
}
|
|
877
|
+
/** True if auth has failed and SDK stopped emitting */
|
|
878
|
+
get stopped() {
|
|
879
|
+
var _a, _b;
|
|
880
|
+
return (_b = (_a = this._exporter) === null || _a === void 0 ? void 0 : _a.stopped) !== null && _b !== void 0 ? _b : false;
|
|
881
|
+
}
|
|
882
|
+
/** S79: True if a per-category rate limit is currently active */
|
|
883
|
+
get rateLimited() {
|
|
884
|
+
var _a, _b;
|
|
885
|
+
return (_b = (_a = this._exporter) === null || _a === void 0 ? void 0 : _a.rateLimited) !== null && _b !== void 0 ? _b : false;
|
|
886
|
+
}
|
|
887
|
+
// ─── S81: Serverless wrappers ─────────────────────────────────────────────
|
|
888
|
+
/**
|
|
889
|
+
* Wrap an AWS Lambda handler.
|
|
890
|
+
* Sets flushAt=1 internally, calls shutdown() in a finally block.
|
|
891
|
+
*
|
|
892
|
+
* @example
|
|
893
|
+
* export const handler = meter.withLambda(async (event, context) => {
|
|
894
|
+
* meter.track({ ... });
|
|
895
|
+
* return { statusCode: 200 };
|
|
896
|
+
* });
|
|
897
|
+
*/
|
|
898
|
+
withLambda(handler) {
|
|
899
|
+
// Override flushAt to 1 so every track() flushes immediately
|
|
900
|
+
if (this._processor) {
|
|
901
|
+
this._processor.setFlushAt(1);
|
|
902
|
+
}
|
|
903
|
+
return async (event, context) => {
|
|
904
|
+
try {
|
|
905
|
+
return await handler(event, context);
|
|
906
|
+
}
|
|
907
|
+
finally {
|
|
908
|
+
await this.flush();
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Wrap a Vercel serverless function handler.
|
|
914
|
+
* Sets flushAt=1 internally, calls flush() in a finally block.
|
|
915
|
+
*/
|
|
916
|
+
withVercel(handler) {
|
|
917
|
+
if (this._processor) {
|
|
918
|
+
this._processor.setFlushAt(1);
|
|
919
|
+
}
|
|
920
|
+
return async (req, res) => {
|
|
921
|
+
try {
|
|
922
|
+
await handler(req, res);
|
|
923
|
+
}
|
|
924
|
+
finally {
|
|
925
|
+
await this.flush();
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Wrap an Azure Function handler.
|
|
931
|
+
* Sets flushAt=1 internally, calls flush() in a finally block.
|
|
932
|
+
*/
|
|
933
|
+
withAzureFunction(handler) {
|
|
934
|
+
if (this._processor) {
|
|
935
|
+
this._processor.setFlushAt(1);
|
|
936
|
+
}
|
|
937
|
+
return async (context, input) => {
|
|
938
|
+
try {
|
|
939
|
+
return await handler(context, input);
|
|
940
|
+
}
|
|
941
|
+
finally {
|
|
942
|
+
await this.flush();
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* S82: Health check HTTP server.
|
|
950
|
+
*
|
|
951
|
+
* Serves GET /healthz on DOOW_TRACK_HEALTH_PORT (default 9090).
|
|
952
|
+
* Returns 200 OK with {"status":"ok"} when the sidecar is running.
|
|
953
|
+
*/
|
|
954
|
+
function createHealthServer(port) {
|
|
955
|
+
let server = null;
|
|
956
|
+
const handler = (_req, res) => {
|
|
957
|
+
const body = JSON.stringify({ status: 'ok' });
|
|
958
|
+
res.writeHead(200, {
|
|
959
|
+
'Content-Type': 'application/json',
|
|
960
|
+
'Content-Length': String(Buffer.byteLength(body)),
|
|
961
|
+
});
|
|
962
|
+
res.end(body);
|
|
963
|
+
};
|
|
964
|
+
return {
|
|
965
|
+
get port() {
|
|
966
|
+
return port;
|
|
967
|
+
},
|
|
968
|
+
async start() {
|
|
969
|
+
server = http__namespace.createServer(handler);
|
|
970
|
+
await new Promise((resolve, reject) => {
|
|
971
|
+
server.listen(port, () => resolve());
|
|
972
|
+
server.on('error', reject);
|
|
973
|
+
});
|
|
974
|
+
},
|
|
975
|
+
async stop() {
|
|
976
|
+
if (server) {
|
|
977
|
+
await new Promise((resolve) => {
|
|
978
|
+
server.close(() => resolve());
|
|
979
|
+
});
|
|
980
|
+
server = null;
|
|
981
|
+
}
|
|
982
|
+
},
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* S82: InputReader — unified input source for sidecar and CLI.
|
|
988
|
+
*
|
|
989
|
+
* Supports three modes controlled by DOOW_TRACK_INPUT env var:
|
|
990
|
+
* stdin — newline-delimited JSON from process.stdin (default)
|
|
991
|
+
* file:<path> — tail a file, resuming from last cursor position
|
|
992
|
+
* tcp:<port> — TCP socket server accepting newline-delimited JSON
|
|
993
|
+
*
|
|
994
|
+
* Each valid JSON line is passed to onEvent. Malformed lines call onError.
|
|
995
|
+
* Call stop() to shut down cleanly.
|
|
996
|
+
*/
|
|
997
|
+
// ─── Line splitter ─────────────────────────────────────────────────────────
|
|
998
|
+
/** Split a stream into lines, calling onLine for each complete line. */
|
|
999
|
+
const MAX_LINE_BYTES = 1048576;
|
|
1000
|
+
function pipeLines(readable, onLine, onError) {
|
|
1001
|
+
let buf = '';
|
|
1002
|
+
readable.on('data', (chunk) => {
|
|
1003
|
+
var _a;
|
|
1004
|
+
buf += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
1005
|
+
if (!buf.includes('\n') && buf.length > MAX_LINE_BYTES) {
|
|
1006
|
+
onError === null || onError === void 0 ? void 0 : onError(new Error(`Line exceeds ${MAX_LINE_BYTES} bytes`), '');
|
|
1007
|
+
buf = '';
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
const parts = buf.split('\n');
|
|
1011
|
+
// All but last are complete lines
|
|
1012
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
1013
|
+
const line = parts[i].trim();
|
|
1014
|
+
if (line.length > 0)
|
|
1015
|
+
onLine(line);
|
|
1016
|
+
}
|
|
1017
|
+
buf = (_a = parts[parts.length - 1]) !== null && _a !== void 0 ? _a : '';
|
|
1018
|
+
});
|
|
1019
|
+
readable.on('end', () => {
|
|
1020
|
+
const remaining = buf.trim();
|
|
1021
|
+
if (remaining.length > 0)
|
|
1022
|
+
onLine(remaining);
|
|
1023
|
+
buf = '';
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
// ─── Parse helper ──────────────────────────────────────────────────────────
|
|
1027
|
+
function dispatchLine(line, onEvent, onError) {
|
|
1028
|
+
try {
|
|
1029
|
+
JSON.parse(line); // validate JSON — value not used here; caller validates shape
|
|
1030
|
+
onEvent(line);
|
|
1031
|
+
}
|
|
1032
|
+
catch (e) {
|
|
1033
|
+
onError(e instanceof Error ? e : new Error(String(e)), line);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
// ─── Stdin mode ────────────────────────────────────────────────────────────
|
|
1037
|
+
function createStdinReader(onEvent, onError) {
|
|
1038
|
+
let started = false;
|
|
1039
|
+
return {
|
|
1040
|
+
start() {
|
|
1041
|
+
if (started)
|
|
1042
|
+
return Promise.resolve();
|
|
1043
|
+
started = true;
|
|
1044
|
+
process.stdin.resume();
|
|
1045
|
+
process.stdin.setEncoding('utf8');
|
|
1046
|
+
pipeLines(process.stdin, (line) => dispatchLine(line, onEvent, onError));
|
|
1047
|
+
return Promise.resolve();
|
|
1048
|
+
},
|
|
1049
|
+
stop() {
|
|
1050
|
+
// stdin mode: just let it close naturally
|
|
1051
|
+
return Promise.resolve();
|
|
1052
|
+
},
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
// ─── File tail mode ────────────────────────────────────────────────────────
|
|
1056
|
+
function createFileReader(filePath, onEvent, onError) {
|
|
1057
|
+
let stopped = false;
|
|
1058
|
+
let pollTimer = null;
|
|
1059
|
+
let cursor = 0; // byte offset into file
|
|
1060
|
+
async function readChunk() {
|
|
1061
|
+
if (stopped)
|
|
1062
|
+
return;
|
|
1063
|
+
let stat;
|
|
1064
|
+
try {
|
|
1065
|
+
stat = await fs.promises.stat(filePath);
|
|
1066
|
+
}
|
|
1067
|
+
catch {
|
|
1068
|
+
// File doesn't exist yet — wait
|
|
1069
|
+
scheduleNext();
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (stat.size <= cursor) {
|
|
1073
|
+
// No new data
|
|
1074
|
+
scheduleNext();
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
// Read new bytes from cursor onward
|
|
1078
|
+
await new Promise((resolve) => {
|
|
1079
|
+
const stream = fs.createReadStream(filePath, {
|
|
1080
|
+
start: cursor,
|
|
1081
|
+
end: stat.size - 1,
|
|
1082
|
+
encoding: 'utf8',
|
|
1083
|
+
});
|
|
1084
|
+
let buf = '';
|
|
1085
|
+
stream.on('data', (chunk) => {
|
|
1086
|
+
buf += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
1087
|
+
});
|
|
1088
|
+
stream.on('end', () => {
|
|
1089
|
+
cursor = stat.size;
|
|
1090
|
+
// Process lines
|
|
1091
|
+
const lines = buf.split('\n');
|
|
1092
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
1093
|
+
const line = lines[i].trim();
|
|
1094
|
+
if (line.length > 0)
|
|
1095
|
+
dispatchLine(line, onEvent, onError);
|
|
1096
|
+
}
|
|
1097
|
+
// Last segment may be incomplete — don't advance cursor past it
|
|
1098
|
+
const last = lines[lines.length - 1].trim();
|
|
1099
|
+
if (last.length > 0) {
|
|
1100
|
+
// Rewind cursor to not skip the incomplete line
|
|
1101
|
+
cursor -= Buffer.byteLength(lines[lines.length - 1], 'utf8');
|
|
1102
|
+
}
|
|
1103
|
+
resolve();
|
|
1104
|
+
});
|
|
1105
|
+
stream.on('error', () => resolve());
|
|
1106
|
+
});
|
|
1107
|
+
scheduleNext();
|
|
1108
|
+
}
|
|
1109
|
+
function scheduleNext() {
|
|
1110
|
+
if (stopped)
|
|
1111
|
+
return;
|
|
1112
|
+
pollTimer = setTimeout(() => {
|
|
1113
|
+
void readChunk();
|
|
1114
|
+
}, 200);
|
|
1115
|
+
}
|
|
1116
|
+
return {
|
|
1117
|
+
start() {
|
|
1118
|
+
// Read existing content first, then poll for new content
|
|
1119
|
+
return readChunk();
|
|
1120
|
+
},
|
|
1121
|
+
stop() {
|
|
1122
|
+
stopped = true;
|
|
1123
|
+
if (pollTimer !== null) {
|
|
1124
|
+
clearTimeout(pollTimer);
|
|
1125
|
+
pollTimer = null;
|
|
1126
|
+
}
|
|
1127
|
+
return Promise.resolve();
|
|
1128
|
+
},
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
// ─── TCP mode ──────────────────────────────────────────────────────────────
|
|
1132
|
+
const MAX_TCP_CONNECTIONS = 10;
|
|
1133
|
+
function createTcpReader(port, onEvent, onError) {
|
|
1134
|
+
let server = null;
|
|
1135
|
+
return {
|
|
1136
|
+
async start() {
|
|
1137
|
+
server = net__namespace.createServer((socket) => {
|
|
1138
|
+
socket.setEncoding('utf8');
|
|
1139
|
+
socket.setTimeout(60000, () => socket.destroy());
|
|
1140
|
+
pipeLines(socket, (line) => dispatchLine(line, onEvent, onError), onError);
|
|
1141
|
+
socket.on('error', () => {
|
|
1142
|
+
/* ignore individual socket errors */
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
server.maxConnections = MAX_TCP_CONNECTIONS;
|
|
1146
|
+
await new Promise((resolve, reject) => {
|
|
1147
|
+
server.listen(port, () => resolve());
|
|
1148
|
+
server.on('error', reject);
|
|
1149
|
+
});
|
|
1150
|
+
},
|
|
1151
|
+
async stop() {
|
|
1152
|
+
if (server) {
|
|
1153
|
+
await new Promise((resolve) => {
|
|
1154
|
+
server.close(() => resolve());
|
|
1155
|
+
});
|
|
1156
|
+
server = null;
|
|
1157
|
+
}
|
|
1158
|
+
},
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
// ─── Factory ───────────────────────────────────────────────────────────────
|
|
1162
|
+
function createInputReader(opts) {
|
|
1163
|
+
const { mode, onEvent, onError } = opts;
|
|
1164
|
+
if (mode === 'stdin') {
|
|
1165
|
+
return createStdinReader(onEvent, onError);
|
|
1166
|
+
}
|
|
1167
|
+
if (mode.type === 'file') {
|
|
1168
|
+
return createFileReader(mode.path, onEvent, onError);
|
|
1169
|
+
}
|
|
1170
|
+
if (mode.type === 'tcp') {
|
|
1171
|
+
return createTcpReader(mode.port, onEvent, onError);
|
|
1172
|
+
}
|
|
1173
|
+
// TypeScript exhaustive check
|
|
1174
|
+
const _exhaustive = mode;
|
|
1175
|
+
throw new Error(`Unknown input mode: ${JSON.stringify(_exhaustive)}`);
|
|
1176
|
+
}
|
|
1177
|
+
// ─── Env-based factory ────────────────────────────────────────────────────
|
|
1178
|
+
/**
|
|
1179
|
+
* Parse DOOW_TRACK_INPUT env var and return the appropriate mode config.
|
|
1180
|
+
* "" → stdin
|
|
1181
|
+
* "stdin" → stdin
|
|
1182
|
+
* "file:/path" → file mode
|
|
1183
|
+
* "tcp:9000" → TCP mode on port 9000
|
|
1184
|
+
*/
|
|
1185
|
+
function parseInputMode(envValue) {
|
|
1186
|
+
if (!envValue || envValue === 'stdin')
|
|
1187
|
+
return 'stdin';
|
|
1188
|
+
if (envValue.startsWith('file:')) {
|
|
1189
|
+
const filePath = envValue.slice('file:'.length);
|
|
1190
|
+
if (!filePath)
|
|
1191
|
+
throw new Error(`DOOW_TRACK_INPUT file: mode requires a path`);
|
|
1192
|
+
return { type: 'file', path: filePath };
|
|
1193
|
+
}
|
|
1194
|
+
if (envValue.startsWith('tcp:')) {
|
|
1195
|
+
const portStr = envValue.slice('tcp:'.length);
|
|
1196
|
+
const port = parseInt(portStr, 10);
|
|
1197
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
1198
|
+
throw new Error(`DOOW_TRACK_INPUT tcp: mode requires a valid port number`);
|
|
1199
|
+
}
|
|
1200
|
+
return { type: 'tcp', port };
|
|
1201
|
+
}
|
|
1202
|
+
throw new Error(`Unknown DOOW_TRACK_INPUT value: "${envValue}". Use stdin, file:<path>, or tcp:<port>.`);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* S82: Doow Track Sidecar — entry point.
|
|
1207
|
+
*
|
|
1208
|
+
* Reads events from stdin / file / TCP and batch-POSTs via DoowTracker.
|
|
1209
|
+
*
|
|
1210
|
+
* Required env:
|
|
1211
|
+
* DOOW_TRACK_API_KEY — SDK API key (must start with dk_)
|
|
1212
|
+
*
|
|
1213
|
+
* Optional env:
|
|
1214
|
+
* DOOW_TRACK_INPUT — stdin (default) | file:<path> | tcp:<port>
|
|
1215
|
+
* DOOW_TRACK_HEALTH_PORT — health check port (default 9090)
|
|
1216
|
+
* DOOW_TRACK_ENDPOINT — override API endpoint
|
|
1217
|
+
* DOOW_TRACK_FLUSH_AT — flush event count threshold
|
|
1218
|
+
* DOOW_TRACK_FLUSH_INTERVAL — flush interval ms
|
|
1219
|
+
* DOOW_TRACK_DISABLED — disable SDK
|
|
1220
|
+
* DOOW_TRACK_DEBUG — enable debug logging
|
|
1221
|
+
* DOOW_TRACK_ATTRIBUTION — JSON attribution bag
|
|
1222
|
+
*/
|
|
1223
|
+
async function main() {
|
|
1224
|
+
// ─── Validate required env ───────────────────────────────────────────────
|
|
1225
|
+
var _a;
|
|
1226
|
+
const apiKey = process.env['DOOW_TRACK_API_KEY'];
|
|
1227
|
+
if (!apiKey) {
|
|
1228
|
+
console.error('[doow-sidecar] DOOW_TRACK_API_KEY is required');
|
|
1229
|
+
process.exit(1);
|
|
1230
|
+
}
|
|
1231
|
+
// ─── Build tracker ───────────────────────────────────────────────────────
|
|
1232
|
+
const tracker = new DoowTracker(apiKey, {
|
|
1233
|
+
onError: (err) => {
|
|
1234
|
+
console.error(`[doow-sidecar] SDK error [${err.kind}]: ${err.message}`);
|
|
1235
|
+
},
|
|
1236
|
+
});
|
|
1237
|
+
// ─── Health server ───────────────────────────────────────────────────────
|
|
1238
|
+
const healthPort = parseInt((_a = process.env['DOOW_TRACK_HEALTH_PORT']) !== null && _a !== void 0 ? _a : '9090', 10);
|
|
1239
|
+
const health = createHealthServer(healthPort);
|
|
1240
|
+
await health.start();
|
|
1241
|
+
// ─── Input reader ────────────────────────────────────────────────────────
|
|
1242
|
+
const inputMode = parseInputMode(process.env['DOOW_TRACK_INPUT']);
|
|
1243
|
+
const reader = createInputReader({
|
|
1244
|
+
mode: inputMode,
|
|
1245
|
+
onEvent: (raw) => {
|
|
1246
|
+
try {
|
|
1247
|
+
const event = JSON.parse(raw);
|
|
1248
|
+
tracker.track(event);
|
|
1249
|
+
}
|
|
1250
|
+
catch (e) {
|
|
1251
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
1252
|
+
console.warn(`[doow-sidecar] Malformed event — skipping: ${err.message}`);
|
|
1253
|
+
}
|
|
1254
|
+
},
|
|
1255
|
+
onError: (err, line) => {
|
|
1256
|
+
console.warn(`[doow-sidecar] Malformed line — skipping: ${err.message} | line: ${line.slice(0, 100)}`);
|
|
1257
|
+
},
|
|
1258
|
+
});
|
|
1259
|
+
await reader.start();
|
|
1260
|
+
// ─── Graceful shutdown ───────────────────────────────────────────────────
|
|
1261
|
+
let shuttingDown = false;
|
|
1262
|
+
async function shutdown() {
|
|
1263
|
+
if (shuttingDown)
|
|
1264
|
+
return;
|
|
1265
|
+
shuttingDown = true;
|
|
1266
|
+
process.stderr.write('[doow-sidecar] Shutting down...\n');
|
|
1267
|
+
await reader.stop();
|
|
1268
|
+
await tracker.shutdown();
|
|
1269
|
+
await health.stop();
|
|
1270
|
+
process.stderr.write('[doow-sidecar] Shutdown complete.\n');
|
|
1271
|
+
process.exit(0);
|
|
1272
|
+
}
|
|
1273
|
+
process.on('SIGTERM', () => {
|
|
1274
|
+
void shutdown();
|
|
1275
|
+
});
|
|
1276
|
+
process.on('SIGINT', () => {
|
|
1277
|
+
void shutdown();
|
|
1278
|
+
});
|
|
1279
|
+
process.stderr.write(`[doow-sidecar] Running. Health: http://localhost:${healthPort}/healthz\n`);
|
|
1280
|
+
}
|
|
1281
|
+
main().catch((err) => {
|
|
1282
|
+
console.error('[doow-sidecar] Fatal error:', err);
|
|
1283
|
+
process.exit(1);
|
|
1284
|
+
});
|
|
1285
|
+
//# sourceMappingURL=sidecar.cjs.map
|