@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.
- package/.env.example +15 -0
- package/.eslintrc.js +24 -0
- package/.github/workflows/branch-bypass-alert.yml +68 -0
- package/CODE_OF_CONDUCT.md +57 -0
- package/CONTRIBUTING.md +73 -0
- package/README.md +57 -3
- package/SECURITY.md +46 -0
- package/dist/cli/commands/setup.js +3 -1
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +4 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/core/api/client.d.ts.map +1 -1
- package/dist/core/api/client.js +4 -1
- package/dist/core/api/client.js.map +1 -1
- package/dist/core/tool-context.d.ts +6 -0
- package/dist/core/tool-context.d.ts.map +1 -0
- package/dist/core/tool-context.js +21 -0
- package/dist/core/tool-context.js.map +1 -0
- package/dist/core/tool-tracker.d.ts +4 -0
- package/dist/core/tool-tracker.d.ts.map +1 -0
- package/dist/core/tool-tracker.js +156 -0
- package/dist/core/tool-tracker.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +15 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/tool-metering.d.ts +36 -0
- package/dist/types/tool-metering.d.ts.map +1 -0
- package/dist/types/tool-metering.js +3 -0
- package/dist/types/tool-metering.js.map +1 -0
- package/docs/research/settings-json-telemetry-findings.md +171 -0
- package/examples/README.md +114 -0
- package/examples/validation/validate-installation.sh +212 -0
- package/package.json +1 -7
- package/public-allowlist-node.txt +7 -0
- package/src/cli/commands/backfill.ts +865 -0
- package/src/cli/commands/setup.ts +254 -0
- package/src/cli/commands/status.ts +108 -0
- package/src/cli/commands/test.ts +91 -0
- package/src/cli/index.ts +103 -0
- package/src/core/api/client.ts +194 -0
- package/src/core/config/loader.ts +217 -0
- package/src/core/config/validator.ts +142 -0
- package/src/core/config/writer.ts +212 -0
- package/src/core/shell/detector.ts +92 -0
- package/src/core/shell/profile-updater.ts +131 -0
- package/src/core/tool-context.ts +23 -0
- package/src/core/tool-tracker.ts +204 -0
- package/src/index.ts +12 -0
- package/src/types/index.ts +110 -0
- package/src/types/tool-metering.ts +38 -0
- package/src/utils/constants.ts +80 -0
- package/src/utils/hashing.ts +35 -0
- package/src/utils/masking.ts +32 -0
- package/tests/integration/cli-commands.test.ts +158 -0
- package/tests/unit/backfill-command.test.ts +366 -0
- package/tests/unit/backfill-helpers.test.ts +397 -0
- package/tests/unit/backfill-parse.test.ts +276 -0
- package/tests/unit/backfill-stream.test.ts +147 -0
- package/tests/unit/backfill.test.ts +344 -0
- package/tests/unit/cli-index.test.ts +193 -0
- package/tests/unit/client.test.ts +195 -0
- package/tests/unit/detector.test.ts +247 -0
- package/tests/unit/hashing.test.ts +121 -0
- package/tests/unit/loader.test.ts +272 -0
- package/tests/unit/masking.test.ts +46 -0
- package/tests/unit/profile-updater.test.ts +146 -0
- package/tests/unit/setup.test.ts +557 -0
- package/tests/unit/status.test.ts +149 -0
- package/tests/unit/test.test.ts +165 -0
- package/tests/unit/validator.test.ts +211 -0
- package/tests/unit/writer.test.ts +176 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
parseRelativeDate,
|
|
5
|
+
parseSinceDate,
|
|
6
|
+
toUnixNano,
|
|
7
|
+
sanitizeErrorMessage,
|
|
8
|
+
isRetryableError,
|
|
9
|
+
findJsonlFiles,
|
|
10
|
+
createOtlpPayload,
|
|
11
|
+
} from "../../src/cli/commands/backfill.js";
|
|
12
|
+
|
|
13
|
+
vi.mock("node:fs/promises");
|
|
14
|
+
|
|
15
|
+
describe("parseRelativeDate", () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.useFakeTimers();
|
|
18
|
+
vi.setSystemTime(new Date("2024-01-15T12:00:00Z"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.useRealTimers();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should parse days correctly", () => {
|
|
26
|
+
const result = parseRelativeDate("7d");
|
|
27
|
+
expect(result).toBeInstanceOf(Date);
|
|
28
|
+
expect(result?.toISOString()).toBe("2024-01-08T12:00:00.000Z");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should parse weeks correctly", () => {
|
|
32
|
+
const result = parseRelativeDate("2w");
|
|
33
|
+
expect(result).toBeInstanceOf(Date);
|
|
34
|
+
expect(result?.toISOString()).toBe("2024-01-01T12:00:00.000Z");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should parse months correctly", () => {
|
|
38
|
+
const result = parseRelativeDate("1m");
|
|
39
|
+
expect(result).toBeInstanceOf(Date);
|
|
40
|
+
expect(result?.toISOString()).toBe("2023-12-15T12:00:00.000Z");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should parse uppercase M for months", () => {
|
|
44
|
+
const result = parseRelativeDate("3M");
|
|
45
|
+
expect(result).toBeInstanceOf(Date);
|
|
46
|
+
expect(result?.toISOString()).toBe("2023-10-15T12:00:00.000Z");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should parse years correctly", () => {
|
|
50
|
+
const result = parseRelativeDate("1y");
|
|
51
|
+
expect(result).toBeInstanceOf(Date);
|
|
52
|
+
expect(result?.toISOString()).toBe("2023-01-15T12:00:00.000Z");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should return null for invalid format", () => {
|
|
56
|
+
expect(parseRelativeDate("invalid")).toBeNull();
|
|
57
|
+
expect(parseRelativeDate("7")).toBeNull();
|
|
58
|
+
expect(parseRelativeDate("d7")).toBeNull();
|
|
59
|
+
expect(parseRelativeDate("")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("parseSinceDate", () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
vi.useFakeTimers();
|
|
66
|
+
vi.setSystemTime(new Date("2024-01-15T12:00:00Z"));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
vi.useRealTimers();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should parse relative date format", () => {
|
|
74
|
+
const result = parseSinceDate("7d");
|
|
75
|
+
expect(result).toBeInstanceOf(Date);
|
|
76
|
+
expect(result?.toISOString()).toBe("2024-01-08T12:00:00.000Z");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should parse ISO date format", () => {
|
|
80
|
+
const result = parseSinceDate("2024-01-01T00:00:00Z");
|
|
81
|
+
expect(result).toBeInstanceOf(Date);
|
|
82
|
+
expect(result?.toISOString()).toBe("2024-01-01T00:00:00.000Z");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should return null for invalid date", () => {
|
|
86
|
+
expect(parseSinceDate("invalid-date")).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("toUnixNano", () => {
|
|
91
|
+
it("should convert valid timestamp to nanoseconds", () => {
|
|
92
|
+
const result = toUnixNano("2024-01-01T00:00:00.000Z");
|
|
93
|
+
expect(result).toBe("1704067200000000000");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should return null for invalid timestamp", () => {
|
|
97
|
+
expect(toUnixNano("invalid")).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should handle milliseconds precision", () => {
|
|
101
|
+
const result = toUnixNano("2024-01-01T00:00:00.123Z");
|
|
102
|
+
expect(result).toBe("1704067200123000000");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("sanitizeErrorMessage", () => {
|
|
107
|
+
it("should truncate long error messages", () => {
|
|
108
|
+
const longMessage = "a".repeat(600);
|
|
109
|
+
const result = sanitizeErrorMessage(longMessage);
|
|
110
|
+
expect(result.length).toBe(503);
|
|
111
|
+
expect(result.endsWith("...")).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("should not truncate short error messages", () => {
|
|
115
|
+
const shortMessage = "Short error";
|
|
116
|
+
const result = sanitizeErrorMessage(shortMessage);
|
|
117
|
+
expect(result).toBe(shortMessage);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should handle exactly 500 characters", () => {
|
|
121
|
+
const message = "a".repeat(500);
|
|
122
|
+
const result = sanitizeErrorMessage(message);
|
|
123
|
+
expect(result).toBe(message);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("isRetryableError", () => {
|
|
128
|
+
it("should return true for 429 status code", () => {
|
|
129
|
+
expect(isRetryableError("OTLP request failed: 429 Too Many Requests")).toBe(
|
|
130
|
+
true,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should return false for 4xx status codes except 429", () => {
|
|
135
|
+
expect(isRetryableError("OTLP request failed: 400 Bad Request")).toBe(
|
|
136
|
+
false,
|
|
137
|
+
);
|
|
138
|
+
expect(isRetryableError("OTLP request failed: 401 Unauthorized")).toBe(
|
|
139
|
+
false,
|
|
140
|
+
);
|
|
141
|
+
expect(isRetryableError("OTLP request failed: 403 Forbidden")).toBe(false);
|
|
142
|
+
expect(isRetryableError("OTLP request failed: 404 Not Found")).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should return true for 5xx status codes", () => {
|
|
146
|
+
expect(
|
|
147
|
+
isRetryableError("OTLP request failed: 500 Internal Server Error"),
|
|
148
|
+
).toBe(true);
|
|
149
|
+
expect(isRetryableError("OTLP request failed: 502 Bad Gateway")).toBe(true);
|
|
150
|
+
expect(
|
|
151
|
+
isRetryableError("OTLP request failed: 503 Service Unavailable"),
|
|
152
|
+
).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should return true for errors without status code", () => {
|
|
156
|
+
expect(isRetryableError("Network error")).toBe(true);
|
|
157
|
+
expect(isRetryableError("Connection timeout")).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("findJsonlFiles", () => {
|
|
162
|
+
afterEach(() => {
|
|
163
|
+
vi.restoreAllMocks();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("should find jsonl files in directory", async () => {
|
|
167
|
+
vi.mocked(readdir).mockResolvedValue([
|
|
168
|
+
{ name: "file1.jsonl", isFile: () => true, isDirectory: () => false },
|
|
169
|
+
{ name: "file2.jsonl", isFile: () => true, isDirectory: () => false },
|
|
170
|
+
{ name: "file3.txt", isFile: () => true, isDirectory: () => false },
|
|
171
|
+
] as any);
|
|
172
|
+
|
|
173
|
+
const result = await findJsonlFiles("/test/dir");
|
|
174
|
+
|
|
175
|
+
expect(result.files).toHaveLength(2);
|
|
176
|
+
expect(result.files).toContain("/test/dir/file1.jsonl");
|
|
177
|
+
expect(result.files).toContain("/test/dir/file2.jsonl");
|
|
178
|
+
expect(result.errors).toHaveLength(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("should recursively search subdirectories", async () => {
|
|
182
|
+
vi.mocked(readdir)
|
|
183
|
+
.mockResolvedValueOnce([
|
|
184
|
+
{ name: "file1.jsonl", isFile: () => true, isDirectory: () => false },
|
|
185
|
+
{ name: "subdir", isFile: () => false, isDirectory: () => true },
|
|
186
|
+
] as any)
|
|
187
|
+
.mockResolvedValueOnce([
|
|
188
|
+
{ name: "file2.jsonl", isFile: () => true, isDirectory: () => false },
|
|
189
|
+
] as any);
|
|
190
|
+
|
|
191
|
+
const result = await findJsonlFiles("/test/dir");
|
|
192
|
+
|
|
193
|
+
expect(result.files).toHaveLength(2);
|
|
194
|
+
expect(result.files).toContain("/test/dir/file1.jsonl");
|
|
195
|
+
expect(result.files).toContain("/test/dir/subdir/file2.jsonl");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("should collect errors when directory read fails", async () => {
|
|
199
|
+
vi.mocked(readdir).mockRejectedValue(new Error("Permission denied"));
|
|
200
|
+
|
|
201
|
+
const result = await findJsonlFiles("/test/dir");
|
|
202
|
+
|
|
203
|
+
expect(result.files).toHaveLength(0);
|
|
204
|
+
expect(result.errors).toHaveLength(1);
|
|
205
|
+
expect(result.errors[0]).toContain("Permission denied");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("should skip non-jsonl files", async () => {
|
|
209
|
+
vi.mocked(readdir).mockResolvedValue([
|
|
210
|
+
{ name: "file1.json", isFile: () => true, isDirectory: () => false },
|
|
211
|
+
{ name: "file2.txt", isFile: () => true, isDirectory: () => false },
|
|
212
|
+
{ name: "file3.log", isFile: () => true, isDirectory: () => false },
|
|
213
|
+
] as any);
|
|
214
|
+
|
|
215
|
+
const result = await findJsonlFiles("/test/dir");
|
|
216
|
+
|
|
217
|
+
expect(result.files).toHaveLength(0);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("createOtlpPayload", () => {
|
|
222
|
+
it("should create payload with required fields", () => {
|
|
223
|
+
const records = [
|
|
224
|
+
{
|
|
225
|
+
sessionId: "session-123",
|
|
226
|
+
timestamp: "2024-01-01T00:00:00.000Z",
|
|
227
|
+
model: "claude-3-5-sonnet-20241022",
|
|
228
|
+
inputTokens: 100,
|
|
229
|
+
outputTokens: 50,
|
|
230
|
+
cacheReadTokens: 0,
|
|
231
|
+
cacheCreationTokens: 0,
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const payload = createOtlpPayload(records, { costMultiplier: 1.0 });
|
|
236
|
+
|
|
237
|
+
expect(payload.resourceLogs).toHaveLength(1);
|
|
238
|
+
expect(payload.resourceLogs[0].scopeLogs).toHaveLength(1);
|
|
239
|
+
expect(payload.resourceLogs[0].scopeLogs[0].logRecords).toHaveLength(1);
|
|
240
|
+
|
|
241
|
+
const logRecord = payload.resourceLogs[0].scopeLogs[0].logRecords[0];
|
|
242
|
+
const attrs = logRecord.attributes;
|
|
243
|
+
|
|
244
|
+
expect(attrs.find((a) => a.key === "session.id")?.value.stringValue).toBe(
|
|
245
|
+
"session-123",
|
|
246
|
+
);
|
|
247
|
+
expect(attrs.find((a) => a.key === "model")?.value.stringValue).toBe(
|
|
248
|
+
"claude-3-5-sonnet-20241022",
|
|
249
|
+
);
|
|
250
|
+
expect(attrs.find((a) => a.key === "input_tokens")?.value.intValue).toBe(
|
|
251
|
+
100,
|
|
252
|
+
);
|
|
253
|
+
expect(attrs.find((a) => a.key === "output_tokens")?.value.intValue).toBe(
|
|
254
|
+
50,
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("should include optional email field", () => {
|
|
259
|
+
const records = [
|
|
260
|
+
{
|
|
261
|
+
sessionId: "session-123",
|
|
262
|
+
timestamp: "2024-01-01T00:00:00.000Z",
|
|
263
|
+
model: "claude-3-5-sonnet-20241022",
|
|
264
|
+
inputTokens: 100,
|
|
265
|
+
outputTokens: 50,
|
|
266
|
+
cacheReadTokens: 0,
|
|
267
|
+
cacheCreationTokens: 0,
|
|
268
|
+
},
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
const payload = createOtlpPayload(records, {
|
|
272
|
+
costMultiplier: 1.0,
|
|
273
|
+
email: "test@example.com",
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const logRecord = payload.resourceLogs[0].scopeLogs[0].logRecords[0];
|
|
277
|
+
const attrs = logRecord.attributes;
|
|
278
|
+
|
|
279
|
+
expect(attrs.find((a) => a.key === "user.email")?.value.stringValue).toBe(
|
|
280
|
+
"test@example.com",
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("should include optional organizationName and productName", () => {
|
|
285
|
+
const records = [
|
|
286
|
+
{
|
|
287
|
+
sessionId: "session-123",
|
|
288
|
+
timestamp: "2024-01-01T00:00:00.000Z",
|
|
289
|
+
model: "claude-3-5-sonnet-20241022",
|
|
290
|
+
inputTokens: 100,
|
|
291
|
+
outputTokens: 50,
|
|
292
|
+
cacheReadTokens: 0,
|
|
293
|
+
cacheCreationTokens: 0,
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
const payload = createOtlpPayload(records, {
|
|
298
|
+
costMultiplier: 1.0,
|
|
299
|
+
organizationName: "org-123",
|
|
300
|
+
productName: "prod-456",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const logRecord = payload.resourceLogs[0].scopeLogs[0].logRecords[0];
|
|
304
|
+
const attrs = logRecord.attributes;
|
|
305
|
+
|
|
306
|
+
expect(
|
|
307
|
+
attrs.find((a) => a.key === "organization.name")?.value.stringValue,
|
|
308
|
+
).toBe("org-123");
|
|
309
|
+
expect(attrs.find((a) => a.key === "product.name")?.value.stringValue).toBe(
|
|
310
|
+
"prod-456",
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should support deprecated organizationId and productId fields", () => {
|
|
315
|
+
const records = [
|
|
316
|
+
{
|
|
317
|
+
sessionId: "session-123",
|
|
318
|
+
timestamp: "2024-01-01T00:00:00.000Z",
|
|
319
|
+
model: "claude-3-5-sonnet-20241022",
|
|
320
|
+
inputTokens: 100,
|
|
321
|
+
outputTokens: 50,
|
|
322
|
+
cacheReadTokens: 0,
|
|
323
|
+
cacheCreationTokens: 0,
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
const payload = createOtlpPayload(records, {
|
|
328
|
+
costMultiplier: 1.0,
|
|
329
|
+
organizationId: "org-legacy",
|
|
330
|
+
productId: "prod-legacy",
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const logRecord = payload.resourceLogs[0].scopeLogs[0].logRecords[0];
|
|
334
|
+
const attrs = logRecord.attributes;
|
|
335
|
+
|
|
336
|
+
expect(
|
|
337
|
+
attrs.find((a) => a.key === "organization.name")?.value.stringValue,
|
|
338
|
+
).toBe("org-legacy");
|
|
339
|
+
expect(attrs.find((a) => a.key === "product.name")?.value.stringValue).toBe(
|
|
340
|
+
"prod-legacy",
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("should filter out records with invalid timestamps", () => {
|
|
345
|
+
const records = [
|
|
346
|
+
{
|
|
347
|
+
sessionId: "session-123",
|
|
348
|
+
timestamp: "2024-01-01T00:00:00.000Z",
|
|
349
|
+
model: "claude-3-5-sonnet-20241022",
|
|
350
|
+
inputTokens: 100,
|
|
351
|
+
outputTokens: 50,
|
|
352
|
+
cacheReadTokens: 0,
|
|
353
|
+
cacheCreationTokens: 0,
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
sessionId: "session-456",
|
|
357
|
+
timestamp: "invalid-timestamp",
|
|
358
|
+
model: "claude-3-5-sonnet-20241022",
|
|
359
|
+
inputTokens: 100,
|
|
360
|
+
outputTokens: 50,
|
|
361
|
+
cacheReadTokens: 0,
|
|
362
|
+
cacheCreationTokens: 0,
|
|
363
|
+
},
|
|
364
|
+
];
|
|
365
|
+
|
|
366
|
+
const payload = createOtlpPayload(records, { costMultiplier: 1.0 });
|
|
367
|
+
|
|
368
|
+
expect(payload.resourceLogs[0].scopeLogs[0].logRecords).toHaveLength(1);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("should handle multiple records", () => {
|
|
372
|
+
const records = [
|
|
373
|
+
{
|
|
374
|
+
sessionId: "session-1",
|
|
375
|
+
timestamp: "2024-01-01T00:00:00.000Z",
|
|
376
|
+
model: "claude-3-5-sonnet-20241022",
|
|
377
|
+
inputTokens: 100,
|
|
378
|
+
outputTokens: 50,
|
|
379
|
+
cacheReadTokens: 0,
|
|
380
|
+
cacheCreationTokens: 0,
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
sessionId: "session-2",
|
|
384
|
+
timestamp: "2024-01-01T01:00:00.000Z",
|
|
385
|
+
model: "claude-3-5-sonnet-20241022",
|
|
386
|
+
inputTokens: 200,
|
|
387
|
+
outputTokens: 100,
|
|
388
|
+
cacheReadTokens: 0,
|
|
389
|
+
cacheCreationTokens: 0,
|
|
390
|
+
},
|
|
391
|
+
];
|
|
392
|
+
|
|
393
|
+
const payload = createOtlpPayload(records, { costMultiplier: 1.0 });
|
|
394
|
+
|
|
395
|
+
expect(payload.resourceLogs[0].scopeLogs[0].logRecords).toHaveLength(2);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseJsonlLine,
|
|
4
|
+
calculateStatistics,
|
|
5
|
+
} from "../../src/cli/commands/backfill.js";
|
|
6
|
+
|
|
7
|
+
describe("parseJsonlLine", () => {
|
|
8
|
+
const sinceDate = new Date("2024-01-01T00:00:00Z");
|
|
9
|
+
|
|
10
|
+
it("should return empty object for empty line", () => {
|
|
11
|
+
const result = parseJsonlLine("", null);
|
|
12
|
+
expect(result).toEqual({});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should return empty object for whitespace-only line", () => {
|
|
16
|
+
const result = parseJsonlLine(" ", null);
|
|
17
|
+
expect(result).toEqual({});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should return parseError for invalid JSON", () => {
|
|
21
|
+
const result = parseJsonlLine("invalid json", null);
|
|
22
|
+
expect(result).toEqual({ parseError: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should return empty object for non-assistant type", () => {
|
|
26
|
+
const line = JSON.stringify({ type: "user", message: { usage: {} } });
|
|
27
|
+
const result = parseJsonlLine(line, null);
|
|
28
|
+
expect(result).toEqual({});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should return empty object for missing usage", () => {
|
|
32
|
+
const line = JSON.stringify({ type: "assistant", message: {} });
|
|
33
|
+
const result = parseJsonlLine(line, null);
|
|
34
|
+
expect(result).toEqual({});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should return missingFields for missing timestamp", () => {
|
|
38
|
+
const line = JSON.stringify({
|
|
39
|
+
type: "assistant",
|
|
40
|
+
sessionId: "session-123",
|
|
41
|
+
message: { model: "claude-3", usage: { input_tokens: 10 } },
|
|
42
|
+
});
|
|
43
|
+
const result = parseJsonlLine(line, null);
|
|
44
|
+
expect(result).toEqual({ missingFields: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should return missingFields for missing sessionId", () => {
|
|
48
|
+
const line = JSON.stringify({
|
|
49
|
+
type: "assistant",
|
|
50
|
+
timestamp: "2024-01-15T10:00:00Z",
|
|
51
|
+
message: { model: "claude-3", usage: { input_tokens: 10 } },
|
|
52
|
+
});
|
|
53
|
+
const result = parseJsonlLine(line, null);
|
|
54
|
+
expect(result).toEqual({ missingFields: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should return missingFields for missing model", () => {
|
|
58
|
+
const line = JSON.stringify({
|
|
59
|
+
type: "assistant",
|
|
60
|
+
sessionId: "session-123",
|
|
61
|
+
timestamp: "2024-01-15T10:00:00Z",
|
|
62
|
+
message: { usage: { input_tokens: 10 } },
|
|
63
|
+
});
|
|
64
|
+
const result = parseJsonlLine(line, null);
|
|
65
|
+
expect(result).toEqual({ missingFields: true });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should return empty object for invalid timestamp", () => {
|
|
69
|
+
const line = JSON.stringify({
|
|
70
|
+
type: "assistant",
|
|
71
|
+
sessionId: "session-123",
|
|
72
|
+
timestamp: "invalid-date",
|
|
73
|
+
message: { model: "claude-3", usage: { input_tokens: 10 } },
|
|
74
|
+
});
|
|
75
|
+
const result = parseJsonlLine(line, null);
|
|
76
|
+
expect(result).toEqual({});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should return empty object for date before sinceDate", () => {
|
|
80
|
+
const line = JSON.stringify({
|
|
81
|
+
type: "assistant",
|
|
82
|
+
sessionId: "session-123",
|
|
83
|
+
timestamp: "2023-12-31T23:59:59Z",
|
|
84
|
+
message: { model: "claude-3", usage: { input_tokens: 10 } },
|
|
85
|
+
});
|
|
86
|
+
const result = parseJsonlLine(line, sinceDate);
|
|
87
|
+
expect(result).toEqual({});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should return empty object for zero tokens", () => {
|
|
91
|
+
const line = JSON.stringify({
|
|
92
|
+
type: "assistant",
|
|
93
|
+
sessionId: "session-123",
|
|
94
|
+
timestamp: "2024-01-15T10:00:00Z",
|
|
95
|
+
message: {
|
|
96
|
+
model: "claude-3",
|
|
97
|
+
usage: {
|
|
98
|
+
input_tokens: 0,
|
|
99
|
+
output_tokens: 0,
|
|
100
|
+
cache_read_input_tokens: 0,
|
|
101
|
+
cache_creation_input_tokens: 0,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const result = parseJsonlLine(line, null);
|
|
106
|
+
expect(result).toEqual({});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should parse valid record with all token types", () => {
|
|
110
|
+
const line = JSON.stringify({
|
|
111
|
+
type: "assistant",
|
|
112
|
+
sessionId: "session-123",
|
|
113
|
+
timestamp: "2024-01-15T10:00:00Z",
|
|
114
|
+
message: {
|
|
115
|
+
model: "claude-3",
|
|
116
|
+
usage: {
|
|
117
|
+
input_tokens: 100,
|
|
118
|
+
output_tokens: 50,
|
|
119
|
+
cache_read_input_tokens: 20,
|
|
120
|
+
cache_creation_input_tokens: 10,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
const result = parseJsonlLine(line, null);
|
|
125
|
+
expect(result).toEqual({
|
|
126
|
+
record: {
|
|
127
|
+
sessionId: "session-123",
|
|
128
|
+
timestamp: "2024-01-15T10:00:00Z",
|
|
129
|
+
model: "claude-3",
|
|
130
|
+
inputTokens: 100,
|
|
131
|
+
outputTokens: 50,
|
|
132
|
+
cacheReadTokens: 20,
|
|
133
|
+
cacheCreationTokens: 10,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should parse valid record with missing optional token types", () => {
|
|
139
|
+
const line = JSON.stringify({
|
|
140
|
+
type: "assistant",
|
|
141
|
+
sessionId: "session-123",
|
|
142
|
+
timestamp: "2024-01-15T10:00:00Z",
|
|
143
|
+
message: {
|
|
144
|
+
model: "claude-3",
|
|
145
|
+
usage: {
|
|
146
|
+
input_tokens: 100,
|
|
147
|
+
output_tokens: 50,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
const result = parseJsonlLine(line, null);
|
|
152
|
+
expect(result).toEqual({
|
|
153
|
+
record: {
|
|
154
|
+
sessionId: "session-123",
|
|
155
|
+
timestamp: "2024-01-15T10:00:00Z",
|
|
156
|
+
model: "claude-3",
|
|
157
|
+
inputTokens: 100,
|
|
158
|
+
outputTokens: 50,
|
|
159
|
+
cacheReadTokens: 0,
|
|
160
|
+
cacheCreationTokens: 0,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("calculateStatistics", () => {
|
|
167
|
+
it("should handle empty array", () => {
|
|
168
|
+
const stats = calculateStatistics([]);
|
|
169
|
+
|
|
170
|
+
expect(stats).toEqual({
|
|
171
|
+
totalRecords: 0,
|
|
172
|
+
oldestTimestamp: "",
|
|
173
|
+
newestTimestamp: "",
|
|
174
|
+
totalInputTokens: 0,
|
|
175
|
+
totalOutputTokens: 0,
|
|
176
|
+
totalCacheReadTokens: 0,
|
|
177
|
+
totalCacheCreationTokens: 0,
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("should calculate statistics for single record", () => {
|
|
182
|
+
const records = [
|
|
183
|
+
{
|
|
184
|
+
sessionId: "session-1",
|
|
185
|
+
timestamp: "2024-01-15T10:00:00Z",
|
|
186
|
+
model: "claude-3",
|
|
187
|
+
inputTokens: 100,
|
|
188
|
+
outputTokens: 50,
|
|
189
|
+
cacheReadTokens: 20,
|
|
190
|
+
cacheCreationTokens: 10,
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
const stats = calculateStatistics(records);
|
|
195
|
+
|
|
196
|
+
expect(stats).toEqual({
|
|
197
|
+
totalRecords: 1,
|
|
198
|
+
oldestTimestamp: "2024-01-15T10:00:00Z",
|
|
199
|
+
newestTimestamp: "2024-01-15T10:00:00Z",
|
|
200
|
+
totalInputTokens: 100,
|
|
201
|
+
totalOutputTokens: 50,
|
|
202
|
+
totalCacheReadTokens: 20,
|
|
203
|
+
totalCacheCreationTokens: 10,
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("should calculate statistics for multiple records", () => {
|
|
208
|
+
const records = [
|
|
209
|
+
{
|
|
210
|
+
sessionId: "session-1",
|
|
211
|
+
timestamp: "2024-01-15T10:00:00Z",
|
|
212
|
+
model: "claude-3",
|
|
213
|
+
inputTokens: 100,
|
|
214
|
+
outputTokens: 50,
|
|
215
|
+
cacheReadTokens: 20,
|
|
216
|
+
cacheCreationTokens: 10,
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
sessionId: "session-2",
|
|
220
|
+
timestamp: "2024-01-16T12:00:00Z",
|
|
221
|
+
model: "claude-3",
|
|
222
|
+
inputTokens: 200,
|
|
223
|
+
outputTokens: 100,
|
|
224
|
+
cacheReadTokens: 40,
|
|
225
|
+
cacheCreationTokens: 20,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
sessionId: "session-3",
|
|
229
|
+
timestamp: "2024-01-14T08:00:00Z",
|
|
230
|
+
model: "claude-3",
|
|
231
|
+
inputTokens: 50,
|
|
232
|
+
outputTokens: 25,
|
|
233
|
+
cacheReadTokens: 10,
|
|
234
|
+
cacheCreationTokens: 5,
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
const stats = calculateStatistics(records);
|
|
239
|
+
|
|
240
|
+
expect(stats).toEqual({
|
|
241
|
+
totalRecords: 3,
|
|
242
|
+
oldestTimestamp: "2024-01-14T08:00:00Z",
|
|
243
|
+
newestTimestamp: "2024-01-16T12:00:00Z",
|
|
244
|
+
totalInputTokens: 350,
|
|
245
|
+
totalOutputTokens: 175,
|
|
246
|
+
totalCacheReadTokens: 70,
|
|
247
|
+
totalCacheCreationTokens: 35,
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("should handle records with zero tokens", () => {
|
|
252
|
+
const records = [
|
|
253
|
+
{
|
|
254
|
+
sessionId: "session-1",
|
|
255
|
+
timestamp: "2024-01-15T10:00:00Z",
|
|
256
|
+
model: "claude-3",
|
|
257
|
+
inputTokens: 0,
|
|
258
|
+
outputTokens: 0,
|
|
259
|
+
cacheReadTokens: 0,
|
|
260
|
+
cacheCreationTokens: 0,
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
const stats = calculateStatistics(records);
|
|
265
|
+
|
|
266
|
+
expect(stats).toEqual({
|
|
267
|
+
totalRecords: 1,
|
|
268
|
+
oldestTimestamp: "2024-01-15T10:00:00Z",
|
|
269
|
+
newestTimestamp: "2024-01-15T10:00:00Z",
|
|
270
|
+
totalInputTokens: 0,
|
|
271
|
+
totalOutputTokens: 0,
|
|
272
|
+
totalCacheReadTokens: 0,
|
|
273
|
+
totalCacheCreationTokens: 0,
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|