@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.
- 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/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,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
|
+
}
|