@revenium/claude-code-metering 0.1.3 → 0.1.5

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.
Files changed (78) hide show
  1. package/.env.example +15 -0
  2. package/.eslintrc.js +24 -0
  3. package/.github/workflows/branch-bypass-alert.yml +68 -0
  4. package/CODE_OF_CONDUCT.md +57 -0
  5. package/CONTRIBUTING.md +73 -0
  6. package/README.md +57 -3
  7. package/SECURITY.md +46 -0
  8. package/dist/cli/commands/setup.js +3 -1
  9. package/dist/cli/commands/setup.js.map +1 -1
  10. package/dist/cli/index.d.ts.map +1 -1
  11. package/dist/cli/index.js +4 -1
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/core/api/client.d.ts.map +1 -1
  14. package/dist/core/api/client.js +4 -1
  15. package/dist/core/api/client.js.map +1 -1
  16. package/dist/core/tool-context.d.ts +6 -0
  17. package/dist/core/tool-context.d.ts.map +1 -0
  18. package/dist/core/tool-context.js +21 -0
  19. package/dist/core/tool-context.js.map +1 -0
  20. package/dist/core/tool-tracker.d.ts +4 -0
  21. package/dist/core/tool-tracker.d.ts.map +1 -0
  22. package/dist/core/tool-tracker.js +156 -0
  23. package/dist/core/tool-tracker.js.map +1 -0
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/types/index.d.ts +1 -0
  29. package/dist/types/index.d.ts.map +1 -1
  30. package/dist/types/index.js +15 -0
  31. package/dist/types/index.js.map +1 -1
  32. package/dist/types/tool-metering.d.ts +36 -0
  33. package/dist/types/tool-metering.d.ts.map +1 -0
  34. package/dist/types/tool-metering.js +3 -0
  35. package/dist/types/tool-metering.js.map +1 -0
  36. package/docs/research/settings-json-telemetry-findings.md +171 -0
  37. package/examples/README.md +114 -0
  38. package/examples/validation/validate-installation.sh +212 -0
  39. package/package.json +1 -7
  40. package/public-allowlist-node.txt +7 -0
  41. package/src/cli/commands/backfill.ts +865 -0
  42. package/src/cli/commands/setup.ts +254 -0
  43. package/src/cli/commands/status.ts +108 -0
  44. package/src/cli/commands/test.ts +91 -0
  45. package/src/cli/index.ts +103 -0
  46. package/src/core/api/client.ts +194 -0
  47. package/src/core/config/loader.ts +217 -0
  48. package/src/core/config/validator.ts +142 -0
  49. package/src/core/config/writer.ts +212 -0
  50. package/src/core/shell/detector.ts +92 -0
  51. package/src/core/shell/profile-updater.ts +131 -0
  52. package/src/core/tool-context.ts +23 -0
  53. package/src/core/tool-tracker.ts +204 -0
  54. package/src/index.ts +12 -0
  55. package/src/types/index.ts +110 -0
  56. package/src/types/tool-metering.ts +38 -0
  57. package/src/utils/constants.ts +80 -0
  58. package/src/utils/hashing.ts +35 -0
  59. package/src/utils/masking.ts +32 -0
  60. package/tests/integration/cli-commands.test.ts +158 -0
  61. package/tests/unit/backfill-command.test.ts +366 -0
  62. package/tests/unit/backfill-helpers.test.ts +397 -0
  63. package/tests/unit/backfill-parse.test.ts +276 -0
  64. package/tests/unit/backfill-stream.test.ts +147 -0
  65. package/tests/unit/backfill.test.ts +344 -0
  66. package/tests/unit/cli-index.test.ts +193 -0
  67. package/tests/unit/client.test.ts +195 -0
  68. package/tests/unit/detector.test.ts +247 -0
  69. package/tests/unit/hashing.test.ts +121 -0
  70. package/tests/unit/loader.test.ts +272 -0
  71. package/tests/unit/masking.test.ts +46 -0
  72. package/tests/unit/profile-updater.test.ts +146 -0
  73. package/tests/unit/setup.test.ts +557 -0
  74. package/tests/unit/status.test.ts +149 -0
  75. package/tests/unit/test.test.ts +165 -0
  76. package/tests/unit/validator.test.ts +211 -0
  77. package/tests/unit/writer.test.ts +176 -0
  78. package/tsconfig.json +20 -0
