@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.
@@ -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