@ishlabs/cli 0.17.2 → 0.17.4

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 { 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")
@@ -328,4 +341,18 @@ function injectAgentTipsFooter(cmd) {
328
341
  }
329
342
  }
330
343
  injectAgentTipsFooter(program);
344
+ // Single source of `client.surface` baggage: fires before EVERY
345
+ // subcommand's action handler. `actionCommand.name()` is the leaf
346
+ // command name (e.g. "list" inside `ish workspace list`). For groups,
347
+ // Commander joins names via `.parent` — flatten to the dotted path so
348
+ // the backend sees `client.surface=workspace.list` not just `list`.
349
+ program.hook("preAction", (_thisCommand, actionCommand) => {
350
+ const parts = [];
351
+ let cmd = actionCommand;
352
+ while (cmd && cmd.parent) {
353
+ parts.unshift(cmd.name());
354
+ cmd = cmd.parent;
355
+ }
356
+ setSurfaceBaggage(parts.join(".") || actionCommand.name());
357
+ });
331
358
  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
+ }
@@ -0,0 +1,29 @@
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>;
@@ -0,0 +1,123 @@
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
+ /** Bun-global probe. ``typeof Bun !== 'undefined'`` is the only reliable
28
+ * runtime-detect: ``process.versions.bun`` exists but is set up well after
29
+ * import time on some Bun-compile builds. */
30
+ export function isBunRuntime() {
31
+ // @ts-expect-error — Bun is a runtime global, not a TS-known symbol
32
+ return typeof Bun !== "undefined";
33
+ }
34
+ let _initialized = false;
35
+ export async function initObservability() {
36
+ if (_initialized)
37
+ return;
38
+ _initialized = true;
39
+ const dsn = process.env.ISH_CLI_SENTRY_DSN;
40
+ if (!dsn)
41
+ return; // unset → opt out; the CLI runs untraced.
42
+ const bun = isBunRuntime();
43
+ try {
44
+ const sentryModule = bun
45
+ ? await import("@sentry/bun")
46
+ : await import("@sentry/node");
47
+ const init = sentryModule.init;
48
+ init({
49
+ dsn,
50
+ release: `ish-cli@${pkg.version}`,
51
+ environment: process.env.NODE_ENV ?? "development",
52
+ // Keep traces sampled at 1.0 in dev — CLI invocation volume is low
53
+ // and we want full visibility for distributed-trace continuity with
54
+ // the backend. Backend (ish-backend) is the SLO source of truth.
55
+ tracesSampleRate: 1.0,
56
+ // ARGV (including `--token` flags), workspace ids in `~/.ish/`
57
+ // paths, and `@sentry/node`'s default RequestData integration
58
+ // (Authorization headers) would otherwise ship raw. ``beforeSend``
59
+ // is the single chokepoint; mirrors the backend's ``_scrub_event``.
60
+ beforeSend,
61
+ // Under Bun the default integration set assumes Node internals
62
+ // (diagnostics_channel + http.Agent hooks) that aren't reliably
63
+ // wired in the compiled binary. Strip integrations there.
64
+ ...(bun ? { integrations: [], defaultIntegrations: false } : {}),
65
+ });
66
+ // client.name / client.version must be Sentry tags (not just OTel
67
+ // baggage / span attributes) so the Sentry UI's `tags[client.name]:…`
68
+ // filter works for saved searches and dashboards.
69
+ const setTag = sentryModule.setTag;
70
+ if (setTag) {
71
+ setTag("client.name", "ish-cli");
72
+ setTag("client.version", pkg.version);
73
+ }
74
+ }
75
+ catch (err) {
76
+ // Don't crash the CLI on init failure. Print to stderr at verbose
77
+ // level only — most users will never see this.
78
+ if (process.env.ISH_DEBUG_OBS) {
79
+ console.error("ish-cli: Sentry init failed:", err);
80
+ }
81
+ return;
82
+ }
83
+ if (bun)
84
+ return; // OTel auto-instrumentation only in Node mode.
85
+ try {
86
+ await initOtelNode();
87
+ }
88
+ catch (err) {
89
+ if (process.env.ISH_DEBUG_OBS) {
90
+ console.error("ish-cli: OTel init failed:", err);
91
+ }
92
+ }
93
+ }
94
+ /** Node-only: register the undici fetch auto-instrumentation.
95
+ *
96
+ * `@sentry/node` v10 already wires its own OTel propagator composite that
97
+ * emits W3C `baggage`, `traceparent`, and Sentry's `sentry-trace` — we
98
+ * deliberately do NOT call `propagation.setGlobalPropagator(...)` here
99
+ * (that would clobber Sentry's propagator and break Sentry's distributed
100
+ * tracing).
101
+ *
102
+ * What we DO add is `UndiciInstrumentation`, which patches the global
103
+ * `fetch` / undici so outbound HTTP calls open child spans and let the
104
+ * propagator inject the active context's headers. This is best-effort:
105
+ * Sentry's own `nativeNodeFetchIntegration` also patches `fetch` and will
106
+ * win in many cases — but registering Undici explicitly ensures the
107
+ * `baggage` propagation path is active even in setups where Sentry's
108
+ * fetch integration is opted out.
109
+ *
110
+ * The load-bearing injection path remains `withBaggage(headers)` in
111
+ * `src/lib/api-client.ts:headers()` — it works in both Node and Bun
112
+ * runtimes and doesn't depend on bundler-fragile diagnostics_channel
113
+ * hooks. The undici instrumentation here is a redundant secondary layer.
114
+ */
115
+ async function initOtelNode() {
116
+ const [{ registerInstrumentations }, { UndiciInstrumentation }] = await Promise.all([
117
+ import("@opentelemetry/instrumentation"),
118
+ import("@opentelemetry/instrumentation-undici"),
119
+ ]);
120
+ registerInstrumentations({
121
+ instrumentations: [new UndiciInstrumentation()],
122
+ });
123
+ }
@@ -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.2",
3
+ "version": "0.17.4",
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
  },