Skip to content

ref(toolkit-core): Move pagination, cache, retry, and transport under api-client - #1736

Merged
BYK merged 2 commits into
mainfrom
feat/shared-api-client-layout
Oct 29, 2026
Merged

BYK merged 2 commits into
mainfrom
feat/shared-api-client-layout

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Moves pagination, the response-cache policy, retry, and transport under toolkit-core/src/api-client/, the layout they will have inside @sentry/api (#1669, task 8.3). Every importer is rewritten. Nothing else changes.

It's the second PR in the group 8 stack. #1732, the foundation, has merged, so this one targets main.

What changes

  • Moves. Only the relative imports inside the moved files change:
    • pagination.ts → api-client/pagination.ts
    • api-retry.ts → api-client/retry.ts
    • api-transport.ts → api-client/transport.ts
    • response-cache/{body,index,invalidation,keys,memory-store,policy,store,tiers}.ts → api-client/response-cache/
    • Their tests move with them.
  • Stays put: response-cache/codec.ts. It's the node:zlib codec, host storage that won't move into @sentry/api. It's still excluded from the browser typecheck, and the ./response-cache/codec export doesn't change.
  • Exports: ./pagination, ./api-retry, ./api-transport, and ./response-cache are replaced by the existing ./api-client/* wildcard plus one ./api-client/response-cache entry for the directory index. @sentry/toolkit-core is private, and every consumer is in this workspace and rewritten here.
  • Importers. The codemod rewrites 11 import specifiers across the CLI (including script/generate-api-schema.ts), mcp-core/src/api-client/client.ts, and toolkit-core.
  • Docs: api-patterns.md, the toolkit-core README, and code comments name the new subpaths.

Behavior changes

None.

This PR does not touch packages/mcp-server, packages/cli/src/lib/mcp.ts, or the evals stdio entry. The @sentry/mcp-server retirement PR owns those.

Commits

  1. 85fae9151 Move the modules and rewrite importers (generated, move-transport-layer.mjs)
  2. 85df504be Point comments and docs at the new paths (hand)

Verification

  • The generated commit reproduces exactly: check out origin/main (9409280c1), run the command below, then oxfmt on the changed files. The tree matches 85fae9151.
  • pnpm run tsc (including toolkit-core's browser typecheck), pnpm run lint, and node scripts/check-doc-links.mjs pass.
  • Tests: toolkit-core 306 passed; mcp-core 1881 passed, 6 skipped; mcp-cloudflare 443 passed; mcp-server 87 passed; CLI test/lib test/commands test/script 11895 passed, 46 skipped, 6 failed.
  • The 6 CLI failures are sandbox-only, and they fail the same way without this change. The sandbox runs with CAP_DAC_OVERRIDE, so these permission tests can't make a file unreadable: completions (EACCES, EPERM), dsn/code-scanner (unreadable file), init/tools/file-changes (I/O failure), telemetry (read-only DB), and upgrade (acquireLock permission error). CI runs them normally.

Codemod

The script isn't in the tree. To re-run it, check out the parent of the generated commit and run it from a scratch directory with ts-morph@28.0.0 installed. Then run oxfmt on the added, modified, and renamed package files.

move-transport-layer.mjs (commit 85fae91)
node move-transport-layer.mjs <repo-root>
git add -A -- packages
oxfmt $(git diff --cached --name-only --diff-filter=AMR)
// Codemod: move toolkit-core's pagination, response-cache policy, api-retry,
// and api-transport modules under src/api-client/, and rewrite every
// importer and the package's exports. The node:zlib cache codec stays at
// src/response-cache/codec.ts as a host-side module.
//
// Usage: node move-transport-layer.mjs <toolkit-repo-root>
// (run from a directory where `ts-morph@28.0.0` is installed)
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { Node, Project } from "ts-morph";

const root = path.resolve(process.argv[2] ?? ".");
const coreDir = path.join(root, "packages/toolkit-core");
const coreSrc = path.join(coreDir, "src");

// Old path -> new path, relative to toolkit-core/src.
const MOVES = new Map([
  ["pagination.ts", "api-client/pagination.ts"],
  ["pagination.test.ts", "api-client/pagination.test.ts"],
  ["api-retry.ts", "api-client/retry.ts"],
  ["api-retry.test.ts", "api-client/retry.test.ts"],
  ["api-transport.ts", "api-client/transport.ts"],
  ["api-transport.test.ts", "api-client/transport.test.ts"],
]);
const CACHE_FILES = [
  "body.ts",
  "index.ts",
  "invalidation.ts",
  "invalidation.test.ts",
  "keys.ts",
  "keys.test.ts",
  "memory-store.ts",
  "memory-store.test.ts",
  "policy.ts",
  "policy.test.ts",
  "policy.property.test.ts",
  "store.ts",
  "tiers.ts",
  "tiers.test.ts",
];
for (const file of CACHE_FILES) {
  MOVES.set(`response-cache/${file}`, `api-client/response-cache/${file}`);
}

// Package subpath -> new subpath. `response-cache/codec` does not move.
const SPECIFIERS = new Map([
  ["@sentry/toolkit-core/api-retry", "@sentry/toolkit-core/api-client/retry"],
  [
    "@sentry/toolkit-core/api-transport",
    "@sentry/toolkit-core/api-client/transport",
  ],
  [
    "@sentry/toolkit-core/pagination",
    "@sentry/toolkit-core/api-client/pagination",
  ],
  [
    "@sentry/toolkit-core/response-cache",
    "@sentry/toolkit-core/api-client/response-cache",
  ],
]);

// 1. Move the files inside toolkit-core. ts-morph rewrites relative imports
//    in and to them; then give every rewritten specifier the `.js` suffix
//    the package uses.
const core = new Project({
  tsConfigFilePath: path.join(coreDir, "tsconfig.json"),
});
const moved = new Set();
for (const [from, to] of MOVES) {
  const sourceFile = core.getSourceFileOrThrow(path.join(coreSrc, from));
  sourceFile.move(path.join(coreSrc, to));
  moved.add(sourceFile.getFilePath());
}
for (const sourceFile of core.getSourceFiles()) {
  const importer = sourceFile.getFilePath();
  for (const declaration of [
    ...sourceFile.getImportDeclarations(),
    ...sourceFile.getExportDeclarations(),
  ]) {
    const target = declaration.getModuleSpecifierSourceFile();
    if (!target || !declaration.getModuleSpecifierValue()?.startsWith(".")) {
      continue;
    }
    const targetPath = target.getFilePath();
    if (!moved.has(importer) && !moved.has(targetPath)) continue;
    let specifier = path.relative(
      path.dirname(importer),
      targetPath.replace(/\.ts$/, ""),
    );
    if (!specifier.startsWith(".")) specifier = `./${specifier}`;
    declaration.setModuleSpecifier(`${specifier}.js`);
  }
}
await core.save();

// 2. Rewrite package specifiers everywhere else, including dynamic imports
//    and vi.mock() calls: any string literal equal to an old subpath.
const others = new Project({ skipAddingFilesFromTsConfig: true });
others.addSourceFilesAtPaths([
  path.join(root, "{packages,apps}/*/{src,test,script,scripts}/**/*.{ts,tsx,mts}"),
  `!${path.join(root, "**/node_modules/**")}`,
  `!${path.join(root, "**/dist/**")}`,
  `!${path.join(coreDir, "**")}`,
]);
let rewritten = 0;
for (const sourceFile of others.getSourceFiles()) {
  let changed = false;
  sourceFile.forEachDescendant((node) => {
    if (!Node.isStringLiteral(node)) return;
    const next = SPECIFIERS.get(node.getLiteralValue());
    if (next) {
      node.setLiteralValue(next);
      changed = true;
      rewritten += 1;
    }
  });
  if (changed) await sourceFile.save();
}