@@ -0,0 +1,865 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import chalk from "chalk";
7
+ import ora from "ora";
8
+ import { loadConfig } from "../../core/config/loader.js";
9
+ import { sendOtlpLogs } from "../../core/api/client.js";
10
+ import {
11
+ getCostMultiplier,
12
+ type SubscriptionTier,
13
+ } from "../../utils/constants.js";
14
+ import { generateTransactionId } from "../../utils/hashing.js";
15
+ import type { OTLPLogsPayload } from "../../types/index.js";
16
+
17
+ export interface BackfillOptions {
18
+ since?: string;
19
+ dryRun?: boolean;
20
+ batchSize?: number;
21
+ delay?: number;
22
+ verbose?: boolean;
23
+ }
24
+
25
+ export interface BackfillDependencies {
26
+ loadConfig: typeof loadConfig;
27
+ findJsonlFiles: typeof findJsonlFiles;
28
+ streamJsonlRecords: typeof streamJsonlRecords;
29
+ sendBatchWithRetry: typeof sendBatchWithRetry;
30
+ homedir: typeof homedir;
31
+ }
32
+
33
+ interface UsageData {
34
+ input_tokens?: number;
35
+ output_tokens?: number;
36
+ cache_creation_input_tokens?: number;
37
+ cache_read_input_tokens?: number;
38
+ }
39
+
40
+ interface JsonlEntry {
41
+ type: string;
42
+ sessionId?: string;
43
+ timestamp?: string;
44
+ message?: {
45
+ model?: string;
46
+ usage?: UsageData;
47
+ };
48
+ }
49
+
50
+ interface ParsedRecord {
51
+ sessionId: string;
52
+ timestamp: string;
53
+ model: string;
54
+ inputTokens: number;
55
+ outputTokens: number;
56
+ cacheReadTokens: number;
57
+ cacheCreationTokens: number;
58
+ }
59
+
60
+ interface RetryResult {
61
+ success: boolean;
62
+ attempts: number;
63
+ error?: string;
64
+ }
65
+
66
+ /**
67
+ * Sleep for a specified number of milliseconds.
68
+ */
69
+ export function sleep(ms: number): Promise<void> {
70
+ return new Promise((resolve) => setTimeout(resolve, ms));
71
+ }
72
+
73
+ /**
74
+ * Sanitize error message to prevent API key leakage.
75
+ * Truncates long messages and removes potential sensitive data.
76
+ */
77
+ export function sanitizeErrorMessage(errorMsg: string): string {
78
+ const maxLength = 500;
79
+ let sanitized = errorMsg;
80
+
81
+ if (sanitized.length > maxLength) {
82
+ sanitized = `${sanitized.substring(0, maxLength)}...`;
83
+ }
84
+
85
+ return sanitized;
86
+ }
87
+
88
+ /**
89
+ * Check if an error is retryable based on HTTP status code.
90
+ * 4xx errors (except 429) are not retryable as they indicate client errors.
91
+ */
92
+ export function isRetryableError(errorMsg: string): boolean {
93
+ const statusMatch = errorMsg.match(/OTLP request failed: (\d{3})/);
94
+ if (!statusMatch) {
95
+ return true;
96
+ }
97
+
98
+ const statusCode = parseInt(statusMatch[1], 10);
99
+
100
+ if (statusCode === 429) {
101
+ return true;
102
+ }
103
+
104
+ if (statusCode >= 400 && statusCode < 500) {
105
+ return false;
106
+ }
107
+
108
+ return true;
109
+ }
110
+
111
+ /**
112
+ * Send a batch with retry logic and exponential backoff.
113
+ */
114
+ export async function sendBatchWithRetry(
115
+ endpoint: string,
116
+ apiKey: string,
117
+ payload: OTLPLogsPayload,
118
+ maxRetries: number,
119
+ verbose: boolean,
120
+ ): Promise<RetryResult> {
121
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
122
+ try {
123
+ await sendOtlpLogs(endpoint, apiKey, payload);
124
+ if (verbose && attempt > 0) {
125
+ console.log(chalk.green(` ✓ Succeeded after ${attempt + 1} attempts`));
126
+ }
127
+ return { success: true, attempts: attempt + 1 };
128
+ } catch (error) {
129
+ const rawErrorMsg =
130
+ error instanceof Error ? error.message : "Unknown error";
131
+ const errorMsg = sanitizeErrorMessage(rawErrorMsg);
132
+ const isRetryable = isRetryableError(errorMsg);
133
+
134
+ if (!isRetryable) {
135
+ if (verbose) {
136
+ console.log(
137
+ chalk.red(` ✗ Non-retryable error (client error): ${errorMsg}`),
138
+ );
139
+ }
140
+ return { success: false, attempts: attempt + 1, error: errorMsg };
141
+ }
142
+
143
+ if (attempt < maxRetries - 1) {
144
+ const backoffDelay = 1000 * Math.pow(2, attempt);
145
+ if (verbose) {
146
+ console.log(
147
+ chalk.yellow(` ✗ Attempt ${attempt + 1} failed: ${errorMsg}`),
148
+ );
149
+ console.log(chalk.blue(` ⏳ Retrying in ${backoffDelay}ms...`));
150
+ }
151
+ await sleep(backoffDelay);
152
+ } else {
153
+ if (verbose) {
154
+ console.log(chalk.red(` ✗ All ${maxRetries} attempts failed`));
155
+ }
156
+ return { success: false, attempts: maxRetries, error: errorMsg };
157
+ }
158
+ }
159
+ }
160
+
161
+ return { success: false, attempts: maxRetries };
162
+ }
163
+
164
+ /**
165
+ * Parses a relative date string like "7d" or "1m" into a Date.
166
+ */
167
+ export function parseRelativeDate(input: string): Date | null {
168
+ const match = input.match(/^(\d+)([dmwMy])$/);
169
+ if (!match) return null;
170
+
171
+ const amount = parseInt(match[1], 10);
172
+ const unit = match[2];
173
+ const now = new Date();
174
+
175
+ switch (unit) {
176
+ case "d":
177
+ now.setDate(now.getDate() - amount);
178
+ break;
179
+ case "w":
180
+ now.setDate(now.getDate() - amount * 7);
181
+ break;
182
+ case "m":
183
+ now.setMonth(now.getMonth() - amount);
184
+ break;
185
+ case "M":
186
+ now.setMonth(now.getMonth() - amount);
187
+ break;
188
+ case "y":
189
+ now.setFullYear(now.getFullYear() - amount);
190
+ break;
191
+ default:
192
+ return null;
193
+ }
194
+
195
+ return now;
196
+ }
197
+
198
+ /**
199
+ * Parses the --since option into a Date.
200
+ */
201
+ export function parseSinceDate(since: string): Date | null {
202
+ // Try relative format first
203
+ const relativeDate = parseRelativeDate(since);
204
+ if (relativeDate) return relativeDate;
205
+
206
+ // Try ISO format
207
+ const isoDate = new Date(since);
208
+ if (!isNaN(isoDate.getTime())) return isoDate;
209
+
210
+ return null;
211
+ }
212
+
213
+ /**
214
+ * Recursively finds all .jsonl files in a directory.
215
+ * Returns an object with found files and any errors encountered.
216
+ */
217
+ export async function findJsonlFiles(
218
+ dir: string,
219
+ errors: string[] = [],
220
+ ): Promise<{ files: string[]; errors: string[] }> {
221
+ const files: string[] = [];
222
+
223
+ try {
224
+ const entries = await readdir(dir, { withFileTypes: true });
225
+
226
+ for (const entry of entries) {
227
+ const fullPath = join(dir, entry.name);
228
+
229
+ if (entry.isDirectory()) {
230
+ const result = await findJsonlFiles(fullPath, errors);
231
+ files.push(...result.files);
232
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
233
+ files.push(fullPath);
234
+ }
235
+ }
236
+ } catch (error) {
237
+ const message = error instanceof Error ? error.message : String(error);
238
+ errors.push(`${dir}: ${message}`);
239
+ }
240
+
241
+ return { files, errors };
242
+ }
243
+
244
+ interface StreamResult {
245
+ record?: ParsedRecord;
246
+ parseError?: boolean;
247
+ missingFields?: boolean;
248
+ }
249
+
250
+ export interface RecordStatistics {
251
+ totalRecords: number;
252
+ oldestTimestamp: string;
253
+ newestTimestamp: string;
254
+ totalInputTokens: number;
255
+ totalOutputTokens: number;
256
+ totalCacheReadTokens: number;
257
+ totalCacheCreationTokens: number;
258
+ }
259
+
260
+ export function calculateStatistics(records: ParsedRecord[]): RecordStatistics {
261
+ if (records.length === 0) {
262
+ return {
263
+ totalRecords: 0,
264
+ oldestTimestamp: "",
265
+ newestTimestamp: "",
266
+ totalInputTokens: 0,
267
+ totalOutputTokens: 0,
268
+ totalCacheReadTokens: 0,
269
+ totalCacheCreationTokens: 0,
270
+ };
271
+ }
272
+
273
+ const sortedRecords = [...records].sort(
274
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
275
+ );
276
+
277
+ return {
278
+ totalRecords: records.length,
279
+ oldestTimestamp: sortedRecords[0].timestamp,
280
+ newestTimestamp: sortedRecords[sortedRecords.length - 1].timestamp,
281
+ totalInputTokens: records.reduce((sum, r) => sum + r.inputTokens, 0),
282
+ totalOutputTokens: records.reduce((sum, r) => sum + r.outputTokens, 0),
283
+ totalCacheReadTokens: records.reduce(
284
+ (sum, r) => sum + r.cacheReadTokens,
285
+ 0,
286
+ ),
287
+ totalCacheCreationTokens: records.reduce(
288
+ (sum, r) => sum + r.cacheCreationTokens,
289
+ 0,
290
+ ),
291
+ };
292
+ }
293
+
294
+ export function parseJsonlLine(
295
+ line: string,
296
+ sinceDate: Date | null,
297
+ ): StreamResult {
298
+ if (!line.trim()) {
299
+ return {};
300
+ }
301
+
302
+ let entry: JsonlEntry;
303
+ try {
304
+ entry = JSON.parse(line);
305
+ } catch {
306
+ return { parseError: true };
307
+ }
308
+
309
+ if (entry.type !== "assistant" || !entry.message?.usage) {
310
+ return {};
311
+ }
312
+
313
+ const usage = entry.message.usage;
314
+ const timestamp = entry.timestamp;
315
+ const sessionId = entry.sessionId;
316
+ const model = entry.message.model;
317
+
318
+ if (!timestamp || !sessionId || !model) {
319
+ return { missingFields: true };
320
+ }
321
+
322
+ const entryDate = new Date(timestamp);
323
+ if (!Number.isFinite(entryDate.getTime())) {
324
+ return {};
325
+ }
326
+
327
+ if (sinceDate && entryDate < sinceDate) {
328
+ return {};
329
+ }
330
+
331
+ const totalTokens =
332
+ (usage.input_tokens || 0) +
333
+ (usage.output_tokens || 0) +
334
+ (usage.cache_read_input_tokens || 0) +
335
+ (usage.cache_creation_input_tokens || 0);
336
+
337
+ if (totalTokens === 0) {
338
+ return {};
339
+ }
340
+
341
+ return {
342
+ record: {
343
+ sessionId,
344
+ timestamp,
345
+ model,
346
+ inputTokens: usage.input_tokens || 0,
347
+ outputTokens: usage.output_tokens || 0,
348
+ cacheReadTokens: usage.cache_read_input_tokens || 0,
349
+ cacheCreationTokens: usage.cache_creation_input_tokens || 0,
350
+ },
351
+ };
352
+ }
353
+
354
+ /**
355
+ * Streams a JSONL file and extracts records with usage data.
356
+ * Yields objects indicating either a valid record or a parse error.
357
+ */
358
+ export async function* streamJsonlRecords(
359
+ filePath: string,
360
+ sinceDate: Date | null,
361
+ ): AsyncGenerator<StreamResult> {
362
+ const fileStream = createReadStream(filePath);
363
+ const rl = createInterface({
364
+ input: fileStream,
365
+ crlfDelay: Infinity,
366
+ });
367
+
368
+ try {
369
+ for await (const line of rl) {
370
+ const result = parseJsonlLine(line, sinceDate);
371
+ if (result.record || result.parseError || result.missingFields) {
372
+ yield result;
373
+ }
374
+ }
375
+ } finally {
376
+ fileStream.destroy();
377
+ rl.close();
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Converts a timestamp to nanoseconds since Unix epoch.
383
+ * Returns null if the timestamp is invalid.
384
+ */
385
+ export function toUnixNano(timestamp: string): string | null {
386
+ const date = new Date(timestamp);
387
+ const ms = date.getTime();
388
+ if (!Number.isFinite(ms)) {
389
+ return null;
390
+ }
391
+ return (BigInt(ms) * BigInt(1_000_000)).toString();
392
+ }
393
+
394
+ /**
395
+ * Creates an OTLP logs payload from parsed records.
396
+ * Filters out records with invalid timestamps as a safety measure.
397
+ */
398
+ export interface PayloadOptions {
399
+ costMultiplier: number;
400
+ email?: string;
401
+ organizationName?: string;
402
+ /**
403
+ * @deprecated Use organizationName instead. This field will be removed in a future version.
404
+ */
405
+ organizationId?: string;
406
+ productName?: string;
407
+ /**
408
+ * @deprecated Use productName instead. This field will be removed in a future version.
409
+ */
410
+ productId?: string;
411
+ }
412
+
413
+ export function createOtlpPayload(
414
+ records: ParsedRecord[],
415
+ options: PayloadOptions,
416
+ ): OTLPLogsPayload {
417
+ const {
418
+ costMultiplier,
419
+ email,
420
+ organizationName,
421
+ organizationId,
422
+ productName,
423
+ productId,
424
+ } = options;
425
+
426
+ // Support both new and old field names with fallback
427
+ const organizationValue = organizationName || organizationId;
428
+ const productValue = productName || productId;
429
+
430
+ // Filter and map records, skipping any with invalid timestamps
431
+ const logRecords = records
432
+ .map((record) => {
433
+ const timeUnixNano = toUnixNano(record.timestamp);
434
+ if (timeUnixNano === null) {
435
+ return null;
436
+ }
437
+
438
+ // Build attributes array with required fields
439
+ const attributes: Array<{
440
+ key: string;
441
+ value: { stringValue?: string; intValue?: number };
442
+ }> = [
443
+ {
444
+ key: "transaction_id",
445
+ value: { stringValue: generateTransactionId(record) },
446
+ },
447
+ {
448
+ key: "session.id",
449
+ value: { stringValue: record.sessionId },
450
+ },
451
+ {
452
+ key: "model",
453
+ value: { stringValue: record.model },
454
+ },
455
+ {
456
+ key: "input_tokens",
457
+ value: { intValue: record.inputTokens },
458
+ },
459
+ {
460
+ key: "output_tokens",
461
+ value: { intValue: record.outputTokens },
462
+ },
463
+ {
464
+ key: "cache_read_tokens",
465
+ value: { intValue: record.cacheReadTokens },
466
+ },
467
+ {
468
+ key: "cache_creation_tokens",
469
+ value: { intValue: record.cacheCreationTokens },
470
+ },
471
+ ];
472
+
473
+ // Add optional subscriber/attribution attributes at log record level
474
+ // (backend ClaudeCodeMapper reads these from log record attrs, not resource attrs)
475
+ if (email) {
476
+ attributes.push({ key: "user.email", value: { stringValue: email } });
477
+ }
478
+ if (organizationValue) {
479
+ attributes.push({
480
+ key: "organization.name",
481
+ value: { stringValue: organizationValue },
482
+ });
483
+ }
484
+ if (productValue) {
485
+ attributes.push({
486
+ key: "product.name",
487
+ value: { stringValue: productValue },
488
+ });
489
+ }
490
+
491
+ return {
492
+ timeUnixNano,
493
+ body: { stringValue: "claude_code.api_request" },
494
+ attributes,
495
+ };
496
+ })
497
+ .filter((record): record is NonNullable<typeof record> => record !== null);
498
+
499
+ return {
500
+ resourceLogs: [
501
+ {
502
+ resource: {
503
+ attributes: [
504
+ {
505
+ key: "service.name",
506
+ value: { stringValue: "claude-code" },
507
+ },
508
+ {
509
+ key: "cost_multiplier",
510
+ value: { doubleValue: costMultiplier },
511
+ },
512
+ ],
513
+ },
514
+ scopeLogs: [
515
+ {
516
+ scope: {
517
+ name: "claude-code",
518
+ version: "1.0.0",
519
+ },
520
+ logRecords,
521
+ },
522
+ ],
523
+ },
524
+ ],
525
+ };
526
+ }
527
+
528
+ /**
529
+ * Backfill command - imports historical Claude Code usage data.
530
+ */
531
+ export async function backfillCommand(
532
+ options: BackfillOptions = {},
533
+ deps: Partial<BackfillDependencies> = {},
534
+ ): Promise<void> {
535
+ const {
536
+ since,
537
+ dryRun = false,
538
+ batchSize = 100,
539
+ delay = 100,
540
+ verbose = false,
541
+ } = options;
542
+
543
+ const {
544
+ loadConfig: getConfig = loadConfig,
545
+ findJsonlFiles: findFiles = findJsonlFiles,
546
+ streamJsonlRecords: streamRecords = streamJsonlRecords,
547
+ sendBatchWithRetry: sendBatch = sendBatchWithRetry,
548
+ homedir: getHomedir = homedir,
549
+ } = deps;
550
+
551
+ console.log(chalk.bold("\nRevenium Claude Code Backfill\n"));
552
+
553
+ if (dryRun) {
554
+ console.log(
555
+ chalk.yellow("Running in dry-run mode - no data will be sent\n"),
556
+ );
557
+ }
558
+
559
+ // Load configuration
560
+ const config = await getConfig();
561
+ if (!config) {
562
+ console.log(chalk.red("Configuration not found"));
563
+ console.log(
564
+ chalk.yellow(
565
+ "\nRun `revenium-metering setup` to configure Claude Code metering.",
566
+ ),
567
+ );
568
+ process.exit(1);
569
+ }
570
+
571
+ // Parse since date
572
+ let sinceDate: Date | null = null;
573
+ if (since) {
574
+ sinceDate = parseSinceDate(since);
575
+ if (!sinceDate) {
576
+ console.log(chalk.red(`Invalid --since value: ${since}`));
577
+ console.log(
578
+ chalk.dim(
579
+ "Use ISO format (2024-01-15) or relative format (7d, 1m, 1y)",
580
+ ),
581
+ );
582
+ process.exit(1);
583
+ }
584
+ console.log(
585
+ chalk.dim(`Filtering records since: ${sinceDate.toISOString()}\n`),
586
+ );
587
+ }
588
+
589
+ // Get cost multiplier (use ?? to allow explicit 0 override for free tier/testing)
590
+ const costMultiplier =
591
+ config.costMultiplierOverride ??
592
+ (config.subscriptionTier
593
+ ? getCostMultiplier(config.subscriptionTier as SubscriptionTier)
594
+ : 0.08);
595
+
596
+ // Discover JSONL files
597
+ const projectsDir = join(getHomedir(), ".claude", "projects");
598
+ const discoverSpinner = ora("Discovering JSONL files...").start();
599
+
600
+ const { files: jsonlFiles, errors: discoveryErrors } =
601
+ await findFiles(projectsDir);
602
+
603
+ if (discoveryErrors.length > 0 && verbose) {
604
+ discoverSpinner.warn(
605
+ `Found ${jsonlFiles.length} JSONL file(s) with ${discoveryErrors.length} directory error(s)`,
606
+ );
607
+ console.log(chalk.yellow("\nDirectory access errors:"));
608
+ for (const error of discoveryErrors.slice(0, 5)) {
609
+ console.log(chalk.yellow(` ${error}`));
610
+ }
611
+ if (discoveryErrors.length > 5) {
612
+ console.log(chalk.yellow(` ... and ${discoveryErrors.length - 5} more`));
613
+ }
614
+ } else if (jsonlFiles.length === 0) {
615
+ discoverSpinner.fail("No JSONL files found");
616
+ console.log(chalk.dim(`Searched in: ${projectsDir}`));
617
+ if (discoveryErrors.length > 0) {
618
+ console.log(chalk.yellow("\nDirectory access errors:"));
619
+ for (const error of discoveryErrors) {
620
+ console.log(chalk.yellow(` ${error}`));
621
+ }
622
+ }
623
+ process.exit(1);
624
+ } else {
625
+ discoverSpinner.succeed(`Found ${jsonlFiles.length} JSONL file(s)`);
626
+ }
627
+
628
+ if (verbose) {
629
+ console.log(chalk.dim("\nFiles:"));
630
+ for (const file of jsonlFiles.slice(0, 10)) {
631
+ console.log(chalk.dim(` ${file}`));
632
+ }
633
+ if (jsonlFiles.length > 10) {
634
+ console.log(chalk.dim(` ... and ${jsonlFiles.length - 10} more`));
635
+ }
636
+ console.log("");
637
+ }
638
+
639
+ // Process files and collect records
640
+ const processSpinner = ora("Processing files...").start();
641
+ const allRecords: ParsedRecord[] = [];
642
+ let processedFiles = 0;
643
+ let skippedLines = 0;
644
+ let skippedFiles = 0;
645
+ let skippedMissingFields = 0;
646
+
647
+ for (const file of jsonlFiles) {
648
+ try {
649
+ for await (const result of streamRecords(file, sinceDate)) {
650
+ if (result.parseError) {
651
+ skippedLines++;
652
+ } else if (result.missingFields) {
653
+ skippedMissingFields++;
654
+ } else if (result.record) {
655
+ allRecords.push(result.record);
656
+ }
657
+ }
658
+ processedFiles++;
659
+ processSpinner.text = `Processing files... (${processedFiles}/${jsonlFiles.length})`;
660
+ } catch (error) {
661
+ skippedFiles++;
662
+ if (verbose) {
663
+ const message = error instanceof Error ? error.message : String(error);
664
+ console.log(
665
+ chalk.yellow(`\nWarning: Could not process ${file}: ${message}`),
666
+ );
667
+ }
668
+ }
669
+ }
670
+
671
+ // Build status message with skipped line info
672
+ let statusMessage = `Processed ${processedFiles} files, found ${allRecords.length} usage records`;
673
+ if (skippedLines > 0) {
674
+ statusMessage += chalk.yellow(
675
+ ` (${skippedLines} malformed line${skippedLines > 1 ? "s" : ""} skipped)`,
676
+ );
677
+ }
678
+ if (skippedMissingFields > 0) {
679
+ statusMessage += chalk.yellow(
680
+ ` (${skippedMissingFields} record${skippedMissingFields > 1 ? "s" : ""} missing required fields)`,
681
+ );
682
+ }
683
+ if (skippedFiles > 0) {
684
+ statusMessage += chalk.yellow(
685
+ ` (${skippedFiles} file${skippedFiles > 1 ? "s" : ""} failed)`,
686
+ );
687
+ }
688
+
689
+ processSpinner.succeed(statusMessage);
690
+
691
+ if (allRecords.length === 0) {
692
+ console.log(chalk.yellow("\nNo usage records found to backfill."));
693
+ if (skippedMissingFields > 0) {
694
+ console.log(
695
+ chalk.dim(
696
+ `${skippedMissingFields} record${skippedMissingFields > 1 ? "s were" : " was"} skipped due to missing required fields (timestamp, sessionId, or model).`,
697
+ ),
698
+ );
699
+ }
700
+ if (since) {
701
+ console.log(
702
+ chalk.dim(`Try a broader date range or remove the --since filter.`),
703
+ );
704
+ }
705
+ return;
706
+ }
707
+
708
+ // Calculate statistics
709
+ const stats = calculateStatistics(allRecords);
710
+
711
+ console.log("\n" + chalk.bold("Summary:"));
712
+ console.log(` Records: ${stats.totalRecords.toLocaleString()}`);
713
+ console.log(
714
+ ` Date range: ${stats.oldestTimestamp.split("T")[0]} to ${stats.newestTimestamp.split("T")[0]}`,
715
+ );
716
+ console.log(
717
+ ` Input tokens: ${stats.totalInputTokens.toLocaleString()}`,
718
+ );
719
+ console.log(
720
+ ` Output tokens: ${stats.totalOutputTokens.toLocaleString()}`,
721
+ );
722
+ console.log(
723
+ ` Cache read tokens: ${stats.totalCacheReadTokens.toLocaleString()}`,
724
+ );
725
+ console.log(
726
+ ` Cache creation: ${stats.totalCacheCreationTokens.toLocaleString()}`,
727
+ );
728
+ console.log(` Cost multiplier: ${costMultiplier}`);
729
+
730
+ if (
731
+ verbose &&
732
+ (skippedLines > 0 || skippedMissingFields > 0 || skippedFiles > 0)
733
+ ) {
734
+ console.log("\n" + chalk.dim("Skipped records:"));
735
+ if (skippedLines > 0) {
736
+ console.log(
737
+ chalk.dim(` Malformed JSON: ${skippedLines.toLocaleString()}`),
738
+ );
739
+ }
740
+ if (skippedMissingFields > 0) {
741
+ console.log(
742
+ chalk.dim(
743
+ ` Missing fields: ${skippedMissingFields.toLocaleString()} (timestamp, sessionId, or model)`,
744
+ ),
745
+ );
746
+ }
747
+ if (skippedFiles > 0) {
748
+ console.log(
749
+ chalk.dim(` Failed files: ${skippedFiles.toLocaleString()}`),
750
+ );
751
+ }
752
+ }
753
+
754
+ if (dryRun) {
755
+ console.log(
756
+ "\n" +
757
+ chalk.yellow("Dry run complete. Use without --dry-run to send data."),
758
+ );
759
+
760
+ if (verbose) {
761
+ console.log("\n" + chalk.dim("Sample OTLP payload (first batch):"));
762
+ const sampleRecords = allRecords.slice(0, Math.min(batchSize, 3));
763
+ const samplePayload = createOtlpPayload(sampleRecords, {
764
+ costMultiplier,
765
+ email: config.email,
766
+ organizationName: config.organizationName || config.organizationId,
767
+ productName: config.productName || config.productId,
768
+ });
769
+ console.log(chalk.dim(JSON.stringify(samplePayload, null, 2)));
770
+ }
771
+ return;
772
+ }
773
+
774
+ // Send data in batches
775
+ const totalBatches = Math.ceil(allRecords.length / batchSize);
776
+ const sendSpinner = ora(
777
+ `Sending data... (0/${totalBatches} batches, ~${delay}ms delay)`,
778
+ ).start();
779
+ let sentBatches = 0;
780
+ let sentRecords = 0;
781
+ let permanentlyFailedBatches = 0;
782
+ let totalRetryAttempts = 0;
783
+ const failedBatchDetails: Array<{ batchNumber: number; error: string }> = [];
784
+ const maxRetries = 3;
785
+
786
+ for (let i = 0; i < allRecords.length; i += batchSize) {
787
+ const batchNumber = Math.floor(i / batchSize) + 1;
788
+ const batch = allRecords.slice(i, i + batchSize);
789
+ const payload = createOtlpPayload(batch, {
790
+ costMultiplier,
791
+ email: config.email,
792
+ organizationName: config.organizationName || config.organizationId,
793
+ productName: config.productName || config.productId,
794
+ });
795
+
796
+ sendSpinner.text = `Sending batch ${batchNumber}/${totalBatches}...`;
797
+
798
+ const result = await sendBatch(
799
+ config.endpoint,
800
+ config.apiKey,
801
+ payload,
802
+ maxRetries,
803
+ verbose,
804
+ );
805
+
806
+ totalRetryAttempts += result.attempts;
807
+
808
+ if (result.success) {
809
+ sentBatches++;
810
+ sentRecords += batch.length;
811
+ sendSpinner.text = `Sending data... (${sentBatches}/${totalBatches} batches, ~${delay}ms delay)`;
812
+ } else {
813
+ permanentlyFailedBatches++;
814
+ failedBatchDetails.push({
815
+ batchNumber,
816
+ error: result.error || "Unknown error",
817
+ });
818
+ }
819
+
820
+ // Apply rate limiting delay between batches (except after the last batch)
821
+ if (i + batchSize < allRecords.length) {
822
+ sendSpinner.text = `Waiting ${delay}ms before next batch...`;
823
+ await sleep(delay);
824
+ }
825
+ }
826
+
827
+ if (permanentlyFailedBatches === 0) {
828
+ sendSpinner.succeed(
829
+ `Sent ${sentRecords.toLocaleString()} records in ${sentBatches} batches`,
830
+ );
831
+ } else {
832
+ sendSpinner.warn(
833
+ `Sent ${sentRecords.toLocaleString()} records in ${sentBatches} batches (${permanentlyFailedBatches} permanently failed)`,
834
+ );
835
+ }
836
+
837
+ // Show retry statistics if there were retries
838
+ const retriedBatches = totalRetryAttempts - totalBatches;
839
+ if (retriedBatches > 0 && verbose) {
840
+ console.log("\n" + chalk.bold("Retry Statistics:"));
841
+ console.log(` Total retry attempts: ${retriedBatches}`);
842
+ console.log(
843
+ ` Average attempts/batch: ${(totalRetryAttempts / totalBatches).toFixed(2)}`,
844
+ );
845
+ }
846
+
847
+ // Show permanently failed batches details
848
+ if (permanentlyFailedBatches > 0) {
849
+ console.log("\n" + chalk.red.bold("Permanently Failed Batches:"));
850
+ for (const failed of failedBatchDetails) {
851
+ console.log(chalk.red(` Batch ${failed.batchNumber}: ${failed.error}`));
852
+ }
853
+ console.log(
854
+ "\n" +
855
+ chalk.yellow(
856
+ "Tip: You can re-run the backfill command to retry failed batches.",
857
+ ),
858
+ );
859
+ }
860
+
861
+ console.log("\n" + chalk.green.bold("Backfill complete!"));
862
+ console.log(
863
+ chalk.dim("Check your Revenium dashboard to see the imported data."),
864
+ );
865
+ }