@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,212 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { writeFile, mkdir, chmod } from "node:fs/promises";
|
|
4
|
+
import {
|
|
5
|
+
CLAUDE_CONFIG_DIR,
|
|
6
|
+
REVENIUM_ENV_FILE,
|
|
7
|
+
CONFIG_FILE_MODE,
|
|
8
|
+
ENV_VARS,
|
|
9
|
+
getCostMultiplier,
|
|
10
|
+
type SubscriptionTier,
|
|
11
|
+
} from "../../utils/constants.js";
|
|
12
|
+
import type { ReveniumConfig } from "../../types/index.js";
|
|
13
|
+
import { getFullOtlpEndpoint } from "./loader.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Escapes a value for use in shell export statements.
|
|
17
|
+
* Wraps the value in double quotes and escapes special characters.
|
|
18
|
+
*/
|
|
19
|
+
function escapeShellValue(value: string): string {
|
|
20
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`")}"`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Escapes a value for use in OTEL_RESOURCE_ATTRIBUTES.
|
|
25
|
+
* OTEL_RESOURCE_ATTRIBUTES uses comma as delimiter and equals as key-value separator.
|
|
26
|
+
* Characters that need escaping: comma (,), equals (=), and double-quote (").
|
|
27
|
+
* We URL-encode these characters to ensure safe parsing.
|
|
28
|
+
*/
|
|
29
|
+
function escapeResourceAttributeValue(value: string): string {
|
|
30
|
+
return value
|
|
31
|
+
.replace(/%/g, "%25") // Escape % first to avoid double-encoding
|
|
32
|
+
.replace(/,/g, "%2C")
|
|
33
|
+
.replace(/=/g, "%3D")
|
|
34
|
+
.replace(/"/g, "%22");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Gets the path to the Claude config directory.
|
|
39
|
+
*/
|
|
40
|
+
function getClaudeConfigDir(): string {
|
|
41
|
+
return join(homedir(), CLAUDE_CONFIG_DIR);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generates the content for the revenium.env file.
|
|
46
|
+
*/
|
|
47
|
+
function generateEnvContent(config: ReveniumConfig): string {
|
|
48
|
+
const fullEndpoint = getFullOtlpEndpoint(config.endpoint);
|
|
49
|
+
|
|
50
|
+
const lines: string[] = [
|
|
51
|
+
"# Revenium Claude Code Metering Configuration",
|
|
52
|
+
"# Generated by @revenium/claude-code-metering",
|
|
53
|
+
"#",
|
|
54
|
+
"# To load these variables, add to your shell profile:",
|
|
55
|
+
"# source ~/.claude/revenium.env",
|
|
56
|
+
"",
|
|
57
|
+
"# Enable Claude Code telemetry export",
|
|
58
|
+
`export ${ENV_VARS.TELEMETRY_ENABLED}=1`,
|
|
59
|
+
"",
|
|
60
|
+
"# OTLP endpoint for Revenium metering",
|
|
61
|
+
`export ${ENV_VARS.OTLP_ENDPOINT}=${escapeShellValue(fullEndpoint)}`,
|
|
62
|
+
"",
|
|
63
|
+
"# Authentication header with API key",
|
|
64
|
+
`export ${ENV_VARS.OTLP_HEADERS}=${escapeShellValue(`x-api-key=${config.apiKey}`)}`,
|
|
65
|
+
"",
|
|
66
|
+
"# OTLP protocol (required for Claude Code)",
|
|
67
|
+
`export ${ENV_VARS.OTLP_PROTOCOL}=http/json`,
|
|
68
|
+
"",
|
|
69
|
+
"# Enable OTLP logs exporter (required to send telemetry)",
|
|
70
|
+
"export OTEL_LOGS_EXPORTER=otlp",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// Add optional fields
|
|
74
|
+
if (config.email) {
|
|
75
|
+
lines.push("");
|
|
76
|
+
lines.push("# Subscriber email for attribution");
|
|
77
|
+
lines.push(
|
|
78
|
+
`export ${ENV_VARS.SUBSCRIBER_EMAIL}=${escapeShellValue(config.email)}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (config.subscriptionTier) {
|
|
83
|
+
const tier = config.subscriptionTier as SubscriptionTier;
|
|
84
|
+
const costMultiplier =
|
|
85
|
+
config.costMultiplierOverride ?? getCostMultiplier(tier);
|
|
86
|
+
const discountPercent = Math.round((1 - costMultiplier) * 100);
|
|
87
|
+
|
|
88
|
+
lines.push("");
|
|
89
|
+
lines.push("# Claude Code subscription tier");
|
|
90
|
+
lines.push(
|
|
91
|
+
`export ${ENV_VARS.SUBSCRIPTION}=${escapeShellValue(config.subscriptionTier)}`,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
lines.push("");
|
|
95
|
+
lines.push("# Cost multiplier for subscription tier");
|
|
96
|
+
lines.push(
|
|
97
|
+
"# This adjusts Claude Code costs based on your subscription discount",
|
|
98
|
+
);
|
|
99
|
+
lines.push(`# ${tier}: ${discountPercent}% discount vs API rates`);
|
|
100
|
+
if (config.costMultiplierOverride !== undefined) {
|
|
101
|
+
lines.push("# (custom override applied)");
|
|
102
|
+
lines.push(`export ${ENV_VARS.COST_MULTIPLIER}=${costMultiplier}`);
|
|
103
|
+
}
|
|
104
|
+
// Build OTEL_RESOURCE_ATTRIBUTES with cost_multiplier and optional organization.name/product.name.
|
|
105
|
+
// Special characters (,=") in values are URL-encoded to ensure safe parsing.
|
|
106
|
+
// Note: The backend ONLY reads organization.name and product.name from resourceAttributes,
|
|
107
|
+
// ignoring any auto-generated values in log record attributes from Claude Code.
|
|
108
|
+
const resourceAttrs: string[] = [`cost_multiplier=${costMultiplier}`];
|
|
109
|
+
|
|
110
|
+
// Support both new (organizationName) and old (organizationId) field names with fallback
|
|
111
|
+
const organizationValue = config.organizationName || config.organizationId;
|
|
112
|
+
if (organizationValue) {
|
|
113
|
+
resourceAttrs.push(
|
|
114
|
+
`organization.name=${escapeResourceAttributeValue(organizationValue)}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Support both new (productName) and old (productId) field names with fallback
|
|
119
|
+
const productValue = config.productName || config.productId;
|
|
120
|
+
if (productValue) {
|
|
121
|
+
resourceAttrs.push(
|
|
122
|
+
`product.name=${escapeResourceAttributeValue(productValue)}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
lines.push(`export OTEL_RESOURCE_ATTRIBUTES="${resourceAttrs.join(",")}"`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Add advanced configuration section
|
|
129
|
+
lines.push("");
|
|
130
|
+
lines.push(
|
|
131
|
+
"# ─────────────────────────────────────────────────────────────────────────────",
|
|
132
|
+
);
|
|
133
|
+
lines.push("# Organization & Product Attribution (Optional)");
|
|
134
|
+
lines.push(
|
|
135
|
+
"# ─────────────────────────────────────────────────────────────────────────────",
|
|
136
|
+
);
|
|
137
|
+
lines.push("#");
|
|
138
|
+
lines.push(
|
|
139
|
+
"# To attribute Claude Code costs to a specific organization or product, you must",
|
|
140
|
+
);
|
|
141
|
+
lines.push(
|
|
142
|
+
"# add them to OTEL_RESOURCE_ATTRIBUTES above. The backend ONLY reads these values",
|
|
143
|
+
);
|
|
144
|
+
lines.push(
|
|
145
|
+
"# from OTEL_RESOURCE_ATTRIBUTES - standalone environment variables are NOT sent.",
|
|
146
|
+
);
|
|
147
|
+
lines.push("#");
|
|
148
|
+
lines.push("# HOW TO CONFIGURE:");
|
|
149
|
+
lines.push(
|
|
150
|
+
"# Edit the OTEL_RESOURCE_ATTRIBUTES line above to include organization.name and/or product.name:",
|
|
151
|
+
);
|
|
152
|
+
lines.push("#");
|
|
153
|
+
|
|
154
|
+
// Get current cost multiplier for the example
|
|
155
|
+
const exampleMultiplier =
|
|
156
|
+
config.subscriptionTier && config.costMultiplierOverride === undefined
|
|
157
|
+
? getCostMultiplier(config.subscriptionTier as SubscriptionTier)
|
|
158
|
+
: (config.costMultiplierOverride ?? "0.08");
|
|
159
|
+
lines.push(
|
|
160
|
+
`# OTEL_RESOURCE_ATTRIBUTES="cost_multiplier=${exampleMultiplier},organization.name=my-org,product.name=my-product"`,
|
|
161
|
+
);
|
|
162
|
+
lines.push("#");
|
|
163
|
+
lines.push("# ATTRIBUTE DESCRIPTIONS:");
|
|
164
|
+
lines.push(
|
|
165
|
+
"# organization.name - Attribute costs to a customer/company (e.g., client name, team)",
|
|
166
|
+
);
|
|
167
|
+
lines.push(
|
|
168
|
+
"# product.name - Attribute costs to a product/project (e.g., mobile-app, backend-api)",
|
|
169
|
+
);
|
|
170
|
+
lines.push("#");
|
|
171
|
+
lines.push(
|
|
172
|
+
"# After editing, restart your terminal or run: source ~/.claude/revenium.env",
|
|
173
|
+
);
|
|
174
|
+
lines.push("#");
|
|
175
|
+
lines.push(
|
|
176
|
+
"# Alternatively, re-run setup with --organization and --product flags:",
|
|
177
|
+
);
|
|
178
|
+
lines.push(
|
|
179
|
+
"# npx @revenium/claude-code-metering setup --organization my-org --product my-product",
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
lines.push("");
|
|
183
|
+
return lines.join("\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Writes the Revenium configuration to ~/.claude/revenium.env.
|
|
188
|
+
* Creates the directory if it doesn't exist and sets file permissions to 600.
|
|
189
|
+
*/
|
|
190
|
+
export async function writeConfig(config: ReveniumConfig): Promise<string> {
|
|
191
|
+
const configDir = getClaudeConfigDir();
|
|
192
|
+
const configPath = join(configDir, REVENIUM_ENV_FILE);
|
|
193
|
+
|
|
194
|
+
// Ensure the directory exists
|
|
195
|
+
await mkdir(configDir, { recursive: true });
|
|
196
|
+
|
|
197
|
+
// Generate and write the content
|
|
198
|
+
const content = generateEnvContent(config);
|
|
199
|
+
await writeFile(configPath, content, { encoding: "utf-8" });
|
|
200
|
+
|
|
201
|
+
// Set restrictive permissions (owner read/write only)
|
|
202
|
+
await chmod(configPath, CONFIG_FILE_MODE);
|
|
203
|
+
|
|
204
|
+
return configPath;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Gets the path where the config file would be written.
|
|
209
|
+
*/
|
|
210
|
+
export function getConfigFilePath(): string {
|
|
211
|
+
return join(getClaudeConfigDir(), REVENIUM_ENV_FILE);
|
|
212
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import type { ShellType } from "../../types/index.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detects the current shell type based on environment variables.
|
|
8
|
+
*/
|
|
9
|
+
export function detectShell(): ShellType {
|
|
10
|
+
const shell = process.env.SHELL || "";
|
|
11
|
+
|
|
12
|
+
if (shell.includes("zsh")) {
|
|
13
|
+
return "zsh";
|
|
14
|
+
}
|
|
15
|
+
if (shell.includes("fish")) {
|
|
16
|
+
return "fish";
|
|
17
|
+
}
|
|
18
|
+
if (shell.includes("bash")) {
|
|
19
|
+
return "bash";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Fallback: check for rc files
|
|
23
|
+
const home = homedir();
|
|
24
|
+
if (existsSync(join(home, ".zshrc"))) {
|
|
25
|
+
return "zsh";
|
|
26
|
+
}
|
|
27
|
+
if (existsSync(join(home, ".config", "fish", "config.fish"))) {
|
|
28
|
+
return "fish";
|
|
29
|
+
}
|
|
30
|
+
if (existsSync(join(home, ".bashrc"))) {
|
|
31
|
+
return "bash";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return "unknown";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Gets the profile file path for a given shell type.
|
|
39
|
+
*/
|
|
40
|
+
export function getProfilePath(shellType: ShellType): string | null {
|
|
41
|
+
const home = homedir();
|
|
42
|
+
|
|
43
|
+
switch (shellType) {
|
|
44
|
+
case "zsh":
|
|
45
|
+
return join(home, ".zshrc");
|
|
46
|
+
case "bash":
|
|
47
|
+
// Prefer .bashrc, fallback to .bash_profile
|
|
48
|
+
if (existsSync(join(home, ".bashrc"))) {
|
|
49
|
+
return join(home, ".bashrc");
|
|
50
|
+
}
|
|
51
|
+
return join(home, ".bash_profile");
|
|
52
|
+
case "fish":
|
|
53
|
+
return join(home, ".config", "fish", "config.fish");
|
|
54
|
+
default:
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validates that a config path contains only safe characters.
|
|
61
|
+
* Throws an error if the path contains potentially dangerous characters.
|
|
62
|
+
* Allows spaces since paths are properly quoted in shell commands.
|
|
63
|
+
*/
|
|
64
|
+
export function validateConfigPath(path: string): void {
|
|
65
|
+
const unsafeCharsRegex = /[;|&$`"'\\<>(){}[\]!*?#\n\r\t]/;
|
|
66
|
+
|
|
67
|
+
if (unsafeCharsRegex.test(path)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Invalid config path: contains unsafe characters. Path must not contain shell metacharacters like semicolons, pipes, backticks, or quotes.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Generates the source command for a given shell type.
|
|
76
|
+
* Validates the config path before generating the command.
|
|
77
|
+
*/
|
|
78
|
+
export function getSourceCommand(
|
|
79
|
+
shellType: ShellType,
|
|
80
|
+
configPath: string
|
|
81
|
+
): string {
|
|
82
|
+
validateConfigPath(configPath);
|
|
83
|
+
|
|
84
|
+
switch (shellType) {
|
|
85
|
+
case "fish":
|
|
86
|
+
// Fish uses a different syntax for sourcing env files
|
|
87
|
+
return `# Source Revenium Claude Code metering config\nif test -f "${configPath}"\n export (cat "${configPath}" | grep -v '^#' | xargs -L 1)\nend`;
|
|
88
|
+
default:
|
|
89
|
+
// Bash and Zsh use the same syntax
|
|
90
|
+
return `# Source Revenium Claude Code metering config\nif [ -f "${configPath}" ]; then\n source "${configPath}"\nfi`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import type { ShellType, ShellUpdateResult } from "../../types/index.js";
|
|
4
|
+
import { detectShell, getProfilePath, getSourceCommand } from "./detector.js";
|
|
5
|
+
import { getConfigFilePath } from "../config/writer.js";
|
|
6
|
+
|
|
7
|
+
/** Marker comment to identify our configuration block */
|
|
8
|
+
const CONFIG_MARKER_START = "# >>> revenium-claude-code-metering >>>";
|
|
9
|
+
const CONFIG_MARKER_END = "# <<< revenium-claude-code-metering <<<";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Checks if the shell profile already has the Revenium source command.
|
|
13
|
+
*/
|
|
14
|
+
async function hasReveniumConfig(profilePath: string): Promise<boolean> {
|
|
15
|
+
if (!existsSync(profilePath)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const content = await readFile(profilePath, "utf-8");
|
|
20
|
+
return content.includes(CONFIG_MARKER_START);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generates the complete configuration block for the shell profile.
|
|
25
|
+
*/
|
|
26
|
+
function generateConfigBlock(shellType: ShellType, configPath: string): string {
|
|
27
|
+
const sourceCmd = getSourceCommand(shellType, configPath);
|
|
28
|
+
return `\n${CONFIG_MARKER_START}\n${sourceCmd}\n${CONFIG_MARKER_END}\n`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Removes existing Revenium configuration from profile content.
|
|
33
|
+
*/
|
|
34
|
+
function removeExistingConfig(content: string): string {
|
|
35
|
+
const startIndex = content.indexOf(CONFIG_MARKER_START);
|
|
36
|
+
const endIndex = content.indexOf(CONFIG_MARKER_END);
|
|
37
|
+
|
|
38
|
+
if (startIndex === -1 || endIndex === -1) {
|
|
39
|
+
return content;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const before = content.substring(0, startIndex).trimEnd();
|
|
43
|
+
const after = content
|
|
44
|
+
.substring(endIndex + CONFIG_MARKER_END.length)
|
|
45
|
+
.trimStart();
|
|
46
|
+
|
|
47
|
+
return before + (after ? "\n" + after : "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Updates the shell profile to source the Revenium configuration file.
|
|
52
|
+
* Returns details about the update operation.
|
|
53
|
+
*/
|
|
54
|
+
export async function updateShellProfile(): Promise<ShellUpdateResult> {
|
|
55
|
+
const shellType = detectShell();
|
|
56
|
+
|
|
57
|
+
if (shellType === "unknown") {
|
|
58
|
+
return {
|
|
59
|
+
success: false,
|
|
60
|
+
shellType,
|
|
61
|
+
message:
|
|
62
|
+
"Could not detect shell type. Please manually add the source command to your shell profile.",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const profilePath = getProfilePath(shellType);
|
|
67
|
+
|
|
68
|
+
if (!profilePath) {
|
|
69
|
+
return {
|
|
70
|
+
success: false,
|
|
71
|
+
shellType,
|
|
72
|
+
message: `Could not determine profile path for ${shellType}.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const configPath = getConfigFilePath();
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
// Check if already configured
|
|
80
|
+
if (await hasReveniumConfig(profilePath)) {
|
|
81
|
+
// Remove existing and re-add (in case config path changed)
|
|
82
|
+
let content = await readFile(profilePath, "utf-8");
|
|
83
|
+
content = removeExistingConfig(content);
|
|
84
|
+
const configBlock = generateConfigBlock(shellType, configPath);
|
|
85
|
+
await writeFile(profilePath, content + configBlock, "utf-8");
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
success: true,
|
|
89
|
+
shellType,
|
|
90
|
+
profilePath,
|
|
91
|
+
message: `Updated existing configuration in ${profilePath}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Add new configuration
|
|
96
|
+
let content = "";
|
|
97
|
+
if (existsSync(profilePath)) {
|
|
98
|
+
content = await readFile(profilePath, "utf-8");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const configBlock = generateConfigBlock(shellType, configPath);
|
|
102
|
+
await writeFile(profilePath, content + configBlock, "utf-8");
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
success: true,
|
|
106
|
+
shellType,
|
|
107
|
+
profilePath,
|
|
108
|
+
message: `Added configuration to ${profilePath}`,
|
|
109
|
+
};
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const errorMessage =
|
|
112
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
113
|
+
return {
|
|
114
|
+
success: false,
|
|
115
|
+
shellType,
|
|
116
|
+
profilePath,
|
|
117
|
+
message: `Failed to update shell profile: ${errorMessage}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Gets instructions for manual shell profile configuration.
|
|
124
|
+
*/
|
|
125
|
+
export function getManualInstructions(shellType: ShellType): string {
|
|
126
|
+
const configPath = getConfigFilePath();
|
|
127
|
+
const sourceCmd = getSourceCommand(shellType, configPath);
|
|
128
|
+
const profilePath = getProfilePath(shellType);
|
|
129
|
+
|
|
130
|
+
return `Add the following to ${profilePath || "your shell profile"}:\n\n${sourceCmd}`;
|
|
131
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { ToolContext } from "../types/tool-metering.js";
|
|
3
|
+
|
|
4
|
+
const toolContextStorage = new AsyncLocalStorage<ToolContext>();
|
|
5
|
+
|
|
6
|
+
export function setToolContext(context: ToolContext): void {
|
|
7
|
+
toolContextStorage.enterWith(context);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getToolContext(): ToolContext {
|
|
11
|
+
return toolContextStorage.getStore() ?? {};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function clearToolContext(): void {
|
|
15
|
+
toolContextStorage.enterWith({});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function runWithToolContext<T>(
|
|
19
|
+
context: ToolContext,
|
|
20
|
+
fn: () => T,
|
|
21
|
+
): T {
|
|
22
|
+
return toolContextStorage.run(context, fn);
|
|
23
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ToolMetadata,
|
|
3
|
+
ToolEventPayload,
|
|
4
|
+
ToolCallReport,
|
|
5
|
+
} from "../types/tool-metering.js";
|
|
6
|
+
import type { OTLPLogsPayload } from "../types/index.js";
|
|
7
|
+
import { getToolContext } from "./tool-context.js";
|
|
8
|
+
import { sendOtlpLogs } from "./api/client.js";
|
|
9
|
+
import { loadConfig } from "./config/loader.js";
|
|
10
|
+
|
|
11
|
+
const MIDDLEWARE_SOURCE = "revenium-claude-code-sdk";
|
|
12
|
+
|
|
13
|
+
function isPromise<T>(value: unknown): value is Promise<T> {
|
|
14
|
+
return value !== null && typeof value === "object" && typeof (value as Promise<T>).then === "function";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function extractOutputFields(result: unknown, fields: string[]): Record<string, unknown> {
|
|
18
|
+
if (typeof result !== "object" || result === null) {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const extracted: Record<string, unknown> = {};
|
|
23
|
+
for (const field of fields) {
|
|
24
|
+
if (field in result) {
|
|
25
|
+
extracted[field] = (result as Record<string, unknown>)[field];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return extracted;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildToolEventPayload(
|
|
32
|
+
toolId: string,
|
|
33
|
+
durationMs: number,
|
|
34
|
+
success: boolean,
|
|
35
|
+
errorMessage?: string,
|
|
36
|
+
metadata?: ToolMetadata,
|
|
37
|
+
): ToolEventPayload {
|
|
38
|
+
const context = getToolContext();
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
return {
|
|
41
|
+
toolId,
|
|
42
|
+
sessionId: context?.sessionId ?? "unknown",
|
|
43
|
+
startTime: now - durationMs,
|
|
44
|
+
endTime: now,
|
|
45
|
+
durationMs,
|
|
46
|
+
success,
|
|
47
|
+
errorMessage,
|
|
48
|
+
metadata,
|
|
49
|
+
userId: context?.userId,
|
|
50
|
+
organizationName: context?.organizationName,
|
|
51
|
+
productName: context?.productName,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createToolOtlpPayload(event: ToolEventPayload): OTLPLogsPayload {
|
|
56
|
+
const logAttributes: Array<{ key: string; value: { stringValue?: string; intValue?: number; boolValue?: boolean } }> = [
|
|
57
|
+
{ key: "tool.id", value: { stringValue: event.toolId } },
|
|
58
|
+
{ key: "session.id", value: { stringValue: event.sessionId } },
|
|
59
|
+
{ key: "duration_ms", value: { intValue: event.durationMs } },
|
|
60
|
+
{ key: "success", value: { boolValue: event.success } },
|
|
61
|
+
{ key: "middleware.source", value: { stringValue: MIDDLEWARE_SOURCE } },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
if (event.errorMessage) {
|
|
65
|
+
logAttributes.push({ key: "error.message", value: { stringValue: event.errorMessage } });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (event.userId) {
|
|
69
|
+
logAttributes.push({ key: "user.id", value: { stringValue: event.userId } });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (event.organizationName) {
|
|
73
|
+
logAttributes.push({ key: "organization.name", value: { stringValue: event.organizationName } });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (event.productName) {
|
|
77
|
+
logAttributes.push({ key: "product.name", value: { stringValue: event.productName } });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (event.metadata?.description) {
|
|
81
|
+
logAttributes.push({ key: "tool.description", value: { stringValue: event.metadata.description } });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (event.metadata?.category) {
|
|
85
|
+
logAttributes.push({ key: "tool.category", value: { stringValue: event.metadata.category } });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (event.metadata?.version) {
|
|
89
|
+
logAttributes.push({ key: "tool.version", value: { stringValue: event.metadata.version } });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (event.metadata?.tags && event.metadata.tags.length > 0) {
|
|
93
|
+
logAttributes.push({ key: "tool.tags", value: { stringValue: event.metadata.tags.join(",") } });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
resourceLogs: [
|
|
98
|
+
{
|
|
99
|
+
resource: {
|
|
100
|
+
attributes: [
|
|
101
|
+
{ key: "service.name", value: { stringValue: "claude-code" } },
|
|
102
|
+
{ key: "middleware.source", value: { stringValue: MIDDLEWARE_SOURCE } },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
scopeLogs: [
|
|
106
|
+
{
|
|
107
|
+
scope: {
|
|
108
|
+
name: "tool_metering",
|
|
109
|
+
version: "0.1.0",
|
|
110
|
+
},
|
|
111
|
+
logRecords: [
|
|
112
|
+
{
|
|
113
|
+
timeUnixNano: (event.endTime * 1_000_000).toString(),
|
|
114
|
+
body: { stringValue: "tool.call" },
|
|
115
|
+
attributes: logAttributes,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function sendToolEvent(event: ToolEventPayload): Promise<void> {
|
|
126
|
+
const context = getToolContext();
|
|
127
|
+
const config = await loadConfig();
|
|
128
|
+
|
|
129
|
+
const endpoint = context?.endpoint ?? config?.endpoint;
|
|
130
|
+
const apiKey = context?.apiKey ?? config?.apiKey;
|
|
131
|
+
|
|
132
|
+
if (!endpoint || !apiKey) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const payload = createToolOtlpPayload(event);
|
|
137
|
+
await sendOtlpLogs(endpoint, apiKey, payload);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function dispatchToolEvent(event: ToolEventPayload): void {
|
|
141
|
+
sendToolEvent(event).catch(() => {});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function meterTool<T>(
|
|
145
|
+
toolId: string,
|
|
146
|
+
fn: () => T | Promise<T>,
|
|
147
|
+
metadata?: ToolMetadata,
|
|
148
|
+
): Promise<T> {
|
|
149
|
+
const startTime = performance.now();
|
|
150
|
+
|
|
151
|
+
const handleSuccess = (result: T): T => {
|
|
152
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
153
|
+
|
|
154
|
+
let finalMetadata = metadata;
|
|
155
|
+
if (metadata?.outputFields && metadata.outputFields.length > 0) {
|
|
156
|
+
const extracted = extractOutputFields(result, metadata.outputFields);
|
|
157
|
+
finalMetadata = {
|
|
158
|
+
...metadata,
|
|
159
|
+
usageMetadata: {
|
|
160
|
+
...metadata.usageMetadata,
|
|
161
|
+
...extracted,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const event = buildToolEventPayload(toolId, durationMs, true, undefined, finalMetadata);
|
|
167
|
+
dispatchToolEvent(event);
|
|
168
|
+
return result;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const handleError = (error: unknown): never => {
|
|
172
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
173
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
174
|
+
const event = buildToolEventPayload(toolId, durationMs, false, errorMessage, metadata);
|
|
175
|
+
dispatchToolEvent(event);
|
|
176
|
+
throw error;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const result = fn();
|
|
181
|
+
|
|
182
|
+
if (isPromise<T>(result)) {
|
|
183
|
+
return result.then(handleSuccess, handleError);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return Promise.resolve(handleSuccess(result));
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return Promise.reject(handleError(error));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function reportToolCall(
|
|
193
|
+
toolId: string,
|
|
194
|
+
report: ToolCallReport,
|
|
195
|
+
): void {
|
|
196
|
+
const event = buildToolEventPayload(
|
|
197
|
+
toolId,
|
|
198
|
+
report.durationMs,
|
|
199
|
+
report.success,
|
|
200
|
+
report.errorMessage,
|
|
201
|
+
report.metadata,
|
|
202
|
+
);
|
|
203
|
+
dispatchToolEvent(event);
|
|
204
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Re-export types and utilities for programmatic use
|
|
2
|
+
export * from './types/index.js';
|
|
3
|
+
export * from './utils/constants.js';
|
|
4
|
+
export * from './utils/hashing.js';
|
|
5
|
+
export * from './utils/masking.js';
|
|
6
|
+
export * from './core/config/loader.js';
|
|
7
|
+
export * from './core/config/writer.js';
|
|
8
|
+
export * from './core/config/validator.js';
|
|
9
|
+
export * from './core/api/client.js';
|
|
10
|
+
export * from './core/shell/detector.js';
|
|
11
|
+
export * from './core/tool-context.js';
|
|
12
|
+
export * from './core/tool-tracker.js';
|