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