@ishlabs/cli 0.17.3 → 0.17.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/dist/index.js CHANGED
@@ -26,8 +26,21 @@ import { tagAlias, ALIAS_PREFIX } from "./lib/alias-store.js";
26
26
  import { output } from "./lib/output.js";
27
27
  import { ishDir } from "./lib/paths.js";
28
28
  import { findInstalledSkill } from "./lib/skill-content.js";
29
+ import { exitWithFlush, initObservability } from "./lib/observability.js";
30
+ import { setSurfaceBaggage } from "./lib/baggage.js";
29
31
  import pkg from "../package.json" with { type: "json" };
30
32
  const { version } = pkg;
33
+ // Bootstrap observability before anything else so the very first
34
+ // outbound API call (e.g. `ish login` polling) carries baggage +
35
+ // traceparent. Failures are swallowed inside `initObservability`;
36
+ // belt-and-suspenders try/catch here so a synchronous throw from the
37
+ // dynamic import can't crash the CLI either.
38
+ try {
39
+ await initObservability();
40
+ }
41
+ catch {
42
+ /* observability outage must never crash the CLI */
43
+ }
31
44
  program
32
45
  .name("ish")
33
46
  .description("ish CLI — run studies and asks against AI tester audiences")
@@ -49,7 +62,8 @@ program.exitOverride((err) => {
49
62
  if (err.code === "commander.helpDisplayed"
50
63
  || err.code === "commander.version"
51
64
  || err.code === "commander.help") {
52
- process.exit(0);
65
+ void exitWithFlush(0);
66
+ return;
53
67
  }
54
68
  // Detect --json without relying on parsed opts (parse may have failed).
55
69
  const useJson = process.argv.includes("--json") || !process.stdout.isTTY;
@@ -67,7 +81,7 @@ program.exitOverride((err) => {
67
81
  console.error(`Error: ${err.message}`);
68
82
  console.error(" → Run `ish <command> --help` for usage");
69
83
  }
70
- process.exit(EXIT_USAGE);
84
+ void exitWithFlush(EXIT_USAGE);
71
85
  });
72
86
  // Global options
73
87
  program
@@ -328,4 +342,18 @@ function injectAgentTipsFooter(cmd) {
328
342
  }
329
343
  }
330
344
  injectAgentTipsFooter(program);
345
+ // Single source of `client.surface` baggage: fires before EVERY
346
+ // subcommand's action handler. `actionCommand.name()` is the leaf
347
+ // command name (e.g. "list" inside `ish workspace list`). For groups,
348
+ // Commander joins names via `.parent` — flatten to the dotted path so
349
+ // the backend sees `client.surface=workspace.list` not just `list`.
350
+ program.hook("preAction", (_thisCommand, actionCommand) => {
351
+ const parts = [];
352
+ let cmd = actionCommand;
353
+ while (cmd && cmd.parent) {
354
+ parts.unshift(cmd.name());
355
+ cmd = cmd.parent;
356
+ }
357
+ setSurfaceBaggage(parts.join(".") || actionCommand.name());
358
+ });
331
359
  program.parse();
@@ -2,6 +2,7 @@
2
2
  * Shared HTTP API client for the Ish backend.
3
3
  */
4
4
  import { API_BASE } from "./auth.js";
