@revenium/claude-code-metering 0.1.4 → 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 (75) 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/core/api/client.d.ts.map +1 -1
  11. package/dist/core/api/client.js +4 -1
  12. package/dist/core/api/client.js.map +1 -1
  13. package/dist/core/tool-context.d.ts +6 -0
  14. package/dist/core/tool-context.d.ts.map +1 -0
  15. package/dist/core/tool-context.js +21 -0
  16. package/dist/core/tool-context.js.map +1 -0
  17. package/dist/core/tool-tracker.d.ts +4 -0
  18. package/dist/core/tool-tracker.d.ts.map +1 -0
  19. package/dist/core/tool-tracker.js +156 -0
  20. package/dist/core/tool-tracker.js.map +1 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/types/index.d.ts +1 -0
  26. package/dist/types/index.d.ts.map +1 -1
  27. package/dist/types/index.js +15 -0
  28. package/dist/types/index.js.map +1 -1
  29. package/dist/types/tool-metering.d.ts +36 -0
  30. package/dist/types/tool-metering.d.ts.map +1 -0
  31. package/dist/types/tool-metering.js +3 -0
  32. package/dist/types/tool-metering.js.map +1 -0
  33. package/docs/research/settings-json-telemetry-findings.md +171 -0
  34. package/examples/README.md +114 -0
  35. package/examples/validation/validate-installation.sh +212 -0
  36. package/package.json +1 -7
  37. package/public-allowlist-node.txt +7 -0
  38. package/src/cli/commands/backfill.ts +865 -0
  39. package/src/cli/commands/setup.ts +254 -0
  40. package/src/cli/commands/status.ts +108 -0
  41. package/src/cli/commands/test.ts +91 -0
  42. package/src/cli/index.ts +103 -0
  43. package/src/core/api/client.ts +194 -0
  44. package/src/core/config/loader.ts +217 -0
  45. package/src/core/config/validator.ts +142 -0
  46. package/src/core/config/writer.ts +212 -0
  47. package/src/core/shell/detector.ts +92 -0
  48. package/src/core/shell/profile-updater.ts +131 -0
  49. package/src/core/tool-context.ts +23 -0
  50. package/src/core/tool-tracker.ts +204 -0
  51. package/src/index.ts +12 -0
  52. package/src/types/index.ts +110 -0
  53. package/src/types/tool-metering.ts +38 -0
  54. package/src/utils/constants.ts +80 -0
  55. package/src/utils/hashing.ts +35 -0
  56. package/src/utils/masking.ts +32 -0
  57. package/tests/integration/cli-commands.test.ts +158 -0
  58. package/tests/unit/backfill-command.test.ts +366 -0
  59. package/tests/unit/backfill-helpers.test.ts +397 -0
  60. package/tests/unit/backfill-parse.test.ts +276 -0
  61. package/tests/unit/backfill-stream.test.ts +147 -0
  62. package/tests/unit/backfill.test.ts +344 -0
  63. package/tests/unit/cli-index.test.ts +193 -0
  64. package/tests/unit/client.test.ts +195 -0
  65. package/tests/unit/detector.test.ts +247 -0
  66. package/tests/unit/hashing.test.ts +121 -0
  67. package/tests/unit/loader.test.ts +272 -0
  68. package/tests/unit/masking.test.ts +46 -0
  69. package/tests/unit/profile-updater.test.ts +146 -0
  70. package/tests/unit/setup.test.ts +557 -0
  71. package/tests/unit/status.test.ts +149 -0
  72. package/tests/unit/test.test.ts +165 -0
  73. package/tests/unit/validator.test.ts +211 -0
  74. package/tests/unit/writer.test.ts +176 -0
  75. package/tsconfig.json +20 -0