// 3. Point the package exports at the new paths. The `./api-client/*`
//    wildcard covers pagination, retry, and transport; the cache needs its
//    index.
const manifestPath = path.join(coreDir, "package.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
const exports = {};
for (const [key, value] of Object.entries(manifest.exports)) {
  if (["./api-retry", "./api-transport", "./pagination", "./response-cache"].includes(key)) {
    continue;
  }
  if (key === "./api-client/*") {
    exports["./api-client/response-cache"] = {
      types: "./src/api-client/response-cache/index.ts",
      default: "./src/api-client/response-cache/index.ts",
    };
  }
  exports[key] = value;
}
manifest.exports = exports;
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);

console.log(`moved ${moved.size} files, rewrote ${rewritten} specifiers`);

via BYK.

--

View Junior Session in Sentry

sentry-junior Bot and others added 2 commits October 29, 2026 19:56
… api-client

Generated by the ts-morph codemod move-transport-layer.mjs (script in the
PR description), run from a scratch directory with ts-morph@28.0.0
installed:

  node move-transport-layer.mjs <repo-root>
  oxfmt <added and modified files>

It moves pagination.ts, api-retry.ts (now retry.ts), api-transport.ts
(now transport.ts), and the response-cache policy modules into
toolkit-core/src/api-client/, with their tests. The node:zlib codec
stays at src/response-cache/codec.ts as a host-side module. It rewrites
every importer, including the CLI and MCP package subpaths, and the
toolkit-core exports.

