@revenium/claude-code-metering 0.1.0 → 0.1.2

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 (59) hide show
  1. package/CHANGELOG.md +22 -28
  2. package/README.md +323 -139
  3. package/dist/cli/commands/backfill.d.ts +106 -1
  4. package/dist/cli/commands/backfill.d.ts.map +1 -1
  5. package/dist/cli/commands/backfill.js +359 -146
  6. package/dist/cli/commands/backfill.js.map +1 -1
  7. package/dist/cli/commands/setup.d.ts +2 -0
  8. package/dist/cli/commands/setup.d.ts.map +1 -1
  9. package/dist/cli/commands/setup.js +55 -49
  10. package/dist/cli/commands/setup.js.map +1 -1
  11. package/dist/cli/commands/status.d.ts.map +1 -1
  12. package/dist/cli/commands/status.js +23 -30
  13. package/dist/cli/commands/status.js.map +1 -1
  14. package/dist/cli/commands/test.d.ts.map +1 -1
  15. package/dist/cli/commands/test.js +4 -3
  16. package/dist/cli/commands/test.js.map +1 -1
  17. package/dist/cli/index.d.ts +2 -1
  18. package/dist/cli/index.d.ts.map +1 -1
  19. package/dist/cli/index.js +44 -30
  20. package/dist/cli/index.js.map +1 -1
  21. package/dist/core/api/client.d.ts +17 -8
  22. package/dist/core/api/client.d.ts.map +1 -1
  23. package/dist/core/api/client.js +58 -49
  24. package/dist/core/api/client.js.map +1 -1
  25. package/dist/core/config/loader.d.ts +5 -13
  26. package/dist/core/config/loader.d.ts.map +1 -1
  27. package/dist/core/config/loader.js +70 -46
  28. package/dist/core/config/loader.js.map +1 -1
  29. package/dist/core/config/validator.d.ts +5 -1
  30. package/dist/core/config/validator.d.ts.map +1 -1
  31. package/dist/core/config/validator.js +37 -22
  32. package/dist/core/config/validator.js.map +1 -1
  33. package/dist/core/config/writer.d.ts +1 -1
  34. package/dist/core/config/writer.d.ts.map +1 -1
  35. package/dist/core/config/writer.js +82 -74
  36. package/dist/core/config/writer.js.map +1 -1
  37. package/dist/core/shell/detector.d.ts +8 -1
  38. package/dist/core/shell/detector.d.ts.map +1 -1
  39. package/dist/core/shell/detector.js +38 -24
  40. package/dist/core/shell/detector.js.map +1 -1
  41. package/dist/core/shell/profile-updater.d.ts +1 -1
  42. package/dist/core/shell/profile-updater.d.ts.map +1 -1
  43. package/dist/core/shell/profile-updater.js +40 -27
  44. package/dist/core/shell/profile-updater.js.map +1 -1
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +1 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/types/index.d.ts +30 -25
  50. package/dist/types/index.d.ts.map +1 -1
  51. package/dist/utils/constants.d.ts +2 -2
  52. package/dist/utils/constants.d.ts.map +1 -1
  53. package/dist/utils/constants.js +2 -2
  54. package/dist/utils/constants.js.map +1 -1
  55. package/dist/utils/hashing.d.ts +18 -0
  56. package/dist/utils/hashing.d.ts.map +1 -0
  57. package/dist/utils/hashing.js +27 -0
  58. package/dist/utils/hashing.js.map +1 -0
  59. package/package.json +6 -3
@@ -3,6 +3,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.sleep = sleep;
7
+ exports.sanitizeErrorMessage = sanitizeErrorMessage;
8
+ exports.isRetryableError = isRetryableError;
9
+ exports.sendBatchWithRetry = sendBatchWithRetry;
10
+ exports.parseRelativeDate = parseRelativeDate;
11
+ exports.parseSinceDate = parseSinceDate;
12
+ exports.findJsonlFiles = findJsonlFiles;
13
+ exports.calculateStatistics = calculateStatistics;
14
+ exports.parseJsonlLine = parseJsonlLine;
15
+ exports.streamJsonlRecords = streamJsonlRecords;
16
+ exports.toUnixNano = toUnixNano;
17
+ exports.createOtlpPayload = createOtlpPayload;
6
18
  exports.backfillCommand = backfillCommand;
7
19
  const node_fs_1 = require("node:fs");
8
20
  const promises_1 = require("node:fs/promises");
@@ -14,6 +26,83 @@ const ora_1 = __importDefault(require("ora"));
14
26
  const loader_js_1 = require("../../core/config/loader.js");