5
+ import { withBaggage } from "./baggage.js";
5
6
  function mapErrorCode(status) {
6
7
  if (status === 401)
7
8
  return "auth_failed";
@@ -93,10 +94,27 @@ export class ApiClient {
93
94
  return this.baseUrl;
94
95
  }
95
96
  headers() {
96
- return {
97
+ // `withBaggage` reads the active OTel baggage and adds a `baggage`
98
+ // header. In Node mode, OTel's undici auto-instrumentation also
99
+ // injects the same header; the manual + auto layers are idempotent
100
+ // (same values, last write wins). In Bun-compile mode auto-instrumentation
101
+ // is broken — this manual call is the only injection path.
102
+ const base = {
97
103
  Authorization: `Bearer ${this.token}`,
98
104
  "Content-Type": "application/json",
99
105
  };
106
+ const withBag = withBaggage(base);
107
+ // `withBaggage` returns HeadersInit; narrow back to the
108
+ // Record<string,string> shape the rest of this class expects.
109
+ if (withBag instanceof Headers) {
110
+ const out = {};
111
+ withBag.forEach((v, k) => { out[k] = v; });
112
+ return out;
113
+ }
114
+ if (Array.isArray(withBag)) {
115
+ return Object.fromEntries(withBag);
116
+ }
117
+ return withBag;
100
118
  }
101
119
  async get(path, params, opts) {
102
120
  let url = `${this.baseUrl}${path}`;
package/dist/lib/auth.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import * as fs from "node:fs";
6
6
  import { loadConfig, saveConfig } from "../config.js";
7
7
  import { refreshTokens, isTokenExpired } from "../auth.js";
8
+ import { withBaggage } from "./baggage.js";
8
9
  export const DEFAULT_API_URL = "https://api.ishlabs.io";
9
10
  export const API_BASE = "/api/v1";
10
11
  export function resolveApiUrl(apiUrlArg, dev) {
@@ -17,7 +18,7 @@ export function resolveApiUrl(apiUrlArg, dev) {
17
18
  async function verifyToken(token, apiUrl) {
18
19
  try {
19
20
  const resp = await fetch(`${apiUrl}${API_BASE}/connect/active`, {
20
- headers: { Authorization: `Bearer ${token}` },
21
+ headers: withBaggage({ Authorization: `Bearer ${token}` }),
21
22
  signal: AbortSignal.timeout(10_000),
22
23
  });
23
24
  // 404 = valid token, no connection (expected). 401/403 = bad token.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Helpers for setting OTel Baggage entries and propagating them on
3
+ * outbound fetches.
4
+ *
5
+ * The CLI carries three baggage entries on every backend call so the
6
+ * backend can attribute spans + Sentry events to the right surface:
7
+ *
8
+ * baggage: client.name=ish-cli,client.surface=<command>,client.version=<x.y.z>
9
+ *
10
+ * In Node mode, `@opentelemetry/instrumentation-undici` will inject the
11
+ * active baggage onto outbound `fetch` automatically once
12
+ * ``setSurfaceBaggage`` has run — no per-call wiring needed.
13
+ *
14
+ * In Bun-compile mode auto-instrumentation breaks, so ``withBaggage(headers)``
15
+ * is the manual fallback: it reads the active baggage and adds a
16
+ * ``baggage`` header to a ``HeadersInit`` value.
17
+ *
18
+ * All helpers are safe to call even when the OTel SDK isn't initialised
19
+ * — `@opentelemetry/api` provides no-op fallbacks so the import never
20
+ * throws and the calls degrade gracefully.
21
+ */
22
+ export declare const BAGGAGE_KEY_CLIENT_NAME = "client.name";
23
+ export declare const BAGGAGE_KEY_CLIENT_SURFACE = "client.surface";
24
+ export declare const BAGGAGE_KEY_CLIENT_VERSION = "client.version";
25
+ export declare const BAGGAGE_KEY_CONSENT_ANALYTICS = "consent.analytics";
26
+ /** The fixed ``client.name`` we tag every CLI invocation with. The plan
27
+ * pins this string — backend dashboards filter on it. */
28
+ export declare const CLIENT_NAME = "ish-cli";
29
+ /**
30
+ * Stamp the active OTel baggage with ``client.name`` / ``client.surface``
31
+ * / ``client.version`` and stash a process-local copy for the
32
+ * manual-injection ``withBaggage`` helper.
33
+ *
34
+ * Idempotent — subsequent calls overwrite the previous surface, which is
35
+ * what we want when Commander invokes ``preAction`` once per resolved
36
+ * subcommand.
37
+ *
38
+ * Why process-local (`_activeBaggage`) instead of relying purely on OTel's
39
+ * context manager: OTel's API tracks active context via scope-bound
40
+ * `context.with(newCtx, fn)` semantics — there is no "set the global
41
+ * active context" primitive (by design). Commander's `preAction` hook
42
+ * runs BEFORE the action and can't wrap it in a context scope. The
43
+ * cleanest cross-runtime path is to keep the canonical baggage in a
44
+ * module-local variable and inject it manually via ``withBaggage()`` at
45
+ * each outbound fetch site. This works identically under Node and the
46
+ * Bun-compiled binary, and the auto-instrumentation Sentry provides
47
+ * still handles its own `sentry-trace` propagation in parallel.
48
+ *
49
+ * Safe to call even with no OTel SDK registered — `@opentelemetry/api`
50
+ * ships no-op fallbacks.
51
+ */
52
+ export declare function setSurfaceBaggage(commandName: string): void;
53
+ /**
54
+ * Manually inject the active baggage into a ``HeadersInit`` value.
55
+ *
56
+ * Use this for outbound HTTP requests in environments where the OTel
57
+ * undici instrumentation can't auto-propagate — primarily the
58
+ * ``bun build --compile`` standalone binary, where bundler hooks break
59
+ * diagnostics_channel patching. In Node mode this still runs but is
60
+ * harmless: the instrumentation also adds the header and our manual
61
+ * one is overwritten by the live OTel context value (same content).
62
+ *
63
+ * The return type matches `HeadersInit` so callers can drop this in
64
+ * wherever they were already passing headers.
65
+ */
66
+ export declare function withBaggage(headers?: HeadersInit): HeadersInit;
67
+ /** Test-only: clear process-local baggage between assertions. Not
68
+ * exported from the package root. */
69
+ export declare function _resetForTests(): void;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Helpers for setting OTel Baggage entries and propagating them on
3
+ * outbound fetches.
4
+ *
5
+ * The CLI carries three baggage entries on every backend call so the
6
+ * backend can attribute spans + Sentry events to the right surface:
7
+ *
8
+ * baggage: client.name=ish-cli,client.surface=<command>,client.version=<x.y.z>
9
+ *
10
+ * In Node mode, `@opentelemetry/instrumentation-undici` will inject the
11
+ * active baggage onto outbound `fetch` automatically once
12
+ * ``setSurfaceBaggage`` has run — no per-call wiring needed.
13
+ *
14
+ * In Bun-compile mode auto-instrumentation breaks, so ``withBaggage(headers)``
15
+ * is the manual fallback: it reads the active baggage and adds a
16
+ * ``baggage`` header to a ``HeadersInit`` value.
17
+ *
18
+ * All helpers are safe to call even when the OTel SDK isn't initialised
19
+ * — `@opentelemetry/api` provides no-op fallbacks so the import never
20
+ * throws and the calls degrade gracefully.
21
+ */
22
+ import { propagation, trace, context as otelContext } from "@opentelemetry/api";
23
+ import pkg from "../../package.json" with { type: "json" };
24
+ export const BAGGAGE_KEY_CLIENT_NAME = "client.name";
25
+ export const BAGGAGE_KEY_CLIENT_SURFACE = "client.surface";
26
+ export const BAGGAGE_KEY_CLIENT_VERSION = "client.version";
27
+ export const BAGGAGE_KEY_CONSENT_ANALYTICS = "consent.analytics";
28
+ /** The fixed ``client.name`` we tag every CLI invocation with. The plan
29
+ * pins this string — backend dashboards filter on it. */
30
+ export const CLIENT_NAME = "ish-cli";
31
+ /**
32
+ * Stamp the active OTel baggage with ``client.name`` / ``client.surface``
33
+ * / ``client.version`` and stash a process-local copy for the
34
+ * manual-injection ``withBaggage`` helper.
35
+ *
36
+ * Idempotent — subsequent calls overwrite the previous surface, which is
37
+ * what we want when Commander invokes ``preAction`` once per resolved
38
+ * subcommand.
39
+ *
40
+ * Why process-local (`_activeBaggage`) instead of relying purely on OTel's
41
+ * context manager: OTel's API tracks active context via scope-bound
42
+ * `context.with(newCtx, fn)` semantics — there is no "set the global
43
+ * active context" primitive (by design). Commander's `preAction` hook
44
+ * runs BEFORE the action and can't wrap it in a context scope. The
45
+ * cleanest cross-runtime path is to keep the canonical baggage in a
46
+ * module-local variable and inject it manually via ``withBaggage()`` at
47
+ * each outbound fetch site. This works identically under Node and the
48
+ * Bun-compiled binary, and the auto-instrumentation Sentry provides
49
+ * still handles its own `sentry-trace` propagation in parallel.
50
+ *
51
+ * Safe to call even with no OTel SDK registered — `@opentelemetry/api`
52
+ * ships no-op fallbacks.
53
+ */
54
+ export function setSurfaceBaggage(commandName) {
55
+ const existing = propagation.getActiveBaggage() ?? propagation.createBaggage();
56
+ const next = existing
57
+ .setEntry(BAGGAGE_KEY_CLIENT_NAME, { value: CLIENT_NAME })
58
+ .setEntry(BAGGAGE_KEY_CLIENT_SURFACE, { value: commandName })
59
+ .setEntry(BAGGAGE_KEY_CLIENT_VERSION, { value: pkg.version })
60
+ // CLI runs on a developer's machine with no client-side consent UI.
61
+ // Emit "unknown" so the backend resolver falls through to the
62
+ // authenticated user's persisted consent rather than default-denying
63
+ // via baggage absence.
64
+ .setEntry(BAGGAGE_KEY_CONSENT_ANALYTICS, { value: "unknown" });
65
+ _activeBaggage = next;
66
+ }
67
+ /** Process-local copy of the active baggage for the manual-injection path.
68
+ * Populated by `setSurfaceBaggage`; read by `withBaggage`. The OTel API
69
+ * also tracks it but we keep this so the Bun-compile path doesn't need
70
+ * a working context manager. */
71
+ let _activeBaggage;
72
+ /**
73
+ * Manually inject the active baggage into a ``HeadersInit`` value.
74
+ *
75
+ * Use this for outbound HTTP requests in environments where the OTel
76
+ * undici instrumentation can't auto-propagate — primarily the
77
+ * ``bun build --compile`` standalone binary, where bundler hooks break
78
+ * diagnostics_channel patching. In Node mode this still runs but is
79
+ * harmless: the instrumentation also adds the header and our manual
80
+ * one is overwritten by the live OTel context value (same content).
81
+ *
82
+ * The return type matches `HeadersInit` so callers can drop this in
83
+ * wherever they were already passing headers.
84
+ */
85
+ export function withBaggage(headers) {
86
+ const baggage = _activeBaggage ?? propagation.getActiveBaggage();
87
+ const traceparent = _activeTraceparent();
88
+ if (!baggage && !traceparent)
89
+ return headers ?? {};
90
+ // Build the W3C `baggage` header value. Format is
91
+ // `key1=value1,key2=value2`. We don't URL-encode here because all
92
+ // our values are plain ASCII (command names, version strings).
93
+ const entries = baggage ? baggage.getAllEntries() : [];
94
+ const baggageValue = entries.length > 0
95
+ ? entries.map(([k, v]) => `${k}=${v.value}`).join(",")
96
+ : null;
97
+ if (!baggageValue && !traceparent)
98
+ return headers ?? {};
99
+ // Merge with whatever the caller already had. Handle the three
100
+ // HeadersInit shapes (Headers, Record, array of tuples).
101
+ if (headers instanceof Headers) {
102
+ const merged = new Headers(headers);
103
+ if (baggageValue)
104
+ merged.set("baggage", baggageValue);
105
+ if (traceparent)
106
+ merged.set("traceparent", traceparent);
107
+ return merged;
108
+ }
109
+ if (Array.isArray(headers)) {
110
+ const filtered = headers.filter(([k]) => {
111
+ const lower = k.toLowerCase();
112
+ return lower !== "baggage" && lower !== "traceparent";
113
+ });
114
+ if (baggageValue)
115
+ filtered.push(["baggage", baggageValue]);
116
+ if (traceparent)
117
+ filtered.push(["traceparent", traceparent]);
118
+ return filtered;
119
+ }
120
+ const out = { ...(headers ?? {}) };
121
+ if (baggageValue)
122
+ out.baggage = baggageValue;
123
+ if (traceparent)
124
+ out.traceparent = traceparent;
125
+ return out;
126
+ }
127
+ /**
128
+ * Derive a W3C `traceparent` header value from the currently-active OTel
129
+ * span context.
130
+ *
131
+ * Why this lives here and not in `observability.ts`: `@sentry/node`'s OTel
132
+ * propagator handles `traceparent` injection for HTTP calls patched by its
133
+ * `nativeNodeFetchIntegration`, but the standalone-binary path (`bun build
134
+ * --compile`) and a handful of raw `fetch()` callers in the CLI bypass that
135
+ * patch. Emitting the header explicitly here keeps CLI → backend trace
136
+ * stitching working regardless of which runtime layer is active.
137
+ *
138
+ * Returns `undefined` when there is no active span (e.g. before
139
+ * `initObservability` has installed a tracer provider, or when called
140
+ * outside any tracer scope) so we don't emit an invalid all-zeros id.
141
+ *
142
+ * Format per W3C Trace Context: `00-<trace-id>-<span-id>-<flags>` where
143
+ * `<flags>` is two lowercase hex chars (`01` = sampled).
144
+ */
145
+ function _activeTraceparent() {
146
+ const span = trace.getSpan(otelContext.active());
147
+ if (!span)
148
+ return undefined;
149
+ const ctx = span.spanContext();
150
+ // The OTel no-op SDK reports an all-zero context; treat that as "no span".
151
+ if (!ctx.traceId ||
152
+ ctx.traceId === "00000000000000000000000000000000" ||
153
+ !ctx.spanId ||
154
+ ctx.spanId === "0000000000000000") {
155
+ return undefined;
156
+ }
157
+ const flags = ctx.traceFlags.toString(16).padStart(2, "0");
158
+ return `00-${ctx.traceId}-${ctx.spanId}-${flags}`;
159
+ }
160
+ /** Test-only: clear process-local baggage between assertions. Not
161
+ * exported from the package root. */
162
+ export function _resetForTests() {
163
+ _activeBaggage = undefined;
164
+ }
@@ -10,6 +10,7 @@ import { outputError, setVerbose, setFields, setGetField } from "./output.js";
10
10
  import { setColorsEnabled, colorsEnabled } from "./colors.js";
11
11
  import { loadConfig } from "../config.js";
12
12
  import { resolveId } from "./alias-store.js";
13
+ import { exitWithFlush } from "./observability.js";
13
14
  function isSimulatable(p) {
14
15
  return Boolean(p.simulation_config_id) || Boolean(p.simulation_config);
15
16
  }
@@ -277,6 +278,10 @@ export function getGlobals(cmd) {
277
278
  || process.argv.includes("--get")
278
279
  || !process.stdout.isTTY;
279
280
  outputError(err, useJson);
281
+ // Sync exit path: getGlobals is called from non-async Commander hooks,
282
+ // so we can't await exitWithFlush here. Usage errors are typed early-
283
+ // exits without rich Sentry signal; accept losing the flush on this
284
+ // one site rather than cascading async through every getGlobals caller.
280
285
  process.exit(exitCodeFromError(err));
281
286
  }
282
287
  // Apply side effects (verbose, fields, colors, get-field, active workspace)
@@ -409,7 +414,7 @@ export async function withClient(cmd, fn) {
409
414
  }
410
415
  catch (err) {
411
416
  outputError(err, globals.json);
412
- process.exit(exitCodeFromError(err));
417
+ await exitWithFlush(exitCodeFromError(err));
413
418
  }
414
419
  }
415
420
  /**
@@ -424,7 +429,7 @@ export async function runInline(cmd, fn) {
424
429
  }
425
430
  catch (err) {
426
431
  outputError(err, globals.json);
427
- process.exit(exitCodeFromError(err));
432
+ await exitWithFlush(exitCodeFromError(err));
428
433
  }
429
434
  }
430
435
  export function getWebUrl(globals, path) {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Observability bootstrap for the CLI.
3
+ *
4
+ * Initialises Sentry (runtime-detected: `@sentry/bun` under Bun, `@sentry/node`
5
+ * under Node) and — in Node mode only — the OpenTelemetry SDK with the W3C
6
+ * tracecontext + baggage propagators and undici fetch auto-instrumentation.
7
+ *
8
+ * Why runtime detection: ish-cli ships two ways. `npm i -g @ishlabs/cli`
9
+ * runs the compiled JS on the user's Node. `curl | sh` / `brew` install the
10
+ * `bun build --compile` standalone binary. The Sentry packages differ
11
+ * (`@sentry/bun` wraps Bun's runtime hooks; `@sentry/node` uses Node's).
12
+ * `@sentry/profiling-node` is incompatible with Bun (native addon) and is
13
+ * deliberately not added as a dep.
14
+ *
15
+ * Why OTel only in Node mode: under `bun build --compile`, the bundler
16
+ * inlines `@opentelemetry/instrumentation-undici`'s require-time hooks in a
17
+ * way that breaks the diagnostics_channel auto-patching. Manual baggage
18
+ * injection via ``withBaggage(headers)`` in ``src/lib/api-client.ts`` is the
19
+ * documented fallback for the Bun-compiled binary — see
20
+ * `.docs/observability/cli-bun-compile.md`.
21
+ *
22
+ * Init failures are swallowed: an observability outage must never crash
23
+ * the CLI for the end user.
24
+ */
25
+ /** Bun-global probe. ``typeof Bun !== 'undefined'`` is the only reliable
26
+ * runtime-detect: ``process.versions.bun`` exists but is set up well after
27
+ * import time on some Bun-compile builds. */
28
+ export declare function isBunRuntime(): boolean;
29
+ export declare function initObservability(): Promise<void>;
30
+ /**
31
+ * Drain the Sentry buffer before exiting the process.
32
+ *
33
+ * Why this exists: Sentry's transport buffers events asynchronously. `ish`
34
+ * is a short-lived process — most commands call `process.exit(code)` within
35
+ * milliseconds of init, so the buffered envelope is dropped before the HTTP
36
+ * batch fires. Net effect: zero telemetry from real CLI invocations.
37
+ *
38
+ * Wrap every `process.exit(code)` callsite with `exitWithFlush(code)` instead,
39
+ * OR await `flushObservability()` directly before exit.
40
+ *
41
+ * Always safe to call: no-op when Sentry wasn't initialised (no DSN, or
42
+ * init failed). Never throws — an observability outage must not crash the
43
+ * CLI.
44
+ */
45
+ export declare function flushObservability(timeoutMs?: number): Promise<void>;
46
+ /**
47
+ * Async-flush-then-exit helper. Use this in place of `process.exit(code)`
48
+ * anywhere we want telemetry to ship before teardown.
49
+ *
50
+ * The Node `beforeExit` event would fire automatically on natural process
51
+ * end, but it does NOT fire when `process.exit()` is called explicitly —
52
+ * which the CLI does from at least 5 sites. This helper closes that gap.
53
+ */
54
+ export declare function exitWithFlush(code: number): Promise<never>;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Observability bootstrap for the CLI.
3
+ *
4
+ * Initialises Sentry (runtime-detected: `@sentry/bun` under Bun, `@sentry/node`
5
+ * under Node) and — in Node mode only — the OpenTelemetry SDK with the W3C
6
+ * tracecontext + baggage propagators and undici fetch auto-instrumentation.
7
+ *
8
+ * Why runtime detection: ish-cli ships two ways. `npm i -g @ishlabs/cli`
9
+ * runs the compiled JS on the user's Node. `curl | sh` / `brew` install the
10
+ * `bun build --compile` standalone binary. The Sentry packages differ
11
+ * (`@sentry/bun` wraps Bun's runtime hooks; `@sentry/node` uses Node's).
12
+ * `@sentry/profiling-node` is incompatible with Bun (native addon) and is
13
+ * deliberately not added as a dep.
14
+ *
15
+ * Why OTel only in Node mode: under `bun build --compile`, the bundler
16
+ * inlines `@opentelemetry/instrumentation-undici`'s require-time hooks in a
17
+ * way that breaks the diagnostics_channel auto-patching. Manual baggage
18
+ * injection via ``withBaggage(headers)`` in ``src/lib/api-client.ts`` is the
19
+ * documented fallback for the Bun-compiled binary — see
20
+ * `.docs/observability/cli-bun-compile.md`.
21
+ *
22
+ * Init failures are swallowed: an observability outage must never crash
23
+ * the CLI for the end user.
24
+ */
25
+ import pkg from "../../package.json" with { type: "json" };
26
+ import { beforeSend } from "./sentry-scrub.js";
27
+ /** Module-local reference to the loaded Sentry module so `flushObservability`
28
+ * can call `flush()` without re-importing (which would lose runtime-pinned
29
+ * state and create a second Hub in some bundler configurations). */
30
+ let _sentryModule = null;
31
+ /** Bun-global probe. ``typeof Bun !== 'undefined'`` is the only reliable
32
+ * runtime-detect: ``process.versions.bun`` exists but is set up well after
33
+ * import time on some Bun-compile builds. */
34
+ export function isBunRuntime() {
35
+ // @ts-expect-error — Bun is a runtime global, not a TS-known symbol
36
+ return typeof Bun !== "undefined";
37
+ }
38
+ let _initialized = false;
39
+ export async function initObservability() {
40
+ if (_initialized)
41
+ return;
42
+ _initialized = true;
43
+ const dsn = process.env.ISH_CLI_SENTRY_DSN;
44
+ if (!dsn)
45
+ return; // unset → opt out; the CLI runs untraced.
46
+ const bun = isBunRuntime();
47
+ try {
48
+ const sentryModule = bun
49
+ ? await import("@sentry/bun")
50
+ : await import("@sentry/node");
51
+ _sentryModule = sentryModule;
52
+ const init = sentryModule.init;
53
+ init({
54
+ dsn,
55
+ release: `ish-cli@${pkg.version}`,
56
+ environment: process.env.NODE_ENV ?? "development",
57
+ // Keep traces sampled at 1.0 in dev — CLI invocation volume is low
58
+ // and we want full visibility for distributed-trace continuity with
59
+ // the backend. Backend (ish-backend) is the SLO source of truth.
60
+ tracesSampleRate: 1.0,
61
+ // ARGV (including `--token` flags), workspace ids in `~/.ish/`
62
+ // paths, and `@sentry/node`'s default RequestData integration
63
+ // (Authorization headers) would otherwise ship raw. ``beforeSend``
64
+ // is the single chokepoint; mirrors the backend's ``_scrub_event``.
65
+ beforeSend,
66
+ // Under Bun the default integration set assumes Node internals
67
+ // (diagnostics_channel + http.Agent hooks) that aren't reliably
68
+ // wired in the compiled binary. Strip integrations there.
69
+ ...(bun ? { integrations: [], defaultIntegrations: false } : {}),
70
+ });
71
+ // client.name / client.version must be Sentry tags (not just OTel
72
+ // baggage / span attributes) so the Sentry UI's `tags[client.name]:…`
73
+ // filter works for saved searches and dashboards.
74
+ const setTag = sentryModule.setTag;
75
+ if (setTag) {
76
+ setTag("client.name", "ish-cli");
77
+ setTag("client.version", pkg.version);
78
+ }
79
+ }
80
+ catch (err) {
81
+ // Don't crash the CLI on init failure. Print to stderr at verbose
82
+ // level only — most users will never see this.
83
+ if (process.env.ISH_DEBUG_OBS) {
84
+ console.error("ish-cli: Sentry init failed:", err);
85
+ }
86
+ return;
87
+ }
88
+ if (bun)
89
+ return; // OTel auto-instrumentation only in Node mode.
90
+ try {
91
+ await initOtelNode();
92
+ }
93
+ catch (err) {
94
+ if (process.env.ISH_DEBUG_OBS) {
95
+ console.error("ish-cli: OTel init failed:", err);
96
+ }
97
+ }
98
+ }
99
+ /** Node-only: register the undici fetch auto-instrumentation.
100
+ *
101
+ * `@sentry/node` v10 already wires its own OTel propagator composite that
102
+ * emits W3C `baggage`, `traceparent`, and Sentry's `sentry-trace` — we
103
+ * deliberately do NOT call `propagation.setGlobalPropagator(...)` here
104
+ * (that would clobber Sentry's propagator and break Sentry's distributed
105
+ * tracing).
106
+ *
107
+ * What we DO add is `UndiciInstrumentation`, which patches the global
108
+ * `fetch` / undici so outbound HTTP calls open child spans and let the
109
+ * propagator inject the active context's headers. This is best-effort:
110
+ * Sentry's own `nativeNodeFetchIntegration` also patches `fetch` and will
111
+ * win in many cases — but registering Undici explicitly ensures the
112
+ * `baggage` propagation path is active even in setups where Sentry's
113
+ * fetch integration is opted out.
114
+ *
115
+ * The load-bearing injection path remains `withBaggage(headers)` in
116
+ * `src/lib/api-client.ts:headers()` — it works in both Node and Bun
117
+ * runtimes and doesn't depend on bundler-fragile diagnostics_channel
118
+ * hooks. The undici instrumentation here is a redundant secondary layer.
119
+ */
120
+ async function initOtelNode() {
121
+ const [{ registerInstrumentations }, { UndiciInstrumentation }] = await Promise.all([
122
+ import("@opentelemetry/instrumentation"),
123
+ import("@opentelemetry/instrumentation-undici"),
124
+ ]);
125
+ registerInstrumentations({
126
+ instrumentations: [new UndiciInstrumentation()],
127
+ });
128
+ }
129
+ /**
130
+ * Drain the Sentry buffer before exiting the process.
131
+ *
132
+ * Why this exists: Sentry's transport buffers events asynchronously. `ish`
133
+ * is a short-lived process — most commands call `process.exit(code)` within
134
+ * milliseconds of init, so the buffered envelope is dropped before the HTTP
135
+ * batch fires. Net effect: zero telemetry from real CLI invocations.
136
+ *
137
+ * Wrap every `process.exit(code)` callsite with `exitWithFlush(code)` instead,
138
+ * OR await `flushObservability()` directly before exit.
139
+ *
140
+ * Always safe to call: no-op when Sentry wasn't initialised (no DSN, or
141
+ * init failed). Never throws — an observability outage must not crash the
142
+ * CLI.
143
+ */
144
+ export async function flushObservability(timeoutMs = 2000) {
145
+ if (!_initialized || _sentryModule?.flush === undefined)
146
+ return;
147
+ try {
148
+ await _sentryModule.flush(timeoutMs);
149
+ }
150
+ catch {
151
+ // Swallow — same rationale as init failure.
152
+ }
153
+ }
154
+ /**
155
+ * Async-flush-then-exit helper. Use this in place of `process.exit(code)`
156
+ * anywhere we want telemetry to ship before teardown.
157
+ *
158
+ * The Node `beforeExit` event would fire automatically on natural process
159
+ * end, but it does NOT fire when `process.exit()` is called explicitly —
160
+ * which the CLI does from at least 5 sites. This helper closes that gap.
161
+ */
162
+ export async function exitWithFlush(code) {
163
+ await flushObservability();
164
+ process.exit(code);
165
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Sentry `beforeSend` scrubber for the CLI.
3
+ *
4
+ * Mirrors the backend's ``_scrub_event`` policy (``app/core/sentry.py``) so
5
+ * secrets and PII shaped by the same regex are stripped on the way out of
6
+ * every client. Substring-match is conservative — better to over-redact a
7
+ * benign field name than ship a token. Applied recursively up to
8
+ * ``_MAX_DEPTH`` levels; past that, the subtree is left as-is rather than
9
+ * truncated, so a pathological event surface can't blow the stack.
10
+ *
11
+ * Specifically scrubbed:
12
+ * - ``event.request.headers`` — ``@sentry/node``'s default ``RequestData``
13
+ * integration attaches ``Authorization`` / ``Cookie`` verbatim; without
14
+ * this walk, every captured 500 ships the user's session JWT to Sentry.
15
+ * - ``event.extra`` and ``event.contexts.*`` — fan-out values surfaced
16
+ * via ``Sentry.setExtra`` / ``Sentry.setContext``.
17
+ * - ``event.breadcrumbs[].data`` — auto-instrumented HTTP crumbs land
18
+ * request headers here too.
19
+ * - ``event.request.url`` — the query string is stripped entirely;
20
+ * ``?token=…`` shows up in CLI HTTP traces.
21
+ *
22
+ * Control-flow exceptions (user-cancelled, aborts) drop to ``null`` so
23
+ * Sentry doesn't see them as noise.
24
+ */
25
+ import type { Event, EventHint } from "@sentry/node";
26
+ /** Walk an arbitrary value and redact secret-shaped keys. */
27
+ declare function scrubValue(value: unknown, depth: number): unknown;
28
+ declare function stripQueryString(url: string): string;
29
+ declare function isControlFlowException(hint: EventHint | undefined): boolean;
30
+ /**
31
+ * Sentry ``beforeSend`` hook — returns ``null`` to drop the event, or the
32
+ * (mutated in place) event to send it.
33
+ */
34
+ export declare function beforeSend(event: Event, hint?: EventHint): Event | null;
35
+ export declare const _internals: {
36
+ scrubValue: typeof scrubValue;
37
+ stripQueryString: typeof stripQueryString;
38
+ isControlFlowException: typeof isControlFlowException;
39
+ SECRET_KEY_RE: RegExp;
40
+ REDACTED: string;
41
+ MAX_DEPTH: number;
42
+ };
43
+ export {};
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Sentry `beforeSend` scrubber for the CLI.
3
+ *
4
+ * Mirrors the backend's ``_scrub_event`` policy (``app/core/sentry.py``) so
5
+ * secrets and PII shaped by the same regex are stripped on the way out of
6
+ * every client. Substring-match is conservative — better to over-redact a
7
+ * benign field name than ship a token. Applied recursively up to
8
+ * ``_MAX_DEPTH`` levels; past that, the subtree is left as-is rather than
9
+ * truncated, so a pathological event surface can't blow the stack.
10
+ *
11
+ * Specifically scrubbed:
12
+ * - ``event.request.headers`` — ``@sentry/node``'s default ``RequestData``
13
+ * integration attaches ``Authorization`` / ``Cookie`` verbatim; without
14
+ * this walk, every captured 500 ships the user's session JWT to Sentry.
15
+ * - ``event.extra`` and ``event.contexts.*`` — fan-out values surfaced
16
+ * via ``Sentry.setExtra`` / ``Sentry.setContext``.
17
+ * - ``event.breadcrumbs[].data`` — auto-instrumented HTTP crumbs land
18
+ * request headers here too.
19
+ * - ``event.request.url`` — the query string is stripped entirely;
20
+ * ``?token=…`` shows up in CLI HTTP traces.
21
+ *
22
+ * Control-flow exceptions (user-cancelled, aborts) drop to ``null`` so
23
+ * Sentry doesn't see them as noise.
24
+ */
25
+ const _SECRET_KEY_RE = /api[_-]?key|token|password|secret|authorization|cookie|bearer|email|full[_-]?name|phone/i;
26
+ const _REDACTED = "[REDACTED]";
27
+ // Hard ceiling for nested-scrub recursion. Mirrors backend's _SCRUB_MAX_DEPTH.
28
+ // Sentry breadcrumb data nests one or two levels deep
29
+ // (``crumb.data.headers.Authorization``); 5 leaves headroom without letting a
30
+ // pathological event blow the stack.
31
+ const _MAX_DEPTH = 5;
32
+ // Exception ``name`` values that represent expected control flow, not a
33
+ // crash. Mirrors backend's ``_DROP_EXCEPTIONS`` set adapted to JS: Node's
34
+ // ``AbortError`` (HTTP aborted by signal) and the SIGINT-driven cancel
35
+ // path used by ``ish connect``. Match by name so we don't have to import
36
+ // the constructor.
37
+ const _DROP_EXCEPTION_NAMES = new Set([
38
+ "AbortError",
39
+ "CancelError",
40
+ "UserCancelledError",
41
+ ]);
42
+ /** Walk an arbitrary value and redact secret-shaped keys. */
43
+ function scrubValue(value, depth) {
44
+ if (depth >= _MAX_DEPTH || value === null || value === undefined) {
45
+ return value;
46
+ }
47
+ if (Array.isArray(value)) {
48
+ return value.map((item) => scrubValue(item, depth + 1));
49
+ }
50
+ if (typeof value !== "object") {
51
+ return value;
52
+ }
53
+ const obj = value;
54
+ const out = {};
55
+ for (const [k, v] of Object.entries(obj)) {
56
+ if (_SECRET_KEY_RE.test(k)) {
57
+ out[k] = _REDACTED;
58
+ }
59
+ else if (v !== null && typeof v === "object") {
60
+ out[k] = scrubValue(v, depth + 1);
61
+ }
62
+ else {
63
+ out[k] = v;
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+ function scrubDict(dict) {
69
+ return scrubValue(dict, 0);
70
+ }
71
+ function stripQueryString(url) {
72
+ const q = url.indexOf("?");
73
+ return q === -1 ? url : url.slice(0, q);
74
+ }
75
+ function isControlFlowException(hint) {
76
+ const exc = hint?.originalException;
77
+ if (exc === null || exc === undefined)
78
+ return false;
79
+ if (typeof exc !== "object")
80
+ return false;
81
+ const name = exc.name;
82
+ return typeof name === "string" && _DROP_EXCEPTION_NAMES.has(name);
83
+ }
84
+ /**
85
+ * Sentry ``beforeSend`` hook — returns ``null`` to drop the event, or the
86
+ * (mutated in place) event to send it.
87
+ */
88
+ export function beforeSend(event, hint) {
89
+ if (isControlFlowException(hint))
90
+ return null;
91
+ const request = event.request;
92
+ if (request !== undefined) {
93
+ if (request.headers !== undefined && request.headers !== null) {
94
+ request.headers = scrubDict(request.headers);
95
+ }
96
+ if (typeof request.url === "string") {
97
+ request.url = stripQueryString(request.url);
98
+ }
99
+ if (request.cookies !== undefined && request.cookies !== null) {
100
+ // Every cookie value is sensitive; cookie *names* don't match the
101
+ // secret regex on their own. Redact values, keep names so debug
102
+ // context still shows which cookies were attached.
103
+ const cookies = request.cookies;
104
+ const redacted = {};
105
+ for (const name of Object.keys(cookies))
106
+ redacted[name] = _REDACTED;
107
+ request.cookies = redacted;
108
+ }
109
+ }
110
+ if (event.extra !== undefined && event.extra !== null) {
111
+ event.extra = scrubDict(event.extra);
112
+ }
113
+ if (event.contexts !== undefined && event.contexts !== null) {
114
+ const contexts = event.contexts;
115
+ for (const [ctxName, ctxValue] of Object.entries(contexts)) {
116
+ if (ctxValue !== null && typeof ctxValue === "object") {
117
+ contexts[ctxName] = scrubDict(ctxValue);
118
+ }
119
+ }
120
+ }
121
+ if (Array.isArray(event.breadcrumbs)) {
122
+ for (const crumb of event.breadcrumbs) {
123
+ if (crumb !== null
124
+ && typeof crumb === "object"
125
+ && crumb.data !== null
126
+ && typeof crumb.data === "object") {
127
+ crumb.data = scrubDict(crumb.data);
128
+ }
129
+ }
130
+ }
131
+ return event;
132
+ }
133
+ // Re-exports for tests — these would otherwise be private.
134
+ export const _internals = {
135
+ scrubValue,
136
+ stripQueryString,
137
+ isControlFlowException,
138
+ SECRET_KEY_RE: _SECRET_KEY_RE,
139
+ REDACTED: _REDACTED,
140
+ MAX_DEPTH: _MAX_DEPTH,
141
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ishlabs/cli",
3
- "version": "0.17.3",
3
+ "version": "0.17.5",
4
4
  "description": "The command-line interface for ish",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,13 @@
37
37
  "email": "support@ishlabs.io"
38
38
  },
39
39
  "dependencies": {
40
+ "@opentelemetry/api": "^1.9.0",
41
+ "@opentelemetry/core": "^1.30.0",
42
+ "@opentelemetry/instrumentation": "^0.57.0",
43
+ "@opentelemetry/instrumentation-undici": "^0.10.0",
44
+ "@opentelemetry/sdk-node": "^0.57.0",
45
+ "@sentry/bun": "^10.13.0",
46
+ "@sentry/node": "^10.13.0",
40
47
  "commander": "^13.0.0",
41
48
  "playwright-core": "^1.58.2"
42
49
  },