@respan/cli 0.5.3 → 0.6.1

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.
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Shared utilities for Respan CLI hooks.
3
+ *
4
+ * Provides auth loading, config loading, state management, span construction,
5
+ * and API submission. Used by claude-code, codex-cli, and gemini-cli hooks.
6
+ */
7
+ import * as fs from 'node:fs';
8
+ import * as os from 'node:os';
9
+ import * as path from 'node:path';
10
+ // Resolve the v2/traces endpoint (OTLP JSON format)
11
+ export function resolveTracesEndpoint(baseUrl) {
12
+ const DEFAULT = 'https://api.respan.ai/api/v2/traces';
13
+ if (!baseUrl)
14
+ return DEFAULT;
15
+ const normalized = baseUrl.replace(/\/+$/, '');
16
+ if (normalized.endsWith('/api'))
17
+ return `${normalized}/v2/traces`;
18
+ return `${normalized}/api/v2/traces`;
19
+ }
20
+ // Keep old name for backward compat in gemini-cli detached senders
21
+ export const resolveTracingIngestEndpoint = resolveTracesEndpoint;
22
+ // ── Logging ───────────────────────────────────────────────────────
23
+ let _logFile = null;
24
+ let _debug = false;
25
+ export function initLogging(logFile, debug) {
26
+ _logFile = logFile;
27
+ _debug = debug;
28
+ const dir = path.dirname(logFile);
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ }
31
+ export function log(level, message) {
32
+ if (!_logFile)
33
+ return;
34
+ const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
35
+ fs.appendFileSync(_logFile, `${ts} [${level}] ${message}\n`);
36
+ }
37
+ export function debug(message) {
38
+ if (_debug)
39
+ log('DEBUG', message);
40
+ }
41
+ // ── Credentials ───────────────────────────────────────────────────
42
+ const DEFAULT_BASE_URL = 'https://api.respan.ai/api';
43
+ export function resolveCredentials() {
44
+ let apiKey = process.env.RESPAN_API_KEY ?? '';
45
+ let baseUrl = process.env.RESPAN_BASE_URL ?? DEFAULT_BASE_URL;
46
+ if (!apiKey) {
47
+ const credsFile = path.join(os.homedir(), '.respan', 'credentials.json');
48
+ if (fs.existsSync(credsFile)) {
49
+ try {
50
+ const creds = JSON.parse(fs.readFileSync(credsFile, 'utf-8'));
51
+ const configFile = path.join(os.homedir(), '.respan', 'config.json');
52
+ let profile = 'default';
53
+ if (fs.existsSync(configFile)) {
54
+ const cfg = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
55
+ profile = cfg.activeProfile ?? 'default';
56
+ }
57
+ const cred = creds[profile] ?? {};
58
+ apiKey = cred.apiKey ?? cred.accessToken ?? '';
59
+ if (!baseUrl || baseUrl === DEFAULT_BASE_URL) {
60
+ baseUrl = cred.baseUrl ?? baseUrl;
61
+ }
62
+ // Ensure base_url ends with /api
63
+ if (baseUrl && !baseUrl.replace(/\/+$/, '').endsWith('/api')) {
64
+ baseUrl = baseUrl.replace(/\/+$/, '') + '/api';
65
+ }
66
+ if (apiKey) {
67
+ debug(`Using API key from credentials.json (profile: ${profile})`);
68
+ }
69
+ }
70
+ catch (e) {
71
+ debug(`Failed to read credentials.json: ${e}`);
72
+ }
73
+ }
74
+ }
75
+ if (!apiKey)
76
+ return null;
77
+ return { apiKey, baseUrl };
78
+ }
79
+ // ── Config loading ────────────────────────────────────────────────
80
+ const KNOWN_CONFIG_KEYS = new Set([
81
+ 'customer_id', 'span_name', 'workflow_name', 'base_url', 'project_id',
82
+ ]);
83
+ export function loadRespanConfig(configPath) {
84
+ if (!fs.existsSync(configPath)) {
85
+ return { fields: {}, properties: {} };
86
+ }
87
+ try {
88
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
89
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
90
+ return { fields: {}, properties: {} };
91
+ }
92
+ const fields = {};
93
+ const properties = {};
94
+ for (const [k, v] of Object.entries(raw)) {
95
+ if (KNOWN_CONFIG_KEYS.has(k)) {
96
+ fields[k] = String(v);
97
+ }
98
+ else {
99
+ properties[k] = String(v);
100
+ }
101
+ }
102
+ return { fields, properties };
103
+ }
104
+ catch (e) {
105
+ debug(`Failed to load config from ${configPath}: ${e}`);
106
+ return { fields: {}, properties: {} };
107
+ }
108
+ }
109
+ // ── State management ──────────────────────────────────────────────
110
+ export function loadState(statePath) {
111
+ if (!fs.existsSync(statePath))
112
+ return {};
113
+ try {
114
+ return JSON.parse(fs.readFileSync(statePath, 'utf-8'));
115
+ }
116
+ catch {
117
+ return {};
118
+ }
119
+ }
120
+ export function saveState(statePath, state) {
121
+ const dir = path.dirname(statePath);
122
+ fs.mkdirSync(dir, { recursive: true });
123
+ const tmpPath = statePath + '.tmp.' + process.pid;
124
+ try {
125
+ fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2));
126
+ fs.renameSync(tmpPath, statePath);
127
+ }
128
+ catch (e) {
129
+ try {
130
+ fs.unlinkSync(tmpPath);
131
+ }
132
+ catch { }
133
+ // Fallback to direct write
134
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
135
+ }
136
+ }
137
+ // ── File locking ──────────────────────────────────────────────────
138
+ /**
139
+ * Simple advisory file lock using mkdir (atomic on all platforms).
140
+ * Returns an unlock function, or null if lock couldn't be acquired.
141
+ */
142
+ export function acquireLock(lockPath, timeoutMs = 5000) {
143
+ const deadline = Date.now() + timeoutMs;
144
+ while (Date.now() < deadline) {
145
+ try {
146
+ fs.mkdirSync(lockPath);
147
+ return () => {
148
+ try {
149
+ fs.rmdirSync(lockPath);
150
+ }
151
+ catch { }
152
+ };
153
+ }
154
+ catch {
155
+ // Lock held by another process, wait
156
+ const waitMs = Math.min(100, deadline - Date.now());
157
+ if (waitMs > 0) {
158
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs);
159
+ }
160
+ }
161
+ }
162
+ debug('Could not acquire lock within timeout, proceeding without lock');
163
+ return () => { }; // No-op unlock
164
+ }
165
+ // ── Timestamp helpers ─────────────────────────────────────────────
166
+ export function nowISO() {
167
+ return new Date().toISOString();
168
+ }
169
+ export function parseTimestamp(ts) {
170
+ try {
171
+ const d = new Date(ts);
172
+ return isNaN(d.getTime()) ? null : d;
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ }
178
+ export function latencySeconds(start, end) {
179
+ const s = parseTimestamp(start);
180
+ const e = parseTimestamp(end);
181
+ if (s && e)
182
+ return (e.getTime() - s.getTime()) / 1000;
183
+ return undefined;
184
+ }
185
+ // ── Truncation ────────────────────────────────────────────────────
186
+ export function truncate(text, maxChars = 4000) {
187
+ if (text.length <= maxChars)
188
+ return text;
189
+ return text.slice(0, maxChars) + '\n... (truncated)';
190
+ }
191
+ // ── Span construction helpers ─────────────────────────────────────
192
+ /**
193
+ * No-op — v1 platform defaults are no longer needed with OTLP v2 format.
194
+ * Kept for API compatibility with hook files.
195
+ */
196
+ export function addDefaults(span) {
197
+ return span;
198
+ }
199
+ export function addDefaultsToAll(spans) {
200
+ return spans;
201
+ }
202
+ /**
203
+ * Resolve config overrides for span fields. Env vars take precedence over config file.
204
+ */
205
+ export function resolveSpanFields(config, defaults) {
206
+ const fields = config?.fields ?? {};
207
+ return {
208
+ workflowName: process.env.RESPAN_WORKFLOW_NAME ?? fields.workflow_name ?? defaults.workflowName,
209
+ spanName: process.env.RESPAN_SPAN_NAME ?? fields.span_name ?? defaults.spanName,
210
+ customerId: process.env.RESPAN_CUSTOMER_ID ?? fields.customer_id ?? '',
211
+ };
212
+ }
213
+ /**
214
+ * Build metadata from config properties + env overrides.
215
+ */
216
+ export function buildMetadata(config, base = {}) {
217
+ const metadata = { ...base };
218
+ if (config?.properties) {
219
+ Object.assign(metadata, config.properties);
220
+ }
221
+ const envMetadata = process.env.RESPAN_METADATA;
222
+ if (envMetadata) {
223
+ try {
224
+ const extra = JSON.parse(envMetadata);
225
+ if (typeof extra === 'object' && extra !== null) {
226
+ Object.assign(metadata, extra);
227
+ }
228
+ }
229
+ catch { }
230
+ }
231
+ return metadata;
232
+ }
233
+ // ── OTLP JSON conversion ──────────────────────────────────────────
234
+ /** Convert a JS value to an OTLP attribute value object. */
235
+ function toOtlpValue(value) {
236
+ if (value === null || value === undefined)
237
+ return null;
238
+ if (typeof value === 'string')
239
+ return { stringValue: value };
240
+ if (typeof value === 'number') {
241
+ return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
242
+ }
243
+ if (typeof value === 'boolean')
244
+ return { boolValue: value };
245
+ if (Array.isArray(value)) {
246
+ const values = value.map(toOtlpValue).filter(Boolean);
247
+ return { arrayValue: { values } };
248
+ }
249
+ if (typeof value === 'object') {
250
+ // Convert object to kvlist
251
+ const values = Object.entries(value)
252
+ .map(([k, v]) => {
253
+ const converted = toOtlpValue(v);
254
+ return converted ? { key: k, value: converted } : null;
255
+ })
256
+ .filter(Boolean);
257
+ return { kvlistValue: { values } };
258
+ }
259
+ return { stringValue: String(value) };
260
+ }
261
+ /** Convert a flat key-value map to OTLP attribute list. */
262
+ function toOtlpAttributes(attrs) {
263
+ const result = [];
264
+ for (const [key, value] of Object.entries(attrs)) {
265
+ if (value === null || value === undefined)
266
+ continue;
267
+ const converted = toOtlpValue(value);
268
+ if (converted)
269
+ result.push({ key, value: converted });
270
+ }
271
+ return result;
272
+ }
273
+ /** Convert ISO timestamp to nanosecond string. */
274
+ function isoToNanos(iso) {
275
+ const d = new Date(iso);
276
+ if (isNaN(d.getTime()))
277
+ return '0';
278
+ return String(BigInt(d.getTime()) * 1000000n);
279
+ }
280
+ /** Generate a 32-char hex trace ID from a string (MD5-like hash). */
281
+ function stringToTraceId(s) {
282
+ let hash = 0;
283
+ for (let i = 0; i < s.length; i++) {
284
+ hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0;
285
+ }
286
+ // Produce 32 hex chars from two different hash seeds
287
+ let hash2 = 0;
288
+ for (let i = s.length - 1; i >= 0; i--) {
289
+ hash2 = ((hash2 << 7) - hash2 + s.charCodeAt(i)) | 0;
290
+ }
291
+ const hex1 = (hash >>> 0).toString(16).padStart(8, '0');
292
+ const hex2 = (hash2 >>> 0).toString(16).padStart(8, '0');
293
+ // Use both hashes + length-based component for 32 chars
294
+ const hex3 = (s.length * 2654435761 >>> 0).toString(16).padStart(8, '0');
295
+ const hex4 = ((hash ^ hash2) >>> 0).toString(16).padStart(8, '0');
296
+ return (hex1 + hex2 + hex3 + hex4).slice(0, 32);
297
+ }
298
+ /** Generate a 16-char hex span ID from a string. */
299
+ function stringToSpanId(s) {
300
+ let hash = 0;
301
+ for (let i = 0; i < s.length; i++) {
302
+ hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0;
303
+ }
304
+ const hex1 = (hash >>> 0).toString(16).padStart(8, '0');
305
+ let hash2 = 0;
306
+ for (let i = s.length - 1; i >= 0; i--) {
307
+ hash2 = ((hash2 << 3) - hash2 + s.charCodeAt(i)) | 0;
308
+ }
309
+ const hex2 = (hash2 >>> 0).toString(16).padStart(8, '0');
310
+ return (hex1 + hex2).slice(0, 16);
311
+ }
312
+ /** Convert SpanData[] to OTLP JSON payload for /v2/traces. */
313
+ export function toOtlpPayload(spans) {
314
+ const otlpSpans = spans.map((span) => {
315
+ // Build OTEL-compatible attributes from SpanData fields
316
+ const attrs = {};
317
+ // Respan-specific attributes
318
+ if (span.thread_identifier)
319
+ attrs['respan.threads.thread_identifier'] = span.thread_identifier;
320
+ if (span.customer_identifier)
321
+ attrs['respan.customer_params.customer_identifier'] = span.customer_identifier;
322
+ if (span.span_workflow_name)
323
+ attrs['traceloop.workflow.name'] = span.span_workflow_name;
324
+ if (span.span_path)
325
+ attrs['traceloop.entity.path'] = span.span_path;
326
+ // Span kind mapping
327
+ const isRoot = !span.span_parent_id;
328
+ const isLlm = span.span_name.includes('.chat');
329
+ const isTool = span.span_name.startsWith('Tool:');
330
+ const isThinking = span.span_name.startsWith('Thinking') || span.span_name === 'Reasoning';
331
+ if (isLlm) {
332
+ attrs['traceloop.span.kind'] = 'task';
333
+ attrs['llm.request.type'] = 'chat';
334
+ }
335
+ else if (isTool) {
336
+ attrs['traceloop.span.kind'] = 'tool';
337
+ }
338
+ else if (isRoot) {
339
+ attrs['traceloop.span.kind'] = 'workflow';
340
+ }
341
+ else if (isThinking) {
342
+ attrs['traceloop.span.kind'] = 'task';
343
+ }
344
+ // Model and provider
345
+ if (span.model)
346
+ attrs['gen_ai.request.model'] = span.model;
347
+ if (span.provider_id)
348
+ attrs['gen_ai.system'] = span.provider_id;
349
+ // Token usage
350
+ if (span.prompt_tokens !== undefined)
351
+ attrs['gen_ai.usage.prompt_tokens'] = span.prompt_tokens;
352
+ if (span.completion_tokens !== undefined)
353
+ attrs['gen_ai.usage.completion_tokens'] = span.completion_tokens;
354
+ if (span.total_tokens !== undefined)
355
+ attrs['llm.usage.total_tokens'] = span.total_tokens;
356
+ // Input/output as traceloop entity fields
357
+ if (span.input)
358
+ attrs['traceloop.entity.input'] = span.input;
359
+ if (span.output)
360
+ attrs['traceloop.entity.output'] = span.output;
361
+ // Metadata as respan.metadata JSON
362
+ if (span.metadata && Object.keys(span.metadata).length > 0) {
363
+ attrs['respan.metadata'] = JSON.stringify(span.metadata);
364
+ }
365
+ // Environment
366
+ attrs['respan.entity.log_method'] = 'ts_tracing';
367
+ // Compute start/end nanos
368
+ const startNanos = isoToNanos(span.start_time);
369
+ let endNanos = isoToNanos(span.timestamp);
370
+ // If start == end and we have latency, compute end
371
+ if (startNanos === endNanos && span.latency && span.latency > 0) {
372
+ const startMs = new Date(span.start_time).getTime();
373
+ endNanos = String(BigInt(Math.round(startMs + span.latency * 1000)) * 1000000n);
374
+ }
375
+ const otlpSpan = {
376
+ traceId: stringToTraceId(span.trace_unique_id),
377
+ spanId: stringToSpanId(span.span_unique_id),
378
+ name: span.span_name,
379
+ kind: isLlm ? 3 : 1, // 3=CLIENT (LLM calls), 1=INTERNAL
380
+ startTimeUnixNano: startNanos,
381
+ endTimeUnixNano: endNanos,
382
+ attributes: toOtlpAttributes(attrs),
383
+ status: { code: 1 }, // STATUS_CODE_OK
384
+ };
385
+ if (span.span_parent_id) {
386
+ otlpSpan.parentSpanId = stringToSpanId(span.span_parent_id);
387
+ }
388
+ return otlpSpan;
389
+ });
390
+ return {
391
+ resourceSpans: [{
392
+ resource: {
393
+ attributes: toOtlpAttributes({
394
+ 'service.name': 'respan-cli-hooks',
395
+ }),
396
+ },
397
+ scopeSpans: [{
398
+ scope: { name: 'respan-cli-hooks', version: '0.5.3' },
399
+ spans: otlpSpans,
400
+ }],
401
+ }],
402
+ };
403
+ }
404
+ // ── API submission ────────────────────────────────────────────────
405
+ export async function sendSpans(spans, apiKey, baseUrl, context) {
406
+ const url = resolveTracesEndpoint(baseUrl);
407
+ const headers = {
408
+ 'Authorization': `Bearer ${apiKey}`,
409
+ 'Content-Type': 'application/json',
410
+ 'X-Respan-Dogfood': '1', // Anti-recursion: prevent trace loops on ingest
411
+ };
412
+ // Convert to OTLP JSON format for v2/traces
413
+ const payload = toOtlpPayload(spans);
414
+ const body = JSON.stringify(payload);
415
+ const spanNames = spans.map(s => s.span_name);
416
+ debug(`Sending ${spans.length} spans (${body.length} bytes) to ${url} for ${context}: ${spanNames.join(', ')}`);
417
+ if (_debug) {
418
+ const debugDir = _logFile ? path.dirname(_logFile) : os.tmpdir();
419
+ const debugFile = path.join(debugDir, `respan_spans_${context.replace(/\s+/g, '_')}.json`);
420
+ fs.writeFileSync(debugFile, body);
421
+ debug(`Dumped OTLP payload to ${debugFile}`);
422
+ }
423
+ for (let attempt = 0; attempt < 3; attempt++) {
424
+ try {
425
+ const controller = new AbortController();
426
+ const timeout = setTimeout(() => controller.abort(), 30_000);
427
+ const response = await fetch(url, {
428
+ method: 'POST',
429
+ headers,
430
+ body,
431
+ signal: controller.signal,
432
+ });
433
+ clearTimeout(timeout);
434
+ if (response.status < 400) {
435
+ const text = await response.text();
436
+ debug(`Sent ${spans.length} spans for ${context} (attempt ${attempt + 1}): ${text.slice(0, 300)}`);
437
+ return;
438
+ }
439
+ if (response.status < 500) {
440
+ const text = await response.text();
441
+ log('ERROR', `Spans rejected for ${context}: HTTP ${response.status} - ${text.slice(0, 200)}`);
442
+ return;
443
+ }
444
+ // 5xx — retry
445
+ debug(`Server error for ${context} (attempt ${attempt + 1}), retrying...`);
446
+ await sleep(1000);
447
+ }
448
+ catch (e) {
449
+ if (attempt < 2) {
450
+ await sleep(1000);
451
+ }
452
+ else {
453
+ log('ERROR', `Failed to send spans for ${context}: ${e}`);
454
+ }
455
+ }
456
+ }
457
+ log('ERROR', `Failed to send ${spans.length} spans for ${context} after 3 attempts`);
458
+ }
459
+ function sleep(ms) {
460
+ return new Promise(resolve => setTimeout(resolve, ms));
461
+ }
@@ -34,11 +34,11 @@ export declare function findProjectRoot(): string;
34
34
  * build. Reading at runtime keeps the hook version-locked to the CLI
35
35
  * version — upgrading the CLI and re-running integrate updates the hook.
36
36
  */
37
- export declare function getHookScript(): string;
38
37
  /**
39
- * Return the bundled Codex CLI hook script contents.
38
+ * Return the bundled JS hook script contents for the given CLI tool.
39
+ * These are standalone Node.js scripts bundled with esbuild.
40
40
  */
41
- export declare function getCodexHookScript(): string;
41
+ export declare function getJsHookScript(tool: 'claude-code' | 'gemini-cli' | 'codex-cli'): string;
42
42
  /**
43
43
  * Deep merge source into target.
44
44
  * Objects are recursively merged; arrays and primitives from source overwrite.
@@ -82,17 +82,13 @@ export function findProjectRoot() {
82
82
  * build. Reading at runtime keeps the hook version-locked to the CLI
83
83
  * version — upgrading the CLI and re-running integrate updates the hook.
84
84
  */
85
- export function getHookScript() {
86
- const dir = path.dirname(fileURLToPath(import.meta.url));
87
- const hookPath = path.join(dir, '..', 'assets', 'hook.py');
88
- return fs.readFileSync(hookPath, 'utf-8');
89
- }
90
85
  /**
91
- * Return the bundled Codex CLI hook script contents.
86
+ * Return the bundled JS hook script contents for the given CLI tool.
87
+ * These are standalone Node.js scripts bundled with esbuild.
92
88
  */
93
- export function getCodexHookScript() {
89
+ export function getJsHookScript(tool) {
94
90
  const dir = path.dirname(fileURLToPath(import.meta.url));
95
- const hookPath = path.join(dir, '..', 'assets', 'codex_hook.py');
91
+ const hookPath = path.join(dir, '..', 'hooks', `${tool}.cjs`);
96
92
  return fs.readFileSync(hookPath, 'utf-8');
97
93
  }
98
94
  // ── Utilities ─────────────────────────────────────────────────────────────