diff --git a/README.md b/README.md index a855b4cf..6ff460b5 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,8 @@ The Claude API can also send server-side spans through a Console destination. Re [`@telemetry-dev/cli`](packages/cli) reads traces, sessions, logs, and usage from a terminal, a script, or an agent's shell. It uses the same read-only access as the telemetry.dev MCP server, so it works where an MCP client isn't available. ```sh -npx @telemetry-dev/cli login +npm install --global @telemetry-dev/cli +telemetry login ``` ## Development diff --git a/packages/cli/README.md b/packages/cli/README.md index 3fd26a32..9bc83a67 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -10,14 +10,15 @@ The CLI signs in to the same read-only API that the telemetry.dev MCP server giv npm install --global @telemetry-dev/cli ``` -Or run it without installing: +Node.js 20 or later is required. The CLI doesn't update itself; to update it, run `npm install --global @telemetry-dev/cli@latest`. It tells you when a newer version is out: see [Updates](#updates). + +To try it without installing, start each command with `npx @telemetry-dev/cli` instead of `telemetry`. The CLI's own hints then use the same prefix, and the same goes for `pnpm dlx`, `yarn dlx`, and `bunx`. ```sh npx @telemetry-dev/cli login +npx @telemetry-dev/cli traces ``` -Node.js 20 or later is required. - ## Sign in ```sh @@ -26,7 +27,7 @@ telemetry login `login` opens your browser. Choose a workspace and select **Allow**. To let the CLI read captured prompts, outputs, and tool arguments, also check **Also read captured prompts, outputs, and tool arguments**. Without that permission, every command except `payload` works. -Over SSH, in a container, or anywhere without a browser, run `telemetry login --no-browser`. Open the printed link on any device. After you select **Allow**, the browser tries to load an address that starts with `http://127.0.0.1`. The page doesn't load. Copy the whole address and paste it into the terminal. +Over SSH, in a container, or anywhere without a browser, run `telemetry login --no-browser`. Open the printed link on any device. After you select **Allow**, the browser tries to load an address that starts with `http://127.0.0.1`. The page doesn't load. Copy the whole address and paste it into the terminal. `telemetry login` takes a pasted address too, for when the browser doesn't open or you sign in on another device. In a shell with no terminal to paste into, such as an agent's, `telemetry login --no-browser` prints the link and exits. After you select **Allow**, finish the sign-in with the address the browser ended on: @@ -34,6 +35,8 @@ In a shell with no terminal to paste into, such as an agent's, `telemetry login telemetry login --finish 'http://127.0.0.1:53124/callback?code=…' ``` +On Windows, use double quotes instead, which cmd.exe and PowerShell both read. The commands the CLI prints use them there. + A script or agent can run `telemetry login --json` instead. It never opens a browser or waits. It prints the link to pass on to a person, then exits: ```json @@ -44,7 +47,7 @@ A script or agent can run `telemetry login --json` instead. It never opens a bro } ``` -`telemetry login --finish '
' --json` then prints the sign-in, with the same fields as `telemetry status --json` and `"status": "signed_in"`. When the workspace has exactly one project, it becomes the default. `project` is `null` otherwise, and also when the projects couldn't be loaded. +`telemetry login --finish '
' --json` then prints the sign-in, with the same fields as `telemetry status --json` and `"status": "signed_in"`. The default project stays when the chosen workspace still has it. Otherwise, when the workspace has exactly one project, it becomes the default. `project` is `null` in any other case, and also when the projects couldn't be loaded. A sign-in covers one workspace. To read another workspace, run `telemetry login` again and choose it. The new sign-in replaces the old one, and the CLI asks telemetry.dev to revoke the old one. @@ -61,13 +64,13 @@ telemetry usage --range 30d --by user telemetry payload --field output ``` -Run `telemetry --help` to see each command's options and examples. +Run `telemetry --help` to see each command's options and examples. Options go after the command: `telemetry traces --json`, not `telemetry --json traces`. -In human output, `telemetry trace` shows a summary above the span tree. It lists up to 5 of the failed spans where failures began, with their path. It also lists up to 5 of the operations that ran more than once, with their wall time, which counts overlapping calls once. When they list results, `traces` and `sessions` end with a likely next command on stderr. `trace` ends with the `payload` command that reads the full error, when a span where a failure began has an error message and the sign-in can read payloads. +In human output, `telemetry trace` shows a summary above the span tree. It lists up to 5 of the failed spans where failures began, with their path. It also lists up to 5 of the operations that ran more than once, with their wall time, which counts overlapping calls once. In the span tree, each failed span has its id and error under it, for `telemetry payload`. When they list results, `traces` and `sessions` end with a likely next command on stderr. `trace` ends with the `payload` command that reads the full error, when a span where a failure began has an error message and the sign-in can read payloads. A command reads its project from `--project `. Without that option, it uses `TELEMETRY_DEV_PROJECT`, then the default that you set with `telemetry use`. If the workspace has only one project, the command uses it. -List commands return 20 results by default. Use `--limit` to return from 1 to 50 results. When more results are available, the command prints the same command with `--cursor` for the next page. Keep the other options as they are when you pass `--cursor`. Only `--limit` may change between pages. +List commands return 20 results by default. Use `--limit` to return from 1 to 50 results. When more results are available, the command prints the same command with `--cursor` for the next page. On Windows, when an option holds text that no quoting keeps as typed, such as `%` or `$`, it prints only the `--cursor` to add. Keep the other options as they are when you pass `--cursor`. Only `--limit` may change between pages. ### Time windows @@ -121,10 +124,10 @@ Captured text comes from your users, models, and tools. telemetry.dev removes AN | 4 | You are not signed in, the sign-in expired, or access is denied | | 130 | Interrupted with Ctrl-C | -With `--json`, an error is one line of JSON on stderr, and stdout stays empty. Ctrl-C exits with 130 and no JSON, and `status --json` reports a missing sign-in as data on stdout with exit code 4. +With `--json`, an error is one line of JSON on stderr, and stdout stays empty. Ctrl-C exits with 130 and no JSON, and `status --json` reports a missing sign-in as data on stdout with exit code 4. `status` checks a saved sign-in with telemetry.dev, so one that expired, was revoked, or lost access to the workspace is reported the same way, with `"signed_in": false`. When the check itself fails, for example offline or while telemetry.dev can't renew the sign-in, `status` still reports the saved sign-in and says on stderr that it couldn't check it. With `--json`, `"sign_in_verified": false` says the same. ```text -{"error":{"code":"not_signed_in","message":"You're not signed in. Run `telemetry login` first.","hint":"telemetry login","retryable":false,"exit_code":4,"cli_version":"0.1.0"}} +{"error":{"code":"not_signed_in","message":"You're not signed in. Run telemetry login first.","hint":"telemetry login","retryable":false,"exit_code":4,"cli_version":"0.1.0"}} ``` Branch on `code`, not on `message`, whose wording can change. `hint` is a command to run next, when there is one. `telemetry login` needs a person at a browser, so an agent should run `telemetry login --json` and pass the link on rather than run `telemetry login`. `retryable` is true when running the same command again may succeed. New codes may be added, and existing codes keep their meaning. @@ -158,11 +161,26 @@ Branch on `code`, not on `message`, whose wording can change. `hint` is a comman ## Configuration -| Variable | Default | Purpose | -| -------------------------- | ------------------------- | -------------------------------------------------------------------------- | -| `TELEMETRY_DEV_PROJECT` | none | The project to read when you don't pass `--project` | -| `TELEMETRY_DEV_URL` | `https://telemetry.dev` | The telemetry.dev deployment to sign in to | -| `TELEMETRY_DEV_CONFIG_DIR` | `~/.config/telemetry-dev` | Where the sign-in is saved. The default follows `XDG_CONFIG_HOME` when set | -| `NO_COLOR` | none | Turns off color. `--no-color` does the same | +| Variable | Default | Purpose | +| ------------------------------- | ------------------------- | ------------------------------------------------------------------------------------- | +| `TELEMETRY_DEV_PROJECT` | none | The project to read when you don't pass `--project` | +| `TELEMETRY_DEV_URL` | `https://telemetry.dev` | The telemetry.dev deployment to sign in to | +| `TELEMETRY_DEV_CONFIG_DIR` | `~/.config/telemetry-dev` | Where the sign-in is saved. The default follows `XDG_CONFIG_HOME` when set | +| `NO_COLOR` | none | Turns off color. `--no-color` does the same | +| `TELEMETRY_DEV_NO_UPDATE_CHECK` | none | Any value turns off the check for a newer version. `NO_UPDATE_NOTIFIER` does the same | The sign-in is saved in `session.json`, which only your user can read. A sign-in to another deployment through `TELEMETRY_DEV_URL` gets its own file next to it. The file holds a refresh token, so keep it private. `telemetry logout` revokes the token at telemetry.dev and removes it from the file, and says so when the server doesn't confirm the revocation. `telemetry logout --json` prints `{"was_signed_in": true, "revoked": false}` in that case; `revoked` is `null` when there was no sign-in. To disconnect the CLI from your account entirely, open account settings at telemetry.dev. + +## Updates + +When you run the CLI on a terminal, it checks npm for a newer version at most once a day. If one is out, it prints a line on stderr after the command's output, with the command that updates the way you installed it: + +```text +telemetry 0.2.0 is available (you have 0.1.6). Update: npm install --global @telemetry-dev/cli@latest +``` + +The check never delays a command. It runs while the command does, and when the command finishes first, the next run checks again. It reads the CLI's `latest` tag from the npm registry, or from `npm_config_registry` when that is set, so npm sees your IP address and the package name. telemetry.dev isn't involved. The answer is saved in `update-check.json` next to the sign-in. + +There is no check with `--json`, when stdout or stderr isn't a terminal, in CI (`CI` is set), for a project that has the CLI as a dependency, or when `TELEMETRY_DEV_NO_UPDATE_CHECK` or `NO_UPDATE_NOTIFIER` is set. `telemetry status --json` reports `cli_version` and `latest_cli_version`, the latest version the last check found. `latest_cli_version` is `null` before the first check, when checks are off, and when the last check found a version older than this CLI, as after an update. + +Separately, every request the CLI sends to telemetry.dev names the CLI and its version, as MCP clients do, so telemetry.dev can tell which versions are in use. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 6a6d9625..fb74e1f7 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -69,7 +69,7 @@ export class Api { ).catch(async (error: unknown) => { const { OAuthError, UnauthorizedError } = await import("@modelcontextprotocol/client"); - return translateError(error, [OAuthError, UnauthorizedError]); + return translateError(error, [OAuthError, UnauthorizedError], this.session.server); }); const text = result.content.find((block) => block.type === "text")?.text ?? ""; diff --git a/packages/cli/src/auth.ts b/packages/cli/src/auth.ts index a486fe6b..4144e8f3 100644 --- a/packages/cli/src/auth.ts +++ b/packages/cli/src/auth.ts @@ -13,8 +13,9 @@ import type { } from "@modelcontextprotocol/client"; import { CliError, EXIT, translateError } from "./errors.ts"; +import { BIN } from "./invocation.ts"; import { loadSession, mcpUrl, type Session, updateSession } from "./session.ts"; -import { oneLine } from "./terminal.ts"; +import { ADDRESS, oneLine } from "./terminal.ts"; export const PAYLOADS_SCOPE = "telemetry:payloads"; @@ -186,11 +187,35 @@ export class SessionAuthProvider implements OAuthClientProvider { } redirectToAuthorization(url: URL) { + // The SDK drops a refused refresh token through invalidateCredentials before it asks + // for a new authorization, but keeps one whose refresh got no answer, such as a + // network error or a 5xx. It also skips, without dropping them, tokens another + // issuer stamped. telemetry.dev sends no insufficient_scope challenge, the SDK's + // other reason to reauthorize with a refresh token in hand. + if (!this.#login && this.session.tokens?.refresh_token && this.#tokensFromIssuer()) { + throw new CliError( + `Couldn't renew the sign-in with ${new URL(this.session.server).host}. Try again shortly.`, + { code: "server_error", retryable: true }, + ); + } + if (!this.#login) throw sessionExpired(); this.#login.open(url); } + // Compared as the SDK compares them, allowing one trailing slash of difference. A + // stamp or issuer that isn't known yet can't rule the tokens out. + #tokensFromIssuer() { + const stamped = this.session.tokens?.issuer; + const issuer = this.session.discovery?.authorizationServerMetadata?.issuer; + const bare = (url: string) => url.replace(/\/$/, ""); + + return ( + typeof stamped !== "string" || typeof issuer !== "string" || bare(stamped) === bare(issuer) + ); + } + saveCodeVerifier(verifier: string) { this.#verifier = verifier; } @@ -264,10 +289,10 @@ export class SessionAuthProvider implements OAuthClientProvider { } const sessionExpired = () => - new CliError("Your sign-in has expired or was disconnected. Run `telemetry login` again.", { + new CliError(`Your sign-in has expired or was disconnected. Run ${BIN} login again.`, { exitCode: EXIT.auth, code: "sign_in_expired", - hint: "telemetry login", + hint: `${BIN} login`, }); export const grantedScopes = (session: Session) => @@ -400,11 +425,27 @@ const readPastedCallback = async (state: string, signal: AbortSignal): Promise process.kill(process.pid, "SIGINT")); + // Readline rejects a question for Ctrl-D on a terminal, but not when stdin ends. + const ended = new Promise((_, reject) => { + prompt.once("close", () => reject(new Error("stdin ended"))); + }); + ended.catch(() => {}); + try { for (;;) { + const question = prompt.question( + "If the page after Allow doesn't load, paste its address here: ", + { signal }, + ); const answer = ( - await prompt.question("If the page after Allow doesn't load, paste its address here: ", { - signal, + await Promise.race([question, ended]).catch((error: unknown) => { + if (signal.aborted) throw error; + + throw new CliError("Sign-in was cancelled.", { + exitCode: EXIT.auth, + code: "sign_in_failed", + hint: `${BIN} login`, + }); }) ).trim(); @@ -481,16 +522,16 @@ const authorize = async ( if (error instanceof OAuthError) { throw new CliError( error.code === "invalid_grant" - ? "That sign-in link was already used or has expired. Run `telemetry login` again." + ? `That sign-in link was already used or has expired. Run ${BIN} login again.` : `Sign-in failed: ${oneLine(error.message).slice(0, 300)}`, - { exitCode: EXIT.auth, code: "sign_in_failed", hint: "telemetry login" }, + { exitCode: EXIT.auth, code: "sign_in_failed", hint: `${BIN} login` }, ); } if (error instanceof IssuerMismatchError || error instanceof AuthorizationServerMismatchError) { throw new CliError( - "The sign-in answer came from a different authorization server than this sign-in started with. Run `telemetry login` again.", - { exitCode: EXIT.auth, code: "sign_in_failed", hint: "telemetry login" }, + `The sign-in answer came from a different authorization server than this sign-in started with. Run ${BIN} login again.`, + { exitCode: EXIT.auth, code: "sign_in_failed", hint: `${BIN} login` }, ); } @@ -499,11 +540,11 @@ const authorize = async ( throw new CliError(`Sign-in failed: ${oneLine(error.message).slice(0, 300)}`, { exitCode: EXIT.auth, code: "sign_in_failed", - hint: "telemetry login", + hint: `${BIN} login`, }); } - return translateError(error, []); + return translateError(error, [], provider.session.server); } }; @@ -516,7 +557,7 @@ const redeem = async (provider: SessionAuthProvider, params: URLSearchParams) => error === "access_denied" ? "Sign-in was cancelled in the browser." : `Sign-in failed: ${oneLine(params.get("error_description") ?? error)}.`, - { exitCode: EXIT.auth, code: "sign_in_failed", hint: "telemetry login" }, + { exitCode: EXIT.auth, code: "sign_in_failed", hint: `${BIN} login` }, ); } @@ -524,8 +565,8 @@ const redeem = async (provider: SessionAuthProvider, params: URLSearchParams) => if (!code) { throw new CliError( - "The browser returned without an authorization code. Run `telemetry login` again.", - { exitCode: EXIT.auth, code: "sign_in_failed", hint: "telemetry login" }, + `The browser returned without an authorization code. Run ${BIN} login again.`, + { exitCode: EXIT.auth, code: "sign_in_failed", hint: `${BIN} login` }, ); } @@ -597,15 +638,15 @@ export const login = async ({ if (finish.trim() === "") { throw new CliError( - "Pass the address the browser ended on: telemetry login --finish '
'", + `Pass the address the browser ended on: ${BIN} login --finish ${ADDRESS}`, EXIT.usage, ); } if (!pending) { throw new CliError( - "No sign-in is waiting to finish. Run `telemetry login --no-browser` to start one.", - { exitCode: EXIT.auth, code: "no_pending_sign_in", hint: "telemetry login --no-browser" }, + `No sign-in is waiting to finish. Run ${BIN} login --no-browser to start one.`, + { exitCode: EXIT.auth, code: "no_pending_sign_in", hint: `${BIN} login --no-browser` }, ); } @@ -677,21 +718,25 @@ export const login = async ({ timeout = setTimeout( () => reject( - new CliError( - "Sign-in timed out after 10 minutes. Run `telemetry login` to try again.", - { exitCode: EXIT.auth, code: "sign_in_failed", hint: "telemetry login" }, - ), + new CliError(`Sign-in timed out after 10 minutes. Run ${BIN} login to try again.`, { + exitCode: EXIT.auth, + code: "sign_in_failed", + hint: `${BIN} login`, + }), ), LOGIN_TIMEOUT_MS, ); }), ]; - if (!useBrowser) { + // Also with a browser: one that never opened, such as xdg-open missing or failing, + // or a person who signed in on another device, leaves only the pasted address. + if (process.stdin.isTTY) { const pasted = readPastedCallback(state, abort.signal).then(({ params }) => params); // The prompt rejects when it is aborted after the browser answered first. pasted.catch(() => {}); - waits.push(pasted); + // A prompt closed with Ctrl-D only stops asking while a browser can still answer. + waits.push(useBrowser ? pasted.catch(() => new Promise(() => {})) : pasted); } return { status: "signed-in", session: await redeem(provider, await Promise.race(waits)) }; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1bb5cdd8..3cb63177 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -20,7 +20,9 @@ import { useCommand, } from "./commands.ts"; import { CliError, type ErrorCode, EXIT, usageError } from "./errors.ts"; -import { clean, disableColor, SHOW_CURSOR, style } from "./terminal.ts"; +import { BIN } from "./invocation.ts"; +import { clean, disableColor, SHOW_CURSOR, shellQuote, shellWords, style } from "./terminal.ts"; +import { finishUpdateCheck, startUpdateCheck } from "./update.ts"; type Option = { readonly type: "string" | "boolean"; @@ -44,7 +46,7 @@ const project: Option = { type: "string", short: "p", value: "name|id", - description: "Project to read (default: telemetry use)", + description: `Project to read (default: ${BIN} use)`, }; const limit: Option = { type: "string", @@ -71,7 +73,7 @@ const until: Option = { const COMMANDS: Readonly> = { login: { summary: "Sign in with your browser", - usage: "telemetry login [--no-browser | --finish
] [--json]", + usage: `${BIN} login [--no-browser | --finish
] [--json]`, options: { "no-browser": { type: "boolean", @@ -89,45 +91,45 @@ const COMMANDS: Readonly> = { }, }, examples: [ - "telemetry login", - "telemetry login --no-browser # over SSH, in a container, or from an agent's shell", - "telemetry login --finish 'http://127.0.0.1:53124/callback?code=…'", - "telemetry login --json # a script passes the link on, then runs --finish", + `${BIN} login`, + `${BIN} login --no-browser # over SSH, in a container, or from an agent's shell`, + `${BIN} login --finish ${shellQuote("http://127.0.0.1:53124/callback?code=…")}`, + `${BIN} login --json # a script passes the link on, then runs --finish`, ], run: loginCommand, }, logout: { summary: "Sign out on this machine", - usage: "telemetry logout [--json]", + usage: `${BIN} logout [--json]`, options: { json }, - examples: ["telemetry logout"], + examples: [`${BIN} logout`], run: logoutCommand, }, status: { summary: "Show who you're signed in as", - usage: "telemetry status [--json]", + usage: `${BIN} status [--json]`, options: { json }, - examples: ["telemetry status", "telemetry status --json"], + examples: [`${BIN} status`, `${BIN} status --json`], run: statusCommand, }, projects: { summary: "List the workspace's projects", - usage: "telemetry projects [--json]", + usage: `${BIN} projects [--json]`, options: { json }, - examples: ["telemetry projects"], + examples: [`${BIN} projects`], run: projectsCommand, }, use: { summary: "Set the default project", - usage: "telemetry use ", + usage: `${BIN} use `, arguments: 1, options: { json }, - examples: ["telemetry use support-bot"], + examples: [`${BIN} use support-bot`], run: useCommand, }, traces: { summary: "Find traces, newest first", - usage: "telemetry traces [options]", + usage: `${BIN} traces [options]`, options: { errors: { type: "boolean", description: "Only traces that failed" }, name: { type: "string", value: "text", description: "Trace name contains this text" }, @@ -161,25 +163,25 @@ const COMMANDS: Readonly> = { json, }, examples: [ - "telemetry traces --errors --since 2h", - "telemetry traces --name checkout --env production", - "telemetry traces --model gpt-5 --since 2026-10-29 --until 2026-10-30", - "telemetry traces --tool search_docs --errors --since 1d", - "telemetry traces --session conv_8f2 --json", + `${BIN} traces --errors --since 2h`, + `${BIN} traces --name checkout --env production`, + `${BIN} traces --model gpt-5 --since 2026-10-29 --until 2026-10-30`, + `${BIN} traces --tool search_docs --errors --since 1d`, + `${BIN} traces --session conv_8f2 --json`, ], run: tracesCommand, }, trace: { summary: "Show a trace's spans, timeline, and logs", - usage: "telemetry trace [options]", + usage: `${BIN} trace [options]`, arguments: 1, options: { project, json }, - examples: ["telemetry trace 4bf92f3577b34da6a3ce929d0e0e4736"], + examples: [`${BIN} trace 4bf92f3577b34da6a3ce929d0e0e4736`], run: traceCommand, }, sessions: { summary: "Find sessions by the start of their latest trace, newest first", - usage: "telemetry sessions [options]", + usage: `${BIN} sessions [options]`, options: { range: { type: "string", @@ -203,14 +205,14 @@ const COMMANDS: Readonly> = { json, }, examples: [ - "telemetry sessions --range 7d --errors", - "telemetry sessions --agent support-bot --since 2026-10-29T09:00", + `${BIN} sessions --range 7d --errors`, + `${BIN} sessions --agent support-bot --since 2026-10-29T09:00`, ], run: sessionsCommand, }, logs: { summary: "List recent logs, newest first", - usage: "telemetry logs [options]", + usage: `${BIN} logs [options]`, options: { level: { type: "string", @@ -226,15 +228,15 @@ const COMMANDS: Readonly> = { json, }, examples: [ - "telemetry logs --level error", - "telemetry logs --level warn --since 30m", - "telemetry logs --range 30d --json", + `${BIN} logs --level error`, + `${BIN} logs --level warn --since 30m`, + `${BIN} logs --range 30d --json`, ], run: logsCommand, }, usage: { summary: "Summarize requests, errors, latency, tokens, and cost", - usage: "telemetry usage [options]", + usage: `${BIN} usage [options]`, options: { range: { type: "string", @@ -249,12 +251,12 @@ const COMMANDS: Readonly> = { project, json, }, - examples: ["telemetry usage", "telemetry usage --range 30d --by user"], + examples: [`${BIN} usage`, `${BIN} usage --range 30d --by user`], run: usageCommand, }, payload: { summary: "Print a span's captured input and output", - usage: "telemetry payload [options]", + usage: `${BIN} payload [options]`, arguments: 2, options: { field: { @@ -266,8 +268,8 @@ const COMMANDS: Readonly> = { json, }, examples: [ - "telemetry payload 4bf92f35… 00f067aa…", - "telemetry payload 4bf92f35… 00f067aa… --field output > answer.txt", + `${BIN} payload 4bf92f35… 00f067aa…`, + `${BIN} payload 4bf92f35… 00f067aa… --field output > answer.txt`, ], run: payloadCommand, }, @@ -275,7 +277,7 @@ const COMMANDS: Readonly> = { const GLOBAL: Readonly> = { help: { type: "boolean", short: "h", description: "Show help" }, - version: { type: "boolean", description: "Print the version" }, + version: { type: "boolean", short: "v", description: "Print the version" }, "no-color": { type: "boolean", description: "Print without color" }, }; @@ -299,7 +301,7 @@ const topHelp = () => { `${style.bold("telemetry")} ${style.dim(VERSION)} ${TAGLINE}`, "", style.bold("Usage"), - " telemetry [options]", + ` ${BIN} [options]`, "", style.bold("Commands"), ...names.map((name) => ` ${name.padEnd(width)} ${style.dim(COMMANDS[name]!.summary)}`), @@ -308,12 +310,13 @@ const topHelp = () => { ...optionLines(GLOBAL), "", style.bold("Environment"), - ` TELEMETRY_DEV_PROJECT ${style.dim("Project to read when --project is not given")}`, - ` TELEMETRY_DEV_URL ${style.dim("telemetry.dev deployment (default https://telemetry.dev)")}`, - ` TELEMETRY_DEV_CONFIG_DIR ${style.dim("Where the sign-in is kept (default ~/.config/telemetry-dev)")}`, - ` NO_COLOR ${style.dim("Print without color")}`, + ` TELEMETRY_DEV_PROJECT ${style.dim("Project to read when --project is not given")}`, + ` TELEMETRY_DEV_URL ${style.dim("telemetry.dev deployment (default https://telemetry.dev)")}`, + ` TELEMETRY_DEV_CONFIG_DIR ${style.dim("Where the sign-in is kept (default ~/.config/telemetry-dev)")}`, + ` NO_COLOR ${style.dim("Print without color")}`, + ` TELEMETRY_DEV_NO_UPDATE_CHECK ${style.dim("Don't check npm for a newer version")}`, "", - `Run ${style.bold("telemetry --help")} for a command's options and examples.`, + `Run ${style.bold(`${BIN} --help`)} for a command's options and examples.`, ].join("\n"); }; @@ -360,6 +363,16 @@ const closest = (input: string, names: readonly string[]) => { const commandNamed = (name: string | undefined) => name !== undefined && Object.hasOwn(COMMANDS, name) ? COMMANDS[name] : undefined; +/** Whether some command reads `arg` as an option followed by its value, as in `--name x`. */ +const takesValue = (arg: string | undefined) => + Object.values(COMMANDS).some((command) => + Object.entries(command.options).some( + ([name, option]) => + option.type === "string" && + (arg === `--${name}` || (option.short !== undefined && arg === `-${option.short}`)), + ), + ); + // Arguments after `--` are values, never options. const optionArgs = (argv: readonly string[]) => { const end = argv.indexOf("--"); @@ -378,7 +391,8 @@ const main = async (argv: readonly string[]) => { const name = first === -1 || argv[first]!.startsWith("-") ? undefined : argv[first]; if (name === undefined) { - if (argv.includes("--version")) return void process.stdout.write(`${VERSION}\n`); + if (argv.includes("--version") || argv.includes("-v")) + return void process.stdout.write(`${VERSION}\n`); if (argv.includes("--help") || argv.includes("-h")) { return void process.stdout.write(`${topHelp()}\n`); @@ -386,7 +400,27 @@ const main = async (argv: readonly string[]) => { const unknown = argv.find((arg) => arg !== "--no-color"); - if (unknown !== undefined) throw usageError(`Unknown option ${unknown}. Run telemetry --help.`); + // As in `telemetry --json traces`. With one command named, it moves to the front; + // with several, or one right after an option that takes a value, a name may be that + // value, so no order is guessed. + const named = optionArgs(argv).filter((arg) => commandNamed(arg)); + + if (named.length > 0) { + const at = argv.indexOf(named[0]!); + const reordered = + named.length === 1 && !takesValue(argv[at - 1]) + ? shellWords([argv[at]!, ...argv.slice(0, at), ...argv.slice(at + 1)]) + : undefined; + + throw usageError( + reordered + ? `Put the command before its options: ${BIN} ${reordered}` + : `Put the command before its options, as in ${BIN} traces --json.`, + reordered ? { hint: `${BIN} ${reordered}` } : {}, + ); + } + + if (unknown !== undefined) throw usageError(`Unknown option ${unknown}. Run ${BIN} --help.`); // A script or agent that runs `telemetry` alone gets the reference, not the welcome. return process.stdout.isTTY ? home() : void process.stdout.write(`${topHelp()}\n`); @@ -400,9 +434,9 @@ const main = async (argv: readonly string[]) => { const command = commandNamed(topic); if (!command) { - throw usageError(`Unknown command "${topic}". Run telemetry --help.`, { + throw usageError(`Unknown command "${topic}". Run ${BIN} --help.`, { code: "unknown_command", - hint: "telemetry --help", + hint: `${BIN} --help`, }); } @@ -415,8 +449,8 @@ const main = async (argv: readonly string[]) => { const suggestion = closest(name, Object.keys(COMMANDS)); throw usageError( - `Unknown command "${name}".${suggestion ? ` Did you mean \`telemetry ${suggestion}\`?` : ""} Run telemetry --help.`, - { code: "unknown_command", hint: "telemetry --help" }, + `Unknown command "${name}".${suggestion ? ` Did you mean ${BIN} ${suggestion}?` : ""} Run ${BIN} --help.`, + { code: "unknown_command", hint: `${BIN} --help` }, ); } @@ -440,16 +474,16 @@ const main = async (argv: readonly string[]) => { } catch (error) { const message = error instanceof Error ? error.message.split("\n")[0] : String(error); - throw usageError(`${message} Run \`telemetry ${name} --help\`.`, { - hint: `telemetry ${name} --help`, + throw usageError(`${message} Run ${BIN} ${name} --help.`, { + hint: `${BIN} ${name} --help`, }); } const extra = parsed.positionals[command.arguments ?? 0]; if (extra !== undefined) { - throw usageError(`Unexpected argument "${extra}". Run \`telemetry ${name} --help\`.`, { - hint: `telemetry ${name} --help`, + throw usageError(`Unexpected argument "${extra}". Run ${BIN} ${name} --help.`, { + hint: `${BIN} ${name} --help`, }); } @@ -514,4 +548,6 @@ process.stdout.on("error", (error: NodeJS.ErrnoException) => { throw error; }); +startUpdateCheck(jsonOutput); await main(process.argv.slice(2)).catch(fail); +await finishUpdateCheck(); diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 676afba5..d221bf97 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,6 +1,7 @@ -import { Api } from "./api.ts"; +import { Api, VERSION } from "./api.ts"; import { grantedScopes, login, logout, PAYLOADS_SCOPE } from "./auth.ts"; import { CliError, EXIT, notSignedIn, payloadsNotAllowed, usageError } from "./errors.ts"; +import { BIN, INSTALL } from "./invocation.ts"; import { printMark } from "./mark.ts"; import { DEFAULT_SERVER, @@ -11,6 +12,7 @@ import { updateSession, } from "./session.ts"; import { + ADDRESS, type Cell, clean, type Column, @@ -28,9 +30,12 @@ import { outputWidth, type Paint, reportStatus, + shellQuote, + shellWords, style, table, } from "./terminal.ts"; +import { cachedLatestVersion } from "./update.ts"; import type { LogList, PayloadPage, @@ -125,14 +130,14 @@ const matchProject = (projects: ProjectList["projects"], wanted: string): Projec if (byName.length > 1) { throw usageError( - `Several projects are named "${wanted}". Pass its id instead: telemetry projects`, - { code: "project_ambiguous", hint: "telemetry projects" }, + `Several projects are named "${wanted}". Pass its id instead: ${BIN} projects`, + { code: "project_ambiguous", hint: `${BIN} projects` }, ); } throw usageError(`No project "${wanted}" in this workspace. Projects: ${names || "none"}.`, { code: "project_not_found", - hint: "telemetry projects", + hint: `${BIN} projects`, }); }; @@ -155,13 +160,13 @@ const resolveProject = async (api: Api, context: Context): Promise => { if (projects.length === 1) return toProject(projects[0]!); if (projects.length === 0) - throw new CliError("This workspace has no projects yet. Create one at telemetry.dev.", { + throw new CliError(`This workspace has no projects yet. Create one at ${api.session.server}`, { code: "no_projects", }); throw usageError( - `Choose a project with --project , or set a default with \`telemetry use \`. Projects: ${projectNames(projects)}.`, - { code: "project_required", hint: "telemetry projects" }, + `Choose a project with --project , or set a default with ${BIN} use . Projects: ${projectNames(projects)}.`, + { code: "project_required", hint: `${BIN} projects` }, ); }; @@ -380,18 +385,11 @@ const noteCapped = (context: Context, window: Window | undefined) => { }; /** - * `arg` quoted for a POSIX shell, so a suggested command runs as shown when pasted. A - * leading `=` is quoted too: zsh expands `=name` to a command's path. - */ -const shellQuote = (arg: string) => - /^[\w@%+:,./-][\w@%+=:,./-]*$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`; - -/** - * A telemetry command to suggest, quoted for a POSIX shell, that keeps this command's + * A telemetry command to suggest, quoted for this shell, that keeps this command's * project so it reads the same one when pasted. Option values are joined with `=`, so a - * value starting with `-` isn't read as an option. Undefined when an id starts with `-` - * or a value holds characters the terminal line can't show as they are, since the - * printed command would then name something else. + * value starting with `-` isn't read as an option. Undefined when an id starts with `-`, + * a value holds characters the terminal line can't show as they are, or the shell has + * no quoting for one, since the printed command would then name something else. */ const suggestion = ( context: Context, @@ -407,14 +405,15 @@ const suggestion = ( if (!Object.values(values).every(shown)) return undefined; - return [ - "telemetry", + const args = [ command, ...ids, ...Object.entries(values).map(([name, value]) => `--${name}=${value}`), - ] - .map(shellQuote) - .join(" "); + ]; + + const words = shellWords(args); + + return words === undefined ? undefined : `${BIN} ${words}`; }; const labelled = (label: string, command: string | undefined) => @@ -448,9 +447,13 @@ const nextPage = (context: Context, cursor: string | null) => { } // A cursor is base64url, which can start with `-`. - const command = [...kept, `--cursor=${cursor}`].map((arg) => shellQuote(clean(arg))).join(" "); + const command = shellWords([...kept, `--cursor=${cursor}`].map(clean)); - return `More: telemetry ${command}`; + // Still say there is more when no quoting keeps the command as typed. The cursor is + // plain base64url, so it reads the same in every shell. + return command === undefined + ? `More: run the same command with --cursor=${clean(cursor)}` + : `More: ${BIN} ${command}`; }; const statusCell = (status: string): Cell => @@ -472,8 +475,9 @@ export async function home() { out(); if (!signedIn) { - out(`Get started: ${style.bold("telemetry login")}`); - out(style.dim("Then run telemetry --help to see every command.")); + out(`Get started: ${style.bold(`${BIN} login`)}`); + out(style.dim(`Then run ${BIN} --help to see every command.`)); + if (INSTALL) out(style.dim(`To run it as telemetry, install it: ${INSTALL}`)); out(); return; @@ -484,12 +488,20 @@ export async function home() { out(`${who}${project}`); out(); + // TELEMETRY_DEV_PROJECT, when set, picks the project for every command. + if (!session.project && !process.env.TELEMETRY_DEV_PROJECT?.trim()) { + out( + `No default project. To set one: ${style.bold(`${BIN} projects`)}, then ${style.bold(`${BIN} use `)}`, + ); + out(); + } + const suggestions: [string, string][] = [ - ["telemetry traces --errors", "Recent failed runs"], - ["telemetry trace ", "A run's spans, timing, and logs"], - ["telemetry sessions", "Conversations and agent runs"], - ["telemetry usage", "Requests, tokens, and cost"], - ["telemetry logs --level error", "Recent error logs"], + [`${BIN} traces --errors`, "Recent failed runs"], + [`${BIN} trace `, "A run's spans, timing, and logs"], + [`${BIN} sessions`, "Conversations and agent runs"], + [`${BIN} usage`, "Requests, tokens, and cost"], + [`${BIN} logs --level error`, "Recent error logs"], ]; const width = Math.max(...suggestions.map(([command]) => command.length)); @@ -497,7 +509,8 @@ export async function home() { for (const [command, summary] of suggestions) out(` ${command.padEnd(width)} ${style.dim(summary)}`); out(); - out(style.dim("Run telemetry --help for every command.")); + out(style.dim(`Run ${BIN} --help for every command.`)); + if (INSTALL) out(style.dim(`To run it as telemetry, install it: ${INSTALL}`)); out(); } @@ -550,7 +563,7 @@ export async function loginCommand(context: Context) { printJson({ status: "pending", authorization_url: authorizationUrl, - next: "telemetry login --finish '
'", + next: `${BIN} login --finish ${ADDRESS}`, }); return; @@ -560,7 +573,7 @@ export async function loginCommand(context: Context) { "After you select Allow, the browser opens an http://127.0.0.1 address that doesn't load. Copy the whole address and run:", ); note(); - note(` telemetry login --finish '
'`); + note(` ${BIN} login --finish ${ADDRESS}`); return; } @@ -585,15 +598,20 @@ export async function loginCommand(context: Context) { await api.close(); } - if (projects?.length === 1) { - const project = toProject(projects[0]!); + // Signing in again, such as after the sign-in expired, keeps the default project when + // the chosen workspace still has it. + const kept = projects?.find(({ id }) => id === session.project?.id); + const chosen = kept ?? (projects?.length === 1 ? projects[0] : undefined); + + if (chosen) { + const project = toProject(chosen); signedIn.project = project; await updateSession(signedIn.server, (saved) => ({ ...saved, project })); } if (asJson) { - printJson({ status: "signed_in", ...sessionJson(signedIn) }); + printJson({ status: "signed_in", ...(await sessionJson(signedIn)) }); return; } @@ -606,7 +624,7 @@ export async function loginCommand(context: Context) { if (!grantedScopes(signedIn).has(PAYLOADS_SCOPE)) { note( style.dim( - " Captured prompts and outputs stay hidden. Sign in again and allow them to use `telemetry payload`.", + ` Captured prompts and outputs stay hidden. Sign in again and allow them to use ${BIN} payload.`, stream, ), ); @@ -614,22 +632,22 @@ export async function loginCommand(context: Context) { if (lookupFailure) { note(style.dim(` Couldn't load your projects: ${oneLine(lookupFailure.message)}`, stream)); - note(style.dim(" Run telemetry projects to list them.", stream)); + note(style.dim(` Run ${BIN} projects to list them.`, stream)); } else if (signedIn.project) { note(` Project ${style.bold(signedIn.project.name, stream)}`); note(); - note( - `Try ${style.bold("telemetry traces", stream)} or ${style.bold("telemetry usage", stream)}.`, - ); + note(`Try ${style.bold(`${BIN} traces`, stream)} or ${style.bold(`${BIN} usage`, stream)}.`); } else if (projects && projects.length > 1) { note(` ${projects.length} projects: ${projectNames(projects)}`); note(); note( - `Choose a default: ${style.bold(`telemetry use ${shellQuote(toProject(projects[0]!).name)}`, stream)}`, + `Choose a default: ${style.bold(`${BIN} use ${shellQuote(toProject(projects[0]!).name) ?? ""}`, stream)}`, ); } else { - note(" This workspace has no projects yet. Create one at telemetry.dev."); + note(` This workspace has no projects yet. Create one at ${signedIn.server}`); } + + if (INSTALL) note(style.dim(`To run it as telemetry, install it: ${INSTALL}`, stream)); } export async function logoutCommand(context: Context) { @@ -662,19 +680,23 @@ export async function logoutCommand(context: Context) { if (!revoked) note( style.dim( - "The server didn't confirm the sign-out, so the sign-in may still work elsewhere. To end it, disconnect the CLI in account settings at telemetry.dev.", + `The server didn't confirm the sign-out, so the sign-in may still work elsewhere. To end it, disconnect the CLI in account settings at ${session.server}.`, process.stderr, ), ); } -/** What `status --json` and `login --json` say about a sign-in. */ -const sessionJson = (session: Session) => ({ +/** What `status --json` and `login --json` say about a sign-in and this CLI. */ +const sessionJson = async (session: Session) => ({ signed_in: Boolean(session.tokens), + // Whether telemetry.dev confirmed signed_in, rather than only the saved sign-in. + sign_in_verified: true, server: session.server, email: session.email ?? null, project: session.project ?? null, payloads_allowed: grantedScopes(session).has(PAYLOADS_SCOPE), + cli_version: VERSION, + latest_cli_version: await cachedLatestVersion(), }); export async function statusCommand(context: Context) { @@ -682,25 +704,58 @@ export async function statusCommand(context: Context) { const session = await loadSession(server); const scopes = grantedScopes(session); + // Saved tokens can have expired, been revoked, or belong to a workspace the account + // left, so one call checks them. A failure for another reason, such as no network, + // leaves the sign-in reported as saved. + let refused: CliError | undefined; + let unchecked: CliError | undefined; + + if (session.tokens) { + const api = new Api(session); + + try { + await api.call("list_projects", {}, "Checking sign-in"); + } catch (error) { + if (!(error instanceof CliError)) throw error; + + if (error.exitCode === EXIT.auth) refused = error; + else unchecked = error; + } finally { + await api.close(); + } + } + + const signedIn = Boolean(session.tokens) && !refused; + if (json(context)) { - printJson(sessionJson(session)); + printJson({ + ...(await sessionJson(session)), + signed_in: signedIn, + sign_in_verified: !unchecked, + }); + } else if (refused) { + out(refused.message); } else if (session.tokens) { out( `${style.green("✓")} Signed in${session.email ? ` as ${style.bold(oneLine(session.email))}` : ""}`, ); out( - ` Project ${session.project ? oneLine(session.project.name) : style.dim("none set · telemetry use ")}`, + ` Project ${session.project ? oneLine(session.project.name) : style.dim(`none set · ${BIN} use `)}`, ); out( ` Payloads ${scopes.has(PAYLOADS_SCOPE) ? "allowed" : style.dim("not allowed · sign in again to allow")}`, ); if (server !== DEFAULT_SERVER) out(` Server ${server}`); + + if (unchecked) { + note(style.dim(`Couldn't check the sign-in: ${oneLine(unchecked.message)}`, process.stderr)); + } } else { - out(`Not signed in. Run ${style.bold("telemetry login")}.`); + out(`Not signed in. Run ${style.bold(`${BIN} login`)}.`); } - if (!session.tokens) process.exitCode = EXIT.auth; + if (!signedIn) process.exitCode = EXIT.auth; } export async function projectsCommand(context: Context) { @@ -735,7 +790,7 @@ export async function projectsCommand(context: Context) { export async function useCommand(context: Context) { const wanted = context.positionals[0]; - if (!wanted) throw usageError("Name the project to use: telemetry use "); + if (!wanted) throw usageError(`Name the project to use: ${BIN} use `); await withApi(async (api, session) => { const { projects } = await api.call("list_projects", {}, "Finding project"); @@ -751,6 +806,8 @@ export async function useCommand(context: Context) { }); } +const QUICKSTART_URL = "https://telemetry.dev/docs/quickstart"; + // A server from before windows would ignore these, so the result must have a window. A server // with windows but without agent and tool refuses those two (refusedArguments). const TRACE_FILTERS = [ @@ -800,10 +857,21 @@ export async function tracesCommand(context: Context) { if (json(context)) return printJson(page); if (page.traces.length === 0) { + const narrowed = + window.flags.length > 0 || + sentFilters.length > 0 || + ["name", "env", "session", "user", "cursor"].some((name) => string(context, name)) || + context.values.errors === true; + + // Right after sign-in, the first traces command often finds a project with no + // traces sent yet, and the next step is setting up the SDK, not a wider search. + // Without --env, telemetry.dev leaves out experiment runs, which may be all there is. nothing( - window.flags.length > 0 && page.window - ? `No traces match ${windowText(page.window)}.` - : "No traces match.", + !narrowed + ? `No traces in ${project.name} outside experiments, which --env experiment lists. To send some, see ${QUICKSTART_URL}` + : window.flags.length > 0 && page.window + ? `No traces match ${windowText(page.window)}.` + : "No traces match.", ); return noteCapped(context, page.window); @@ -848,8 +916,19 @@ export async function tracesCommand(context: Context) { }); } +const RANGE_HOURS = { "24h": 24, "72h": 72, "7d": 168, "30d": 720 } as const; + +/** A hint naming only the --range values wider than this search's, default 24h. */ +const widerRanges = (range: string | undefined) => { + const hours = RANGE_HOURS[(range ?? "24h") as keyof typeof RANGE_HOURS]; + const wider = (["7d", "30d"] as const) + .filter((name) => RANGE_HOURS[name] > hours) + .map((name) => `--range ${name}`); + + return wider.length === 0 ? "" : ` Widen the window with ${wider.join(" or ")}.`; +}; + export async function sessionsCommand(context: Context) { - const RANGE = { "24h": 24, "72h": 72, "7d": 168, "30d": 720 } as const; const window = timeWindow(context); await withApi(async (api) => { @@ -859,7 +938,7 @@ export async function sessionsCommand(context: Context) { "search_sessions", { project_id: project.id, - range_hours: choice(context, "range", RANGE), + range_hours: choice(context, "range", RANGE_HOURS), ...window.args, contains: string(context, "contains"), agent: string(context, "agent"), @@ -880,9 +959,7 @@ export async function sessionsCommand(context: Context) { nothing( window.flags.length > 0 && page.window ? `No sessions match ${windowText(page.window)}.` - : string(context, "range") === "30d" - ? "No sessions match." - : "No sessions match. Widen the window with --range 7d or --range 30d.", + : `No sessions match.${widerRanges(string(context, "range"))}`, ); return noteCapped(context, page.window); @@ -1019,7 +1096,7 @@ const levelPaint = (level: string): Paint => export async function traceCommand(context: Context) { const traceId = context.positionals[0]; - if (!traceId) throw usageError("Name the trace to show: telemetry trace "); + if (!traceId) throw usageError(`Name the trace to show: ${BIN} trace `); await withApi(async (api, session) => { const project = await resolveProject(api, context); @@ -1387,9 +1464,11 @@ function renderTrace(page: TraceDetailPage, spans: readonly Span[], logs: TraceD : `+${formatDuration(offset)}`, ]); - if (failedSpan && span.error) { + // The span id leads, so it survives a cut line: `payload` needs it to read the error. + if (failedSpan) { const guide = prefix.replace(/├─ $/, "│ ").replace(/└─ $/, " "); - errors.set(rows.length - 1, `${guide} ${oneLine(span.error)}`); + const error = span.error ? `: ${oneLine(span.error)}` : ""; + errors.set(rows.length - 1, `${guide} span ${oneLine(span.id)}${error}`); } } @@ -1697,7 +1776,7 @@ export async function payloadCommand(context: Context) { if (!traceId || !spanId) { throw usageError( - "Name the span: telemetry payload . Span ids are in `telemetry trace --json`.", + `Name the span: ${BIN} payload . ${BIN} trace shows the id of each failed span, and --json shows every span's.`, ); } diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts index 1c7ce116..d370c619 100644 --- a/packages/cli/src/errors.ts +++ b/packages/cli/src/errors.ts @@ -1,3 +1,5 @@ +import { BIN } from "./invocation.ts"; + /** Exit codes, documented in the README. */ export const EXIT = { ok: 0, @@ -70,8 +72,8 @@ export const usageError = (message: string, details: Omit new CliError( - 'This sign-in can\'t read captured prompts and outputs. Run `telemetry login`, check "Also read captured prompts, outputs, and tool arguments", and select Allow.', - { exitCode: EXIT.auth, code: "payloads_not_allowed", hint: "telemetry login" }, + `This sign-in can't read captured prompts and outputs. Run ${BIN} login, check "Also read captured prompts, outputs, and tool arguments", and select Allow.`, + { exitCode: EXIT.auth, code: "payloads_not_allowed", hint: `${BIN} login` }, ); /** @@ -88,15 +90,15 @@ export const toolError = (text: string, meta: unknown): CliError => { switch (error?.code) { case "access_denied": return new CliError( - "Your account no longer has access to this workspace. Run `telemetry login` and choose a workspace you belong to.", - { exitCode: EXIT.auth, code: "access_denied", hint: "telemetry login" }, + `Your account no longer has access to this workspace. Run ${BIN} login and choose a workspace you belong to.`, + { exitCode: EXIT.auth, code: "access_denied", hint: `${BIN} login` }, ); case "payloads_not_allowed": return payloadsNotAllowed(); case "project_not_found": return usageError( - "That project isn't in this workspace; it may have been deleted. Run `telemetry projects` to list the projects, and `telemetry use ` to change the default.", - { code: "project_not_found", hint: "telemetry projects" }, + `That project isn't in this workspace; it may have been deleted. Run ${BIN} projects to list the projects, and ${BIN} use to change the default.`, + { code: "project_not_found", hint: `${BIN} projects` }, ); case "invalid_cursor": return usageError( @@ -123,10 +125,10 @@ export const toolError = (text: string, meta: unknown): CliError => { }; export const notSignedIn = () => - new CliError("You're not signed in. Run `telemetry login` first.", { + new CliError(`You're not signed in. Run ${BIN} login first.`, { exitCode: EXIT.auth, code: "not_signed_in", - hint: "telemetry login", + hint: `${BIN} login`, }); // Each is a refresh or authorization the server refused for good. @@ -150,14 +152,43 @@ const TIMEOUT_CODES = new Set([ "UND_ERR_BODY_TIMEOUT", ]); -export const translateError = (error: unknown, authErrors: AuthErrors): never => { +const PROXY_VARIABLES = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]; + +/** + * Node's fetch ignores proxy variables unless NODE_USE_ENV_PROXY=1 or --use-env-proxy + * was set at startup, which Node.js 22.21 and 24.0 added (Node.js docs, "Enterprise + * Network Configuration"), so behind a proxy the CLI connects directly and fails. + */ +const proxyNote = () => { + if (!PROXY_VARIABLES.some((name) => process.env[name])) return ""; + + const [major = 0, minor = 0] = process.versions.node.split(".").map(Number); + const supported = major >= 24 || (major === 22 && minor >= 21); + const enabled = + process.env.NODE_USE_ENV_PROXY === "1" || + process.execArgv.includes("--use-env-proxy") || + (process.env.NODE_OPTIONS ?? "").split(/\s+/).includes("--use-env-proxy"); + + if (!supported) { + return ` A proxy is set, but Node.js ${process.versions.node} ignores it. Node.js 22.21+ or 24+ uses it with NODE_USE_ENV_PROXY=1 set.`; + } + + return enabled ? "" : " A proxy is set, but Node.js uses it only with NODE_USE_ENV_PROXY=1 set."; +}; + +/** + * A failed request to `server` as a CliError: a refused sign-in, an unreachable or slow + * server, or anything else, named by the server's host. + */ +export const translateError = (error: unknown, authErrors: AuthErrors, server: string): never => { if (error instanceof CliError) throw error; if (authErrors.some((type) => error instanceof type)) { - throw new CliError( - "Your sign-in has expired or was disconnected. Run `telemetry login` again.", - { exitCode: EXIT.auth, code: "sign_in_expired", hint: "telemetry login" }, - ); + throw new CliError(`Your sign-in has expired or was disconnected. Run ${BIN} login again.`, { + exitCode: EXIT.auth, + code: "sign_in_expired", + hint: `${BIN} login`, + }); } // The network error sits at the bottom of a chain: the SDK wraps fetch's @@ -178,9 +209,11 @@ export const translateError = (error: unknown, authErrors: AuthErrors): never => chain.findLast((link): link is Error => link instanceof Error && link.message !== "") ?.message ?? String(error); + const host = new URL(server).host; + if (network) { throw new CliError( - `Couldn't reach telemetry.dev (${network}). Check your connection and try again.`, + `Couldn't reach ${host} (${network}). Check your connection and try again.${proxyNote()}`, { code: "unreachable", retryable: true }, ); } @@ -192,11 +225,11 @@ export const translateError = (error: unknown, authErrors: AuthErrors): never => link instanceof Error && (link.name === "TimeoutError" || link.name === "AbortError"), ) ) { - throw new CliError("telemetry.dev took too long to answer. Try again shortly.", { + throw new CliError(`${host} took too long to answer. Try again shortly.${proxyNote()}`, { code: "timeout", retryable: true, }); } - throw new CliError(`telemetry.dev request failed: ${message}`, { code: "request_failed" }); + throw new CliError(`${host} request failed: ${message}`, { code: "request_failed" }); }; diff --git a/packages/cli/src/invocation.ts b/packages/cli/src/invocation.ts new file mode 100644 index 00000000..02f2603d --- /dev/null +++ b/packages/cli/src/invocation.ts @@ -0,0 +1,99 @@ +import { existsSync, realpathSync } from "node:fs"; + +import packageJson from "../package.json" with { type: "json" }; + +/** + * Package runners start the CLI from a cache directory and put no `telemetry` on PATH, + * so a hint that says `telemetry traces` fails once the runner exits. The paths are + * where npx, pnpm dlx, Yarn's dlx, and bunx unpack a package; a global install, a + * project's devDependency, and `npm run` all start it from elsewhere. Yarn has no + * global installs since version 2, so its install is npm's. + */ +const RUNNERS = [ + { path: /\/_npx\//, run: "npx", install: "npm install --global" }, + // /dlx///node_modules, where the cache directory can be anywhere. + { + path: /\/pnpm[^/]*\/dlx\/|\/dlx\/[0-9a-f]{32,}\/[^/]+\/node_modules\//, + run: "pnpm dlx", + install: "pnpm add --global", + }, + { path: /\/xfs-[0-9a-f]+\/dlx-\d+\//, run: "yarn dlx", install: "npm install --global" }, + // bunx--, so a project folder named bunx-something isn't one. + { path: /\/bunx-\d+-/, run: "bunx", install: "bun add --global" }, +]; + +/** + * Where package managers other than npm keep global packages, matched + * case-insensitively for Windows' AppData folders. + */ +const GLOBALS = [ + { path: /\/\.?volta\/tools\//i, update: "volta install" }, + // Under a PNPM_HOME of any name, pnpm 11+ keeps them in global/v/. + { + path: /\/pnpm\/global\/|\/global\/v\d+\/[0-9a-f]{64}\/node_modules\//i, + update: "pnpm add --global", + }, + { path: /\/\.bun\/install\/global\//i, update: "bun add --global" }, + { path: /\/yarn\/(data\/)?global\//i, update: "yarn global add" }, +]; + +const NPM_GLOBAL = { update: "npm install --global" }; +const PROJECT = { update: undefined }; +const PACKAGE_FOLDER = `/node_modules/${packageJson.name}/`; + +/** + * How the CLI at `script` was installed. npm keeps global packages in + * `/lib/node_modules` on macOS and Linux, and in `\node_modules` next + * to the `telemetry.cmd` it writes on Windows, also under nvm-windows, fnm, or Scoop. + * A project named lib has a package.json there instead. Every other `node_modules` is + * a project's: next to its package.json, in Yarn's Plug'n'Play cache, in pnpm's + * virtual store, or in Deno's. + */ +const placed = (script: string) => { + const known = GLOBALS.find(({ path }) => path.test(script)); + + if (known) return known; + + const at = script.indexOf(PACKAGE_FOLDER); + const folder = script.slice(0, at); + const npmGlobal = + at !== -1 && + !existsSync(`${folder}/package.json`) && + (folder.endsWith("/lib") || existsSync(`${folder}/telemetry.cmd`)); + + if (npmGlobal) return NPM_GLOBAL; + + return script.includes("/node_modules/") ? PROJECT : undefined; +}; + +const slashes = (path: string) => path.replaceAll("\\", "/"); + +// The path as started keeps a runner's or a bin directory's symlink; the real path is +// where the package manager keeps the package. +const started = slashes(process.argv[1] ?? ""); +const real = (() => { + try { + return slashes(realpathSync(started)); + } catch { + return started; + } +})(); + +const runner = RUNNERS.find(({ path }) => path.test(started) || path.test(real)); +const installed = placed(started) ?? placed(real); + +/** The command that runs this CLI again from the shell that started it. */ +export const BIN = runner ? `${runner.run} ${packageJson.name}` : "telemetry"; + +/** How to install `telemetry` on PATH, when a package runner started this one. */ +export const INSTALL = runner && `${runner.install} ${packageJson.name}`; + +/** + * What to run for the newest version, or undefined when a project's lockfile pins it. + * An install it can't place, such as one started from source, gets npm's command. + */ +export const UPDATE = runner + ? `Run ${runner.run} ${packageJson.name}@latest, or install it: ${INSTALL}` + : installed && !installed.update + ? undefined + : `Update: ${installed?.update ?? "npm install --global"} ${packageJson.name}@latest`; diff --git a/packages/cli/src/terminal.ts b/packages/cli/src/terminal.ts index 152a9608..e33bb5ed 100644 --- a/packages/cli/src/terminal.ts +++ b/packages/cli/src/terminal.ts @@ -60,6 +60,45 @@ const CONTROL = new RegExp( /** Captured text, safe to print: escape sequences and control characters removed. */ export const clean = (text: string) => stripVTControlCharacters(text).replace(CONTROL, ""); +// A leading `=` is quoted too: zsh expands `=name` to a command's path. +const POSIX_PLAIN = /^[\w@%+:,./-][\w@%+=:,./-]*$/; +// PowerShell reads a leading @ as splatting and a comma as an array separator. +const WINDOWS_PLAIN = /^[\w+=:./-]+$/; +// cmd expands %name% inside double quotes, PowerShell expands $ and ` inside them and +// also ends quoted text at a typographic quote, both end it at ", and Windows argument +// parsing reads a backslash before the closing quote as a literal quote. No quoting +// keeps these as typed in both shells. +const WINDOWS_UNQUOTABLE = /["%$`\u201C-\u201E]|\\$/; + +/** + * `arg` quoted for the shell it will be pasted into, so a printed command runs as shown: + * a POSIX shell, or on Windows both cmd.exe and PowerShell, which share double quotes. + * Undefined on Windows for an argument neither quoting keeps as typed. + */ +export const shellQuote = (arg: string): string | undefined => { + if (process.platform !== "win32") { + return POSIX_PLAIN.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`; + } + + if (WINDOWS_PLAIN.test(arg)) return arg; + + return WINDOWS_UNQUOTABLE.test(arg) ? undefined : `"${arg}"`; +}; + +/** + * Where to paste the address the browser ended on in `login --finish`, quoted, since + * the address holds `&`, which every shell reads as the end of a command. + */ +// "
" has none of the characters WINDOWS_UNQUOTABLE rejects. +export const ADDRESS = shellQuote("
")!; + +/** `args` quoted and joined, or undefined when one of them can't be quoted. */ +export const shellWords = (args: readonly string[]) => { + const quoted = args.map(shellQuote); + + return quoted.every((arg) => arg !== undefined) ? quoted.join(" ") : undefined; +}; + /** Captured text on one line, for a table cell. */ export const oneLine = (text: string) => clean(text.replace(/\s+/g, " ")).trim(); diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts new file mode 100644 index 00000000..0741e10f --- /dev/null +++ b/packages/cli/src/update.ts @@ -0,0 +1,182 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import packageJson from "../package.json" with { type: "json" }; + +import { UPDATE } from "./invocation.ts"; +import { configDir } from "./session.ts"; +import { style } from "./terminal.ts"; + +/** + * Says when npm has a newer CLI. The check asks the registry at most once a day and + * never delays a command: it runs alongside the command, and is dropped, for the next + * run to repeat, when the command finishes first. It never installs anything: the + * CLI can't reliably tell how it was installed, and agents run it. + */ + +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +const CHECK_TIMEOUT_MS = 5000; +const STABLE = /^(\d{1,9})\.(\d{1,9})\.(\d{1,9})$/; + +type Check = { latest?: string; checkedAt: number }; + +const cachePath = () => join(configDir(), "update-check.json"); + +/** Turned off with either variable, or for a project's dependency, which its lockfile pins. */ +const checksAllowed = () => + !process.env.TELEMETRY_DEV_NO_UPDATE_CHECK && !process.env.NO_UPDATE_NOTIFIER && Boolean(UPDATE); + +const stable = (value: unknown) => + typeof value === "string" && STABLE.test(value) ? value : undefined; + +const readCheck = async (): Promise => { + try { + const saved = JSON.parse(await readFile(cachePath(), "utf8")) as Record | null; + const checkedAt = typeof saved?.checked_at === "string" ? Date.parse(saved.checked_at) : NaN; + + if (Number.isNaN(checkedAt)) return undefined; + + const latest = stable(saved?.latest); + + return { checkedAt, ...(latest ? { latest } : {}) }; + } catch { + return undefined; + } +}; + +// Written like the session, through a private temporary file renamed into place, but +// without the session's lock: commands that check at once write the same answer. +const writeCheck = async (check: Check) => { + const dir = configDir(); + const temporary = join(dir, `.update-check.${randomBytes(6).toString("hex")}.tmp`); + const saved = { + ...(check.latest ? { latest: check.latest } : {}), + checked_at: new Date(check.checkedAt).toISOString(), + }; + + try { + await mkdir(dir, { recursive: true, mode: 0o700 }); + await writeFile(temporary, `${JSON.stringify(saved)}\n`, { mode: 0o600, flag: "wx" }); + await rename(temporary, cachePath()); + } catch { + await rm(temporary, { force: true }).catch(() => {}); + } +}; + +const registryUrl = () => { + const configured = process.env.npm_config_registry?.trim() || "https://registry.npmjs.org/"; + const base = configured.endsWith("/") ? configured : `${configured}/`; + + return new URL(`-/package/${packageJson.name.replace("/", "%2f")}/dist-tags`, base); +}; + +/** Whether `latest` is newer than `current`; a prerelease is older than its release. */ +const isNewer = (latest: string, current: string) => { + const [core = "", prerelease] = current.split("-", 2); + const have = core.split(".").map(Number); + const want = latest.split(".").map(Number); + + for (let index = 0; index < 3; index += 1) { + if (want[index]! !== have[index]) return want[index]! > (have[index] ?? 0); + } + + return prerelease !== undefined; +}; + +/** + * The latest version the last check found, for `status --json`; never asks the registry. + * Null when that is older than this CLI, as after an update the check hasn't seen. + */ +export const cachedLatestVersion = async () => { + const latest = checksAllowed() ? (await readCheck())?.latest : undefined; + + return latest && (latest === packageJson.version || isNewer(latest, packageJson.version)) + ? latest + : null; +}; + +let running: + | { saved: Promise; fresh: Promise; abort: () => void } + | undefined; + +/** + * Starts the day's check when a person will see its answer: on a terminal, outside CI, + * and not with --json, which agents read. + */ +export const startUpdateCheck = (json: boolean) => { + if (json || !process.stdout.isTTY || !process.stderr.isTTY || process.env.CI) return; + + if (!checksAllowed()) return; + + const controller = new AbortController(); + let answered = false; + let dropped = false; + const saved = readCheck(); + + const fresh = saved.then(async (check) => { + const now = Date.now(); + + if (check && check.checkedAt <= now && now - check.checkedAt < CHECK_INTERVAL_MS) { + return undefined; + } + + let latest: string | undefined; + const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS).unref(); + + try { + const response = await fetch(registryUrl(), { + headers: { accept: "application/json" }, + signal: controller.signal, + }); + + latest = response.ok + ? stable(((await response.json()) as Record | null)?.latest) + : undefined; + answered = true; + } catch { + // Dropped because the command finished first: the next run asks again. + if (dropped) return undefined; + } finally { + clearTimeout(timeout); + } + + // A failed check counts too, so a machine offline asks once a day, not every run. + const found = latest ?? check?.latest; + const next = { checkedAt: now, ...(found ? { latest: found } : {}) }; + await writeCheck(next); + + return next; + }); + + running = { + saved, + fresh, + // An unanswered request, or a body that stalls, would keep the process open after the + // command finished. Once npm's answer is read, saving it takes milliseconds. + abort: () => { + if (answered) return; + + dropped = true; + controller.abort(); + }, + }; +}; + +/** After the command's output: ends the check, then prints one line if npm has a newer CLI. */ +export const finishUpdateCheck = async () => { + if (!running) return; + + const { saved, fresh, abort } = running; + running = undefined; + abort(); + + const check = (await fresh.catch(() => undefined)) ?? (await saved); + const latest = check?.latest; + + if (!latest || !isNewer(latest, packageJson.version)) return; + + process.stderr.write( + `${style.dim(`telemetry ${latest} is available (you have ${packageJson.version}). ${UPDATE}`, process.stderr)}\n`, + ); +}; diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index 6948b696..f74607f8 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -1,6 +1,16 @@ import { execFile, spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtemp, readdir, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + symlink, + utimes, + writeFile, +} from "node:fs/promises"; import { type AddressInfo, createServer } from "node:net"; import { hostname, tmpdir } from "node:os"; import { join } from "node:path"; @@ -9,9 +19,11 @@ import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, beforeEach, expect, test, vi } from "vitest"; +import { VERSION } from "../src/api.ts"; import { type Session, sessionFile, updateSession } from "../src/session.ts"; import { beforeRefresh, + beforeTool, calls, EMAIL, logs, @@ -21,6 +33,7 @@ import { QUIET_TRACE_ID, QUIET_TRACE_URL, REFRESH_TOKEN, + registry, serverState, startFixture, TOKEN, @@ -63,6 +76,14 @@ beforeEach(async () => { home = await mkdtemp(join(tmpdir(), "telemetry-cli-")); calls.length = 0; delete beforeRefresh.run; + delete beforeTool.wait; + Object.assign(registry, { + latest: undefined, + hang: false, + requests: 0, + onRequest: undefined, + onAnswer: undefined, + }); Object.assign(serverState, { failProjects: false, removedFromWorkspace: false, @@ -70,16 +91,17 @@ beforeEach(async () => { traceUrl: QUIET_TRACE_URL, windows: true, agentAndTool: true, + empty: false, }); return () => rm(home, { recursive: true, force: true }); }); -const runWith = (env: Record, ...args: string[]) => +const runScript = (script: string | string[], env: Record, ...args: string[]) => new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => { execFile( process.execPath, - [CLI, ...args], + [...[script].flat(), ...args], { env: { PATH: process.env.PATH, @@ -94,6 +116,8 @@ const runWith = (env: Record, ...args: string[]) => ); }); +const runWith = (env: Record, ...args: string[]) => runScript(CLI, env, ...args); + const run = (...args: string[]) => runWith({}, ...args); /** The address of a port that was just closed, which refuses connections. */ @@ -162,6 +186,83 @@ test("commands exit 4 when signed out, and status reports it", async () => { const status = await run("status", "--json"); expect(status.code).toBe(4); expect(JSON.parse(status.stdout)).toMatchObject({ signed_in: false }); + + // A saved sign-in the server no longer accepts, with no refresh token to renew it. + await writeFile( + sessionPath(), + JSON.stringify({ + server: fixture.url, + client: { client_id: "fixture-client" }, + tokens: { access_token: "revoked", token_type: "Bearer" }, + email: EMAIL, + }), + { mode: 0o600 }, + ); + + const refused = await run("status"); + expect(refused.code).toBe(4); + expect(refused.stdout).toContain("Your sign-in has expired or was disconnected."); + expect(refused.stdout).not.toContain("Signed in"); + expect(JSON.parse((await run("status", "--json")).stdout)).toMatchObject({ + signed_in: false, + email: EMAIL, + }); + + // One that can't be checked, here for want of a server, is still reported as saved. + const closed = await closedServer(); + await writeFile( + join(home, sessionFile(closed)), + JSON.stringify({ server: closed, tokens: { access_token: TOKEN, token_type: "Bearer" } }), + ); + const unchecked = await runWith({ TELEMETRY_DEV_URL: closed }, "status"); + expect(unchecked.code).toBe(0); + expect(unchecked.stdout).toContain("Signed in"); + expect(unchecked.stderr).toContain( + `Couldn't check the sign-in: Couldn't reach ${new URL(closed).host} (ECONNREFUSED).`, + ); + expect( + JSON.parse((await runWith({ TELEMETRY_DEV_URL: closed }, "status", "--json")).stdout), + ).toMatchObject({ signed_in: true, sign_in_verified: false }); + + // A refresh the server can't answer, here an outage, isn't a refused sign-in either. + await writeFile( + sessionPath(), + JSON.stringify({ + server: fixture.url, + client: { client_id: "fixture-client" }, + tokens: { access_token: "expired", token_type: "Bearer", refresh_token: REFRESH_TOKEN }, + email: EMAIL, + }), + { mode: 0o600 }, + ); + beforeRefresh.run = async () => "unavailable"; + const outage = await run("status"); + expect(outage.code).toBe(0); + expect(outage.stdout).toContain("Signed in"); + expect(outage.stderr).toContain( + `Couldn't check the sign-in: Couldn't renew the sign-in with ${new URL(fixture.url).host}.`, + ); + expect(JSON.parse((await run("status", "--json")).stdout)).toMatchObject({ + signed_in: true, + sign_in_verified: false, + }); + expect((await run("traces")).code).toBe(1); + + // Tokens another authorization server issued can't be renewed here, outage or not. + const session = JSON.parse(await readFile(sessionPath(), "utf8")) as Session; + await writeFile( + sessionPath(), + JSON.stringify({ ...session, tokens: { ...session.tokens, issuer: "https://elsewhere.test" } }), + { mode: 0o600 }, + ); + expect((await run("status")).code).toBe(4); + + delete beforeRefresh.run; + await writeFile(sessionPath(), JSON.stringify(session), { mode: 0o600 }); + expect(JSON.parse((await run("status", "--json")).stdout)).toMatchObject({ + signed_in: true, + sign_in_verified: true, + }); }); test("payload --field prints the whole text across pages", async () => { @@ -202,6 +303,10 @@ test("trace leads with where the run failed and which calls repeated, counting p expect(lines).toContain( " invoke_agent researcher › execute_tool fetch_page › GET /page TimeoutError: Request timed out after 700 ms", ); + // Each failed span's id is in the tree, where payload can take it from. + expect(lines.filter((line) => /^\s*[│ ]*\s+span \S+/.test(line))).toEqual( + expect.arrayContaining([expect.stringMatching(/span t2: .*700 ms/)]), + ); expect(lines.indexOf("Errors (1)")).toBeGreaterThan(0); expect(lines.indexOf("Repeated (1)")).toBeGreaterThan(lines.indexOf("Errors (1)")); expect(lines.findIndex((line) => line.startsWith("SPAN"))).toBeGreaterThan( @@ -330,6 +435,34 @@ test("a mistyped command suggests the closest one and exits 2", async () => { const result = await run("tarces"); expect(result.code).toBe(2); expect(result.stderr).toContain("telemetry traces"); + + // Options before the command get the command moved to the front, ready to run. + const reordered = await run("-p", "my bot", "traces", "--errors"); + expect(reordered.code).toBe(2); + expect(reordered.stderr).toContain( + "Put the command before its options: telemetry traces -p 'my bot' --errors", + ); + expect(JSON.parse((await run("--json", "traces")).stderr).error).toMatchObject({ + code: "invalid_arguments", + hint: "telemetry traces --json", + }); + + // With two command names, one could be an option's value, so no order is guessed. + for (const unsure of [await run("--name", "logs", "traces"), await run("--name", "trace")]) { + expect(unsure.stderr).toContain("Put the command before its options, as in telemetry traces"); + expect(unsure.stderr).not.toContain("--name"); + } + + // Commands in messages paste as they are: in a POSIX shell, backticks would run them. + for (const message of [await run("traces"), await run("traces", "--bogus")]) { + expect(message.stderr).not.toContain("`"); + } +}); + +test("-v prints the version, like --version", async () => { + for (const flag of ["-v", "--version"]) { + expect(await run(flag)).toMatchObject({ code: 0, stdout: `${VERSION}\n` }); + } }); test("input that isn't valid exits 2 before calling telemetry.dev, even from a script", async () => { @@ -401,6 +534,21 @@ test("with --json, an error is one JSON line on stderr with a code to branch on, ), ).toMatchObject({ exit: 1, code: "unreachable", retryable: true }); + // The error names the deployment it couldn't reach, and a proxy that Node.js ignores. + const unreachable = (env: Record) => + runWith({ TELEMETRY_DEV_URL: closed, ...env }, "traces", "--project", PROJECTS[0]!.id); + const host = new URL(closed).host; + const proxied = await unreachable({ HTTPS_PROXY: "http://proxy.example.test:8080" }); + expect(proxied.stderr).toContain(`Couldn't reach ${host} (ECONNREFUSED).`); + expect(proxied.stderr).toContain("NODE_USE_ENV_PROXY=1"); + expect((await unreachable({})).stderr).not.toContain("proxy"); + const enabled = await unreachable({ + HTTPS_PROXY: "http://proxy.example.test:8080", + NODE_USE_ENV_PROXY: "1", + }); + expect(enabled.stderr).toContain(`Couldn't reach ${host}`); + expect(enabled.stderr).not.toContain("proxy"); + // After `--`, --json is a value, so the error stays the line a person reads. const value = await run("use", "--", "--json"); expect(value.code).toBe(2); @@ -856,6 +1004,7 @@ test("a shell without a terminal signs in with --no-browser, then --finish, keep server: fixture.url, client: { client_id: "fixture-client" }, tokens: { access_token: TOKEN, token_type: "Bearer", refresh_token: "previous-refresh" }, + project: { id: PROJECTS[1].id, name: "eval-runner" }, }), { mode: 0o600 }, ); @@ -887,8 +1036,13 @@ test("a shell without a terminal signs in with --no-browser, then --finish, keep expect(finished.stderr).toContain("eval-runner"); expect(finished.stderr).not.toMatch(CONTROL); + // The workspace has two projects, so only the previous default can say which to keep. const session = await savedSession(); - expect(session).toMatchObject({ email: EMAIL, tokens: { refresh_token: REFRESH_TOKEN } }); + expect(session).toMatchObject({ + email: EMAIL, + tokens: { refresh_token: REFRESH_TOKEN }, + project: { id: PROJECTS[1].id }, + }); expect(session.pending).toBeUndefined(); // The sign-in it replaced no longer works at the server. @@ -903,6 +1057,433 @@ test("a shell without a terminal signs in with --no-browser, then --finish, keep expect(again.code).toBe(4); }); +test("a browser sign-in also takes the pasted address, for a browser that never opened or is on another device", async () => { + // Openers that open nothing, as when xdg-open is missing or has no browser to hand to. + const bin = await mkdtemp(join(tmpdir(), "telemetry-cli-bin-")); + for (const name of ["open", "xdg-open"]) { + await writeFile(join(bin, name), "#!/bin/sh\n", { mode: 0o755 }); + } + + // `login` on a terminal, once its paste prompt is up. + const prompted = async (...args: string[]) => { + const child = spawn( + process.execPath, + ["--import", "data:text/javascript,process.stdin.isTTY=true", CLI, "login", ...args], + { + env: { + PATH: `${bin}:${process.env.PATH}`, + HOME: home, + TELEMETRY_DEV_URL: fixture.url, + TELEMETRY_DEV_CONFIG_DIR: home, + NO_COLOR: "1", + DISPLAY: ":0", + }, + }, + ); + const output = { stderr: "" }; + child.stderr.on("data", (chunk: Buffer) => { + output.stderr += chunk.toString(); + }); + const exited = new Promise((resolve) => child.on("exit", resolve)); + + for (let waited = 0; !output.stderr.includes("paste its address here"); waited += 25) { + if (waited > 4000) { + child.kill(); + throw new Error(`no paste prompt:\n${output.stderr}`); + } + await sleep(25); + } + + const link = output.stderr.match(/http:\/\/127\.0\.0\.1:\d+\/authorize\?\S+/)![0]; + + return { child, output, exited, link }; + }; + + try { + const pasted = await prompted(); + expect(pasted.output.stderr).toContain("Opening your browser"); + + // Approved on a device that can't reach this machine's loopback address. + const approved = await fetch(pasted.link, { redirect: "manual" }); + pasted.child.stdin.end(`${approved.headers.get("location")}\n`); + expect(await pasted.exited).toBe(0); + expect(pasted.output.stderr).toContain(`Signed in as ${EMAIL}`); + expect(await savedSession()).toMatchObject({ tokens: { refresh_token: REFRESH_TOKEN } }); + + // Ctrl-D only ends the prompt; the browser on this machine still finishes. + const closed = await prompted(); + closed.child.stdin.end(); + await sleep(100); + await fetch(closed.link); + expect(await closed.exited).toBe(0); + expect(closed.output.stderr).toContain(`Signed in as ${EMAIL}`); + + // Without a browser, Ctrl-D leaves nothing to wait for. + const cancelled = await prompted("--no-browser"); + cancelled.child.stdin.end(); + expect(await cancelled.exited).toBe(4); + expect(cancelled.output.stderr).toContain("error Sign-in was cancelled."); + expect(cancelled.output.stderr).not.toContain(" at "); + } finally { + await rm(bin, { recursive: true, force: true }); + } +}); + +// Windows as far as the CLI can tell; Node's path module was already chosen at startup. +const WINDOWS = [ + "--import", + "data:text/javascript,Object.defineProperty(process,'platform',{value:'win32'})", +]; + +test("on Windows, printed commands use the double quotes cmd.exe and PowerShell share, or aren't printed", async () => { + const windows = (...args: string[]) => runScript([...WINDOWS, CLI], {}, ...args); + + const started = await windows("login", "--no-browser"); + expect(started.stderr).toContain(` telemetry login --finish "
"`); + expect(JSON.parse((await windows("login", "--json")).stdout).next).toBe( + `telemetry login --finish "
"`, + ); + + await signIn(PROJECTS[0]); + const spaced = await windows("traces", "--name", "support answer"); + expect(spaced.code).toBe(0); + expect(spaced.stderr).toMatch(/More: telemetry traces --name "support answer" --cursor=\S+/); + + // cmd.exe expands %name% even in quotes, and a backslash before the closing quote + // escapes it, so only the cursor is printed for these. + for (const name of ["100%done%", "build\\"]) { + const unquotable = await windows("traces", "--name", name); + expect(unquotable.code).toBe(0); + expect(unquotable.stderr).not.toContain("More: telemetry"); + expect(unquotable.stderr).toMatch(/More: run the same command with --cursor=\S+/); + } +}); + +test("started by npx, every next step runs through npx, since npx leaves no telemetry on PATH", async () => { + // Where npx unpacks a package, with the bin linked the way npx links it. + const bin = join(home, ".npm", "_npx", "ce03004f394ebab2", "node_modules", ".bin"); + await mkdir(bin, { recursive: true }); + await symlink(CLI, join(bin, "telemetry.ts")); + const npx = (...args: string[]) => runScript(join(bin, "telemetry.ts"), {}, ...args); + + // A default project deleted since, which the new sign-in must not keep. + await writeFile( + sessionPath(), + JSON.stringify({ + server: fixture.url, + project: { id: "0f0e0d0c-0b0a-4908-8706-050403020100", name: "deleted" }, + }), + { mode: 0o600 }, + ); + + const started = await npx("login", "--no-browser"); + expect(started.stderr).toContain(" npx @telemetry-dev/cli login --finish '
'"); + + const link = started.stderr.match(/http:\/\/127\.0\.0\.1:\d+\/authorize\?\S+/)![0]; + const approved = await fetch(link, { redirect: "manual" }); + const finished = await npx("login", "--finish", approved.headers.get("location")!); + expect(finished.code).toBe(0); + expect(finished.stderr).toContain("Choose a default: npx @telemetry-dev/cli use support-bot"); + expect(finished.stderr).toContain( + "To run it as telemetry, install it: npm install --global @telemetry-dev/cli", + ); + expect(finished.stderr).not.toMatch(/(? join(home, "update-check.json"); + +/** The CLI started from `script` on a terminal, asking the fixture for npm's dist-tags. */ +const onTerminal = (script: string, env: Record, ...args: string[]) => + runScript([...TERMINAL, script], { npm_config_registry: `${fixture.url}/`, ...env }, ...args); + +/** A link to the CLI where a package manager would start it from. */ +const installedAt = async (...path: string[]) => { + const script = join(home, ...path); + await mkdir(join(script, ".."), { recursive: true }); + await symlink(CLI, script); + + return script; +}; + +const checkedToday = (latest: string) => + writeFile(updateCheckPath(), JSON.stringify({ latest, checked_at: new Date().toISOString() }), { + mode: 0o600, + }); + +test("on a terminal, a newer CLI on npm is announced after the output with the update for how it was installed, and npm is asked once a day", async () => { + await signIn(PROJECTS[0]); + registry.latest = NEWER; + + // telemetry.dev answers after npm, as it does for a command that waits on a query. + beforeTool.wait = new Promise((resolve) => { + registry.onAnswer = resolve; + }); + + const first = await onTerminal(CLI, {}, "traces"); + expect(first.code).toBe(0); + expect(first.stdout).toContain("triage classify ticket"); + expect( + first.stderr.endsWith(`\n${NOTICE} Update: npm install --global @telemetry-dev/cli@latest\n`), + ).toBe(true); + expect((await stat(updateCheckPath())).mode & 0o777).toBe(0o600); + + // Within the day, npm isn't asked again, even by a command that gives it time to answer. + beforeTool.wait = Promise.race([ + new Promise((resolve) => { + registry.onRequest = resolve; + }), + sleep(500), + ]); + expect((await onTerminal(CLI, {}, "traces")).stderr).toContain(NOTICE); + expect(registry.requests).toBe(1); + delete beforeTool.wait; + + // The day's answer is saved, so a command that ends at once still says it. + const status = await onTerminal(CLI, {}, "status"); + expect(status.stderr).toContain(NOTICE); + expect(registry.requests).toBe(1); + + // Runner paths are the ones npx, pnpm dlx with its cache moved, Yarn's dlx, and bunx + // use. npm's global folder can have any prefix, with Windows' shim next to it. + const PKG = ["node_modules", "@telemetry-dev", "cli", "cli.ts"]; + await mkdir(join(home, "npm-global"), { recursive: true }); + await writeFile(join(home, "npm-global", "telemetry.cmd"), ""); + const updates: [string[], string][] = [ + [ + [".npm", "_npx", "ce03004f394ebab2", "node_modules", ".bin", "telemetry.ts"], + "Run npx @telemetry-dev/cli@latest, or install it: npm install --global @telemetry-dev/cli", + ], + [ + [ + "cache", + "dlx", + "c2d73a85dbd5d3bd60e628be629939cd", + "mw15n7mj-nzn", + "node_modules", + ".bin", + "telemetry.ts", + ], + "Run pnpm dlx @telemetry-dev/cli@latest, or install it: pnpm add --global @telemetry-dev/cli", + ], + [ + ["xfs-768a7bf7", "dlx-32213", ".yarn", "cache", "cli.zip", ...PKG], + "Run yarn dlx @telemetry-dev/cli@latest, or install it: npm install --global @telemetry-dev/cli", + ], + [ + ["bunx-501-@telemetry-dev", "cli@latest", ...PKG], + "Run bunx @telemetry-dev/cli@latest, or install it: bun add --global @telemetry-dev/cli", + ], + [ + ["pnpm", "global", "v11", "a1", ...PKG], + "Update: pnpm add --global @telemetry-dev/cli@latest", + ], + [["npm-global", ...PKG], "Update: npm install --global @telemetry-dev/cli@latest"], + [[".npm-global", "lib", ...PKG], "Update: npm install --global @telemetry-dev/cli@latest"], + [ + [".volta", "tools", "image", "packages", "@telemetry-dev", "cli", "lib", ...PKG], + "Update: volta install @telemetry-dev/cli@latest", + ], + [ + ["AppData", "Local", "Volta", "tools", "image", "packages", "@telemetry-dev", "cli", ...PKG], + "Update: volta install @telemetry-dev/cli@latest", + ], + [ + [".my-pnpm", "global", "v11", "a".repeat(64), ...PKG], + "Update: pnpm add --global @telemetry-dev/cli@latest", + ], + [[".bun", "install", "global", ...PKG], "Update: bun add --global @telemetry-dev/cli@latest"], + [ + ["AppData", "Local", "Yarn", "Data", "global", ...PKG], + "Update: yarn global add @telemetry-dev/cli@latest", + ], + ]; + + for (const [path, update] of updates) { + expect((await onTerminal(await installedAt(...path), {}, "status")).stderr).toContain( + `${NOTICE} ${update}`, + ); + } + + // A project's lockfile pins its version, so it neither asks npm nor says anything: + // also in a folder named like npm's global one, bunx's, or pnpm dlx's, and from Yarn's + // Plug'n'Play cache, which has no package.json next to its node_modules. + await rm(updateCheckPath()); + + for (const [project, ...path] of [ + ["lib", ...PKG], + ["bunx-demo", "node_modules", ".bin", "telemetry.ts"], + ["dlx", "apps", "web", "node_modules", ".bin", "telemetry.ts"], + ] as const) { + await mkdir(join(home, project), { recursive: true }); + await writeFile(join(home, project, "package.json"), "{}"); + const dependency = await onTerminal(await installedAt(project, ...path), {}, "status"); + expect(dependency.stderr).not.toContain("is available"); + } + + const pnp = [".yarn", "berry", "cache", "@telemetry-dev-cli-npm-0.1.6-abc-10c0.zip", ...PKG]; + expect((await onTerminal(await installedAt(...pnp), {}, "status")).stderr).not.toContain( + "is available", + ); + + expect(registry.requests).toBe(1); + + // Agents read the saved answer from status --json, which never asks npm itself. + await checkedToday(NEWER); + expect(JSON.parse((await run("status", "--json")).stdout)).toMatchObject({ + cli_version: VERSION, + latest_cli_version: NEWER, + }); + expect( + JSON.parse((await runWith({ TELEMETRY_DEV_NO_UPDATE_CHECK: "1" }, "status", "--json")).stdout), + ).toMatchObject({ cli_version: VERSION, latest_cli_version: null }); + // An answer from before this CLI was updated is older than it, so not the latest. + await checkedToday("0.0.1"); + expect(JSON.parse((await run("status", "--json")).stdout)).toMatchObject({ + latest_cli_version: null, + }); + expect(registry.requests).toBe(1); +}); + +test("the update check never delays a command, and stays out of --json, pipes, CI, and when turned off", async () => { + await signIn(PROJECTS[0]); + registry.latest = NEWER; + registry.hang = true; + + // The command finishes while npm's answer is still owed. + beforeTool.wait = new Promise((resolve) => { + registry.onRequest = resolve; + }); + const started = Date.now(); + const stalled = await onTerminal(CLI, {}, "traces"); + expect(stalled.code).toBe(0); + expect(Date.now() - started).toBeLessThan(3000); + expect(stalled.stderr).not.toContain("is available"); + await expect(stat(updateCheckPath())).rejects.toThrow(); + delete beforeTool.wait; + + // With an answer saved that a person on a terminal would be shown: + await checkedToday(NEWER); + registry.requests = 0; + + for (const quiet of [ + await onTerminal(CLI, {}, "status", "--json"), + await runWith({ npm_config_registry: `${fixture.url}/` }, "status"), + await onTerminal(CLI, { CI: "true" }, "status"), + await onTerminal(CLI, { TELEMETRY_DEV_NO_UPDATE_CHECK: "1" }, "status"), + await onTerminal(CLI, { NO_UPDATE_NOTIFIER: "1" }, "status"), + ]) { + expect(quiet.code).toBe(0); + expect(quiet.stderr).not.toContain("is available"); + } + + // A day-old answer is asked again. + await writeFile( + updateCheckPath(), + JSON.stringify({ + latest: NEWER, + checked_at: new Date(Date.now() - 25 * 3600_000).toISOString(), + }), + ); + registry.hang = false; + beforeTool.wait = new Promise((resolve) => { + registry.onAnswer = resolve; + }); + await onTerminal(CLI, {}, "traces"); + expect(registry.requests).toBe(1); +}); + +test("npm's answer is announced only when it is a newer stable version, and never reaches the terminal as given", async () => { + await signIn(PROJECTS[0]); + + for (const latest of [`${NEWER}-rc.1`, "0.0.1", VERSION, `${NEWER}\u001b]52;c;aGk=\u0007`]) { + await rm(updateCheckPath(), { force: true }); + registry.latest = latest; + beforeTool.wait = new Promise((resolve) => { + registry.onAnswer = resolve; + }); + + const result = await onTerminal(CLI, {}, "traces"); + expect(result.code).toBe(0); + expect(result.stderr).not.toContain("is available"); + expect(result.stderr).not.toContain("]52;"); + } + + // A corrupt saved answer is asked again and replaced. + await writeFile(updateCheckPath(), "{not json"); + registry.latest = NEWER; + beforeTool.wait = new Promise((resolve) => { + registry.onAnswer = resolve; + }); + expect((await onTerminal(CLI, {}, "traces")).stderr).toContain(NOTICE); + expect(JSON.parse(await readFile(updateCheckPath(), "utf8"))).toMatchObject({ latest: NEWER }); +}); + +test("right after sign-in, the next step fits what the workspace has: a project to choose, or traces to send", async () => { + // With several projects and no default, the welcome leads with choosing one. + await signIn(); + const welcome = await onTerminal(CLI, { TELEMETRY_DEV_NO_UPDATE_CHECK: "1" }); + expect(welcome.code).toBe(0); + expect(welcome.stdout).toContain( + "No default project. To set one: telemetry projects, then telemetry use ", + ); + // TELEMETRY_DEV_PROJECT picks one for every command, so there is nothing to choose. + expect( + ( + await onTerminal(CLI, { + TELEMETRY_DEV_NO_UPDATE_CHECK: "1", + TELEMETRY_DEV_PROJECT: PROJECTS[1].name, + }) + ).stdout, + ).not.toContain("No default project"); + + await signIn(PROJECTS[0]); + expect((await onTerminal(CLI, { TELEMETRY_DEV_NO_UPDATE_CHECK: "1" })).stdout).not.toContain( + "No default project", + ); + + // A project no traces were sent to yet points at setting up the SDK. + serverState.empty = true; + const first = await run("traces"); + expect(first.code).toBe(0); + expect(first.stderr).toContain( + "No traces in support-bot outside experiments, which --env experiment lists. To send some, see https://telemetry.dev/docs/quickstart", + ); + + // A search that was narrowed found nothing for its own reasons. + for (const narrowed of [["--errors"], ["--name", "checkout"], ["--since", "2h"]]) { + const result = await run("traces", ...narrowed); + expect(result.stderr).toContain("No traces match"); + expect(result.stderr).not.toContain("quickstart"); + } + + // An empty sessions search suggests only windows wider than the one it searched. + const widen = async (...range: string[]) => (await run("sessions", ...range)).stderr.trim(); + expect(await widen()).toBe("No sessions match. Widen the window with --range 7d or --range 30d."); + expect(await widen("--range", "7d")).toBe( + "No sessions match. Widen the window with --range 30d.", + ); + expect(await widen("--range", "30d")).toBe("No sessions match."); +}); + test("login --json prints the link without opening a browser or waiting, and --finish --json prints the sign-in", async () => { // A browser that could open: an `open` and `xdg-open` that record being run. const bin = await mkdtemp(join(tmpdir(), "telemetry-cli-bin-")); diff --git a/packages/cli/tests/fixture.ts b/packages/cli/tests/fixture.ts index 147aa62b..421a4eb8 100644 --- a/packages/cli/tests/fixture.ts +++ b/packages/cli/tests/fixture.ts @@ -535,7 +535,8 @@ const resolveWindow = (args: Record, defaultMs: number | null) const server = () => { const mcp = new McpServer({ name: "telemetry.dev fixture", version: "1.0.0" }); const tool = (name: string, handler: (args: Record) => unknown) => - mcp.registerTool(name, { inputSchema: loose }, (args: Record) => { + mcp.registerTool(name, { inputSchema: loose }, async (args: Record) => { + await beforeTool.wait; calls.push({ tool: name, args }); const value = serverState.removedFromWorkspace ? new ToolFailure("Not a member of this workspace", "access_denied") @@ -582,15 +583,21 @@ const server = () => { } const window = resolveWindow(args, null); + const found = serverState.empty ? { traces: [], next_cursor: null } : traces(); - return window instanceof ToolFailure ? window : { ...traces(), window }; + return window instanceof ToolFailure ? window : { ...found, window }; }); tool("search_sessions", (args) => { if (!serverState.windows) return sessions(); const window = resolveWindow(args, Number(args.range_hours ?? 24) * UNIT_MS.h); - return window instanceof ToolFailure ? window : { ...sessions(), window }; + return window instanceof ToolFailure + ? window + : { + ...(serverState.empty ? { ...sessions(), sessions: [], next_cursor: null } : sessions()), + window, + }; }); tool("get_trace", (args) => Object.hasOwn(TRACE_DETAILS, String(args.trace_id)) @@ -687,16 +694,32 @@ export const serverState = { windows: true, /** search_traces takes agent and tool; false acts as a server with windows from before them. */ agentAndTool: true, + /** search_traces and search_sessions find nothing, as in a project nothing was sent to yet. */ + empty: false, }; /** * Runs before the token endpoint answers a refresh, to act as another CLI process or a - * server that rotates tokens. "accept" or "refuse" overrides the usual check. + * server that rotates tokens. "accept" or "refuse" overrides the usual check; + * "unavailable" answers 503, as a server in an outage would. */ export const beforeRefresh: { - run?: (refreshToken: string) => Promise<"accept" | "refuse" | undefined>; + run?: (refreshToken: string) => Promise<"accept" | "refuse" | "unavailable" | undefined>; } = {}; +/** Every tool waits for this first, so a command can outlast the CLI's update check. */ +export const beforeTool: { wait?: Promise } = {}; + +/** The npm registry's dist-tags for the CLI, which the fixture also serves. */ +export const registry = { + latest: undefined as string | undefined, + /** Takes the request and never answers, as a stalled connection wouldn't. */ + hang: false, + requests: 0, + onRequest: undefined as (() => void) | undefined, + onAnswer: undefined as (() => void) | undefined, +}; + const handler = createMcpHandler(() => server(), { legacy: "stateless", responseMode: "json" }); export const REFRESH_TOKEN = "fixture-refresh-token"; @@ -732,6 +755,17 @@ export const startFixture = async (port = 0) => { const http = createServer(async (request, response) => { const url = new URL(request.url ?? "/", origin); + if (url.pathname === "/-/package/@telemetry-dev%2fcli/dist-tags") { + registry.requests += 1; + registry.onRequest?.(); + + if (registry.hang) return; + + json(response, 200, { latest: registry.latest }); + + return registry.onAnswer?.(); + } + if (url.pathname === "/.well-known/oauth-protected-resource/mcp") { return json(response, 200, { resource: `${origin}/mcp`, authorization_servers: [origin] }); } @@ -785,6 +819,10 @@ export const startFixture = async (port = 0) => { ? await beforeRefresh.run?.(form.get("refresh_token") ?? "") : undefined; + if (verdict === "unavailable") { + return response.writeHead(503, { "Content-Type": "text/plain" }).end("Service Unavailable"); + } + if ( form.get("grant_type") === "refresh_token" && (verdict === "refuse" || diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 70467cdc..6d2d7ec3 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -60,6 +60,8 @@ await server.connect(transport); `instrumentMcpTransport(transport)` mutates and returns the same transport and is idempotent. +Request spans also carry `mcp.client.name` and `mcp.client.version`, from the `clientInfo` the client reports, each cut to 128 characters. Since protocol 2026-07-28, every request carries it. On older protocol versions, only `initialize` does, and later requests on the same transport reuse it. The OpenTelemetry MCP conventions don't define these two attributes yet. + ## Payload capture Tool arguments and successful tool results are not captured by default. Enable them with `instrumentMcpTransport(transport, { capturePayloads: true })`. Core `@telemetry-dev/sdk` capture and masking settings still apply. diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index d4c9b992..aefeaec9 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -71,6 +71,11 @@ interface RequestSpanAttributes extends Record { "mcp.method.name": string; } +interface ClientInfo { + name?: string; + version?: string; +} + interface InjectedRequest extends JsonRecord { params: JsonRecord; } @@ -79,6 +84,11 @@ const INSTRUMENTED = new WeakSet(); const PROTOCOL_VERSIONS = new WeakMap(); const CALLER_ERROR_CODES = new Set([-32700, -32600, -32601, -32602, -32002, -32021, -32022]); const PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion"; +const CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo"; +const CLIENT_INFOS = new WeakMap(); +// clientInfo is self-reported by the peer, so its values are bounded before they +// become attributes. +const MAX_CLIENT_INFO_LENGTH = 128; function asRecord(value: unknown): JsonRecord | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -183,6 +193,41 @@ function responseProtocolVersion( return PROTOCOL_VERSIONS.get(transport) ?? transport.protocolVersion; } +function toClientInfo(value: JsonValue | undefined): ClientInfo | undefined { + const record = asRecord(value); + const bounded = (field: JsonValue | undefined) => + typeof field === "string" && field !== "" ? field.slice(0, MAX_CLIENT_INFO_LENGTH) : undefined; + const name = bounded(record?.name); + const version = bounded(record?.version); + + if (name === undefined && version === undefined) return undefined; + + return { ...(name !== undefined && { name }), ...(version !== undefined && { version }) }; +} + +/** + * The calling client's name and version: from each request's _meta since protocol + * 2026-07-28, else from initialize, which is the only legacy request that carries it. + */ +function clientInfo( + transport: InstrumentableTransport, + message: ParsedMessage, +): ClientInfo | undefined { + const modern = toClientInfo(message.meta?.[CLIENT_INFO_META_KEY]); + + if (modern !== undefined) return modern; + + if (message.method === "initialize") { + const legacy = toClientInfo(message.params?.clientInfo); + + if (legacy !== undefined) CLIENT_INFOS.set(transport, legacy); + + return legacy; + } + + return CLIENT_INFOS.get(transport); +} + function requestAttributes( transport: InstrumentableTransport, message: ParsedMessage, @@ -202,6 +247,11 @@ function requestAttributes( const version = protocolVersion(transport, message); if (version !== undefined) attributes["mcp.protocol.version"] = version; + const client = clientInfo(transport, message); + + if (client?.name !== undefined) attributes["mcp.client.name"] = client.name; + + if (client?.version !== undefined) attributes["mcp.client.version"] = client.version; const target = targetFor(method, message.params); if (method === "tools/call" && target !== undefined) attributes["gen_ai.tool.name"] = target; diff --git a/packages/mcp/tests/mcp.test.ts b/packages/mcp/tests/mcp.test.ts index bd0e590d..69d8d431 100644 --- a/packages/mcp/tests/mcp.test.ts +++ b/packages/mcp/tests/mcp.test.ts @@ -164,7 +164,7 @@ test("propagates baggage only when enabled", async () => { test("works through the official v2 client and server connection lifecycle", async () => { const spans = setup(); const [clientRaw, serverRaw] = InMemoryTransport.createLinkedPair(); - const client = new Client({ name: "test-client", version: "1.0.0" }); + const client = new Client({ name: "test-client", version: "3.2.1" }); const server = new McpServer({ name: "test-server", version: "1.0.0" }); let preexistingCalls = 0; serverRaw.onmessage = function (message) { @@ -192,6 +192,13 @@ test("works through the official v2 client and server connection lifecycle", asy expect( toolSpans.every((span) => typeof span.attributes["mcp.protocol.version"] === "string"), ).toBe(true); + // The client's own info, on both sides, never the server's. + for (const span of toolSpans) { + expect(span.attributes).toMatchObject({ + "mcp.client.name": "test-client", + "mcp.client.version": "3.2.1", + }); + } expect(preexistingCalls).toBe(1); await client.close(); @@ -232,6 +239,8 @@ test("completes responses before a per-request HTTP transport closes", async () _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}, + // Self-reported, so an overlong name is cut. + "io.modelcontextprotocol/clientInfo": { name: "c".repeat(500), version: "0.1.7" }, }, }, }), @@ -246,6 +255,8 @@ test("completes responses before a per-request HTTP transport closes", async () }); const [span] = await exported(spans, 1); expect(span?.attributes["mcp.method.name"]).toBe("tools/list"); + expect(span?.attributes["mcp.client.name"]).toBe("c".repeat(128)); + expect(span?.attributes["mcp.client.version"]).toBe("0.1.7"); expect(span?.attributes["error.type"]).toBeUndefined(); expect(span?.status.code).toBe(SpanStatusCode.UNSET); await handler.close();