@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.
- package/dist/commands/auth/login.js +2 -2
- package/dist/commands/integrate/claude-code.js +9 -28
- package/dist/commands/integrate/codex-cli.js +7 -27
- package/dist/commands/integrate/gemini-cli.js +99 -53
- package/dist/hooks/claude-code.cjs +951 -0
- package/dist/hooks/claude-code.d.ts +1 -0
- package/dist/hooks/claude-code.js +641 -0
- package/dist/hooks/codex-cli.cjs +793 -0
- package/dist/hooks/codex-cli.d.ts +1 -0
- package/dist/hooks/codex-cli.js +469 -0
- package/dist/hooks/gemini-cli.cjs +826 -0
- package/dist/hooks/gemini-cli.d.ts +1 -0
- package/dist/hooks/gemini-cli.js +563 -0
- package/dist/hooks/shared.d.ts +82 -0
- package/dist/hooks/shared.js +461 -0
- package/dist/lib/integrate.d.ts +3 -3
- package/dist/lib/integrate.js +4 -8
- package/oclif.manifest.json +466 -466
- package/package.json +6 -3
- package/dist/assets/codex_hook.py +0 -897
- package/dist/assets/hook.py +0 -1052
|
@@ -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
|
+
}
|
package/dist/lib/integrate.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
package/dist/lib/integrate.js
CHANGED
|
@@ -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
|
|
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
|
|
89
|
+
export function getJsHookScript(tool) {
|
|
94
90
|
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
95
|
-
const hookPath = path.join(dir, '..', '
|
|
91
|
+
const hookPath = path.join(dir, '..', 'hooks', `${tool}.cjs`);
|
|
96
92
|
return fs.readFileSync(hookPath, 'utf-8');
|
|
97
93
|
}
|
|
98
94
|
// ── Utilities ─────────────────────────────────────────────────────────────
|