Co-Authored-By: BYK <burak.kaya@sentry.io>
Comments in the CLI, MCP, and transport, the toolkit-core README, and
api-patterns.md name the moved modules by their new api-client paths.

Co-Authored-By: BYK <burak.kaya@sentry.io>
@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
sentry-local Ready Ready Preview Oct 29, 2026 8:12pm UTC

Request Review

@BYK
BYK merged commit 479f349 into main Oct 29, 2026
36 of 37 checks passed
@BYK
BYK deleted the feat/shared-api-client-layout branch October 29, 2026 21:23
BYK pushed a commit that referenced this pull request Oct 29, 2026
#1737)

Adds the shared request core in `toolkit-core/src/api-client/core.ts`
and switches `SentryApiService` onto it. This is the first slice of
#1669 task 8.4. #1736 has merged, so this PR now targets `main`.

8.4 as written covers the request core *and* every domain's endpoint
wrappers. `SentryApiService` is about 6,000 lines with about 100
endpoint methods, so I'm splitting 8.4 into the core first, then domain
PRs on top. This PR is the core.

## What changes

- **`createApiClientCore`** (toolkit-core, browser-portable). The host
injects its transport, per-request headers, and logger. `request(origin,
path, init, { allowStatuses })`:
- sends to `<origin>/api/0<path>` with `Content-Type: application/json`
plus the host's headers (they replace any the caller passes, as before);
- turns network failures into `Unable to connect to <url> - …`, as a
`ConfigurationError` for ENOTFOUND, EAI_AGAIN, ECONNREFUSED, ETIMEDOUT,
and connect timeouts;
  - rethrows the caller's abort unchanged;
- maps an error response to the status's `ApiError` through
`readErrorResponse`, with the same three warnings as before.

  `requestJson` also parses the body with `readJsonResponse`.
- **`SentryApiService`.** `request` and `requestJSON` keep their
signatures and delegate to the core, so no endpoint method changes.
`SentryApiService` supplies `User-Agent`, `Authorization` (still a
`ConfigurationError` for a malformed token, before anything is sent),
the `X-Sentry-MCP-*` attribution headers, the existing shared transport,
and `logWarn` under the `api.client` scope. Its copies of the network
error table and the error-response mapping are deleted.
- **Still in mcp-core:** `sdkClient`, which now goes through the core
via `request`, and `sdkListPage`.
- Moving `sdkClient` means toolkit-core depends on `@sentry/api`. Today
that resolves a second `@sentry/api` copy, because toolkit-core pins
TypeScript through the `^5.8.3` catalog while mcp-core resolves 5.9.3.
The domain-wrapper PRs will fix that skew first.
- `sdkListPage` stays because its rules differ from the shared
`paginate`: an exact `per_page`, and an error on extra rows or a
repeated cursor. Merging them would change behavior, so that waits for
the merge step.
- **Docs:** the toolkit-core README's API client section and
`api-patterns.md` describe the core.

## Behavior changes

- **Warning `host` field.** The three error-response warnings (HTML
page, unparseable JSON, no string `detail`) now log the host the request
actually went to. Before, they logged the service's default host even
when a call overrode it, e.g. a regional host. Messages, error classes,
`detail`, `responseBody`, and `retryAfterMs` don't change.

This PR does not touch `packages/mcp-server`,
`packages/cli/src/lib/mcp.ts`, or the evals stdio entry. The
`@sentry/mcp-server` retirement PR owns those.

## Commits

Both commits are hand-written. No codemod.

1. `5710b8e8d` Add `createApiClientCore` with unit tests
2. `fc6aa0875` Switch `SentryApiService` onto it, plus docs

## Verification

