@respan/cli 0.13.3 → 0.14.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/collector/config.d.ts +21 -0
- package/dist/commands/collector/config.js +73 -0
- package/dist/commands/collector/start.d.ts +26 -0
- package/dist/commands/collector/start.js +198 -0
- package/dist/commands/collector/status.d.ts +17 -0
- package/dist/commands/collector/status.js +113 -0
- package/dist/commands/collector/stop.d.ts +18 -0
- package/dist/commands/collector/stop.js +91 -0
- package/dist/commands/integrate/pi.d.ts +28 -0
- package/dist/commands/integrate/pi.js +198 -0
- package/dist/lib/collector-runtime.d.ts +93 -0
- package/dist/lib/collector-runtime.js +361 -0
- package/dist/lib/collector.d.ts +135 -0
- package/dist/lib/collector.js +289 -0
- package/dist/lib/pi-integrate.d.ts +49 -0
- package/dist/lib/pi-integrate.js +74 -0
- package/dist/lib/skill-refs.generated.d.ts +1 -1
- package/dist/lib/skill-refs.generated.js +7 -0
- package/oclif.manifest.json +1286 -664
- package/package.json +7 -2
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { Flags } from '@oclif/core';
|
|
3
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
4
|
+
import { isBinaryInstalled } from '../../lib/agents.js';
|
|
5
|
+
import { DEFAULT_BASE_URL, integrateFlags, readJsonFile, writeJsonFile, parseAttrs, resolveScope, findProjectRoot, } from '../../lib/integrate.js';
|
|
6
|
+
import { PI_PACKAGE_SPEC, buildPiRespanConfig, piConfigPath, piInstallArgs, } from '../../lib/pi-integrate.js';
|
|
7
|
+
import { DEFAULT_COLLECTOR_PORT, collectorBaseUrl } from '../../lib/collector.js';
|
|
8
|
+
class IntegratePi extends BaseCommand {
|
|
9
|
+
async run() {
|
|
10
|
+
const { flags } = await this.parse(IntegratePi);
|
|
11
|
+
this.globalFlags = flags;
|
|
12
|
+
try {
|
|
13
|
+
const dryRun = flags['dry-run'];
|
|
14
|
+
// pi loads global packages from ~/.pi/agent, so global is the default.
|
|
15
|
+
const scope = resolveScope(flags, 'global');
|
|
16
|
+
const projectRoot = scope === 'local' ? findProjectRoot() : process.cwd();
|
|
17
|
+
const configPath = piConfigPath(scope, projectRoot);
|
|
18
|
+
// ── Disable mode ─────────────────────────────────────────────
|
|
19
|
+
if (flags.disable) {
|
|
20
|
+
const disabled = buildPiRespanConfig(readJsonFile(configPath), { enabled: false });
|
|
21
|
+
if (dryRun) {
|
|
22
|
+
this.log(`[dry-run] Would write: ${configPath}`);
|
|
23
|
+
this.log(JSON.stringify(disabled, null, 2));
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
writeJsonFile(configPath, disabled);
|
|
27
|
+
this.log(`Disabled tracing in: ${configPath}`);
|
|
28
|
+
}
|
|
29
|
+
this.log('');
|
|
30
|
+
this.log('pi tracing disabled (the extension package stays installed). Run "respan integrate pi" to re-enable.');
|
|
31
|
+
this.log(`To uninstall the extension package: pi remove ${PI_PACKAGE_SPEC}`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// ── Enable mode (default) ────────────────────────────────────
|
|
35
|
+
// Auth precheck only — the key is read by the extension at runtime.
|
|
36
|
+
this.resolveApiKey();
|
|
37
|
+
const baseUrl = flags['base-url'];
|
|
38
|
+
const projectId = flags['project-id'];
|
|
39
|
+
const customerId = flags['customer-id'];
|
|
40
|
+
// The extension treats span_name as an alias of workflow_name.
|
|
41
|
+
const workflowName = flags['workflow-name'] ?? flags['span-name'];
|
|
42
|
+
const traceScope = flags['trace-scope'];
|
|
43
|
+
const attrs = parseAttrs(flags.attrs);
|
|
44
|
+
const withCollector = flags['with-collector'];
|
|
45
|
+
const collectorPort = flags['collector-port'] ?? DEFAULT_COLLECTOR_PORT;
|
|
46
|
+
if (flags['collector-port'] !== undefined && !withCollector) {
|
|
47
|
+
this.warn('--collector-port has no effect without --with-collector.');
|
|
48
|
+
}
|
|
49
|
+
if (withCollector && baseUrl && baseUrl.replace(/\/+$/, '') !== DEFAULT_BASE_URL) {
|
|
50
|
+
this.warn(`--with-collector overrides --base-url: traces go to ${collectorBaseUrl(collectorPort)}. ` +
|
|
51
|
+
'Point the collector at your endpoint with `respan collector start --export-url ...`.');
|
|
52
|
+
}
|
|
53
|
+
// ── 1. Install the pi package ─────────────────────────────────
|
|
54
|
+
const installCommand = ['pi', ...piInstallArgs(scope)].join(' ');
|
|
55
|
+
if (!isBinaryInstalled('pi')) {
|
|
56
|
+
const message = 'pi is not installed. Install it with `npm install -g @earendil-works/pi-coding-agent` (see https://pi.dev)';
|
|
57
|
+
if (dryRun) {
|
|
58
|
+
this.warn(message);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
this.error(message);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (dryRun) {
|
|
65
|
+
this.log(`[dry-run] Would run: ${installCommand}`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
this.log(`Installing ${PI_PACKAGE_SPEC}...`);
|
|
69
|
+
try {
|
|
70
|
+
execSync(installCommand, { stdio: 'pipe', cwd: projectRoot });
|
|
71
|
+
this.log(`Installed ${PI_PACKAGE_SPEC} (${scope}).`);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
75
|
+
this.warn(`Failed to install ${PI_PACKAGE_SPEC}: ${msg}`);
|
|
76
|
+
this.warn(`You may need to install it manually: ${installCommand}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// ── 2. Write respan.json (non-secret) ─────────────────────────
|
|
80
|
+
const config = buildPiRespanConfig(readJsonFile(configPath), {
|
|
81
|
+
enabled: true,
|
|
82
|
+
customerId,
|
|
83
|
+
workflowName,
|
|
84
|
+
projectId,
|
|
85
|
+
traceScope,
|
|
86
|
+
baseUrl,
|
|
87
|
+
collectorPort: withCollector ? collectorPort : undefined,
|
|
88
|
+
attrs,
|
|
89
|
+
});
|
|
90
|
+
if (dryRun) {
|
|
91
|
+
this.log(`[dry-run] Would write: ${configPath}`);
|
|
92
|
+
this.log(JSON.stringify(config, null, 2));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
writeJsonFile(configPath, config);
|
|
96
|
+
this.log(`Wrote Respan config: ${configPath}`);
|
|
97
|
+
}
|
|
98
|
+
// ── Done ──────────────────────────────────────────────────────
|
|
99
|
+
this.log('');
|
|
100
|
+
this.log(`pi integration complete (${scope}). Restart pi to load the extension.`);
|
|
101
|
+
this.log('');
|
|
102
|
+
this.log('Auth: RESPAN_API_KEY or ~/.respan/credentials.json (from `respan auth login`)');
|
|
103
|
+
this.log(`Config: ${configPath} (shareable, non-secret)`);
|
|
104
|
+
if (withCollector) {
|
|
105
|
+
this.log('');
|
|
106
|
+
this.log(`Traces go through the local collector at ${collectorBaseUrl(collectorPort)} (base_url in respan.json).`);
|
|
107
|
+
this.log('Start it once per machine; it keeps running across pi sessions:');
|
|
108
|
+
this.log(` respan collector start${collectorPort === DEFAULT_COLLECTOR_PORT ? '' : ` --port ${collectorPort}`}`);
|
|
109
|
+
this.log('Remove base_url from respan.json to send directly to Respan again.');
|
|
110
|
+
}
|
|
111
|
+
this.log('');
|
|
112
|
+
this.log('Set properties via integrate flags or edit respan.json:');
|
|
113
|
+
this.log(' respan integrate pi --customer-id "frank" --workflow-name "my-agent"');
|
|
114
|
+
this.log(' respan integrate pi --attrs \'{"team":"platform","env":"staging"}\'');
|
|
115
|
+
this.log(' respan integrate pi --trace-scope run # one trace per prompt instead of one per session ("trace_scope")');
|
|
116
|
+
this.log('');
|
|
117
|
+
this.log('Override per-session with env vars:');
|
|
118
|
+
this.log(' export RESPAN_CUSTOMER_ID="your-name"');
|
|
119
|
+
this.log(' export RESPAN_PI_TRACE_SCOPE=run # or session (default)');
|
|
120
|
+
this.log(' export RESPAN_PI_TRACING=false # skip tracing for one session');
|
|
121
|
+
this.log('');
|
|
122
|
+
this.log('One-off run without installing (from a project that has the package):');
|
|
123
|
+
this.log(' pi -e ./node_modules/@respan/instrumentation-pi/dist/extension.js');
|
|
124
|
+
this.log('');
|
|
125
|
+
this.log('Debug: RESPAN_PI_DEBUG=true pi (logs to stderr only; nothing is written to disk)');
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
this.handleError(error);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
resolveApiKey() {
|
|
132
|
+
const auth = this.getAuth();
|
|
133
|
+
if (auth.apiKey)
|
|
134
|
+
return auth.apiKey;
|
|
135
|
+
if (auth.accessToken) {
|
|
136
|
+
this.warn('Using access token (JWT) which may expire. Consider using an API key instead.');
|
|
137
|
+
return auth.accessToken;
|
|
138
|
+
}
|
|
139
|
+
this.error('No API key found. Pass --api-key, set RESPAN_API_KEY, or run: respan auth login');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
IntegratePi.description = `Integrate Respan with the pi coding agent (https://pi.dev).
|
|
143
|
+
|
|
144
|
+
Installs the @respan/instrumentation-pi pi package — a pi extension that
|
|
145
|
+
traces every agent run as one turn span per prompt with chat and tool spans underneath,
|
|
146
|
+
correlating all runs of a pi session by its session id — and writes a
|
|
147
|
+
non-secret respan.json config for it.
|
|
148
|
+
|
|
149
|
+
Scope:
|
|
150
|
+
--global pi install npm:@respan/instrumentation-pi + ~/.pi/agent/respan.json (default)
|
|
151
|
+
--local pi install -l npm:@respan/instrumentation-pi + .pi/respan.json in the project root
|
|
152
|
+
|
|
153
|
+
Trace scope (--trace-scope, "trace_scope" in respan.json):
|
|
154
|
+
session one multi-root trace per pi session, across processes and pauses (default)
|
|
155
|
+
run one trace per agent run; runs of a session share a thread id
|
|
156
|
+
|
|
157
|
+
Durable delivery (--with-collector):
|
|
158
|
+
Sets base_url to a local Respan collector (http://127.0.0.1:4318) so traces
|
|
159
|
+
go through its persistent queue and survive Respan outages. Run
|
|
160
|
+
"respan collector start" once per machine.
|
|
161
|
+
|
|
162
|
+
Credentials are never written to these files: the extension reads
|
|
163
|
+
RESPAN_API_KEY or ~/.respan/credentials.json (from "respan auth login").`;
|
|
164
|
+
IntegratePi.examples = [
|
|
165
|
+
'respan integrate pi',
|
|
166
|
+
'respan integrate pi --disable',
|
|
167
|
+
'respan integrate pi --local',
|
|
168
|
+
'respan integrate pi --customer-id frank --workflow-name email-agent',
|
|
169
|
+
'respan integrate pi --trace-scope run',
|
|
170
|
+
'respan integrate pi --with-collector',
|
|
171
|
+
'respan integrate pi --project-id my-project --attrs \'{"env":"prod"}\'',
|
|
172
|
+
'respan integrate pi --dry-run',
|
|
173
|
+
];
|
|
174
|
+
IntegratePi.flags = {
|
|
175
|
+
...BaseCommand.baseFlags,
|
|
176
|
+
...integrateFlags,
|
|
177
|
+
// The shared descriptions advertise the claude-code default; pi's is "pi".
|
|
178
|
+
'workflow-name': Flags.string({
|
|
179
|
+
description: 'traceloop.workflow.name on pi turn spans (default: pi)',
|
|
180
|
+
}),
|
|
181
|
+
'span-name': Flags.string({
|
|
182
|
+
description: 'Alias of --workflow-name (default: pi)',
|
|
183
|
+
}),
|
|
184
|
+
'trace-scope': Flags.string({
|
|
185
|
+
description: 'Trace scope: "session" = one multi-root trace per pi session (default), "run" = one trace per agent run',
|
|
186
|
+
options: ['run', 'session'],
|
|
187
|
+
}),
|
|
188
|
+
'with-collector': Flags.boolean({
|
|
189
|
+
description: 'Send traces through a local Respan collector (respan collector start) for durable delivery',
|
|
190
|
+
default: false,
|
|
191
|
+
}),
|
|
192
|
+
'collector-port': Flags.integer({
|
|
193
|
+
description: `Port of the local collector, with --with-collector (default: ${DEFAULT_COLLECTOR_PORT})`,
|
|
194
|
+
min: 1,
|
|
195
|
+
max: 65535,
|
|
196
|
+
}),
|
|
197
|
+
};
|
|
198
|
+
export default IntegratePi;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type CollectorMetricsSummary, type CollectorPaths } from './collector.js';
|
|
2
|
+
export type CollectorMode = 'docker' | 'binary';
|
|
3
|
+
/** Smallest --max-size-mib otelcol-contrib 0.160.0 accepts with the config's compaction settings. */
|
|
4
|
+
export declare const MIN_MAX_SIZE_MIB = 100;
|
|
5
|
+
export declare const collectorSizingFlags: {
|
|
6
|
+
'export-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
'data-dir': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
'max-size-mib': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
'queue-size': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
port: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
};
|
|
12
|
+
export declare const collectorModeFlags: {
|
|
13
|
+
docker: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
|
+
binary: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Ingest URL for the collector: --export-url / RESPAN_COLLECTOR_EXPORT_URL,
|
|
18
|
+
* else derived from the resolved auth base URL. Falls back to the configured
|
|
19
|
+
* base URL (flag / RESPAN_API_BASE_URL / SaaS default) when not logged in, so
|
|
20
|
+
* `collector config --print` works without credentials.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveExportUrl(flags: {
|
|
23
|
+
'export-url'?: string;
|
|
24
|
+
'api-key'?: string;
|
|
25
|
+
'base-url'?: string;
|
|
26
|
+
profile?: string;
|
|
27
|
+
}): string;
|
|
28
|
+
export interface CommandResult {
|
|
29
|
+
status: number | null;
|
|
30
|
+
stdout: string;
|
|
31
|
+
stderr: string;
|
|
32
|
+
}
|
|
33
|
+
/** Run `docker <args>` to completion. Never rejects; a missing binary is a failed result. */
|
|
34
|
+
export declare function runDocker(args: string[], env?: NodeJS.ProcessEnv): Promise<CommandResult>;
|
|
35
|
+
/** True when the Docker CLI is installed and can reach a daemon. */
|
|
36
|
+
export declare function dockerAvailable(): boolean;
|
|
37
|
+
export interface ContainerState {
|
|
38
|
+
exists: boolean;
|
|
39
|
+
running: boolean;
|
|
40
|
+
/** created | running | paused | restarting | removing | exited | dead */
|
|
41
|
+
status?: string;
|
|
42
|
+
startedAt?: string;
|
|
43
|
+
restartCount?: number;
|
|
44
|
+
image?: string;
|
|
45
|
+
}
|
|
46
|
+
export declare function dockerContainerState(name?: string): ContainerState;
|
|
47
|
+
export declare function dockerVolumeExists(name?: string): boolean;
|
|
48
|
+
export declare function readPid(pidPath: string): number | undefined;
|
|
49
|
+
export declare function isPidAlive(pid: number): boolean;
|
|
50
|
+
export interface RunningCollector {
|
|
51
|
+
mode: CollectorMode;
|
|
52
|
+
pid?: number;
|
|
53
|
+
container?: ContainerState;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* What this CLI started, if anything: the container (in any state — an exited
|
|
57
|
+
* container still owns the name) or a live pid from the pid file. A stale pid
|
|
58
|
+
* file is removed. `mode` restricts the check to one runtime.
|
|
59
|
+
*/
|
|
60
|
+
export declare function detectRunning(paths: CollectorPaths, mode?: CollectorMode): RunningCollector | undefined;
|
|
61
|
+
export declare function spawnCollectorBinary(paths: CollectorPaths, env: NodeJS.ProcessEnv): number;
|
|
62
|
+
export declare function sleep(ms: number): Promise<void>;
|
|
63
|
+
/** SIGTERM, wait for exit, SIGKILL as a last resort. */
|
|
64
|
+
export declare function stopPid(pid: number, timeoutMs?: number): Promise<'stopped' | 'killed' | 'not-running'>;
|
|
65
|
+
export declare function tailFile(filePath: string, lines: number): string;
|
|
66
|
+
export declare function directorySize(dir: string): number;
|
|
67
|
+
export declare function healthUrl(port?: number): string;
|
|
68
|
+
export declare function metricsUrl(port?: number): string;
|
|
69
|
+
export interface HealthStatus {
|
|
70
|
+
healthy: boolean;
|
|
71
|
+
/** health_check body status, e.g. "Server available", or "HTTP 503". */
|
|
72
|
+
status?: string;
|
|
73
|
+
uptime?: string;
|
|
74
|
+
upSince?: string;
|
|
75
|
+
error?: string;
|
|
76
|
+
}
|
|
77
|
+
/** GET the health_check extension (200 once the pipeline is ready). */
|
|
78
|
+
export declare function checkHealth(port?: number): Promise<HealthStatus>;
|
|
79
|
+
/** Poll health until it passes, the timeout elapses, or `isAlive` reports the process gone. */
|
|
80
|
+
export declare function waitForHealth(port: number, timeoutMs: number, isAlive?: () => boolean): Promise<HealthStatus>;
|
|
81
|
+
export declare function fetchMetrics(port?: number): Promise<CollectorMetricsSummary | undefined>;
|
|
82
|
+
export declare function downloadFile(url: string, dest: string): Promise<void>;
|
|
83
|
+
export declare function sha256File(filePath: string): Promise<string>;
|
|
84
|
+
export interface CollectorLogger {
|
|
85
|
+
log(message: string): void;
|
|
86
|
+
warn(message: string): void;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Make sure ~/.respan/collector/bin/otelcol-contrib exists: download the
|
|
90
|
+
* release tarball, verify its SHA-256 against the published `.sha256` asset
|
|
91
|
+
* (warn when that cannot be fetched), extract the binary and drop the tarball.
|
|
92
|
+
*/
|
|
93
|
+
export declare function ensureCollectorBinary(paths: CollectorPaths, logger: CollectorLogger): Promise<string>;
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { Flags } from '@oclif/core';
|
|
8
|
+
import { DEFAULT_BASE_URL, resolveAuth } from './auth.js';
|
|
9
|
+
import { ensureDir } from './integrate.js';
|
|
10
|
+
import { COLLECTOR_VERSION, CONTAINER_NAME, DEFAULT_COLLECTOR_PORT, DEFAULT_HEALTH_PORT, DEFAULT_METRICS_PORT, DEFAULT_QUEUE_SIZE, VOLUME_NAME, binaryDownloadUrl, checksumUrl, exportUrlFromBaseUrl, parsePrometheusText, parseSha256, summarizeCollectorMetrics, } from './collector.js';
|
|
11
|
+
/** Smallest --max-size-mib otelcol-contrib 0.160.0 accepts with the config's compaction settings. */
|
|
12
|
+
export const MIN_MAX_SIZE_MIB = 100;
|
|
13
|
+
// ── Shared flags ──────────────────────────────────────────────────────────
|
|
14
|
+
export const collectorSizingFlags = {
|
|
15
|
+
'export-url': Flags.string({
|
|
16
|
+
description: 'Respan ingest URL (default: <base URL>/api/v2/traces from your auth config)',
|
|
17
|
+
env: 'RESPAN_COLLECTOR_EXPORT_URL',
|
|
18
|
+
}),
|
|
19
|
+
'data-dir': Flags.string({
|
|
20
|
+
description: 'Persistent queue directory, binary mode only (default: ~/.respan/collector/data)',
|
|
21
|
+
}),
|
|
22
|
+
'max-size-mib': Flags.integer({
|
|
23
|
+
// file_storage rejects a max_size below its compaction rebound threshold (100 MiB by default).
|
|
24
|
+
description: 'Disk cap for the persistent queue in MiB (minimum 100)',
|
|
25
|
+
default: 512,
|
|
26
|
+
min: MIN_MAX_SIZE_MIB,
|
|
27
|
+
}),
|
|
28
|
+
'queue-size': Flags.integer({
|
|
29
|
+
description: 'Batches kept while Respan is unreachable',
|
|
30
|
+
default: DEFAULT_QUEUE_SIZE,
|
|
31
|
+
min: 1,
|
|
32
|
+
}),
|
|
33
|
+
port: Flags.integer({
|
|
34
|
+
description: 'Local OTLP/HTTP port the SDKs send to',
|
|
35
|
+
default: DEFAULT_COLLECTOR_PORT,
|
|
36
|
+
min: 1,
|
|
37
|
+
max: 65535,
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
export const collectorModeFlags = {
|
|
41
|
+
docker: Flags.boolean({
|
|
42
|
+
description: 'Use the Docker container (default when Docker is available)',
|
|
43
|
+
default: false,
|
|
44
|
+
exclusive: ['binary'],
|
|
45
|
+
}),
|
|
46
|
+
binary: Flags.boolean({
|
|
47
|
+
description: 'Use the otelcol-contrib binary in ~/.respan/collector/bin',
|
|
48
|
+
default: false,
|
|
49
|
+
exclusive: ['docker'],
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Ingest URL for the collector: --export-url / RESPAN_COLLECTOR_EXPORT_URL,
|
|
54
|
+
* else derived from the resolved auth base URL. Falls back to the configured
|
|
55
|
+
* base URL (flag / RESPAN_API_BASE_URL / SaaS default) when not logged in, so
|
|
56
|
+
* `collector config --print` works without credentials.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveExportUrl(flags) {
|
|
59
|
+
if (flags['export-url'])
|
|
60
|
+
return flags['export-url'];
|
|
61
|
+
let baseUrl;
|
|
62
|
+
try {
|
|
63
|
+
baseUrl = resolveAuth(flags).baseUrl;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
baseUrl = flags['base-url'] || process.env.RESPAN_API_BASE_URL || DEFAULT_BASE_URL;
|
|
67
|
+
}
|
|
68
|
+
return exportUrlFromBaseUrl(baseUrl);
|
|
69
|
+
}
|
|
70
|
+
/** Run `docker <args>` to completion. Never rejects; a missing binary is a failed result. */
|
|
71
|
+
export function runDocker(args, env = process.env) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
let stdout = '';
|
|
74
|
+
let stderr = '';
|
|
75
|
+
const child = spawn('docker', args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
76
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
77
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
78
|
+
child.on('error', (err) => resolve({ status: null, stdout, stderr: stderr || err.message }));
|
|
79
|
+
child.on('close', (status) => resolve({ status, stdout, stderr }));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function dockerSync(args) {
|
|
83
|
+
const result = spawnSync('docker', args, {
|
|
84
|
+
encoding: 'utf-8',
|
|
85
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
86
|
+
timeout: 15000,
|
|
87
|
+
});
|
|
88
|
+
return { ok: !result.error && result.status === 0, stdout: result.stdout ?? '' };
|
|
89
|
+
}
|
|
90
|
+
/** True when the Docker CLI is installed and can reach a daemon. */
|
|
91
|
+
export function dockerAvailable() {
|
|
92
|
+
return dockerSync(['info']).ok;
|
|
93
|
+
}
|
|
94
|
+
export function dockerContainerState(name = CONTAINER_NAME) {
|
|
95
|
+
const result = dockerSync([
|
|
96
|
+
'inspect', '--format', '{{.State.Status}}|{{.State.StartedAt}}|{{.RestartCount}}|{{.Config.Image}}', name,
|
|
97
|
+
]);
|
|
98
|
+
if (!result.ok)
|
|
99
|
+
return { exists: false, running: false };
|
|
100
|
+
const [status, startedAt, restartCount, image] = result.stdout.trim().split('|');
|
|
101
|
+
return {
|
|
102
|
+
exists: true,
|
|
103
|
+
running: status === 'running',
|
|
104
|
+
status,
|
|
105
|
+
startedAt,
|
|
106
|
+
restartCount: Number.parseInt(restartCount ?? '', 10) || 0,
|
|
107
|
+
image,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export function dockerVolumeExists(name = VOLUME_NAME) {
|
|
111
|
+
return dockerSync(['volume', 'inspect', name]).ok;
|
|
112
|
+
}
|
|
113
|
+
// ── Processes ─────────────────────────────────────────────────────────────
|
|
114
|
+
export function readPid(pidPath) {
|
|
115
|
+
try {
|
|
116
|
+
const pid = Number.parseInt(fs.readFileSync(pidPath, 'utf-8').trim(), 10);
|
|
117
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function isPidAlive(pid) {
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, 0);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
return err.code === 'EPERM';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* What this CLI started, if anything: the container (in any state — an exited
|
|
134
|
+
* container still owns the name) or a live pid from the pid file. A stale pid
|
|
135
|
+
* file is removed. `mode` restricts the check to one runtime.
|
|
136
|
+
*/
|
|
137
|
+
export function detectRunning(paths, mode) {
|
|
138
|
+
if (mode !== 'binary') {
|
|
139
|
+
const container = dockerContainerState();
|
|
140
|
+
if (container.exists)
|
|
141
|
+
return { mode: 'docker', container };
|
|
142
|
+
}
|
|
143
|
+
if (mode !== 'docker') {
|
|
144
|
+
const pid = readPid(paths.pidPath);
|
|
145
|
+
if (pid !== undefined) {
|
|
146
|
+
if (isPidAlive(pid))
|
|
147
|
+
return { mode: 'binary', pid };
|
|
148
|
+
fs.rmSync(paths.pidPath, { force: true });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
export function spawnCollectorBinary(paths, env) {
|
|
154
|
+
ensureDir(paths.root);
|
|
155
|
+
const logFd = fs.openSync(paths.logPath, 'a');
|
|
156
|
+
try {
|
|
157
|
+
const child = spawn(paths.binaryPath, ['--config', paths.configPath], {
|
|
158
|
+
detached: true,
|
|
159
|
+
stdio: ['ignore', logFd, logFd],
|
|
160
|
+
env,
|
|
161
|
+
});
|
|
162
|
+
// Spawn failures surface asynchronously; the caller notices through the
|
|
163
|
+
// pid liveness check instead of an unhandled 'error' event.
|
|
164
|
+
child.on('error', () => { });
|
|
165
|
+
if (child.pid === undefined) {
|
|
166
|
+
throw new Error(`Failed to start ${paths.binaryPath}`);
|
|
167
|
+
}
|
|
168
|
+
child.unref();
|
|
169
|
+
fs.writeFileSync(paths.pidPath, `${child.pid}\n`);
|
|
170
|
+
return child.pid;
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
fs.closeSync(logFd);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export function sleep(ms) {
|
|
177
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
178
|
+
}
|
|
179
|
+
/** SIGTERM, wait for exit, SIGKILL as a last resort. */
|
|
180
|
+
export async function stopPid(pid, timeoutMs = 10000) {
|
|
181
|
+
if (!isPidAlive(pid))
|
|
182
|
+
return 'not-running';
|
|
183
|
+
try {
|
|
184
|
+
process.kill(pid, 'SIGTERM');
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return 'not-running';
|
|
188
|
+
}
|
|
189
|
+
const deadline = Date.now() + timeoutMs;
|
|
190
|
+
while (Date.now() < deadline) {
|
|
191
|
+
if (!isPidAlive(pid))
|
|
192
|
+
return 'stopped';
|
|
193
|
+
await sleep(250);
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
process.kill(pid, 'SIGKILL');
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// already gone
|
|
200
|
+
}
|
|
201
|
+
return 'killed';
|
|
202
|
+
}
|
|
203
|
+
export function tailFile(filePath, lines) {
|
|
204
|
+
try {
|
|
205
|
+
return fs.readFileSync(filePath, 'utf-8').trimEnd().split('\n').slice(-lines).join('\n');
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return '';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
export function directorySize(dir) {
|
|
212
|
+
let entries;
|
|
213
|
+
try {
|
|
214
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
let total = 0;
|
|
220
|
+
for (const entry of entries) {
|
|
221
|
+
const full = path.join(dir, entry.name);
|
|
222
|
+
if (entry.isDirectory()) {
|
|
223
|
+
total += directorySize(full);
|
|
224
|
+
}
|
|
225
|
+
else if (entry.isFile()) {
|
|
226
|
+
try {
|
|
227
|
+
total += fs.statSync(full).size;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// removed while walking
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return total;
|
|
235
|
+
}
|
|
236
|
+
// ── Health & metrics ──────────────────────────────────────────────────────
|
|
237
|
+
export function healthUrl(port = DEFAULT_HEALTH_PORT) {
|
|
238
|
+
return `http://127.0.0.1:${port}/`;
|
|
239
|
+
}
|
|
240
|
+
export function metricsUrl(port = DEFAULT_METRICS_PORT) {
|
|
241
|
+
return `http://127.0.0.1:${port}/metrics`;
|
|
242
|
+
}
|
|
243
|
+
async function fetchWithTimeout(url, timeoutMs) {
|
|
244
|
+
const controller = new AbortController();
|
|
245
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
246
|
+
try {
|
|
247
|
+
return await fetch(url, { signal: controller.signal });
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** GET the health_check extension (200 once the pipeline is ready). */
|
|
254
|
+
export async function checkHealth(port = DEFAULT_HEALTH_PORT) {
|
|
255
|
+
try {
|
|
256
|
+
const response = await fetchWithTimeout(healthUrl(port), 2000);
|
|
257
|
+
const text = await response.text();
|
|
258
|
+
let body = {};
|
|
259
|
+
try {
|
|
260
|
+
body = JSON.parse(text);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// not JSON
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
healthy: response.ok,
|
|
267
|
+
status: typeof body.status === 'string' ? body.status : `HTTP ${response.status}`,
|
|
268
|
+
uptime: typeof body.uptime === 'string' ? body.uptime : undefined,
|
|
269
|
+
upSince: typeof body.upSince === 'string' ? body.upSince : undefined,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
const cause = err.cause;
|
|
274
|
+
return { healthy: false, error: cause?.code ?? (err instanceof Error ? err.message : String(err)) };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** Poll health until it passes, the timeout elapses, or `isAlive` reports the process gone. */
|
|
278
|
+
export async function waitForHealth(port, timeoutMs, isAlive) {
|
|
279
|
+
const deadline = Date.now() + timeoutMs;
|
|
280
|
+
let last = { healthy: false };
|
|
281
|
+
while (Date.now() < deadline) {
|
|
282
|
+
last = await checkHealth(port);
|
|
283
|
+
if (last.healthy)
|
|
284
|
+
return last;
|
|
285
|
+
if (isAlive && !isAlive())
|
|
286
|
+
return { ...last, error: 'process exited' };
|
|
287
|
+
await sleep(500);
|
|
288
|
+
}
|
|
289
|
+
return last;
|
|
290
|
+
}
|
|
291
|
+
export async function fetchMetrics(port = DEFAULT_METRICS_PORT) {
|
|
292
|
+
try {
|
|
293
|
+
const response = await fetchWithTimeout(metricsUrl(port), 2000);
|
|
294
|
+
if (!response.ok)
|
|
295
|
+
return undefined;
|
|
296
|
+
return summarizeCollectorMetrics(parsePrometheusText(await response.text()));
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// ── Binary download ───────────────────────────────────────────────────────
|
|
303
|
+
export async function downloadFile(url, dest) {
|
|
304
|
+
const response = await fetch(url, { redirect: 'follow' });
|
|
305
|
+
if (!response.ok || !response.body) {
|
|
306
|
+
throw new Error(`Download failed (${response.status} ${response.statusText}): ${url}`);
|
|
307
|
+
}
|
|
308
|
+
ensureDir(path.dirname(dest));
|
|
309
|
+
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(dest));
|
|
310
|
+
}
|
|
311
|
+
export async function sha256File(filePath) {
|
|
312
|
+
const hash = createHash('sha256');
|
|
313
|
+
await pipeline(fs.createReadStream(filePath), hash);
|
|
314
|
+
return hash.digest('hex');
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Make sure ~/.respan/collector/bin/otelcol-contrib exists: download the
|
|
318
|
+
* release tarball, verify its SHA-256 against the published `.sha256` asset
|
|
319
|
+
* (warn when that cannot be fetched), extract the binary and drop the tarball.
|
|
320
|
+
*/
|
|
321
|
+
export async function ensureCollectorBinary(paths, logger) {
|
|
322
|
+
if (fs.existsSync(paths.binaryPath))
|
|
323
|
+
return paths.binaryPath;
|
|
324
|
+
const url = binaryDownloadUrl(COLLECTOR_VERSION);
|
|
325
|
+
const tarball = path.join(paths.binDir, `otelcol-contrib_${COLLECTOR_VERSION}.tar.gz`);
|
|
326
|
+
ensureDir(paths.binDir);
|
|
327
|
+
logger.log(`Downloading otelcol-contrib ${COLLECTOR_VERSION} (about 95 MB)...`);
|
|
328
|
+
logger.log(` ${url}`);
|
|
329
|
+
await downloadFile(url, tarball);
|
|
330
|
+
let expected;
|
|
331
|
+
try {
|
|
332
|
+
const response = await fetchWithTimeout(checksumUrl(COLLECTOR_VERSION), 15000);
|
|
333
|
+
if (response.ok)
|
|
334
|
+
expected = parseSha256(await response.text());
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// reported below
|
|
338
|
+
}
|
|
339
|
+
if (expected) {
|
|
340
|
+
const actual = await sha256File(tarball);
|
|
341
|
+
if (actual !== expected) {
|
|
342
|
+
fs.rmSync(tarball, { force: true });
|
|
343
|
+
throw new Error(`SHA-256 mismatch for ${url}: expected ${expected}, got ${actual}. The download was discarded.`);
|
|
344
|
+
}
|
|
345
|
+
logger.log('Verified SHA-256 checksum.');
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
logger.warn('Could not fetch the release checksum; the download was NOT verified.');
|
|
349
|
+
}
|
|
350
|
+
const extract = spawnSync('tar', ['-xzf', tarball, '-C', paths.binDir, 'otelcol-contrib'], {
|
|
351
|
+
encoding: 'utf-8',
|
|
352
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
353
|
+
});
|
|
354
|
+
if (extract.error || extract.status !== 0) {
|
|
355
|
+
throw new Error(`Failed to extract ${tarball}: ${(extract.stderr || extract.error?.message || '').trim()}`);
|
|
356
|
+
}
|
|
357
|
+
fs.chmodSync(paths.binaryPath, 0o755);
|
|
358
|
+
fs.rmSync(tarball, { force: true });
|
|
359
|
+
logger.log(`Installed ${paths.binaryPath}`);
|
|
360
|
+
return paths.binaryPath;
|
|
361
|
+
}
|