15
27
  const client_js_1 = require("../../core/api/client.js");
16
28
  const constants_js_1 = require("../../utils/constants.js");
29
+ const hashing_js_1 = require("../../utils/hashing.js");
30
+ /**
31
+ * Sleep for a specified number of milliseconds.
32
+ */
33
+ function sleep(ms) {
34
+ return new Promise((resolve) => setTimeout(resolve, ms));
35
+ }
36
+ /**
37
+ * Sanitize error message to prevent API key leakage.
38
+ * Truncates long messages and removes potential sensitive data.
39
+ */
40
+ function sanitizeErrorMessage(errorMsg) {
41
+ const maxLength = 500;
42
+ let sanitized = errorMsg;
43
+ if (sanitized.length > maxLength) {
44
+ sanitized = `${sanitized.substring(0, maxLength)}...`;
45
+ }
46
+ return sanitized;
47
+ }
48
+ /**
49
+ * Check if an error is retryable based on HTTP status code.
50
+ * 4xx errors (except 429) are not retryable as they indicate client errors.
51
+ */
52
+ function isRetryableError(errorMsg) {
53
+ const statusMatch = errorMsg.match(/OTLP request failed: (\d{3})/);
54
+ if (!statusMatch) {
55
+ return true;
56
+ }
57
+ const statusCode = parseInt(statusMatch[1], 10);
58
+ if (statusCode === 429) {
59
+ return true;
60
+ }
61
+ if (statusCode >= 400 && statusCode < 500) {
62
+ return false;
63
+ }
64
+ return true;
65
+ }
66
+ /**
67
+ * Send a batch with retry logic and exponential backoff.
68
+ */
69
+ async function sendBatchWithRetry(endpoint, apiKey, payload, maxRetries, verbose) {
70
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
71
+ try {
72
+ await (0, client_js_1.sendOtlpLogs)(endpoint, apiKey, payload);
73
+ if (verbose && attempt > 0) {
74
+ console.log(chalk_1.default.green(` ✓ Succeeded after ${attempt + 1} attempts`));
75
+ }
76
+ return { success: true, attempts: attempt + 1 };
77
+ }
78
+ catch (error) {
79
+ const rawErrorMsg = error instanceof Error ? error.message : "Unknown error";
80
+ const errorMsg = sanitizeErrorMessage(rawErrorMsg);
81
+ const isRetryable = isRetryableError(errorMsg);
82
+ if (!isRetryable) {
83
+ if (verbose) {
84
+ console.log(chalk_1.default.red(` ✗ Non-retryable error (client error): ${errorMsg}`));
85
+ }
86
+ return { success: false, attempts: attempt + 1, error: errorMsg };
87
+ }
88
+ if (attempt < maxRetries - 1) {
89
+ const backoffDelay = 1000 * Math.pow(2, attempt);
90
+ if (verbose) {
91
+ console.log(chalk_1.default.yellow(` ✗ Attempt ${attempt + 1} failed: ${errorMsg}`));
92
+ console.log(chalk_1.default.blue(` ⏳ Retrying in ${backoffDelay}ms...`));
93
+ }
94
+ await sleep(backoffDelay);
95
+ }
96
+ else {
97
+ if (verbose) {
98
+ console.log(chalk_1.default.red(` ✗ All ${maxRetries} attempts failed`));
99
+ }
100
+ return { success: false, attempts: maxRetries, error: errorMsg };
101
+ }
102
+ }
103
+ }
104
+ return { success: false, attempts: maxRetries };
105
+ }
17
106
  /**
18
107
  * Parses a relative date string like "7d" or "1m" into a Date.
19
108
  */
@@ -25,19 +114,19 @@ function parseRelativeDate(input) {
25
114
  const unit = match[2];
26
115
  const now = new Date();
27
116
  switch (unit) {
28
- case 'd':
117
+ case "d":
29
118
  now.setDate(now.getDate() - amount);
30
119
  break;
31
- case 'w':
120
+ case "w":
32
121
  now.setDate(now.getDate() - amount * 7);
33
122
  break;
34
- case 'm':
123
+ case "m":
35
124
  now.setMonth(now.getMonth() - amount);
36
125
  break;
37
- case 'M':
126
+ case "M":
38
127
  now.setMonth(now.getMonth() - amount);
39
128
  break;
40
- case 'y':
129
+ case "y":
41
130
  now.setFullYear(now.getFullYear() - amount);
42
131
  break;
43
132
  default:
@@ -73,7 +162,7 @@ async function findJsonlFiles(dir, errors = []) {
73
162
  const result = await findJsonlFiles(fullPath, errors);
74
163
  files.push(...result.files);
75
164
  }
76
- else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
165
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
77
166
  files.push(fullPath);
78
167
  }
79
168
  }