- `pnpm run tsc` (including toolkit-core's browser typecheck), `pnpm run
lint`, and `node scripts/check-doc-links.mjs` pass.
- toolkit-core: 319 passed, including 13 new `core.test.ts` cases
covering headers, header errors, detail mapping, allowed statuses,
rate-limit wait, HTML and unreadable bodies, network errors, abort, and
`requestJson`.
- mcp-core: 1881 passed, 6 skipped. The existing `client.test.ts` MSW
suites cover the delegated path unchanged.
- mcp-server: 87 passed. mcp-cloudflare: 443 passed.
- The CLI is untouched in this PR; its turn is 8.5.

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3Aa6eddc0161cff61914454da9f74fcdf1 -->

--

[View Junior Session in
Sentry](https://sentry.sentry.io/explore/conversations/agent%3Aa6eddc0161cff61914454da9f74fcdf1/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
sentry-junior Bot added a commit that referenced this pull request Oct 29, 2026
Co-Authored-By: BYK <burak.kaya@sentry.io>
BYK added a commit that referenced this pull request Oct 29, 2026
…mar (#1730)

Part of #1564. Dashboard commands parsed their positionals by hand
inside `func`, and issue commands used their own grammar (#1729). This
moves both onto the shared ID grammar (`grammar/id-arguments.pegjs`,
#1714), parsed before `func` runs. Event view's issue checks (#1733,
#1738) use the new issue kind too.

**Shared grammar: kinds with names.** `IdKind.names` adds one idea to
`id-arguments.pegjs`: a kind may name an item without its ID, and
anything that is not, and may not be, an ID is a name. Kinds without
names keep their grammar paths exactly as before. Kinds with names keep
each line whole (no splitting at spaces), so `Top 10` stays one title
and never becomes title `Top` + ID `10`.
- `ID_KINDS.dashboard`: a numeric ID or a title, which may hold spaces
and slashes (`Latency/p95`). `<org>/[<project>/]<digits>` reads as a
path only when slugs come before the digits, so `Errors.v2/123` and
`/123` are titles. After a target, the title takes the rest of the
arguments (`my-org/ My Dashboard`). A bare first word names an org
(`orgScoped`).
- `ID_KINDS.issue`: an identifier (numeric ID, short ID, selector, issue
URL) is one whole token (`wholeIds`). `src/lib/issue-identifier.ts`
takes it apart with the #1729 grammar, which is unchanged. Text with
spaces is a name, meaning a search.
- `dashboard-arguments.pegjs` is gone. `dashboard list` keeps its thin
`parseDashboardListArgs`, because a bare word there is a title filter
(see #1711).

**Bare first word: org or title?** For `dashboard view My Dashboard`,
the parser makes no decision. It returns the structural reading (org
`My`, title `Dashboard`) and the whole input as one title
(`nameWithTarget`, surfaced as `orgOrTitle`). `resolveDashboardInput`
decides after auth:
- First it checks whether `My` is an org: the cached org list and region
cache, then `GET /organizations/My/` only on a cache miss.
- If the org exists, the title lookup in that org is final, even when it
misses.
- On a 404 or 403 it warns ``No org 'My'; treating "My Dashboard" as the
dashboard title. Use my-org/ to name an org.`` and looks up "My
Dashboard" in the auto-detected org.
- If that misses too, one error names both readings (no org `My` to find
`Dashboard` in, and no `My Dashboard` in `<detected-org>`) and hints at
`<org>/ Dashboard` and at quoting.
- A 5xx or network error from the org check propagates, with no
fallback.
- It never falls back for `my-org/ …` (explicit org), a numeric ref, or
a URL.

**Issue search text.** `issue view "login timeout"` resolves through
`GET /organizations/{org}/issues/?query=…` in the detected org. One hit
is the issue. Zero or several hits raise a `ResolutionError` that lists
up to 5 candidates as runnable commands and points at `sentry issue list
<org>/ --query '…'`. I checked the endpoint against getsentry/sentry
(`organization_group_index.py`, `group_index/index.py`): `query` takes
free text, an exact event ID or short ID gets a fast lookup, and an
explicit query replaces the default `is:unresolved`.

**Search hints.** Text where a trace, span, log, or replay ID belongs
(`trace view login`) now fails with ``Not a trace ID: "login". To
search, try `sentry trace list --query 'login'`.``. The hint comes from
`validate()`, so commands that recover malformed IDs (`trace view`,
`span view`, `log view`) recover first and use the hint only as their
final error. Event IDs get no hint, because `event list` lists one
issue's events.

**event view.** `ID_KINDS.issue.distinctForm` (uppercase short ID or
numeric) replaces `looksLikeIssueShortId` + `isAllDigits` and keeps
#1738's order: `<org>/<short-id>`, then `<short-id>/<event-id>` (even an
all-digit event ID), then `<org>/<numeric-id>`. I added a regression
test for the all-digit event ID and one for that order.

**Behavior changes:**
- `dashboard view my-org My Dashboard` (unquoted title) looks up "My
Dashboard". Before, it used `My` and dropped `Dashboard`. The same goes
for `my-org/ My Dashboard` and `https://acme.sentry.io/ My Dashboard`.
- `dashboard view My Dashboard`: when there is no org `My` (404/403), it
falls back to the title "My Dashboard" in the detected org, with the
warning above. If neither reading finds a dashboard, one error names
both. Dashboard commands with one dashboard ref get this (`view`,
`restore`, `revisions`, `widget edit/delete`, and `widget add` after it
splits off its widget title).
- A numeric ref first is the dashboard, as with every ID kind:
`dashboard view 42 X`, `my-org/42 X`. Before, the first of two arguments
was always the target. After a target, a numeric ID keeps the rest as
extra (`my-org 123 extra` → ID 123, ignored `extra`) instead of joining
them into a title. Arguments after the ID go through the shared rules:
extra, reversed (`42 my-org/`), or the shared "reference different
projects" error when they name another target.
- A lone non-Sentry URL is no longer taken as a dashboard title.
- `issue view "login timeout"` (text with spaces) searches instead of
failing as a malformed identifier. Control characters and URLs are still
rejected.
- Trace/span/log/replay IDs that are plain text get a search hint
instead of "Invalid trace ID".
- Parse errors (no dashboard, an org URL without a dashboard, a
dashboard URL passed to `list`, an invalid target slug) fail before the
auth guard, as in the earlier commits on this branch. They print `Failed
to parse "…" for <placeholder>: …` and exit 252.
- `widget add/edit/delete`: an argument error comes before flag errors
like "Specify --index or --title". `widget add`'s positional completes
cached dashboards. `parseDashboardTarget` had no callers left and is
removed.

**Lint ratchet (#1739):** the dashboard entries are off both
allow-lists, since these commands now parse before `func`. The issue
entries (`issue/utils.ts`, `issue/merge.ts`, which need an array issue
positional) stay.

**Syncing:** this branch merges `main` (no rebase). 54ebfa2 and 7f52f19
merge #1729, #1732–#1734, #1736, #1738 and #1739.

Verification:
- `tsc`, `pnpm run lint` (oxlint, format, ast-grep) and
`scripts/cli-lint-rules.test.mjs` (70/70) pass.
- vitest on `test/lib`, `test/grammar` and the dashboard, issue, event,
trace, span, log, replay and feedback command tests: 10,158 passed. The
6 failures are the sandbox-only permission tests (completions
EACCES/EPERM, code-scanner, file-changes, telemetry readonly, upgrade
lock), which also fail on `main`. The full CLI suite shows the same 6,
plus e2e/eval files that need a build or credentials.
- New tests:
- The bare-org fallback: the org from cache and from the API (no
fallback), org missing then fallback hit with the warning, a 403, org
missing then fallback miss with the combined error, a 5xx (propagates,
no fallback), `my-org/ My Dashboard`, and a numeric ref.
  - Issue search with 0, 1 and several hits.
  - Search hints, including `validate: false` deferral.
  - #1738's event view order.
- Differential runs against this PR's previous parsers:
- Issue arguments: 8,575 cases. 1,388 former errors are now searches
(text with spaces), with 0 other differences.
- Dashboard view: 32,552 cases over 0–3 arguments. Every difference is
in one of the categories above: a numeric ID first (2,600), arguments
after a dashboard URL that name another target (688), a numeric ID after
a target with more words (475), and a lone non-Sentry URL (1).

<!-- junior-request-attribution:start -->
via **BYK**.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3A5a5e7e4543124325a44d731c08dd6089 -->

--

[View Junior Session in
Sentry](https://sentry.sentry.io/explore/conversations/agent%3A5a5e7e4543124325a44d731c08dd6089/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <burak.kaya@sentry.io>

This branch was successfully deployed

1 active deployment
Preview — 85df504b Deployed Oct 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: low PR risk score: low

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant