@ishlabs/cli 0.17.3 → 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 +27 -0
- package/dist/lib/api-client.js +19 -1
- package/dist/lib/auth.js +2 -1
- package/dist/lib/baggage.d.ts +69 -0
- package/dist/lib/baggage.js +164 -0
- package/dist/lib/observability.d.ts +29 -0
- package/dist/lib/observability.js +123 -0
- package/dist/lib/sentry-scrub.d.ts +43 -0
- package/dist/lib/sentry-scrub.js +141 -0
- package/package.json +8 -1
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();
|
package/dist/lib/api-client.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
},
|