@@ -84,6 +173,76 @@ async function findJsonlFiles(dir, errors = []) {
84
173
  }
85
174
  return { files, errors };
86
175
  }
176
+ function calculateStatistics(records) {
177
+ if (records.length === 0) {
178
+ return {
179
+ totalRecords: 0,
180
+ oldestTimestamp: "",
181
+ newestTimestamp: "",
182
+ totalInputTokens: 0,
183
+ totalOutputTokens: 0,
184
+ totalCacheReadTokens: 0,
185
+ totalCacheCreationTokens: 0,
186
+ };
187
+ }
188
+ const sortedRecords = [...records].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
189
+ return {
190
+ totalRecords: records.length,
191
+ oldestTimestamp: sortedRecords[0].timestamp,
192
+ newestTimestamp: sortedRecords[sortedRecords.length - 1].timestamp,
193
+ totalInputTokens: records.reduce((sum, r) => sum + r.inputTokens, 0),
194
+ totalOutputTokens: records.reduce((sum, r) => sum + r.outputTokens, 0),
195
+ totalCacheReadTokens: records.reduce((sum, r) => sum + r.cacheReadTokens, 0),
196
+ totalCacheCreationTokens: records.reduce((sum, r) => sum + r.cacheCreationTokens, 0),
197
+ };
198
+ }
199
+ function parseJsonlLine(line, sinceDate) {
200
+ if (!line.trim()) {
201
+ return {};
202
+ }
203
+ let entry;
204
+ try {
205
+ entry = JSON.parse(line);
206
+ }
207
+ catch {
208
+ return { parseError: true };
209
+ }
210
+ if (entry.type !== "assistant" || !entry.message?.usage) {
211
+ return {};
212
+ }
213
+ const usage = entry.message.usage;
214
+ const timestamp = entry.timestamp;
215
+ const sessionId = entry.sessionId;
216
+ const model = entry.message.model;
217
+ if (!timestamp || !sessionId || !model) {
218
+ return { missingFields: true };
219
+ }
220
+ const entryDate = new Date(timestamp);
221
+ if (!Number.isFinite(entryDate.getTime())) {
222
+ return {};
223
+ }
224
+ if (sinceDate && entryDate < sinceDate) {
225
+ return {};
226
+ }
227
+ const totalTokens = (usage.input_tokens || 0) +
228
+ (usage.output_tokens || 0) +
229
+ (usage.cache_read_input_tokens || 0) +
230
+ (usage.cache_creation_input_tokens || 0);
231
+ if (totalTokens === 0) {
232
+ return {};
233
+ }
234
+ return {
235
+ record: {
236
+ sessionId,
237
+ timestamp,
238
+ model,
239
+ inputTokens: usage.input_tokens || 0,
240
+ outputTokens: usage.output_tokens || 0,
241
+ cacheReadTokens: usage.cache_read_input_tokens || 0,
242
+ cacheCreationTokens: usage.cache_creation_input_tokens || 0,
243
+ },
244
+ };
245
+ }
87
246
  /**
88
247
  * Streams a JSONL file and extracts records with usage data.
89
248
  * Yields objects indicating either a valid record or a parse error.
@@ -96,56 +255,13 @@ async function* streamJsonlRecords(filePath, sinceDate) {
96
255
  });
97
256
  try {
98
257
  for await (const line of rl) {
99
- if (!line.trim())
100
- continue;
101
- try {
102
- const entry = JSON.parse(line);
103
- // Only process assistant messages with usage data
104
- if (entry.type !== 'assistant' || !entry.message?.usage)
105
- continue;
106
- const usage = entry.message.usage;
107
- const timestamp = entry.timestamp;
108
- const sessionId = entry.sessionId;
109
- const model = entry.message.model;
110
- // Skip if missing required fields
111
- if (!timestamp || !sessionId || !model)
112
- continue;
113
- // Validate timestamp is a valid date
114
- const entryDate = new Date(timestamp);
115
- if (!Number.isFinite(entryDate.getTime()))
116
- continue;
117
- // Check date filter
118
- if (sinceDate) {
119
- if (entryDate < sinceDate)
120
- continue;
121
- }
122
- // Skip entries with no actual token usage
123
- const totalTokens = (usage.input_tokens || 0) +
124
- (usage.output_tokens || 0) +
125
- (usage.cache_read_input_tokens || 0) +
126
- (usage.cache_creation_input_tokens || 0);
127
- if (totalTokens === 0)
128
- continue;
129
- yield {
130
- record: {
131
- sessionId,
132
- timestamp,
133
- model,
134
- inputTokens: usage.input_tokens || 0,
135
- outputTokens: usage.output_tokens || 0,
136
- cacheReadTokens: usage.cache_read_input_tokens || 0,
137
- cacheCreationTokens: usage.cache_creation_input_tokens || 0,
138
- },
139
- };
140
- }
141
- catch {
142
- // Invalid JSON line, signal parse error
143
- yield { parseError: true };
258
+ const result = parseJsonlLine(line, sinceDate);
259
+ if (result.record || result.parseError || result.missingFields) {
260
+ yield result;
144
261
  }
145
262
  }
146
263
  }
147
264
  finally {
148
- // Ensure file stream is properly closed even on early exit
149
265
  fileStream.destroy();
150
266
  rl.close();
151
267
  }
@@ -162,55 +278,95 @@ function toUnixNano(timestamp) {
162
278
  }
163
279
  return (BigInt(ms) * BigInt(1_000_000)).toString();
164
280
  }
165
- /**
166
- * Creates an OTEL metrics payload from parsed records.
167
- * Each record generates multiple metrics (input_tokens, output_tokens, etc.)
168
- */
169
- function createOtlpPayload(records, costMultiplier) {
170
- // Build metrics for all records
171
- const allMetrics = [];
172
- for (const record of records) {
281
+ function createOtlpPayload(records, options) {
282
+ const { costMultiplier, email, organizationName, organizationId, productName, productId, } = options;
283
+ // Support both new and old field names with fallback
284
+ const organizationValue = organizationName || organizationId;
285
+ const productValue = productName || productId;
286
+ // Filter and map records, skipping any with invalid timestamps
287
+ const logRecords = records
288
+ .map((record) => {
173
289
  const timeUnixNano = toUnixNano(record.timestamp);
174
- if (timeUnixNano === null)
175
- continue;
176
- // Common attributes for this record
290
+ if (timeUnixNano === null) {
291
+ return null;
292
+ }
293
+ // Build attributes array with required fields
177
294
  const attributes = [
178
- { key: 'ai.transaction_id', value: { stringValue: record.sessionId } },
179
- { key: 'ai.model', value: { stringValue: record.model } },
180
- { key: 'ai.provider', value: { stringValue: 'anthropic' } },
181
- { key: 'cost_multiplier', value: { doubleValue: costMultiplier } },
182
- ];
183
- // Create metrics for each token type
184
- const tokenMetrics = [
185
- { name: 'ai.tokens.input', value: record.inputTokens },
186
- { name: 'ai.tokens.output', value: record.outputTokens },
187
- { name: 'ai.tokens.cache_read', value: record.cacheReadTokens },
188
- { name: 'ai.tokens.cache_creation', value: record.cacheCreationTokens },
295
+ {
296
+ key: "transaction_id",
297
+ value: { stringValue: (0, hashing_js_1.generateTransactionId)(record) },
298
+ },
299
+ {
300
+ key: "session.id",
301
+ value: { stringValue: record.sessionId },
302
+ },
303
+ {
304
+ key: "model",
305
+ value: { stringValue: record.model },
306
+ },
307
+ {
308
+ key: "input_tokens",
309
+ value: { intValue: record.inputTokens },
310
+ },
311
+ {
312
+ key: "output_tokens",
313
+ value: { intValue: record.outputTokens },
314
+ },
315
+ {
316
+ key: "cache_read_tokens",
317
+ value: { intValue: record.cacheReadTokens },
318
+ },
319
+ {
320
+ key: "cache_creation_tokens",
321
+ value: { intValue: record.cacheCreationTokens },
322
+ },
189
323
  ];
190
- for (const metric of tokenMetrics) {
191
- allMetrics.push({
192
- name: metric.name,
193
- sum: {
194
- dataPoints: [{
195
- attributes,
196
- timeUnixNano,
197
- asInt: metric.value,
198
- }],
199
- },
324
+ // Add optional subscriber/attribution attributes at log record level
325
+ // (backend ClaudeCodeMapper reads these from log record attrs, not resource attrs)
326
+ if (email) {
327
+ attributes.push({ key: "user.email", value: { stringValue: email } });
328
+ }
329
+ if (organizationValue) {
330
+ attributes.push({
331
+ key: "organization.name",
332
+ value: { stringValue: organizationValue },
200
333
  });
201
334
  }
202
- }
335
+ if (productValue) {
336
+ attributes.push({
337
+ key: "product.name",
338
+ value: { stringValue: productValue },
339
+ });
340
+ }
341
+ return {
342
+ timeUnixNano,
343
+ body: { stringValue: "claude_code.api_request" },
344
+ attributes,
345
+ };
346
+ })
347
+ .filter((record) => record !== null);
203
348
  return {
204
- resourceMetrics: [
349
+ resourceLogs: [
205
350
  {
206
351
  resource: {
207
352
  attributes: [
208
- { key: 'service.name', value: { stringValue: 'claude-code' } },
353
+ {
354
+ key: "service.name",
355
+ value: { stringValue: "claude-code" },
356
+ },
357
+ {
358
+ key: "cost_multiplier",
359
+ value: { doubleValue: costMultiplier },
360
+ },
209
361
  ],
210
362
  },
211
- scopeMetrics: [
363
+ scopeLogs: [
212
364
  {
213
- metrics: allMetrics,
365
+ scope: {
366
+ name: "claude-code",
367
+ version: "1.0.0",
368
+ },
369
+ logRecords,
214
370
  },
215
371
  ],
216
372
  },
@@ -220,17 +376,18 @@ function createOtlpPayload(records, costMultiplier) {
220
376
  /**
221
377
  * Backfill command - imports historical Claude Code usage data.
222
378
  */
223
- async function backfillCommand(options = {}) {
224
- const { since, dryRun = false, batchSize = 100, verbose = false } = options;
225
- console.log(chalk_1.default.bold('\nRevenium Claude Code Backfill\n'));
379
+ async function backfillCommand(options = {}, deps = {}) {
380
+ const { since, dryRun = false, batchSize = 100, delay = 100, verbose = false, } = options;
381
+ const { loadConfig: getConfig = loader_js_1.loadConfig, findJsonlFiles: findFiles = findJsonlFiles, streamJsonlRecords: streamRecords = streamJsonlRecords, sendBatchWithRetry: sendBatch = sendBatchWithRetry, homedir: getHomedir = node_os_1.homedir, } = deps;
382
+ console.log(chalk_1.default.bold("\nRevenium Claude Code Backfill\n"));
226
383
  if (dryRun) {
227
- console.log(chalk_1.default.yellow('Running in dry-run mode - no data will be sent\n'));
384
+ console.log(chalk_1.default.yellow("Running in dry-run mode - no data will be sent\n"));
228
385
  }
229
386
  // Load configuration
230
- const config = await (0, loader_js_1.loadConfig)();
387
+ const config = await getConfig();
231
388
  if (!config) {
232
- console.log(chalk_1.default.red('Configuration not found'));
233
- console.log(chalk_1.default.yellow('\nRun `revenium-metering setup` to configure Claude Code metering.'));
389
+ console.log(chalk_1.default.red("Configuration not found"));
390
+ console.log(chalk_1.default.yellow("\nRun `revenium-metering setup` to configure Claude Code metering."));
234
391
  process.exit(1);
235
392
  }
236
393
  // Parse since date
@@ -239,21 +396,23 @@ async function backfillCommand(options = {}) {
239
396
  sinceDate = parseSinceDate(since);
240
397
  if (!sinceDate) {
241
398
  console.log(chalk_1.default.red(`Invalid --since value: ${since}`));
242
- console.log(chalk_1.default.dim('Use ISO format (2024-01-15) or relative format (7d, 1m, 1y)'));
399
+ console.log(chalk_1.default.dim("Use ISO format (2024-01-15) or relative format (7d, 1m, 1y)"));
243
400
  process.exit(1);
244
401
  }
245
402
  console.log(chalk_1.default.dim(`Filtering records since: ${sinceDate.toISOString()}\n`));
246
403
  }
247
404
  // Get cost multiplier (use ?? to allow explicit 0 override for free tier/testing)
248
405
  const costMultiplier = config.costMultiplierOverride ??
249
- (config.subscriptionTier ? (0, constants_js_1.getCostMultiplier)(config.subscriptionTier) : 0.08);
406
+ (config.subscriptionTier
407
+ ? (0, constants_js_1.getCostMultiplier)(config.subscriptionTier)
408
+ : 0.08);
250
409
  // Discover JSONL files
251
- const projectsDir = (0, node_path_1.join)((0, node_os_1.homedir)(), '.claude', 'projects');
252
- const discoverSpinner = (0, ora_1.default)('Discovering JSONL files...').start();
253
- const { files: jsonlFiles, errors: discoveryErrors } = await findJsonlFiles(projectsDir);
410
+ const projectsDir = (0, node_path_1.join)(getHomedir(), ".claude", "projects");
411
+ const discoverSpinner = (0, ora_1.default)("Discovering JSONL files...").start();
412
+ const { files: jsonlFiles, errors: discoveryErrors } = await findFiles(projectsDir);
254
413
  if (discoveryErrors.length > 0 && verbose) {
255
414
  discoverSpinner.warn(`Found ${jsonlFiles.length} JSONL file(s) with ${discoveryErrors.length} directory error(s)`);
256
- console.log(chalk_1.default.yellow('\nDirectory access errors:'));
415
+ console.log(chalk_1.default.yellow("\nDirectory access errors:"));
257
416
  for (const error of discoveryErrors.slice(0, 5)) {
258
417
  console.log(chalk_1.default.yellow(` ${error}`));
259
418
  }
@@ -262,10 +421,10 @@ async function backfillCommand(options = {}) {
262
421
  }
263
422
  }
264
423
  else if (jsonlFiles.length === 0) {
265
- discoverSpinner.fail('No JSONL files found');
424
+ discoverSpinner.fail("No JSONL files found");
266
425
  console.log(chalk_1.default.dim(`Searched in: ${projectsDir}`));
267
426
  if (discoveryErrors.length > 0) {
268
- console.log(chalk_1.default.yellow('\nDirectory access errors:'));
427
+ console.log(chalk_1.default.yellow("\nDirectory access errors:"));
269
428
  for (const error of discoveryErrors) {
270
429
  console.log(chalk_1.default.yellow(` ${error}`));
271
430
  }
@@ -276,27 +435,31 @@ async function backfillCommand(options = {}) {
276
435
  discoverSpinner.succeed(`Found ${jsonlFiles.length} JSONL file(s)`);
277
436
  }
278
437
  if (verbose) {
279
- console.log(chalk_1.default.dim('\nFiles:'));
438
+ console.log(chalk_1.default.dim("\nFiles:"));
280
439
  for (const file of jsonlFiles.slice(0, 10)) {
281
440
  console.log(chalk_1.default.dim(` ${file}`));
282
441
  }
283
442
  if (jsonlFiles.length > 10) {
284
443
  console.log(chalk_1.default.dim(` ... and ${jsonlFiles.length - 10} more`));
285
444
  }
286
- console.log('');
445
+ console.log("");
287
446
  }
288
447
  // Process files and collect records
289
- const processSpinner = (0, ora_1.default)('Processing files...').start();
448
+ const processSpinner = (0, ora_1.default)("Processing files...").start();
290
449
  const allRecords = [];
291
450
  let processedFiles = 0;
292
451
  let skippedLines = 0;
293
452
  let skippedFiles = 0;
453
+ let skippedMissingFields = 0;
294
454
  for (const file of jsonlFiles) {
295
455
  try {
296
- for await (const result of streamJsonlRecords(file, sinceDate)) {
456
+ for await (const result of streamRecords(file, sinceDate)) {
297
457
  if (result.parseError) {
298
458
  skippedLines++;
299
459
  }
460
+ else if (result.missingFields) {
461
+ skippedMissingFields++;
462
+ }
300
463
  else if (result.record) {
301
464
  allRecords.push(result.record);
302
465
  }
@@ -315,76 +478,126 @@ async function backfillCommand(options = {}) {
315
478
  // Build status message with skipped line info
316
479
  let statusMessage = `Processed ${processedFiles} files, found ${allRecords.length} usage records`;
317
480
  if (skippedLines > 0) {
318
- statusMessage += chalk_1.default.yellow(` (${skippedLines} malformed line${skippedLines > 1 ? 's' : ''} skipped)`);
481
+ statusMessage += chalk_1.default.yellow(` (${skippedLines} malformed line${skippedLines > 1 ? "s" : ""} skipped)`);
482
+ }
483
+ if (skippedMissingFields > 0) {
484
+ statusMessage += chalk_1.default.yellow(` (${skippedMissingFields} record${skippedMissingFields > 1 ? "s" : ""} missing required fields)`);
319
485
  }
320
486
  if (skippedFiles > 0) {
321
- statusMessage += chalk_1.default.yellow(` (${skippedFiles} file${skippedFiles > 1 ? 's' : ''} failed)`);
487
+ statusMessage += chalk_1.default.yellow(` (${skippedFiles} file${skippedFiles > 1 ? "s" : ""} failed)`);
322
488
  }
323
489
  processSpinner.succeed(statusMessage);
324
490
  if (allRecords.length === 0) {
325
- console.log(chalk_1.default.yellow('\nNo usage records found to backfill.'));
491
+ console.log(chalk_1.default.yellow("\nNo usage records found to backfill."));
492
+ if (skippedMissingFields > 0) {
493
+ console.log(chalk_1.default.dim(`${skippedMissingFields} record${skippedMissingFields > 1 ? "s were" : " was"} skipped due to missing required fields (timestamp, sessionId, or model).`));
494
+ }
326
495
  if (since) {
327
496
  console.log(chalk_1.default.dim(`Try a broader date range or remove the --since filter.`));
328
497
  }
329
498
  return;
330
499
  }
331
- // Sort records by timestamp
332
- allRecords.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
333
- // Show summary
334
- const oldestRecord = allRecords[0];
335
- const newestRecord = allRecords[allRecords.length - 1];
336
- const totalInputTokens = allRecords.reduce((sum, r) => sum + r.inputTokens, 0);
337
- const totalOutputTokens = allRecords.reduce((sum, r) => sum + r.outputTokens, 0);
338
- const totalCacheReadTokens = allRecords.reduce((sum, r) => sum + r.cacheReadTokens, 0);
339
- const totalCacheCreationTokens = allRecords.reduce((sum, r) => sum + r.cacheCreationTokens, 0);
340
- console.log('\n' + chalk_1.default.bold('Summary:'));
341
- console.log(` Records: ${allRecords.length.toLocaleString()}`);
342
- console.log(` Date range: ${oldestRecord.timestamp.split('T')[0]} to ${newestRecord.timestamp.split('T')[0]}`);
343
- console.log(` Input tokens: ${totalInputTokens.toLocaleString()}`);
344
- console.log(` Output tokens: ${totalOutputTokens.toLocaleString()}`);
345
- console.log(` Cache read tokens: ${totalCacheReadTokens.toLocaleString()}`);
346
- console.log(` Cache creation: ${totalCacheCreationTokens.toLocaleString()}`);
500
+ // Calculate statistics
501
+ const stats = calculateStatistics(allRecords);
502
+ console.log("\n" + chalk_1.default.bold("Summary:"));
503
+ console.log(` Records: ${stats.totalRecords.toLocaleString()}`);
504
+ console.log(` Date range: ${stats.oldestTimestamp.split("T")[0]} to ${stats.newestTimestamp.split("T")[0]}`);
505
+ console.log(` Input tokens: ${stats.totalInputTokens.toLocaleString()}`);
506
+ console.log(` Output tokens: ${stats.totalOutputTokens.toLocaleString()}`);
507
+ console.log(` Cache read tokens: ${stats.totalCacheReadTokens.toLocaleString()}`);
508
+ console.log(` Cache creation: ${stats.totalCacheCreationTokens.toLocaleString()}`);
347
509
  console.log(` Cost multiplier: ${costMultiplier}`);
510
+ if (verbose &&
511
+ (skippedLines > 0 || skippedMissingFields > 0 || skippedFiles > 0)) {
512
+ console.log("\n" + chalk_1.default.dim("Skipped records:"));
513
+ if (skippedLines > 0) {
514
+ console.log(chalk_1.default.dim(` Malformed JSON: ${skippedLines.toLocaleString()}`));
515
+ }
516
+ if (skippedMissingFields > 0) {
517
+ console.log(chalk_1.default.dim(` Missing fields: ${skippedMissingFields.toLocaleString()} (timestamp, sessionId, or model)`));
518
+ }
519
+ if (skippedFiles > 0) {
520
+ console.log(chalk_1.default.dim(` Failed files: ${skippedFiles.toLocaleString()}`));
521
+ }
522
+ }
348
523
  if (dryRun) {
349
- console.log('\n' + chalk_1.default.yellow('Dry run complete. Use without --dry-run to send data.'));
524
+ console.log("\n" +
525
+ chalk_1.default.yellow("Dry run complete. Use without --dry-run to send data."));
350
526
  if (verbose) {
351
- console.log('\n' + chalk_1.default.dim('Sample OTLP payload (first batch):'));
527
+ console.log("\n" + chalk_1.default.dim("Sample OTLP payload (first batch):"));
352
528
  const sampleRecords = allRecords.slice(0, Math.min(batchSize, 3));
353
- const samplePayload = createOtlpPayload(sampleRecords, costMultiplier);
529
+ const samplePayload = createOtlpPayload(sampleRecords, {
530
+ costMultiplier,
531
+ email: config.email,
532
+ organizationName: config.organizationName || config.organizationId,
533
+ productName: config.productName || config.productId,
534
+ });
354
535
  console.log(chalk_1.default.dim(JSON.stringify(samplePayload, null, 2)));
355
536
  }
356
537
  return;
357
538
  }
358
539
  // Send data in batches
359
540
  const totalBatches = Math.ceil(allRecords.length / batchSize);
360
- const sendSpinner = (0, ora_1.default)(`Sending data... (0/${totalBatches} batches)`).start();
541
+ const sendSpinner = (0, ora_1.default)(`Sending data... (0/${totalBatches} batches, ~${delay}ms delay)`).start();
361
542
  let sentBatches = 0;
362
543
  let sentRecords = 0;
363
- let failedBatches = 0;
544
+ let permanentlyFailedBatches = 0;
545
+ let totalRetryAttempts = 0;
546
+ const failedBatchDetails = [];
547
+ const maxRetries = 3;
364
548
  for (let i = 0; i < allRecords.length; i += batchSize) {
549
+ const batchNumber = Math.floor(i / batchSize) + 1;
365
550
  const batch = allRecords.slice(i, i + batchSize);
366
- const payload = createOtlpPayload(batch, costMultiplier);
367
- try {
368
- await (0, client_js_1.sendOtlpMetrics)(config.endpoint, config.apiKey, payload);
551
+ const payload = createOtlpPayload(batch, {
552
+ costMultiplier,
553
+ email: config.email,
554
+ organizationName: config.organizationName || config.organizationId,
555
+ productName: config.productName || config.productId,
556
+ });
557
+ sendSpinner.text = `Sending batch ${batchNumber}/${totalBatches}...`;
558
+ const result = await sendBatch(config.endpoint, config.apiKey, payload, maxRetries, verbose);
559
+ totalRetryAttempts += result.attempts;
560
+ if (result.success) {
369
561
  sentBatches++;
370
562
  sentRecords += batch.length;
371
- sendSpinner.text = `Sending data... (${sentBatches}/${totalBatches} batches)`;
563
+ sendSpinner.text = `Sending data... (${sentBatches}/${totalBatches} batches, ~${delay}ms delay)`;
372
564
  }
373
- catch (error) {
374
- failedBatches++;
375
- if (verbose) {
376
- const batchNumber = Math.floor(i / batchSize) + 1;
377
- console.log(chalk_1.default.yellow(`\nBatch ${batchNumber} failed: ${error instanceof Error ? error.message : 'Unknown error'}`));
378
- }
565
+ else {
566
+ permanentlyFailedBatches++;
567
+ failedBatchDetails.push({
568
+ batchNumber,
569
+ error: result.error || "Unknown error",
570
+ });
571
+ }
572
+ // Apply rate limiting delay between batches (except after the last batch)
573
+ if (i + batchSize < allRecords.length) {
574
+ sendSpinner.text = `Waiting ${delay}ms before next batch...`;
575
+ await sleep(delay);
379
576
  }
380
577
  }
381
- if (failedBatches === 0) {
578
+ if (permanentlyFailedBatches === 0) {
382
579
  sendSpinner.succeed(`Sent ${sentRecords.toLocaleString()} records in ${sentBatches} batches`);
383
580
  }
384
581
  else {
385
- sendSpinner.warn(`Sent ${sentRecords.toLocaleString()} records in ${sentBatches} batches (${failedBatches} failed)`);
582
+ sendSpinner.warn(`Sent ${sentRecords.toLocaleString()} records in ${sentBatches} batches (${permanentlyFailedBatches} permanently failed)`);
583
+ }
584
+ // Show retry statistics if there were retries
585
+ const retriedBatches = totalRetryAttempts - totalBatches;
586
+ if (retriedBatches > 0 && verbose) {
587
+ console.log("\n" + chalk_1.default.bold("Retry Statistics:"));
588
+ console.log(` Total retry attempts: ${retriedBatches}`);
589
+ console.log(` Average attempts/batch: ${(totalRetryAttempts / totalBatches).toFixed(2)}`);
590
+ }
591
+ // Show permanently failed batches details
592
+ if (permanentlyFailedBatches > 0) {
593
+ console.log("\n" + chalk_1.default.red.bold("Permanently Failed Batches:"));
594
+ for (const failed of failedBatchDetails) {
595
+ console.log(chalk_1.default.red(` Batch ${failed.batchNumber}: ${failed.error}`));
596
+ }
597
+ console.log("\n" +
598
+ chalk_1.default.yellow("Tip: You can re-run the backfill command to retry failed batches."));
386
599
  }
387
- console.log('\n' + chalk_1.default.green.bold('Backfill complete!'));
388
- console.log(chalk_1.default.dim('Check your Revenium dashboard to see the imported data.'));
600
+ console.log("\n" + chalk_1.default.green.bold("Backfill complete!"));
601
+ console.log(chalk_1.default.dim("Check your Revenium dashboard to see the imported data."));
389
602
  }
390
603
  //# sourceMappingURL=backfill.js.map