@@ -0,0 +1,194 @@
1
+ import type {
2
+ OTLPLogsPayload,
3
+ OTLPResponse,
4
+ HealthCheckResult,
5
+ } from "../../types/index.js";
6
+ import { getFullOtlpEndpoint } from "../config/loader.js";
7
+
8
+ /**
9
+ * Sends an OTLP logs payload to the Revenium endpoint.
10
+ */
11
+ export async function sendOtlpLogs(
12
+ baseEndpoint: string,
13
+ apiKey: string,
14
+ payload: OTLPLogsPayload,
15
+ ): Promise<OTLPResponse> {
16
+ const fullEndpoint = getFullOtlpEndpoint(baseEndpoint);
17
+ const url = `${fullEndpoint}/v1/logs`;
18
+
19
+ const controller = new AbortController();
20
+ const timeoutId = setTimeout(() => controller.abort(), 10_000);
21
+
22
+ const response = await fetch(url, {
23
+ method: "POST",
24
+ headers: {
25
+ "Content-Type": "application/json",
26
+ "x-api-key": apiKey,
27
+ },
28
+ body: JSON.stringify(payload),
29
+ signal: controller.signal,
30
+ }).finally(() => clearTimeout(timeoutId));
31
+
32
+ if (!response.ok) {
33
+ const errorText = await response.text();
34
+ const safeErrorText =
35
+ errorText.length > 200 ? errorText.substring(0, 200) + "..." : errorText;
36
+ throw new Error(
37
+ `OTLP request failed: ${response.status} ${response.statusText} - ${safeErrorText}`,
38
+ );
39
+ }
40
+
41
+ return response.json() as Promise<OTLPResponse>;
42
+ }
43
+
44
+ /**
45
+ * Options for creating a test payload.
46
+ */
47
+ export interface TestPayloadOptions {
48
+ /** Optional subscriber email for attribution */
49
+ email?: string;
50
+ /** Optional organization name to attribute costs to */
51
+ organizationName?: string;
52
+ /**
53
+ * @deprecated Use organizationName instead. This field will be removed in a future version.
54
+ */
55
+ organizationId?: string;
56
+ /** Optional product name to attribute costs to */
57
+ productName?: string;
58
+ /**
59
+ * @deprecated Use productName instead. This field will be removed in a future version.
60
+ */
61
+ productId?: string;
62
+ }
63
+
64
+ /**
65
+ * Creates a minimal test OTLP logs payload.
66
+ */
67
+ export function createTestPayload(
68
+ sessionId: string,
69
+ options?: TestPayloadOptions,
70
+ ): OTLPLogsPayload {
71
+ const now = Date.now() * 1_000_000; // Convert to nanoseconds
72
+
73
+ // Build log record attributes
74
+ // Note: organization.name and product.name go here because ClaudeCodeMapper reads from log record attrs
75
+ const logAttributes: Array<{ key: string; value: { stringValue: string } }> =
76
+ [
77
+ { key: "session.id", value: { stringValue: sessionId } },
78
+ { key: "model", value: { stringValue: "cli-connectivity-test" } },
79
+ { key: "input_tokens", value: { stringValue: "0" } },
80
+ { key: "output_tokens", value: { stringValue: "0" } },
81
+ { key: "cache_read_tokens", value: { stringValue: "0" } },
82
+ { key: "cache_creation_tokens", value: { stringValue: "0" } },
83
+ { key: "cost_usd", value: { stringValue: "0.0" } },
84
+ { key: "duration_ms", value: { stringValue: "0" } },
85
+ ];
86
+
87
+ // Add optional subscriber/attribution attributes at log record level
88
+ // (backend ClaudeCodeMapper reads these from log record attrs, not resource attrs)
89
+ if (options?.email) {
90
+ logAttributes.push({
91
+ key: "user.email",
92
+ value: { stringValue: options.email },
93
+ });
94
+ }
95
+
96
+ // Support both new (organizationName) and old (organizationId) field names with fallback
97
+ const organizationValue =
98
+ options?.organizationName || options?.organizationId;
99
+ if (organizationValue) {
100
+ logAttributes.push({
101
+ key: "organization.name",
102
+ value: { stringValue: organizationValue },
103
+ });
104
+ }
105
+
106
+ // Support both new (productName) and old (productId) field names with fallback
107
+ const productValue = options?.productName || options?.productId;
108
+ if (productValue) {
109
+ logAttributes.push({
110
+ key: "product.name",
111
+ value: { stringValue: productValue },
112
+ });
113
+ }
114
+
115
+ // Build resource attributes (only service.name needed here)
116
+ const resourceAttributes: Array<{
117
+ key: string;
118
+ value: { stringValue: string };
119
+ }> = [{ key: "service.name", value: { stringValue: "claude-code" } }];
120
+
121
+ return {
122
+ resourceLogs: [
123
+ {
124
+ resource: {
125
+ attributes: resourceAttributes,
126
+ },
127
+ scopeLogs: [
128
+ {
129
+ scope: {
130
+ name: "claude_code",
131
+ version: "0.1.0",
132
+ },
133
+ logRecords: [
134
+ {
135
+ timeUnixNano: now.toString(),
136
+ body: { stringValue: "claude_code.api_request" },
137
+ attributes: logAttributes,
138
+ },
139
+ ],
140
+ },
141
+ ],
142
+ },
143
+ ],
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Generates a unique session ID for test payloads.
149
+ */
150
+ export function generateTestSessionId(): string {
151
+ const timestamp = Date.now().toString(36);
152
+ const random = Math.random().toString(36).substring(2, 8);
153
+ return `test-${timestamp}-${random}`;
154
+ }
155
+
156
+ /**
157
+ * Performs a health check by sending a minimal test payload to the endpoint.
158
+ */
159
+ export async function checkEndpointHealth(
160
+ baseEndpoint: string,
161
+ apiKey: string,
162
+ options?: TestPayloadOptions,
163
+ ): Promise<HealthCheckResult> {
164
+ const startTime = Date.now();
165
+
166
+ try {
167
+ const sessionId = generateTestSessionId();
168
+ const payload = createTestPayload(sessionId, options);
169
+ const response = await sendOtlpLogs(baseEndpoint, apiKey, payload);
170
+
171
+ const latencyMs = Date.now() - startTime;
172
+
173
+ return {
174
+ healthy: true,
175
+ statusCode: 200,
176
+ message: `Endpoint healthy. Processed ${response.processedEvents} event(s).`,
177
+ latencyMs,
178
+ };
179
+ } catch (error) {
180
+ const latencyMs = Date.now() - startTime;
181
+ const message = error instanceof Error ? error.message : "Unknown error";
182
+
183
+ // Try to extract status code from error message
184
+ const statusMatch = message.match(/(\d{3})/);
185
+ const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
186
+
187
+ return {
188
+ healthy: false,
189
+ statusCode,
190
+ message,
191
+ latencyMs,
192
+ };
193
+ }
194
+ }
@@ -0,0 +1,217 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { readFile } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import {
6
+ CLAUDE_CONFIG_DIR,
7
+ REVENIUM_ENV_FILE,
8
+ ENV_VARS,
9
+ OTLP_PATH,
10
+ } from "../../utils/constants.js";
11
+ import type { ReveniumConfig } from "../../types/index.js";
12
+ import type { SubscriptionTier } from "../../utils/constants.js";
13
+
14
+ /**
15
+ * Gets the path to the Revenium configuration file.
16
+ */
17
+ export function getConfigPath(): string {
18
+ return join(homedir(), CLAUDE_CONFIG_DIR, REVENIUM_ENV_FILE);
19
+ }
20
+
21
+ /**
22
+ * Checks if the configuration file exists.
23
+ */
24
+ export function configExists(): boolean {
25
+ return existsSync(getConfigPath());
26
+ }
27
+
28
+ /**
29
+ * Parses an .env file content into key-value pairs.
30
+ */
31
+ export function parseEnvContent(content: string): Record<string, string> {
32
+ const result: Record<string, string> = {};
33
+
34
+ for (const line of content.split("\n")) {
35
+ let trimmed = line.trim();
36
+
37
+ // Skip empty lines and comments
38
+ if (!trimmed || trimmed.startsWith("#")) {
39
+ continue;
40
+ }
41
+
42
+ // Handle 'export' prefix
43
+ if (trimmed.startsWith("export ")) {
44
+ trimmed = trimmed.substring(7).trim();
45
+ }
46
+
47
+ const equalsIndex = trimmed.indexOf("=");
48
+ if (equalsIndex === -1) {
49
+ continue;
50
+ }
51
+
52
+ const key = trimmed.substring(0, equalsIndex).trim();
53
+ let value = trimmed.substring(equalsIndex + 1).trim();
54
+
55
+ // Remove surrounding quotes if present and unescape
56
+ if (
57
+ (value.startsWith('"') && value.endsWith('"')) ||
58
+ (value.startsWith("'") && value.endsWith("'"))
59
+ ) {
60
+ value = value.substring(1, value.length - 1);
61
+ // Unescape common shell escape sequences
62
+ value = value
63
+ .replace(/\\"/g, '"')
64
+ .replace(/\\'/g, "'")
65
+ .replace(/\\\$/g, "$")
66
+ .replace(/\\`/g, "`")
67
+ .replace(/\\\\/g, "\\");
68
+ }
69
+
70
+ result[key] = value;
71
+ }
72
+
73
+ return result;
74
+ }
75
+
76
+ /**
77
+ * Extracts the API key from the OTEL_EXPORTER_OTLP_HEADERS value.
78
+ * Format: "x-api-key=hak_xxx"
79
+ */
80
+ function extractApiKeyFromHeaders(headers: string): string | undefined {
81
+ const match = headers.match(/x-api-key=\s*(hak_[^\s"]+)/);
82
+ return match?.[1];
83
+ }
84
+
85
+ /**
86
+ * Extracts the base endpoint from the full OTLP endpoint URL.
87
+ * Example: "https://api.revenium.ai/meter/v2/otlp" -> "https://api.revenium.ai"
88
+ */
89
+ function extractBaseEndpoint(fullEndpoint: string): string {
90
+ try {
91
+ const url = new URL(fullEndpoint);
92
+ // Remove the OTLP path suffix to get the base URL
93
+ // Handle both old path (/meter/v2/ai/otlp) and new path (/meter/v2/otlp)
94
+ const path = url.pathname;
95
+ if (path.includes("/meter/v2/otlp") || path.includes("/meter/v2/ai/otlp")) {
96
+ url.pathname = "";
97
+ }
98
+ return url.origin;
99
+ } catch {
100
+ return fullEndpoint;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Parses OTEL_RESOURCE_ATTRIBUTES value into key-value pairs.
106
+ * Format: "key1=value1,key2=value2"
107
+ */
108
+ function parseOtelResourceAttributes(value: string): Record<string, string> {
109
+ const result: Record<string, string> = {};
110
+ if (!value || typeof value !== "string") return result;
111
+
112
+ const pairs = value.split(",");
113
+ for (const pair of pairs) {
114
+ const trimmed = pair.trim();
115
+ if (!trimmed) continue;
116
+
117
+ const equalsIndex = trimmed.indexOf("=");
118
+ if (equalsIndex === -1) continue;
119
+
120
+ const key = trimmed.substring(0, equalsIndex).trim();
121
+ let attrValue = trimmed.substring(equalsIndex + 1).trim();
122
+
123
+ try {
124
+ attrValue = decodeURIComponent(attrValue);
125
+ } catch {
126
+ // If decoding fails, use raw value
127
+ }
128
+
129
+ if (key) result[key] = attrValue;
130
+ }
131
+ return result;
132
+ }
133
+
134
+ /**
135
+ * Loads the Revenium configuration from the .env file.
136
+ * Returns null if the file doesn't exist.
137
+ */
138
+ export async function loadConfig(): Promise<ReveniumConfig | null> {
139
+ const configPath = getConfigPath();
140
+
141
+ if (!existsSync(configPath)) {
142
+ return null;
143
+ }
144
+
145
+ try {
146
+ const content = await readFile(configPath, "utf-8");
147
+ const env = parseEnvContent(content);
148
+
149
+ const fullEndpoint = env[ENV_VARS.OTLP_ENDPOINT] || "";
150
+ const headers = env[ENV_VARS.OTLP_HEADERS] || "";
151
+ const apiKey = extractApiKeyFromHeaders(headers);
152
+
153
+ if (!apiKey) {
154
+ return null;
155
+ }
156
+
157
+ // Parse cost multiplier override if present
158
+ const costMultiplierStr = env[ENV_VARS.COST_MULTIPLIER];
159
+ const costMultiplierOverride = costMultiplierStr
160
+ ? parseFloat(costMultiplierStr)
161
+ : undefined;
162
+
163
+ // Parse OTEL_RESOURCE_ATTRIBUTES for org/product (primary source)
164
+ const resourceAttrsStr = env["OTEL_RESOURCE_ATTRIBUTES"] || "";
165
+ const resourceAttrs = parseOtelResourceAttributes(resourceAttrsStr);
166
+
167
+ // Support both .name (preferred) and .id (legacy), with fallback to standalone vars
168
+ // Priority: organizationName > organizationId > organization.name > organization.id > env var
169
+ const organizationName =
170
+ resourceAttrs["organization.name"] ||
171
+ resourceAttrs["organization.id"] ||
172
+ env[ENV_VARS.ORGANIZATION_ID];
173
+
174
+ const productName =
175
+ resourceAttrs["product.name"] ||
176
+ resourceAttrs["product.id"] ||
177
+ env[ENV_VARS.PRODUCT_ID];
178
+
179
+ return {
180
+ apiKey,
181
+ endpoint: extractBaseEndpoint(fullEndpoint),
182
+ email: env[ENV_VARS.SUBSCRIBER_EMAIL],
183
+ subscriptionTier: env[ENV_VARS.SUBSCRIPTION] as
184
+ | SubscriptionTier
185
+ | undefined,
186
+ costMultiplierOverride:
187
+ costMultiplierOverride !== undefined && !isNaN(costMultiplierOverride)
188
+ ? costMultiplierOverride
189
+ : undefined,
190
+ organizationName,
191
+ organizationId: organizationName, // Keep for backward compatibility
192
+ productName,
193
+ productId: productName, // Keep for backward compatibility
194
+ };
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Checks if the environment variables are currently loaded in the shell.
202
+ */
203
+ export function isEnvLoaded(): boolean {
204
+ return (
205
+ process.env[ENV_VARS.TELEMETRY_ENABLED] === "1" &&
206
+ !!process.env[ENV_VARS.OTLP_ENDPOINT]
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Gets the full OTLP endpoint URL from a base URL.
212
+ */
213
+ export function getFullOtlpEndpoint(baseUrl: string): string {
214
+ // Remove trailing slash if present
215
+ const cleanUrl = baseUrl.replace(/\/$/, "");
216
+ return `${cleanUrl}${OTLP_PATH}`;
217
+ }
@@ -0,0 +1,142 @@
1
+ import {
2
+ API_KEY_PREFIX,
3
+ SUBSCRIPTION_TIER_CONFIG,
4
+ } from "../../utils/constants.js";
5
+
6
+ const VALID_TIERS = Object.keys(SUBSCRIPTION_TIER_CONFIG);
7
+ import type { ReveniumConfig, ValidationResult } from "../../types/index.js";
8
+
9
+ /**
10
+ * Validates that an API key has the correct format.
11
+ * Valid format: hak_{tenant}_{random}
12
+ */
13
+ export function validateApiKey(apiKey: string): ValidationResult {
14
+ const errors: string[] = [];
15
+
16
+ if (!apiKey || apiKey.trim() === "") {
17
+ errors.push("API key is required");
18
+ return { valid: false, errors };
19
+ }
20
+
21
+ if (!apiKey.startsWith(API_KEY_PREFIX)) {
22
+ errors.push(`API key must start with "${API_KEY_PREFIX}"`);
23
+ }
24
+
25
+ // Check for at least two underscores (hak_tenant_random)
26
+ const parts = apiKey.split("_");
27
+ if (parts.length < 3) {
28
+ errors.push("API key format should be: hak_{tenant}_{key}");
29
+ }
30
+
31
+ // Minimum length check
32
+ if (apiKey.length < 12) {
33
+ errors.push("API key appears too short");
34
+ }
35
+
36
+ return {
37
+ valid: errors.length === 0,
38
+ errors,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Validates an email address format.
44
+ */
45
+ export function validateEmail(email: string): ValidationResult {
46
+ const errors: string[] = [];
47
+
48
+ if (!email || email.trim() === "") {
49
+ // Email is optional
50
+ return { valid: true, errors: [] };
51
+ }
52
+
53
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
54
+ if (!emailRegex.test(email)) {
55
+ errors.push("Invalid email format");
56
+ }
57
+
58
+ return {
59
+ valid: errors.length === 0,
60
+ errors,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Validates a subscription tier.
66
+ */
67
+ export function validateSubscriptionTier(tier: string): ValidationResult {
68
+ const errors: string[] = [];
69
+
70
+ if (!tier || tier.trim() === "") {
71
+ // Tier is optional
72
+ return { valid: true, errors: [] };
73
+ }
74
+
75
+ const lowerTier = tier.toLowerCase();
76
+ if (!VALID_TIERS.includes(lowerTier)) {
77
+ errors.push(
78
+ `Invalid subscription tier. Valid options: ${VALID_TIERS.join(", ")}`
79
+ );
80
+ }
81
+
82
+ return {
83
+ valid: errors.length === 0,
84
+ errors,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Validates an endpoint URL and ensures it uses HTTPS.
90
+ */
91
+ export function validateEndpointUrl(endpoint: string): ValidationResult {
92
+ const errors: string[] = [];
93
+
94
+ if (!endpoint || endpoint.trim() === "") {
95
+ errors.push("Endpoint URL is required");
96
+ return { valid: false, errors };
97
+ }
98
+
99
+ try {
100
+ const url = new URL(endpoint);
101
+ if (url.protocol !== "https:") {
102
+ errors.push(
103
+ "Insecure endpoint: HTTPS is required. Only HTTPS endpoints are allowed."
104
+ );
105
+ }
106
+ } catch {
107
+ errors.push("Invalid endpoint URL format");
108
+ }
109
+
110
+ return {
111
+ valid: errors.length === 0,
112
+ errors,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Validates a complete Revenium configuration.
118
+ */
119
+ export function validateConfig(
120
+ config: Partial<ReveniumConfig>
121
+ ): ValidationResult {
122
+ const allErrors: string[] = [];
123
+
124
+ const apiKeyResult = validateApiKey(config.apiKey || "");
125
+ allErrors.push(...apiKeyResult.errors);
126
+
127
+ const emailResult = validateEmail(config.email || "");
128
+ allErrors.push(...emailResult.errors);
129
+
130
+ if (config.subscriptionTier) {
131
+ const tierResult = validateSubscriptionTier(config.subscriptionTier);
132
+ allErrors.push(...tierResult.errors);
133
+ }
134
+
135
+ const endpointResult = validateEndpointUrl(config.endpoint || "");
136
+ allErrors.push(...endpointResult.errors);
137
+
138
+ return {
139
+ valid: allErrors.length === 0,
140
+ errors: allErrors,
141
+ };
142
+ }