This is page 14 of 15. Use http://codebase.md/getsentry/sentry-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .claude
│ ├── agents
│ │ └── claude-optimizer.md
│ ├── commands
│ │ ├── gh-pr.md
│ │ └── gh-review.md
│ └── settings.json
├── .craft.yml
├── .cursor
│ └── mcp.json
├── .env.example
├── .github
│ └── workflows
│ ├── deploy.yml
│ ├── eval.yml
│ ├── merge-jobs.yml
│ ├── release.yml
│ ├── smoke-tests.yml
│ └── test.yml
├── .gitignore
├── .mcp.json
├── .vscode
│ ├── extensions.json
│ ├── mcp.json
│ └── settings.json
├── AGENTS.md
├── bin
│ └── bump-version.sh
├── biome.json
├── CLAUDE.md
├── codecov.yml
├── core
├── docs
│ ├── adding-tools.mdc
│ ├── api-patterns.mdc
│ ├── architecture.mdc
│ ├── cloudflare
│ │ ├── architecture.md
│ │ ├── deployment.md
│ │ ├── oauth-architecture.md
│ │ └── overview.md
│ ├── coding-guidelines.mdc
│ ├── common-patterns.mdc
│ ├── cursor.mdc
│ ├── deployment.mdc
│ ├── error-handling.mdc
│ ├── github-actions.mdc
│ ├── llms
│ │ ├── document-scopes.mdc
│ │ ├── documentation-style-guide.mdc
│ │ └── README.md
│ ├── logging.mdc
│ ├── monitoring.mdc
│ ├── permissions-and-scopes.md
│ ├── pr-management.mdc
│ ├── quality-checks.mdc
│ ├── README.md
│ ├── search-events-api-patterns.md
│ ├── security.mdc
│ ├── specs
│ │ ├── README.md
│ │ ├── search-events.md
│ │ └── subpath-constraints.md
│ └── testing.mdc
├── LICENSE.md
├── Makefile
├── package.json
├── packages
│ ├── mcp-cloudflare
│ │ ├── .env.example
│ │ ├── components.json
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── favicon.ico
│ │ │ ├── flow-transparent.png
│ │ │ └── flow.jpg
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── app.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── chat
│ │ │ │ │ │ ├── auth-form.tsx
│ │ │ │ │ │ ├── chat-input.tsx
│ │ │ │ │ │ ├── chat-message.tsx
│ │ │ │ │ │ ├── chat-messages.tsx
│ │ │ │ │ │ ├── chat-ui.tsx
│ │ │ │ │ │ ├── chat.tsx
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── tool-invocation.tsx
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── fragments
│ │ │ │ │ │ ├── remote-setup.tsx
│ │ │ │ │ │ ├── setup-guide.tsx
│ │ │ │ │ │ └── stdio-setup.tsx
│ │ │ │ │ └── ui
│ │ │ │ │ ├── accordion.tsx
│ │ │ │ │ ├── backdrop.tsx
│ │ │ │ │ ├── base.tsx
│ │ │ │ │ ├── button.tsx
│ │ │ │ │ ├── code-snippet.tsx
│ │ │ │ │ ├── header.tsx
│ │ │ │ │ ├── icon.tsx
│ │ │ │ │ ├── icons
│ │ │ │ │ │ └── sentry.tsx
│ │ │ │ │ ├── interactive-markdown.tsx
│ │ │ │ │ ├── json-schema-params.tsx
│ │ │ │ │ ├── markdown.tsx
│ │ │ │ │ ├── note.tsx
│ │ │ │ │ ├── prose.tsx
│ │ │ │ │ ├── section.tsx
│ │ │ │ │ ├── slash-command-actions.tsx
│ │ │ │ │ ├── slash-command-text.tsx
│ │ │ │ │ ├── sliding-panel.tsx
│ │ │ │ │ ├── template-vars.tsx
│ │ │ │ │ ├── tool-actions.tsx
│ │ │ │ │ └── typewriter.tsx
│ │ │ │ ├── contexts
│ │ │ │ │ └── auth-context.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ ├── use-mcp-metadata.ts
│ │ │ │ │ ├── use-persisted-chat.ts
│ │ │ │ │ ├── use-scroll-lock.ts
│ │ │ │ │ └── use-streaming-simulation.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── instrument.ts
│ │ │ │ ├── lib
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── main.tsx
│ │ │ │ ├── pages
│ │ │ │ │ └── home.tsx
│ │ │ │ ├── utils
│ │ │ │ │ ├── chat-error-handler.ts
│ │ │ │ │ └── index.ts
│ │ │ │ └── vite-env.d.ts
│ │ │ ├── constants.ts
│ │ │ ├── server
│ │ │ │ ├── app.test.ts
│ │ │ │ ├── app.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── lib
│ │ │ │ │ ├── approval-dialog.test.ts
│ │ │ │ │ ├── approval-dialog.ts
│ │ │ │ │ ├── constraint-utils.test.ts
│ │ │ │ │ ├── constraint-utils.ts
│ │ │ │ │ ├── html-utils.ts
│ │ │ │ │ ├── mcp-handler.test.ts
│ │ │ │ │ ├── mcp-handler.ts
│ │ │ │ │ └── slug-validation.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── oauth
│ │ │ │ │ ├── authorize.test.ts
│ │ │ │ │ ├── callback.test.ts
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── helpers.test.ts
│ │ │ │ │ ├── helpers.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── authorize.ts
│ │ │ │ │ │ ├── callback.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ └── state.ts
│ │ │ │ ├── routes
│ │ │ │ │ ├── chat-oauth.ts
│ │ │ │ │ ├── chat.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── metadata.ts
│ │ │ │ │ ├── search.test.ts
│ │ │ │ │ └── search.ts
│ │ │ │ ├── sentry.config.ts
│ │ │ │ ├── types
│ │ │ │ │ └── chat.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils
│ │ │ │ └── auth-errors.ts
│ │ │ └── test-setup.ts
│ │ ├── tsconfig.client.json
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ ├── tsconfig.server.json
│ │ ├── vite.config.ts
│ │ ├── vitest.config.ts
│ │ ├── worker-configuration.d.ts
│ │ ├── wrangler.canary.jsonc
│ │ └── wrangler.jsonc
│ ├── mcp-server
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── scripts
│ │ │ ├── generate-definitions.ts
│ │ │ └── generate-otel-namespaces.ts
│ │ ├── src
│ │ │ ├── api-client
│ │ │ │ ├── client.test.ts
│ │ │ │ ├── client.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── types.ts
│ │ │ ├── cli
│ │ │ │ ├── parse.test.ts
│ │ │ │ ├── parse.ts
│ │ │ │ ├── resolve.test.ts
│ │ │ │ ├── resolve.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── usage.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── index.ts
│ │ │ ├── internal
│ │ │ │ ├── agents
│ │ │ │ │ ├── callEmbeddedAgent.ts
│ │ │ │ │ ├── openai-provider.ts
│ │ │ │ │ └── tools
│ │ │ │ │ ├── data
│ │ │ │ │ │ ├── __namespaces.json
│ │ │ │ │ │ ├── android.json
│ │ │ │ │ │ ├── app.json
│ │ │ │ │ │ ├── artifact.json
│ │ │ │ │ │ ├── aspnetcore.json
│ │ │ │ │ │ ├── aws.json
│ │ │ │ │ │ ├── azure.json
│ │ │ │ │ │ ├── browser.json
│ │ │ │ │ │ ├── cassandra.json
│ │ │ │ │ │ ├── cicd.json
│ │ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ │ ├── client.json
│ │ │ │ │ │ ├── cloud.json
│ │ │ │ │ │ ├── cloudevents.json
│ │ │ │ │ │ ├── cloudfoundry.json
│ │ │ │ │ │ ├── code.json
│ │ │ │ │ │ ├── container.json
│ │ │ │ │ │ ├── cpu.json
│ │ │ │ │ │ ├── cpython.json
│ │ │ │ │ │ ├── database.json
│ │ │ │ │ │ ├── db.json
│ │ │ │ │ │ ├── deployment.json
│ │ │ │ │ │ ├── destination.json
│ │ │ │ │ │ ├── device.json
│ │ │ │ │ │ ├── disk.json
│ │ │ │ │ │ ├── dns.json
│ │ │ │ │ │ ├── dotnet.json
│ │ │ │ │ │ ├── elasticsearch.json
│ │ │ │ │ │ ├── enduser.json
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ ├── faas.json
│ │ │ │ │ │ ├── feature_flags.json
│ │ │ │ │ │ ├── file.json
│ │ │ │ │ │ ├── gcp.json
│ │ │ │ │ │ ├── gen_ai.json
│ │ │ │ │ │ ├── geo.json
│ │ │ │ │ │ ├── go.json
│ │ │ │ │ │ ├── graphql.json
│ │ │ │ │ │ ├── hardware.json
│ │ │ │ │ │ ├── heroku.json
│ │ │ │ │ │ ├── host.json
│ │ │ │ │ │ ├── http.json
│ │ │ │ │ │ ├── ios.json
│ │ │ │ │ │ ├── jvm.json
│ │ │ │ │ │ ├── k8s.json
│ │ │ │ │ │ ├── linux.json
│ │ │ │ │ │ ├── log.json
│ │ │ │ │ │ ├── mcp.json
│ │ │ │ │ │ ├── messaging.json
│ │ │ │ │ │ ├── network.json
│ │ │ │ │ │ ├── nodejs.json
│ │ │ │ │ │ ├── oci.json
│ │ │ │ │ │ ├── opentracing.json
│ │ │ │ │ │ ├── os.json
│ │ │ │ │ │ ├── otel.json
│ │ │ │ │ │ ├── peer.json
│ │ │ │ │ │ ├── process.json
│ │ │ │ │ │ ├── profile.json
│ │ │ │ │ │ ├── rpc.json
│ │ │ │ │ │ ├── server.json
│ │ │ │ │ │ ├── service.json
│ │ │ │ │ │ ├── session.json
│ │ │ │ │ │ ├── signalr.json
│ │ │ │ │ │ ├── source.json
│ │ │ │ │ │ ├── system.json
│ │ │ │ │ │ ├── telemetry.json
│ │ │ │ │ │ ├── test.json
│ │ │ │ │ │ ├── thread.json
│ │ │ │ │ │ ├── tls.json
│ │ │ │ │ │ ├── url.json
│ │ │ │ │ │ ├── user.json
│ │ │ │ │ │ ├── v8js.json
│ │ │ │ │ │ ├── vcs.json
│ │ │ │ │ │ ├── webengine.json
│ │ │ │ │ │ └── zos.json
│ │ │ │ │ ├── dataset-fields.test.ts
│ │ │ │ │ ├── dataset-fields.ts
│ │ │ │ │ ├── otel-semantics.test.ts
│ │ │ │ │ ├── otel-semantics.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ ├── whoami.test.ts
│ │ │ │ │ └── whoami.ts
│ │ │ │ ├── constraint-helpers.test.ts
│ │ │ │ ├── constraint-helpers.ts
│ │ │ │ ├── context-storage.ts
│ │ │ │ ├── error-handling.ts
│ │ │ │ ├── fetch-utils.test.ts
│ │ │ │ ├── fetch-utils.ts
│ │ │ │ ├── formatting.test.ts
│ │ │ │ ├── formatting.ts
│ │ │ │ ├── issue-helpers.test.ts
│ │ │ │ ├── issue-helpers.ts
│ │ │ │ ├── test-fixtures.ts
│ │ │ │ └── tool-helpers
│ │ │ │ ├── api.test.ts
│ │ │ │ ├── api.ts
│ │ │ │ ├── define.ts
│ │ │ │ ├── enhance-error.ts
│ │ │ │ ├── formatting.ts
│ │ │ │ ├── issue.ts
│ │ │ │ ├── seer.test.ts
│ │ │ │ ├── seer.ts
│ │ │ │ ├── validate-region-url.test.ts
│ │ │ │ └── validate-region-url.ts
│ │ │ ├── permissions.parseScopes.test.ts
│ │ │ ├── permissions.ts
│ │ │ ├── schema.ts
│ │ │ ├── server-context.test.ts
│ │ │ ├── server.ts
│ │ │ ├── telem
│ │ │ │ ├── index.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── sentry.test.ts
│ │ │ │ └── sentry.ts
│ │ │ ├── test-setup.ts
│ │ │ ├── test-utils
│ │ │ │ └── context.ts
│ │ │ ├── toolDefinitions.ts
│ │ │ ├── tools
│ │ │ │ ├── analyze-issue-with-seer.test.ts
│ │ │ │ ├── analyze-issue-with-seer.ts
│ │ │ │ ├── create-dsn.test.ts
│ │ │ │ ├── create-dsn.ts
│ │ │ │ ├── create-project.test.ts
│ │ │ │ ├── create-project.ts
│ │ │ │ ├── create-team.test.ts
│ │ │ │ ├── create-team.ts
│ │ │ │ ├── find-dsns.test.ts
│ │ │ │ ├── find-dsns.ts
│ │ │ │ ├── find-organizations.test.ts
│ │ │ │ ├── find-organizations.ts
│ │ │ │ ├── find-projects.test.ts
│ │ │ │ ├── find-projects.ts
│ │ │ │ ├── find-releases.test.ts
│ │ │ │ ├── find-releases.ts
│ │ │ │ ├── find-teams.test.ts
│ │ │ │ ├── find-teams.ts
│ │ │ │ ├── get-doc.test.ts
│ │ │ │ ├── get-doc.ts
│ │ │ │ ├── get-event-attachment.test.ts
│ │ │ │ ├── get-event-attachment.ts
│ │ │ │ ├── get-issue-details.test.ts
│ │ │ │ ├── get-issue-details.ts
│ │ │ │ ├── get-trace-details.test.ts
│ │ │ │ ├── get-trace-details.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── search-docs.test.ts
│ │ │ │ ├── search-docs.ts
│ │ │ │ ├── search-events
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── utils.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── search-events.test.ts
│ │ │ │ ├── search-issues
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── README.md
│ │ │ │ ├── tools.test.ts
│ │ │ │ ├── types.ts
│ │ │ │ ├── update-issue.test.ts
│ │ │ │ ├── update-issue.ts
│ │ │ │ ├── update-project.test.ts
│ │ │ │ ├── update-project.ts
│ │ │ │ ├── whoami.test.ts
│ │ │ │ └── whoami.ts
│ │ │ ├── transports
│ │ │ │ └── stdio.ts
│ │ │ ├── types.ts
│ │ │ ├── utils
│ │ │ │ ├── slug-validation.test.ts
│ │ │ │ ├── slug-validation.ts
│ │ │ │ ├── url-utils.test.ts
│ │ │ │ └── url-utils.ts
│ │ │ └── version.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ ├── mcp-server-evals
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── bin
│ │ │ │ └── start-mock-stdio.ts
│ │ │ ├── evals
│ │ │ │ ├── autofix.eval.ts
│ │ │ │ ├── create-dsn.eval.ts
│ │ │ │ ├── create-project.eval.ts
│ │ │ │ ├── create-team.eval.ts
│ │ │ │ ├── get-issue.eval.ts
│ │ │ │ ├── get-trace-details.eval.ts
│ │ │ │ ├── list-dsns.eval.ts
│ │ │ │ ├── list-issues.eval.ts
│ │ │ │ ├── list-organizations.eval.ts
│ │ │ │ ├── list-projects.eval.ts
│ │ │ │ ├── list-releases.eval.ts
│ │ │ │ ├── list-tags.eval.ts
│ │ │ │ ├── list-teams.eval.ts
│ │ │ │ ├── search-docs.eval.ts
│ │ │ │ ├── search-events-agent.eval.ts
│ │ │ │ ├── search-events.eval.ts
│ │ │ │ ├── search-issues-agent.eval.ts
│ │ │ │ ├── search-issues.eval.ts
│ │ │ │ ├── update-issue.eval.ts
│ │ │ │ ├── update-project.eval.ts
│ │ │ │ └── utils
│ │ │ │ ├── fixtures.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── runner.ts
│ │ │ │ ├── structuredOutputScorer.ts
│ │ │ │ └── toolPredictionScorer.ts
│ │ │ └── setup-env.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── mcp-server-mocks
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── fixtures
│ │ │ │ ├── autofix-state.json
│ │ │ │ ├── event-attachments.json
│ │ │ │ ├── event.json
│ │ │ │ ├── issue.json
│ │ │ │ ├── performance-event.json
│ │ │ │ ├── project.json
│ │ │ │ ├── tags.json
│ │ │ │ ├── team.json
│ │ │ │ ├── trace-event.json
│ │ │ │ ├── trace-items-attributes-logs-number.json
│ │ │ │ ├── trace-items-attributes-logs-string.json
│ │ │ │ ├── trace-items-attributes-spans-number.json
│ │ │ │ ├── trace-items-attributes-spans-string.json
│ │ │ │ ├── trace-items-attributes.json
│ │ │ │ ├── trace-meta-with-nulls.json
│ │ │ │ ├── trace-meta.json
│ │ │ │ ├── trace-mixed.json
│ │ │ │ └── trace.json
│ │ │ ├── index.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── mcp-server-tsconfig
│ │ ├── package.json
│ │ ├── tsconfig.base.json
│ │ └── tsconfig.vite.json
│ ├── mcp-test-client
│ │ ├── .env.test
│ │ ├── .gitignore
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── agent.ts
│ │ │ ├── auth
│ │ │ │ ├── config.ts
│ │ │ │ └── oauth.ts
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.test.ts
│ │ │ ├── logger.ts
│ │ │ ├── mcp-test-client-remote.ts
│ │ │ ├── mcp-test-client.ts
│ │ │ ├── types.ts
│ │ │ └── version.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ └── smoke-tests
│ ├── package.json
│ ├── src
│ │ └── smoke.test.ts
│ └── vitest.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── scripts
│ └── check-doc-links.mjs
├── turbo.json
└── vitest.workspace.ts
```
# Files
--------------------------------------------------------------------------------
/packages/mcp-server/src/api-client/client.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | getIssueUrl as getIssueUrlUtil,
3 | getTraceUrl as getTraceUrlUtil,
4 | isSentryHost,
5 | } from "../utils/url-utils";
6 | import { logWarn } from "../telem/logging";
7 | import {
8 | OrganizationListSchema,
9 | OrganizationSchema,
10 | ClientKeySchema,
11 | TeamListSchema,
12 | TeamSchema,
13 | ProjectListSchema,
14 | ProjectSchema,
15 | ReleaseListSchema,
16 | IssueListSchema,
17 | IssueSchema,
18 | EventSchema,
19 | EventAttachmentListSchema,
20 | ErrorsSearchResponseSchema,
21 | SpansSearchResponseSchema,
22 | TagListSchema,
23 | ApiErrorSchema,
24 | ClientKeyListSchema,
25 | AutofixRunSchema,
26 | AutofixRunStateSchema,
27 | TraceMetaSchema,
28 | TraceSchema,
29 | UserSchema,
30 | UserRegionsSchema,
31 | } from "./schema";
32 | import { ConfigurationError } from "../errors";
33 | import { createApiError, ApiNotFoundError, ApiValidationError } from "./errors";
34 | import type {
35 | AutofixRun,
36 | AutofixRunState,
37 | ClientKey,
38 | ClientKeyList,
39 | Event,
40 | EventAttachment,
41 | EventAttachmentList,
42 | Issue,
43 | IssueList,
44 | OrganizationList,
45 | Project,
46 | ProjectList,
47 | ReleaseList,
48 | TagList,
49 | Team,
50 | TeamList,
51 | Trace,
52 | TraceMeta,
53 | User,
54 | } from "./types";
55 | // TODO: this is shared - so ideally, for safety, it uses @sentry/core, but currently
56 | // logger isnt exposed (or rather, it is, but its not the right logger)
57 | // import { logger } from "@sentry/node";
58 |
59 | /**
60 | * Mapping of common network error codes to user-friendly messages.
61 | * These help users understand and resolve connection issues.
62 | */
63 | const NETWORK_ERROR_MESSAGES: Record<string, string> = {
64 | EAI_AGAIN: "DNS temporarily unavailable. Check your internet connection.",
65 | ENOTFOUND: "Hostname not found. Verify the URL is correct.",
66 | ECONNREFUSED: "Connection refused. Ensure the service is accessible.",
67 | ETIMEDOUT: "Connection timed out. Check network connectivity.",
68 | ECONNRESET: "Connection reset. Try again in a moment.",
69 | };
70 |
71 | /**
72 | * Custom error class for Sentry API responses.
73 | *
74 | * Provides enhanced error messages for LLM consumption and handles
75 | * common API error scenarios with user-friendly messaging.
76 | *
77 | * @example
78 | * ```typescript
79 | * try {
80 | * await apiService.listIssues({ organizationSlug: "invalid" });
81 | * } catch (error) {
82 | * if (error instanceof ApiError) {
83 | * console.log(`API Error ${error.status}: ${error.message}`);
84 | * }
85 | * }
86 | * ```
87 | */
88 |
89 | type RequestOptions = {
90 | host?: string;
91 | };
92 |
93 | /**
94 | * Sentry API client service for interacting with Sentry's REST API.
95 | *
96 | * This service provides a comprehensive interface to Sentry's API endpoints,
97 | * handling authentication, error processing, multi-region support, and
98 | * response validation through Zod schemas.
99 | *
100 | * Key Features:
101 | * - Multi-region support for Sentry SaaS and self-hosted instances
102 | * - Automatic schema validation with Zod
103 | * - Enhanced error handling with LLM-friendly messages
104 | * - URL generation for Sentry resources (issues, traces)
105 | * - Bearer token authentication
106 | * - Always uses HTTPS for secure connections
107 | *
108 | * @example Basic Usage
109 | * ```typescript
110 | * const apiService = new SentryApiService({
111 | * accessToken: "your-token",
112 | * host: "sentry.io"
113 | * });
114 | *
115 | * const orgs = await apiService.listOrganizations();
116 | * const issues = await apiService.listIssues({
117 | * organizationSlug: "my-org",
118 | * query: "is:unresolved"
119 | * });
120 | * ```
121 | *
122 | * @example Multi-Region Support
123 | * ```typescript
124 | * // Self-hosted instance with hostname
125 | * const selfHosted = new SentryApiService({
126 | * accessToken: "token",
127 | * host: "sentry.company.com"
128 | * });
129 | *
130 | * // Regional endpoint override
131 | * const issues = await apiService.listIssues(
132 | * { organizationSlug: "org" },
133 | * { host: "eu.sentry.io" }
134 | * );
135 | * ```
136 | */
137 | export class SentryApiService {
138 | private accessToken: string | null;
139 | protected host: string;
140 | protected apiPrefix: string;
141 |
142 | /**
143 | * Creates a new Sentry API service instance.
144 | *
145 | * Always uses HTTPS for secure connections.
146 | *
147 | * @param config Configuration object
148 | * @param config.accessToken OAuth access token for authentication (optional for some endpoints)
149 | * @param config.host Sentry hostname (e.g. "sentry.io", "sentry.example.com")
150 | */
151 | constructor({
152 | accessToken = null,
153 | host = "sentry.io",
154 | }: {
155 | accessToken?: string | null;
156 | host?: string;
157 | }) {
158 | this.accessToken = accessToken;
159 | this.host = host;
160 | this.apiPrefix = `https://${host}/api/0`;
161 | }
162 |
163 | /**
164 | * Updates the host for API requests.
165 | *
166 | * Used for multi-region support or switching between Sentry instances.
167 | * Always uses HTTPS protocol.
168 | *
169 | * @param host New hostname to use for API requests
170 | */
171 | setHost(host: string) {
172 | this.host = host;
173 | this.apiPrefix = `https://${this.host}/api/0`;
174 | }
175 |
176 | /**
177 | * Checks if the current host is Sentry SaaS (sentry.io).
178 | *
179 | * Used to determine API endpoint availability and URL formats.
180 | * Self-hosted instances may not have all endpoints available.
181 | *
182 | * @returns True if using Sentry SaaS, false for self-hosted instances
183 | */
184 | private isSaas(): boolean {
185 | return isSentryHost(this.host);
186 | }
187 |
188 | /**
189 | * Internal method for making authenticated requests to Sentry API.
190 | *
191 | * Handles:
192 | * - Bearer token authentication
193 | * - Error response parsing and enhancement
194 | * - Multi-region host overrides
195 | * - Fetch availability validation
196 | *
197 | * @param path API endpoint path (without /api/0 prefix)
198 | * @param options Fetch options
199 | * @param requestOptions Additional request configuration
200 | * @returns Promise resolving to Response object
201 | * @throws {ApiError} Enhanced API errors with user-friendly messages
202 | * @throws {Error} Network or parsing errors
203 | */
204 | private async request(
205 | path: string,
206 | options: RequestInit = {},
207 | { host }: { host?: string } = {},
208 | ): Promise<Response> {
209 | const url = host
210 | ? `https://${host}/api/0${path}`
211 | : `${this.apiPrefix}${path}`;
212 |
213 | const headers: Record<string, string> = {
214 | "Content-Type": "application/json",
215 | "User-Agent": "Sentry MCP Server",
216 | };
217 | if (this.accessToken) {
218 | headers.Authorization = `Bearer ${this.accessToken}`;
219 | }
220 |
221 | // Check if fetch is available, otherwise provide a helpful error message
222 | if (typeof globalThis.fetch === "undefined") {
223 | throw new ConfigurationError(
224 | "fetch is not available. Please use Node.js >= 18 or ensure fetch is available in your environment.",
225 | );
226 | }
227 |
228 | // logger.info(logger.fmt`[sentryApi] ${options.method || "GET"} ${url}`);
229 | let response: Response;
230 | try {
231 | response = await fetch(url, {
232 | ...options,
233 | headers,
234 | });
235 | } catch (error) {
236 | // Extract the root cause from the error chain
237 | let rootCause = error;
238 | while (rootCause instanceof Error && rootCause.cause) {
239 | rootCause = rootCause.cause;
240 | }
241 |
242 | const errorMessage =
243 | rootCause instanceof Error ? rootCause.message : String(rootCause);
244 |
245 | let friendlyMessage = `Unable to connect to ${url}`;
246 |
247 | // Check if we have a specific message for this error
248 | const errorCode = Object.keys(NETWORK_ERROR_MESSAGES).find((code) =>
249 | errorMessage.includes(code),
250 | );
251 |
252 | if (errorCode) {
253 | friendlyMessage += ` - ${NETWORK_ERROR_MESSAGES[errorCode]}`;
254 | } else {
255 | friendlyMessage += ` - ${errorMessage}`;
256 | }
257 |
258 | // DNS resolution failures and connection timeouts to custom hosts are configuration issues
259 | if (
260 | errorCode === "ENOTFOUND" ||
261 | errorCode === "EAI_AGAIN" ||
262 | errorCode === "ECONNREFUSED" ||
263 | errorCode === "ETIMEDOUT" ||
264 | errorMessage.includes("Connect Timeout Error")
265 | ) {
266 | throw new ConfigurationError(friendlyMessage, { cause: error });
267 | }
268 |
269 | throw new Error(friendlyMessage, { cause: error });
270 | }
271 |
272 | // Handle error responses generically
273 | if (!response.ok) {
274 | const errorText = await response.text();
275 | let parsed: unknown | undefined;
276 | try {
277 | parsed = JSON.parse(errorText);
278 | } catch (error) {
279 | // If we can't parse JSON, check if it's HTML (server error)
280 | if (errorText.includes("<!DOCTYPE") || errorText.includes("<html")) {
281 | logWarn("Received HTML error page instead of JSON", {
282 | loggerScope: ["api", "client"],
283 | extra: {
284 | status: response.status,
285 | statusText: response.statusText,
286 | host: this.host,
287 | path,
288 | parseErrorMessage:
289 | error instanceof Error ? error.message : String(error),
290 | },
291 | });
292 | // HTML response instead of JSON typically indicates a server configuration issue
293 | throw createApiError(
294 | `Server error: Received HTML instead of JSON (${response.status} ${response.statusText}). This may indicate an invalid URL or server issue.`,
295 | response.status,
296 | errorText,
297 | undefined,
298 | );
299 | }
300 | logWarn("Failed to parse JSON error response", {
301 | loggerScope: ["api", "client"],
302 | extra: {
303 | status: response.status,
304 | statusText: response.statusText,
305 | host: this.host,
306 | path,
307 | bodyPreview:
308 | errorText.length > 256
309 | ? `${errorText.slice(0, 253)}…`
310 | : errorText,
311 | parseErrorMessage:
312 | error instanceof Error ? error.message : String(error),
313 | },
314 | });
315 | }
316 |
317 | if (parsed) {
318 | const { data, success, error } = ApiErrorSchema.safeParse(parsed);
319 |
320 | if (success) {
321 | // Use the new error factory to create the appropriate error type
322 | throw createApiError(
323 | data.detail,
324 | response.status,
325 | data.detail,
326 | parsed,
327 | );
328 | }
329 |
330 | logWarn("Failed to parse validated API error response", {
331 | loggerScope: ["api", "client"],
332 | extra: {
333 | status: response.status,
334 | statusText: response.statusText,
335 | host: this.host,
336 | path,
337 | bodyPreview:
338 | errorText.length > 256
339 | ? `${errorText.slice(0, 253)}…`
340 | : errorText,
341 | validationErrorMessage:
342 | error instanceof Error ? error.message : String(error),
343 | },
344 | });
345 | }
346 |
347 | // Use the error factory to create the appropriate error type based on status
348 | throw createApiError(
349 | `API request failed: ${response.statusText}\n${errorText}`,
350 | response.status,
351 | errorText,
352 | undefined,
353 | );
354 | }
355 |
356 | return response;
357 | }
358 |
359 | /**
360 | * Safely parses a JSON response, checking Content-Type header first.
361 | *
362 | * @param response The Response object from fetch
363 | * @returns Promise resolving to the parsed JSON object
364 | * @throws {Error} If response is not JSON or parsing fails
365 | */
366 | private async parseJsonResponse(response: Response): Promise<unknown> {
367 | // Handle case where response might not have all properties (e.g., in tests or promise chains)
368 | if (!response.headers?.get) {
369 | return response.json();
370 | }
371 |
372 | const contentType = response.headers.get("content-type");
373 |
374 | // Check if the response is JSON
375 | if (!contentType || !contentType.includes("application/json")) {
376 | const responseText = await response.text();
377 |
378 | // Check if it's HTML
379 | if (
380 | contentType?.includes("text/html") ||
381 | responseText.includes("<!DOCTYPE") ||
382 | responseText.includes("<html")
383 | ) {
384 | // HTML when expecting JSON usually indicates authentication or routing issues
385 | throw new Error(
386 | `Expected JSON response but received HTML (${response.status} ${response.statusText}). This may indicate you're not authenticated, the URL is incorrect, or there's a server issue.`,
387 | );
388 | }
389 |
390 | // Generic non-JSON error
391 | throw new Error(
392 | `Expected JSON response but received ${contentType || "unknown content type"} ` +
393 | `(${response.status} ${response.statusText})`,
394 | );
395 | }
396 |
397 | try {
398 | return await response.json();
399 | } catch (error) {
400 | // JSON parsing failure after successful response
401 | throw new Error(
402 | `Failed to parse JSON response: ${error instanceof Error ? error.message : String(error)}`,
403 | );
404 | }
405 | }
406 |
407 | /**
408 | * Makes a request to the Sentry API and parses the JSON response.
409 | *
410 | * This is the primary method for API calls that expect JSON responses.
411 | * It automatically validates Content-Type and provides helpful error messages
412 | * for common issues like authentication failures or server errors.
413 | *
414 | * @param path API endpoint path (without /api/0 prefix)
415 | * @param options Fetch options
416 | * @param requestOptions Additional request configuration
417 | * @returns Promise resolving to the parsed JSON response
418 | * @throws {ApiError} Enhanced API errors with user-friendly messages
419 | * @throws {Error} Network, parsing, or validation errors
420 | */
421 | private async requestJSON(
422 | path: string,
423 | options: RequestInit = {},
424 | requestOptions?: { host?: string },
425 | ): Promise<unknown> {
426 | const response = await this.request(path, options, requestOptions);
427 | return this.parseJsonResponse(response);
428 | }
429 |
430 | /**
431 | * Generates a Sentry issue URL for browser navigation.
432 | *
433 | * Handles both SaaS (subdomain-based) and self-hosted URL formats.
434 | * Always uses HTTPS protocol.
435 | *
436 | * @param organizationSlug Organization identifier
437 | * @param issueId Issue identifier (short ID or numeric ID)
438 | * @returns Full URL to the issue in Sentry UI
439 | *
440 | * @example
441 | * ```typescript
442 | * // SaaS: https://my-org.sentry.io/issues/PROJ-123
443 | * apiService.getIssueUrl("my-org", "PROJ-123")
444 | *
445 | * // Self-hosted: https://sentry.company.com/organizations/my-org/issues/PROJ-123
446 | * apiService.getIssueUrl("my-org", "PROJ-123")
447 | * ```
448 | */
449 | getIssueUrl(organizationSlug: string, issueId: string): string {
450 | return getIssueUrlUtil(this.host, organizationSlug, issueId);
451 | }
452 |
453 | /**
454 | * Generates a Sentry trace URL for performance investigation.
455 | *
456 | * Always uses HTTPS protocol.
457 | *
458 | * @param organizationSlug Organization identifier
459 | * @param traceId Trace identifier (hex string)
460 | * @returns Full HTTPS URL to the trace in Sentry UI
461 | *
462 | * @example
463 | * ```typescript
464 | * const traceUrl = apiService.getTraceUrl("my-org", "6a477f5b0f31ef7b6b9b5e1dea66c91d");
465 | * // https://my-org.sentry.io/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d
466 | * ```
467 | */
468 | getTraceUrl(organizationSlug: string, traceId: string): string {
469 | return getTraceUrlUtil(this.host, organizationSlug, traceId);
470 | }
471 |
472 | // ================================================================================
473 | // URL BUILDERS FOR DIFFERENT SENTRY APIS
474 | // ================================================================================
475 |
476 | /**
477 | * Builds a URL for the legacy Discover API (used by errors dataset).
478 | *
479 | * The Discover API is the older query interface that includes aggregate
480 | * functions directly in the field list.
481 | *
482 | * @example
483 | * // URL format: /explore/discover/homepage/?field=title&field=count_unique(user)
484 | * buildDiscoverUrl("my-org", "level:error", "123", ["title", "count_unique(user)"], "-timestamp")
485 | */
486 | private buildDiscoverUrl(params: {
487 | organizationSlug: string;
488 | query: string;
489 | projectId?: string;
490 | fields?: string[];
491 | sort?: string;
492 | statsPeriod?: string;
493 | start?: string;
494 | end?: string;
495 | aggregateFunctions?: string[];
496 | groupByFields?: string[];
497 | }): string {
498 | const {
499 | organizationSlug,
500 | query,
501 | projectId,
502 | fields,
503 | sort,
504 | statsPeriod,
505 | start,
506 | end,
507 | aggregateFunctions,
508 | groupByFields,
509 | } = params;
510 |
511 | const urlParams = new URLSearchParams();
512 |
513 | // Discover API specific parameters
514 | urlParams.set("dataset", "errors");
515 | urlParams.set("queryDataset", "error-events");
516 | urlParams.set("query", query);
517 |
518 | if (projectId) {
519 | urlParams.set("project", projectId);
520 | }
521 |
522 | // Discover API includes aggregate functions directly in field list
523 | if (fields && fields.length > 0) {
524 | for (const field of fields) {
525 | urlParams.append("field", field);
526 | }
527 | } else {
528 | // Default fields for Discover
529 | urlParams.append("field", "title");
530 | urlParams.append("field", "project");
531 | urlParams.append("field", "user.display");
532 | urlParams.append("field", "timestamp");
533 | }
534 |
535 | urlParams.set("sort", sort || "-timestamp");
536 |
537 | // Add time parameters - either statsPeriod or start/end
538 | if (start && end) {
539 | urlParams.set("start", start);
540 | urlParams.set("end", end);
541 | } else {
542 | urlParams.set("statsPeriod", statsPeriod || "24h");
543 | }
544 |
545 | // Check if this is an aggregate query
546 | const isAggregate = (aggregateFunctions?.length ?? 0) > 0;
547 | if (isAggregate) {
548 | urlParams.set("mode", "aggregate");
549 | // For aggregate queries in Discover, set yAxis to the first aggregate function
550 | if (aggregateFunctions && aggregateFunctions.length > 0) {
551 | urlParams.set("yAxis", aggregateFunctions[0]);
552 | }
553 | } else {
554 | urlParams.set("yAxis", "count()");
555 | }
556 |
557 | // For SaaS instances, always use sentry.io for web UI URLs regardless of region
558 | // Regional subdomains (e.g., us.sentry.io) are only for API endpoints
559 | const webHost = this.isSaas() ? "sentry.io" : this.host;
560 | const path = this.isSaas()
561 | ? `https://${organizationSlug}.${webHost}/explore/discover/homepage/`
562 | : `https://${this.host}/organizations/${organizationSlug}/explore/discover/homepage/`;
563 |
564 | return `${path}?${urlParams.toString()}`;
565 | }
566 |
567 | /**
568 | * Builds a URL for the modern EAP (Event Analytics Platform) API used by spans/logs.
569 | *
570 | * The EAP API uses structured aggregate queries with separate aggregateField
571 | * parameters containing JSON objects for groupBy and yAxes.
572 | *
573 | * @example
574 | * // URL format: /explore/traces/?aggregateField={"groupBy":"span.op"}&aggregateField={"yAxes":["count()"]}
575 | * buildEapUrl("my-org", "span.op:db", "123", ["span.op", "count()"], "-count()", ["count()"], ["span.op"])
576 | */
577 | private buildEapUrl(params: {
578 | organizationSlug: string;
579 | query: string;
580 | dataset: "spans" | "logs";
581 | projectId?: string;
582 | fields?: string[];
583 | sort?: string;
584 | statsPeriod?: string;
585 | start?: string;
586 | end?: string;
587 | aggregateFunctions?: string[];
588 | groupByFields?: string[];
589 | }): string {
590 | const {
591 | organizationSlug,
592 | query,
593 | dataset,
594 | projectId,
595 | fields,
596 | sort,
597 | statsPeriod,
598 | start,
599 | end,
600 | aggregateFunctions,
601 | groupByFields,
602 | } = params;
603 |
604 | const urlParams = new URLSearchParams();
605 | urlParams.set("query", query);
606 |
607 | if (projectId) {
608 | urlParams.set("project", projectId);
609 | }
610 |
611 | // Determine if this is an aggregate query
612 | const isAggregateQuery =
613 | (aggregateFunctions?.length ?? 0) > 0 ||
614 | fields?.some((field) => field.includes("(") && field.includes(")")) ||
615 | false;
616 |
617 | if (isAggregateQuery) {
618 | // EAP API uses structured aggregate parameters
619 | if (
620 | (aggregateFunctions?.length ?? 0) > 0 ||
621 | (groupByFields?.length ?? 0) > 0
622 | ) {
623 | // Add each groupBy field as a separate aggregateField parameter
624 | if (groupByFields && groupByFields.length > 0) {
625 | for (const field of groupByFields) {
626 | urlParams.append(
627 | "aggregateField",
628 | JSON.stringify({ groupBy: field }),
629 | );
630 | }
631 | }
632 |
633 | // Add aggregate functions (yAxes)
634 | if (aggregateFunctions && aggregateFunctions.length > 0) {
635 | urlParams.append(
636 | "aggregateField",
637 | JSON.stringify({ yAxes: aggregateFunctions }),
638 | );
639 | }
640 | } else {
641 | // Fallback: parse fields to extract aggregate info
642 | const parsedGroupByFields =
643 | fields?.filter(
644 | (field) => !field.includes("(") && !field.includes(")"),
645 | ) || [];
646 | const parsedAggregateFunctions =
647 | fields?.filter(
648 | (field) => field.includes("(") && field.includes(")"),
649 | ) || [];
650 |
651 | for (const field of parsedGroupByFields) {
652 | urlParams.append(
653 | "aggregateField",
654 | JSON.stringify({ groupBy: field }),
655 | );
656 | }
657 |
658 | if (parsedAggregateFunctions.length > 0) {
659 | urlParams.append(
660 | "aggregateField",
661 | JSON.stringify({ yAxes: parsedAggregateFunctions }),
662 | );
663 | }
664 | }
665 |
666 | urlParams.set("mode", "aggregate");
667 | } else {
668 | // Non-aggregate query, add individual fields
669 | if (fields && fields.length > 0) {
670 | for (const field of fields) {
671 | urlParams.append("field", field);
672 | }
673 | }
674 | }
675 |
676 | // Add sort parameter for all queries
677 | if (sort) {
678 | urlParams.set("sort", sort);
679 | }
680 |
681 | // Add time parameters - either statsPeriod or start/end
682 | if (start && end) {
683 | urlParams.set("start", start);
684 | urlParams.set("end", end);
685 | } else {
686 | urlParams.set("statsPeriod", statsPeriod || "24h");
687 | }
688 |
689 | // Add table parameter for spans dataset (required for UI)
690 | if (dataset === "spans") {
691 | urlParams.set("table", "span");
692 | }
693 |
694 | const basePath = dataset === "logs" ? "logs" : "traces";
695 | // For SaaS instances, always use sentry.io for web UI URLs regardless of region
696 | // Regional subdomains (e.g., us.sentry.io) are only for API endpoints
697 | const webHost = this.isSaas() ? "sentry.io" : this.host;
698 | const path = this.isSaas()
699 | ? `https://${organizationSlug}.${webHost}/explore/${basePath}/`
700 | : `https://${this.host}/organizations/${organizationSlug}/explore/${basePath}/`;
701 |
702 | return `${path}?${urlParams.toString()}`;
703 | }
704 |
705 | /**
706 | * Generates a Sentry events explorer URL for viewing search results.
707 | *
708 | * Routes to the appropriate API based on dataset:
709 | * - Errors: Uses legacy Discover API
710 | * - Spans/Logs: Uses modern EAP (Event Analytics Platform) API
711 | *
712 | * @param organizationSlug Organization identifier
713 | * @param query Sentry search query
714 | * @param projectId Optional project filter
715 | * @param dataset Dataset type (spans, errors, or logs)
716 | * @param fields Array of fields to include in results
717 | * @param sort Sort parameter (e.g., "-timestamp", "-count()")
718 | * @param aggregateFunctions Array of aggregate functions (only used for EAP datasets)
719 | * @param groupByFields Array of fields to group by (only used for EAP datasets)
720 | * @param statsPeriod Relative time period (e.g., "24h", "7d")
721 | * @param start Absolute start time (ISO 8601)
722 | * @param end Absolute end time (ISO 8601)
723 | * @returns Full HTTPS URL to the events explorer in Sentry UI
724 | */
725 | getEventsExplorerUrl(
726 | organizationSlug: string,
727 | query: string,
728 | projectId?: string,
729 | dataset: "spans" | "errors" | "logs" = "spans",
730 | fields?: string[],
731 | sort?: string,
732 | aggregateFunctions?: string[],
733 | groupByFields?: string[],
734 | statsPeriod?: string,
735 | start?: string,
736 | end?: string,
737 | ): string {
738 | if (dataset === "errors") {
739 | // Route to legacy Discover API
740 | return this.buildDiscoverUrl({
741 | organizationSlug,
742 | query,
743 | projectId,
744 | fields,
745 | sort,
746 | statsPeriod,
747 | start,
748 | end,
749 | aggregateFunctions,
750 | groupByFields,
751 | });
752 | }
753 |
754 | // Route to modern EAP API (spans and logs)
755 | return this.buildEapUrl({
756 | organizationSlug,
757 | query,
758 | dataset,
759 | projectId,
760 | fields,
761 | sort,
762 | statsPeriod,
763 | start,
764 | end,
765 | aggregateFunctions,
766 | groupByFields,
767 | });
768 | }
769 |
770 | /**
771 | * Retrieves the authenticated user's profile information.
772 | *
773 | * @param opts Request options including host override
774 | * @returns User profile data
775 | * @throws {ApiError} If authentication fails or user not found
776 | */
777 | async getAuthenticatedUser(opts?: RequestOptions): Promise<User> {
778 | // Auth endpoints only exist on the main API server, never on regional endpoints
779 | let authHost: string | undefined;
780 |
781 | if (this.isSaas()) {
782 | // For SaaS, always use the main sentry.io host, not regional hosts
783 | // This handles cases like us.sentry.io, eu.sentry.io, etc.
784 | authHost = "sentry.io";
785 | }
786 | // For self-hosted, use the configured host (authHost remains undefined)
787 |
788 | const body = await this.requestJSON("/auth/", undefined, {
789 | ...opts,
790 | host: authHost,
791 | });
792 | return UserSchema.parse(body);
793 | }
794 |
795 | /**
796 | * Lists all organizations accessible to the authenticated user.
797 | *
798 | * Automatically handles multi-region queries by fetching from all
799 | * available regions and combining results.
800 | *
801 | * @param params Query parameters
802 | * @param params.query Search query to filter organizations by name/slug
803 | * @param opts Request options
804 | * @returns Array of organizations across all accessible regions (limited to 25 results)
805 | *
806 | * @example
807 | * ```typescript
808 | * const orgs = await apiService.listOrganizations();
809 | * orgs.forEach(org => {
810 | * // regionUrl present for Cloud Service, empty for self-hosted
811 | * console.log(`${org.name} (${org.slug}) - ${org.links?.regionUrl || 'No region URL'}`);
812 | * });
813 | * ```
814 | */
815 | async listOrganizations(
816 | params?: { query?: string },
817 | opts?: RequestOptions,
818 | ): Promise<OrganizationList> {
819 | // Build query parameters
820 | const queryParams = new URLSearchParams();
821 | queryParams.set("per_page", "25");
822 | if (params?.query) {
823 | queryParams.set("query", params.query);
824 | }
825 | const queryString = queryParams.toString();
826 | const path = `/organizations/?${queryString}`;
827 |
828 | // For self-hosted instances, the regions endpoint doesn't exist
829 | if (!this.isSaas()) {
830 | const body = await this.requestJSON(path, undefined, opts);
831 | return OrganizationListSchema.parse(body);
832 | }
833 |
834 | // For SaaS, try to use regions endpoint first
835 | try {
836 | // TODO: Sentry is currently not returning all orgs without hitting region endpoints
837 | // The regions endpoint only exists on the main API server, not on regional endpoints
838 | const regionsBody = await this.requestJSON(
839 | "/users/me/regions/",
840 | undefined,
841 | {}, // Don't pass opts to ensure we use the main host
842 | );
843 | const regionData = UserRegionsSchema.parse(regionsBody);
844 |
845 | const allOrganizations = (
846 | await Promise.all(
847 | regionData.regions.map(async (region) =>
848 | this.requestJSON(path, undefined, {
849 | ...opts,
850 | host: new URL(region.url).host,
851 | }),
852 | ),
853 | )
854 | )
855 | .map((data) => OrganizationListSchema.parse(data))
856 | .reduce((acc, curr) => acc.concat(curr), []);
857 |
858 | // Apply the limit after combining results from all regions
859 | return allOrganizations.slice(0, 25);
860 | } catch (error) {
861 | // If regions endpoint fails (e.g., older self-hosted versions identifying as sentry.io),
862 | // fall back to direct organizations endpoint
863 | if (error instanceof ApiNotFoundError) {
864 | // logger.info("Regions endpoint not found, falling back to direct organizations endpoint");
865 | const body = await this.requestJSON(path, undefined, opts);
866 | return OrganizationListSchema.parse(body);
867 | }
868 |
869 | // Re-throw other errors
870 | throw error;
871 | }
872 | }
873 |
874 | /**
875 | * Gets a single organization by slug.
876 | *
877 | * @param organizationSlug Organization identifier
878 | * @param opts Request options including host override
879 | * @returns Organization data
880 | */
881 | async getOrganization(organizationSlug: string, opts?: RequestOptions) {
882 | const body = await this.requestJSON(
883 | `/organizations/${organizationSlug}/`,
884 | undefined,
885 | opts,
886 | );
887 | return OrganizationSchema.parse(body);
888 | }
889 |
890 | /**
891 | * Lists teams within an organization.
892 | *
893 | * @param organizationSlug Organization identifier
894 | * @param params Query parameters
895 | * @param params.query Search query to filter teams by name/slug
896 | * @param opts Request options including host override
897 | * @returns Array of teams in the organization (limited to 25 results)
898 | */
899 | async listTeams(
900 | organizationSlug: string,
901 | params?: { query?: string },
902 | opts?: RequestOptions,
903 | ): Promise<TeamList> {
904 | const queryParams = new URLSearchParams();
905 | queryParams.set("per_page", "25");
906 | if (params?.query) {
907 | queryParams.set("query", params.query);
908 | }
909 | const queryString = queryParams.toString();
910 | const path = `/organizations/${organizationSlug}/teams/?${queryString}`;
911 |
912 | const body = await this.requestJSON(path, undefined, opts);
913 | return TeamListSchema.parse(body);
914 | }
915 |
916 | /**
917 | * Creates a new team within an organization.
918 | *
919 | * @param params Team creation parameters
920 | * @param params.organizationSlug Organization identifier
921 | * @param params.name Team name
922 | * @param opts Request options
923 | * @returns Created team data
924 | * @throws {ApiError} If team creation fails (e.g., name conflicts)
925 | */
926 | async createTeam(
927 | {
928 | organizationSlug,
929 | name,
930 | }: {
931 | organizationSlug: string;
932 | name: string;
933 | },
934 | opts?: RequestOptions,
935 | ): Promise<Team> {
936 | const body = await this.requestJSON(
937 | `/organizations/${organizationSlug}/teams/`,
938 | {
939 | method: "POST",
940 | body: JSON.stringify({ name }),
941 | },
942 | opts,
943 | );
944 | return TeamSchema.parse(body);
945 | }
946 |
947 | /**
948 | * Lists projects within an organization.
949 | *
950 | * @param organizationSlug Organization identifier
951 | * @param params Query parameters
952 | * @param params.query Search query to filter projects by name/slug
953 | * @param opts Request options
954 | * @returns Array of projects in the organization (limited to 25 results)
955 | */
956 | async listProjects(
957 | organizationSlug: string,
958 | params?: { query?: string },
959 | opts?: RequestOptions,
960 | ): Promise<ProjectList> {
961 | const queryParams = new URLSearchParams();
962 | queryParams.set("per_page", "25");
963 | if (params?.query) {
964 | queryParams.set("query", params.query);
965 | }
966 | const queryString = queryParams.toString();
967 | const path = `/organizations/${organizationSlug}/projects/?${queryString}`;
968 |
969 | const body = await this.requestJSON(path, undefined, opts);
970 | return ProjectListSchema.parse(body);
971 | }
972 |
973 | /**
974 | * Gets a single project by slug or ID.
975 | *
976 | * @param params Project fetch parameters
977 | * @param params.organizationSlug Organization identifier
978 | * @param params.projectSlugOrId Project slug or numeric ID
979 | * @param opts Request options
980 | * @returns Project data
981 | */
982 | async getProject(
983 | {
984 | organizationSlug,
985 | projectSlugOrId,
986 | }: {
987 | organizationSlug: string;
988 | projectSlugOrId: string;
989 | },
990 | opts?: RequestOptions,
991 | ): Promise<Project> {
992 | const body = await this.requestJSON(
993 | `/projects/${organizationSlug}/${projectSlugOrId}/`,
994 | undefined,
995 | opts,
996 | );
997 | return ProjectSchema.parse(body);
998 | }
999 |
1000 | /**
1001 | * Creates a new project within a team.
1002 | *
1003 | * @param params Project creation parameters
1004 | * @param params.organizationSlug Organization identifier
1005 | * @param params.teamSlug Team identifier
1006 | * @param params.name Project name
1007 | * @param params.platform Platform identifier (e.g., "javascript", "python")
1008 | * @param opts Request options
1009 | * @returns Created project data
1010 | */
1011 | async createProject(
1012 | {
1013 | organizationSlug,
1014 | teamSlug,
1015 | name,
1016 | platform,
1017 | }: {
1018 | organizationSlug: string;
1019 | teamSlug: string;
1020 | name: string;
1021 | platform?: string;
1022 | },
1023 | opts?: RequestOptions,
1024 | ): Promise<Project> {
1025 | const body = await this.requestJSON(
1026 | `/teams/${organizationSlug}/${teamSlug}/projects/`,
1027 | {
1028 | method: "POST",
1029 | body: JSON.stringify({
1030 | name,
1031 | platform,
1032 | }),
1033 | },
1034 | opts,
1035 | );
1036 | return ProjectSchema.parse(body);
1037 | }
1038 |
1039 | /**
1040 | * Updates an existing project's configuration.
1041 | *
1042 | * @param params Project update parameters
1043 | * @param params.organizationSlug Organization identifier
1044 | * @param params.projectSlug Current project identifier
1045 | * @param params.name New project name (optional)
1046 | * @param params.slug New project slug (optional)
1047 | * @param params.platform New platform identifier (optional)
1048 | * @param opts Request options
1049 | * @returns Updated project data
1050 | */
1051 | async updateProject(
1052 | {
1053 | organizationSlug,
1054 | projectSlug,
1055 | name,
1056 | slug,
1057 | platform,
1058 | }: {
1059 | organizationSlug: string;
1060 | projectSlug: string;
1061 | name?: string;
1062 | slug?: string;
1063 | platform?: string;
1064 | },
1065 | opts?: RequestOptions,
1066 | ): Promise<Project> {
1067 | const updateData: Record<string, any> = {};
1068 | if (name !== undefined) updateData.name = name;
1069 | if (slug !== undefined) updateData.slug = slug;
1070 | if (platform !== undefined) updateData.platform = platform;
1071 |
1072 | const body = await this.requestJSON(
1073 | `/projects/${organizationSlug}/${projectSlug}/`,
1074 | {
1075 | method: "PUT",
1076 | body: JSON.stringify(updateData),
1077 | },
1078 | opts,
1079 | );
1080 | return ProjectSchema.parse(body);
1081 | }
1082 |
1083 | /**
1084 | * Assigns a team to a project.
1085 | *
1086 | * @param params Assignment parameters
1087 | * @param params.organizationSlug Organization identifier
1088 | * @param params.projectSlug Project identifier
1089 | * @param params.teamSlug Team identifier to assign
1090 | * @param opts Request options
1091 | */
1092 | async addTeamToProject(
1093 | {
1094 | organizationSlug,
1095 | projectSlug,
1096 | teamSlug,
1097 | }: {
1098 | organizationSlug: string;
1099 | projectSlug: string;
1100 | teamSlug: string;
1101 | },
1102 | opts?: RequestOptions,
1103 | ): Promise<void> {
1104 | await this.request(
1105 | `/projects/${organizationSlug}/${projectSlug}/teams/${teamSlug}/`,
1106 | {
1107 | method: "POST",
1108 | body: JSON.stringify({}),
1109 | },
1110 | opts,
1111 | );
1112 | }
1113 |
1114 | /**
1115 | * Creates a new client key (DSN) for a project.
1116 | *
1117 | * Client keys are used to identify and authenticate SDK requests to Sentry.
1118 | *
1119 | * @param params Key creation parameters
1120 | * @param params.organizationSlug Organization identifier
1121 | * @param params.projectSlug Project identifier
1122 | * @param params.name Human-readable name for the key (optional)
1123 | * @param opts Request options
1124 | * @returns Created client key with DSN information
1125 | *
1126 | * @example
1127 | * ```typescript
1128 | * const key = await apiService.createClientKey({
1129 | * organizationSlug: "my-org",
1130 | * projectSlug: "my-project",
1131 | * name: "Production"
1132 | * });
1133 | * console.log(`DSN: ${key.dsn.public}`);
1134 | * ```
1135 | */
1136 | async createClientKey(
1137 | {
1138 | organizationSlug,
1139 | projectSlug,
1140 | name,
1141 | }: {
1142 | organizationSlug: string;
1143 | projectSlug: string;
1144 | name?: string;
1145 | },
1146 | opts?: RequestOptions,
1147 | ): Promise<ClientKey> {
1148 | const body = await this.requestJSON(
1149 | `/projects/${organizationSlug}/${projectSlug}/keys/`,
1150 | {
1151 | method: "POST",
1152 | body: JSON.stringify({
1153 | name,
1154 | }),
1155 | },
1156 | opts,
1157 | );
1158 | return ClientKeySchema.parse(body);
1159 | }
1160 |
1161 | /**
1162 | * Lists all client keys (DSNs) for a project.
1163 | *
1164 | * @param params Query parameters
1165 | * @param params.organizationSlug Organization identifier
1166 | * @param params.projectSlug Project identifier
1167 | * @param opts Request options
1168 | * @returns Array of client keys with DSN information
1169 | */
1170 | async listClientKeys(
1171 | {
1172 | organizationSlug,
1173 | projectSlug,
1174 | }: {
1175 | organizationSlug: string;
1176 | projectSlug: string;
1177 | },
1178 | opts?: RequestOptions,
1179 | ): Promise<ClientKeyList> {
1180 | const body = await this.requestJSON(
1181 | `/projects/${organizationSlug}/${projectSlug}/keys/`,
1182 | undefined,
1183 | opts,
1184 | );
1185 | return ClientKeyListSchema.parse(body);
1186 | }
1187 |
1188 | /**
1189 | * Lists releases for an organization or specific project.
1190 | *
1191 | * @param params Query parameters
1192 | * @param params.organizationSlug Organization identifier
1193 | * @param params.projectSlug Project identifier (optional, scopes to specific project)
1194 | * @param params.query Search query for filtering releases
1195 | * @param opts Request options
1196 | * @returns Array of releases with deployment and commit information
1197 | *
1198 | * @example
1199 | * ```typescript
1200 | * // All releases for organization
1201 | * const releases = await apiService.listReleases({
1202 | * organizationSlug: "my-org"
1203 | * });
1204 | *
1205 | * // Search for specific version
1206 | * const filtered = await apiService.listReleases({
1207 | * organizationSlug: "my-org",
1208 | * query: "v1.2.3"
1209 | * });
1210 | * ```
1211 | */
1212 | async listReleases(
1213 | {
1214 | organizationSlug,
1215 | projectSlug,
1216 | query,
1217 | }: {
1218 | organizationSlug: string;
1219 | projectSlug?: string;
1220 | query?: string;
1221 | },
1222 | opts?: RequestOptions,
1223 | ): Promise<ReleaseList> {
1224 | const searchQuery = new URLSearchParams();
1225 | if (query) {
1226 | searchQuery.set("query", query);
1227 | }
1228 |
1229 | const path = projectSlug
1230 | ? `/projects/${organizationSlug}/${projectSlug}/releases/`
1231 | : `/organizations/${organizationSlug}/releases/`;
1232 |
1233 | const body = await this.requestJSON(
1234 | searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path,
1235 | undefined,
1236 | opts,
1237 | );
1238 | return ReleaseListSchema.parse(body);
1239 | }
1240 |
1241 | /**
1242 | * Lists available tags for search queries.
1243 | *
1244 | * Tags represent indexed fields that can be used in Sentry search queries.
1245 | *
1246 | * @param params Query parameters
1247 | * @param params.organizationSlug Organization identifier
1248 | * @param params.dataset Dataset to query tags for ("events", "errors" or "search_issues")
1249 | * @param params.project Numeric project ID to filter tags
1250 | * @param params.statsPeriod Time range for tag statistics (e.g., "24h", "7d")
1251 | * @param params.useCache Whether to use cached results
1252 | * @param params.useFlagsBackend Whether to use flags backend features
1253 | * @param opts Request options
1254 | * @returns Array of available tags with metadata
1255 | *
1256 | * @example
1257 | * ```typescript
1258 | * const tags = await apiService.listTags({
1259 | * organizationSlug: "my-org",
1260 | * dataset: "events",
1261 | * project: "123456",
1262 | * statsPeriod: "24h",
1263 | * useCache: true
1264 | * });
1265 | * tags.forEach(tag => console.log(`${tag.key}: ${tag.name}`));
1266 | * ```
1267 | */
1268 | async listTags(
1269 | {
1270 | organizationSlug,
1271 | dataset,
1272 | project,
1273 | statsPeriod,
1274 | start,
1275 | end,
1276 | useCache,
1277 | useFlagsBackend,
1278 | }: {
1279 | organizationSlug: string;
1280 | dataset?: "events" | "errors" | "search_issues";
1281 | project?: string;
1282 | statsPeriod?: string;
1283 | start?: string;
1284 | end?: string;
1285 | useCache?: boolean;
1286 | useFlagsBackend?: boolean;
1287 | },
1288 | opts?: RequestOptions,
1289 | ): Promise<TagList> {
1290 | const searchQuery = new URLSearchParams();
1291 | if (dataset) {
1292 | searchQuery.set("dataset", dataset);
1293 | }
1294 | if (project) {
1295 | searchQuery.set("project", project);
1296 | }
1297 | // Validate time parameters - can't use both relative and absolute
1298 | if (statsPeriod && (start || end)) {
1299 | throw new ApiValidationError(
1300 | "Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.",
1301 | );
1302 | }
1303 | if ((start && !end) || (!start && end)) {
1304 | throw new ApiValidationError(
1305 | "Both start and end parameters must be provided together for absolute time ranges.",
1306 | );
1307 | }
1308 | // Use either relative time (statsPeriod) or absolute time (start/end)
1309 | if (statsPeriod) {
1310 | searchQuery.set("statsPeriod", statsPeriod);
1311 | } else if (start && end) {
1312 | searchQuery.set("start", start);
1313 | searchQuery.set("end", end);
1314 | }
1315 | if (useCache !== undefined) {
1316 | searchQuery.set("useCache", useCache ? "1" : "0");
1317 | }
1318 | if (useFlagsBackend !== undefined) {
1319 | searchQuery.set("useFlagsBackend", useFlagsBackend ? "1" : "0");
1320 | }
1321 |
1322 | const body = await this.requestJSON(
1323 | searchQuery.toString()
1324 | ? `/organizations/${organizationSlug}/tags/?${searchQuery.toString()}`
1325 | : `/organizations/${organizationSlug}/tags/`,
1326 | undefined,
1327 | opts,
1328 | );
1329 | return TagListSchema.parse(body);
1330 | }
1331 |
1332 | /**
1333 | * Lists trace item attributes available for search queries.
1334 | *
1335 | * Returns all available fields/attributes that can be used in event searches,
1336 | * including both built-in fields and custom tags.
1337 | *
1338 | * @param params Query parameters
1339 | * @param params.organizationSlug Organization identifier
1340 | * @param params.itemType Item type to query attributes for ("spans" or "logs")
1341 | * @param params.project Numeric project ID to filter attributes
1342 | * @param params.statsPeriod Time range for attribute statistics (e.g., "24h", "7d")
1343 | * @param opts Request options
1344 | * @returns Array of available attributes with metadata including type
1345 | */
1346 | async listTraceItemAttributes(
1347 | {
1348 | organizationSlug,
1349 | itemType = "spans",
1350 | project,
1351 | statsPeriod,
1352 | start,
1353 | end,
1354 | }: {
1355 | organizationSlug: string;
1356 | itemType?: "spans" | "logs";
1357 | project?: string;
1358 | statsPeriod?: string;
1359 | start?: string;
1360 | end?: string;
1361 | },
1362 | opts?: RequestOptions,
1363 | ): Promise<Array<{ key: string; name: string; type: "string" | "number" }>> {
1364 | // Fetch both string and number attributes
1365 | const [stringAttributes, numberAttributes] = await Promise.all([
1366 | this.fetchTraceItemAttributesByType(
1367 | organizationSlug,
1368 | itemType,
1369 | "string",
1370 | project,
1371 | statsPeriod,
1372 | start,
1373 | end,
1374 | opts,
1375 | ),
1376 | this.fetchTraceItemAttributesByType(
1377 | organizationSlug,
1378 | itemType,
1379 | "number",
1380 | project,
1381 | statsPeriod,
1382 | start,
1383 | end,
1384 | opts,
1385 | ),
1386 | ]);
1387 |
1388 | // Combine attributes with explicit type information
1389 | const allAttributes: Array<{
1390 | key: string;
1391 | name: string;
1392 | type: "string" | "number";
1393 | }> = [];
1394 |
1395 | // Add string attributes
1396 | for (const attr of stringAttributes) {
1397 | allAttributes.push({
1398 | key: attr.key,
1399 | name: attr.name || attr.key,
1400 | type: "string",
1401 | });
1402 | }
1403 |
1404 | // Add number attributes
1405 | for (const attr of numberAttributes) {
1406 | allAttributes.push({
1407 | key: attr.key,
1408 | name: attr.name || attr.key,
1409 | type: "number",
1410 | });
1411 | }
1412 |
1413 | return allAttributes;
1414 | }
1415 |
1416 | private async fetchTraceItemAttributesByType(
1417 | organizationSlug: string,
1418 | itemType: "spans" | "logs",
1419 | attributeType: "string" | "number",
1420 | project?: string,
1421 | statsPeriod?: string,
1422 | start?: string,
1423 | end?: string,
1424 | opts?: RequestOptions,
1425 | ): Promise<any> {
1426 | const queryParams = new URLSearchParams();
1427 | queryParams.set("itemType", itemType);
1428 | queryParams.set("attributeType", attributeType);
1429 | if (project) {
1430 | queryParams.set("project", project);
1431 | }
1432 | // Validate time parameters - can't use both relative and absolute
1433 | if (statsPeriod && (start || end)) {
1434 | throw new ApiValidationError(
1435 | "Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.",
1436 | );
1437 | }
1438 | if ((start && !end) || (!start && end)) {
1439 | throw new ApiValidationError(
1440 | "Both start and end parameters must be provided together for absolute time ranges.",
1441 | );
1442 | }
1443 | // Use either relative time (statsPeriod) or absolute time (start/end)
1444 | if (statsPeriod) {
1445 | queryParams.set("statsPeriod", statsPeriod);
1446 | } else if (start && end) {
1447 | queryParams.set("start", start);
1448 | queryParams.set("end", end);
1449 | }
1450 |
1451 | const url = `/organizations/${organizationSlug}/trace-items/attributes/?${queryParams.toString()}`;
1452 |
1453 | const body = await this.requestJSON(url, undefined, opts);
1454 | return Array.isArray(body) ? body : [];
1455 | }
1456 |
1457 | /**
1458 | * Lists issues within an organization or project.
1459 | *
1460 | * Issues represent groups of similar errors or problems in your application.
1461 | * Supports Sentry's powerful query syntax for filtering and sorting.
1462 | *
1463 | * @param params Query parameters
1464 | * @param params.organizationSlug Organization identifier
1465 | * @param params.projectSlug Project identifier (optional, scopes to specific project)
1466 | * @param params.query Sentry search query (e.g., "is:unresolved browser:chrome")
1467 | * @param params.sortBy Sort order ("user", "freq", "date", "new")
1468 | * @param opts Request options
1469 | * @returns Array of issues with metadata and statistics
1470 | *
1471 | * @example
1472 | * ```typescript
1473 | * // Recent unresolved issues
1474 | * const issues = await apiService.listIssues({
1475 | * organizationSlug: "my-org",
1476 | * query: "is:unresolved",
1477 | * sortBy: "date"
1478 | * });
1479 | *
1480 | * // High-frequency errors in specific project
1481 | * const critical = await apiService.listIssues({
1482 | * organizationSlug: "my-org",
1483 | * projectSlug: "backend",
1484 | * query: "level:error",
1485 | * sortBy: "freq"
1486 | * });
1487 | * ```
1488 | */
1489 | async listIssues(
1490 | {
1491 | organizationSlug,
1492 | projectSlug,
1493 | query,
1494 | sortBy,
1495 | limit = 10,
1496 | }: {
1497 | organizationSlug: string;
1498 | projectSlug?: string;
1499 | query?: string | null;
1500 | sortBy?: "user" | "freq" | "date" | "new";
1501 | limit?: number;
1502 | },
1503 | opts?: RequestOptions,
1504 | ): Promise<IssueList> {
1505 | const sentryQuery: string[] = [];
1506 | if (query) {
1507 | sentryQuery.push(query);
1508 | }
1509 |
1510 | const queryParams = new URLSearchParams();
1511 | queryParams.set("per_page", String(limit));
1512 | if (sortBy) queryParams.set("sort", sortBy);
1513 | queryParams.set("statsPeriod", "24h");
1514 | queryParams.set("query", sentryQuery.join(" "));
1515 |
1516 | queryParams.append("collapse", "unhandled");
1517 |
1518 | const apiUrl = projectSlug
1519 | ? `/projects/${organizationSlug}/${projectSlug}/issues/?${queryParams.toString()}`
1520 | : `/organizations/${organizationSlug}/issues/?${queryParams.toString()}`;
1521 |
1522 | const body = await this.requestJSON(apiUrl, undefined, opts);
1523 | return IssueListSchema.parse(body);
1524 | }
1525 |
1526 | async getIssue(
1527 | {
1528 | organizationSlug,
1529 | issueId,
1530 | }: {
1531 | organizationSlug: string;
1532 | issueId: string;
1533 | },
1534 | opts?: RequestOptions,
1535 | ): Promise<Issue> {
1536 | const body = await this.requestJSON(
1537 | `/organizations/${organizationSlug}/issues/${issueId}/`,
1538 | undefined,
1539 | opts,
1540 | );
1541 | return IssueSchema.parse(body);
1542 | }
1543 |
1544 | async getEventForIssue(
1545 | {
1546 | organizationSlug,
1547 | issueId,
1548 | eventId,
1549 | }: {
1550 | organizationSlug: string;
1551 | issueId: string;
1552 | eventId: string;
1553 | },
1554 | opts?: RequestOptions,
1555 | ): Promise<Event> {
1556 | const body = await this.requestJSON(
1557 | `/organizations/${organizationSlug}/issues/${issueId}/events/${eventId}/`,
1558 | undefined,
1559 | opts,
1560 | );
1561 | const rawEvent = EventSchema.parse(body);
1562 |
1563 | // Filter out unknown events - only return known error/default/transaction types
1564 | // "default" type represents error events without exception data
1565 | if (rawEvent.type === "error" || rawEvent.type === "default") {
1566 | return rawEvent as Event;
1567 | }
1568 | if (rawEvent.type === "transaction") {
1569 | return rawEvent as Event;
1570 | }
1571 |
1572 | const eventType =
1573 | typeof rawEvent.type === "string" ? rawEvent.type : String(rawEvent.type);
1574 | throw new ApiValidationError(
1575 | `Unknown event type: ${eventType}`,
1576 | 400,
1577 | `Only error, default, and transaction events are supported, got: ${eventType}`,
1578 | body,
1579 | );
1580 | }
1581 |
1582 | async getLatestEventForIssue(
1583 | {
1584 | organizationSlug,
1585 | issueId,
1586 | }: {
1587 | organizationSlug: string;
1588 | issueId: string;
1589 | },
1590 | opts?: RequestOptions,
1591 | ): Promise<Event> {
1592 | return this.getEventForIssue(
1593 | {
1594 | organizationSlug,
1595 | issueId,
1596 | eventId: "latest",
1597 | },
1598 | opts,
1599 | );
1600 | }
1601 |
1602 | async listEventAttachments(
1603 | {
1604 | organizationSlug,
1605 | projectSlug,
1606 | eventId,
1607 | }: {
1608 | organizationSlug: string;
1609 | projectSlug: string;
1610 | eventId: string;
1611 | },
1612 | opts?: RequestOptions,
1613 | ): Promise<EventAttachmentList> {
1614 | const body = await this.requestJSON(
1615 | `/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/`,
1616 | undefined,
1617 | opts,
1618 | );
1619 | return EventAttachmentListSchema.parse(body);
1620 | }
1621 |
1622 | async getEventAttachment(
1623 | {
1624 | organizationSlug,
1625 | projectSlug,
1626 | eventId,
1627 | attachmentId,
1628 | }: {
1629 | organizationSlug: string;
1630 | projectSlug: string;
1631 | eventId: string;
1632 | attachmentId: string;
1633 | },
1634 | opts?: RequestOptions,
1635 | ): Promise<{
1636 | attachment: EventAttachment;
1637 | downloadUrl: string;
1638 | filename: string;
1639 | blob: Blob;
1640 | }> {
1641 | // Get the attachment metadata first
1642 | const attachmentsData = await this.requestJSON(
1643 | `/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/`,
1644 | undefined,
1645 | opts,
1646 | );
1647 |
1648 | const attachments = EventAttachmentListSchema.parse(attachmentsData);
1649 | const attachment = attachments.find((att) => att.id === attachmentId);
1650 |
1651 | if (!attachment) {
1652 | throw new ApiNotFoundError(
1653 | `Attachment with ID ${attachmentId} not found for event ${eventId}`,
1654 | );
1655 | }
1656 |
1657 | // Download the actual file content
1658 | const downloadUrl = `/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/${attachmentId}/?download=1`;
1659 | const downloadResponse = await this.request(
1660 | downloadUrl,
1661 | {
1662 | method: "GET",
1663 | headers: {
1664 | Accept: "application/octet-stream",
1665 | },
1666 | },
1667 | opts,
1668 | );
1669 |
1670 | return {
1671 | attachment,
1672 | downloadUrl: downloadResponse.url,
1673 | filename: attachment.name,
1674 | blob: await downloadResponse.blob(),
1675 | };
1676 | }
1677 |
1678 | async updateIssue(
1679 | {
1680 | organizationSlug,
1681 | issueId,
1682 | status,
1683 | assignedTo,
1684 | }: {
1685 | organizationSlug: string;
1686 | issueId: string;
1687 | status?: string;
1688 | assignedTo?: string;
1689 | },
1690 | opts?: RequestOptions,
1691 | ): Promise<Issue> {
1692 | const updateData: Record<string, any> = {};
1693 | if (status !== undefined) updateData.status = status;
1694 | if (assignedTo !== undefined) updateData.assignedTo = assignedTo;
1695 |
1696 | const body = await this.requestJSON(
1697 | `/organizations/${organizationSlug}/issues/${issueId}/`,
1698 | {
1699 | method: "PUT",
1700 | body: JSON.stringify(updateData),
1701 | },
1702 | opts,
1703 | );
1704 | return IssueSchema.parse(body);
1705 | }
1706 |
1707 | // TODO: Sentry is not yet exposing a reasonable API to fetch trace data
1708 | // async getTrace({
1709 | // organizationSlug,
1710 | // traceId,
1711 | // }: {
1712 | // organizationSlug: string;
1713 | // traceId: string;
1714 | // }): Promise<z.infer<typeof SentryIssueSchema>> {
1715 | // const response = await this.request(
1716 | // `/organizations/${organizationSlug}/issues/${traceId}/`,
1717 | // );
1718 |
1719 | // const body = await response.json();
1720 | // return SentryIssueSchema.parse(body);
1721 | // }
1722 |
1723 | async searchErrors(
1724 | {
1725 | organizationSlug,
1726 | projectSlug,
1727 | filename,
1728 | transaction,
1729 | query,
1730 | sortBy = "last_seen",
1731 | }: {
1732 | organizationSlug: string;
1733 | projectSlug?: string;
1734 | filename?: string;
1735 | transaction?: string;
1736 | query?: string;
1737 | sortBy?: "last_seen" | "count";
1738 | },
1739 | opts?: RequestOptions,
1740 | ) {
1741 | const sentryQuery: string[] = [];
1742 | if (filename) {
1743 | sentryQuery.push(`stack.filename:"*${filename.replace(/"/g, '\\"')}"`);
1744 | }
1745 | if (transaction) {
1746 | sentryQuery.push(`transaction:"${transaction.replace(/"/g, '\\"')}"`);
1747 | }
1748 | if (query) {
1749 | sentryQuery.push(query);
1750 | }
1751 | if (projectSlug) {
1752 | sentryQuery.push(`project:${projectSlug}`);
1753 | }
1754 |
1755 | const queryParams = new URLSearchParams();
1756 | queryParams.set("dataset", "errors");
1757 | queryParams.set("per_page", "10");
1758 | queryParams.set(
1759 | "sort",
1760 | `-${sortBy === "last_seen" ? "last_seen" : "count"}`,
1761 | );
1762 | queryParams.set("statsPeriod", "24h");
1763 | queryParams.append("field", "issue");
1764 | queryParams.append("field", "title");
1765 | queryParams.append("field", "project");
1766 | queryParams.append("field", "last_seen()");
1767 | queryParams.append("field", "count()");
1768 | queryParams.set("query", sentryQuery.join(" "));
1769 | // if (projectSlug) queryParams.set("project", projectSlug);
1770 |
1771 | const apiUrl = `/organizations/${organizationSlug}/events/?${queryParams.toString()}`;
1772 |
1773 | const body = await this.requestJSON(apiUrl, undefined, opts);
1774 | // TODO(dcramer): If you're using an older version of Sentry this API had a breaking change
1775 | // meaning this endpoint will error.
1776 | return ErrorsSearchResponseSchema.parse(body).data;
1777 | }
1778 |
1779 | async searchSpans(
1780 | {
1781 | organizationSlug,
1782 | projectSlug,
1783 | transaction,
1784 | query,
1785 | sortBy = "timestamp",
1786 | }: {
1787 | organizationSlug: string;
1788 | projectSlug?: string;
1789 | transaction?: string;
1790 | query?: string;
1791 | sortBy?: "timestamp" | "duration";
1792 | },
1793 | opts?: RequestOptions,
1794 | ) {
1795 | const sentryQuery: string[] = ["is_transaction:true"];
1796 | if (transaction) {
1797 | sentryQuery.push(`transaction:"${transaction.replace(/"/g, '\\"')}"`);
1798 | }
1799 | if (query) {
1800 | sentryQuery.push(query);
1801 | }
1802 | if (projectSlug) {
1803 | sentryQuery.push(`project:${projectSlug}`);
1804 | }
1805 |
1806 | const queryParams = new URLSearchParams();
1807 | queryParams.set("dataset", "spans");
1808 | queryParams.set("per_page", "10");
1809 | queryParams.set(
1810 | "sort",
1811 | `-${sortBy === "timestamp" ? "timestamp" : "span.duration"}`,
1812 | );
1813 | queryParams.set("allowAggregateConditions", "0");
1814 | queryParams.set("useRpc", "1");
1815 | queryParams.append("field", "id");
1816 | queryParams.append("field", "trace");
1817 | queryParams.append("field", "span.op");
1818 | queryParams.append("field", "span.description");
1819 | queryParams.append("field", "span.duration");
1820 | queryParams.append("field", "transaction");
1821 | queryParams.append("field", "project");
1822 | queryParams.append("field", "timestamp");
1823 | queryParams.set("query", sentryQuery.join(" "));
1824 | // if (projectSlug) queryParams.set("project", projectSlug);
1825 |
1826 | const apiUrl = `/organizations/${organizationSlug}/events/?${queryParams.toString()}`;
1827 |
1828 | const body = await this.requestJSON(apiUrl, undefined, opts);
1829 | return SpansSearchResponseSchema.parse(body).data;
1830 | }
1831 |
1832 | // ================================================================================
1833 | // API QUERY BUILDERS FOR DIFFERENT SENTRY APIS
1834 | // ================================================================================
1835 |
1836 | /**
1837 | * Builds query parameters for the legacy Discover API (primarily used by errors dataset).
1838 | *
1839 | * Note: While the API endpoint is the same for all datasets, we maintain separate
1840 | * builders to make future divergence easier and to keep the code organized.
1841 | */
1842 | private buildDiscoverApiQuery(params: {
1843 | query: string;
1844 | fields: string[];
1845 | limit: number;
1846 | projectId?: string;
1847 | statsPeriod?: string;
1848 | start?: string;
1849 | end?: string;
1850 | sort: string;
1851 | }): URLSearchParams {
1852 | const queryParams = new URLSearchParams();
1853 |
1854 | // Basic parameters
1855 | queryParams.set("per_page", params.limit.toString());
1856 | queryParams.set("query", params.query);
1857 | queryParams.set("dataset", "errors");
1858 |
1859 | // Validate time parameters - can't use both relative and absolute
1860 | if (params.statsPeriod && (params.start || params.end)) {
1861 | throw new ApiValidationError(
1862 | "Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.",
1863 | );
1864 | }
1865 | if ((params.start && !params.end) || (!params.start && params.end)) {
1866 | throw new ApiValidationError(
1867 | "Both start and end parameters must be provided together for absolute time ranges.",
1868 | );
1869 | }
1870 | // Use either relative time (statsPeriod) or absolute time (start/end)
1871 | if (params.statsPeriod) {
1872 | queryParams.set("statsPeriod", params.statsPeriod);
1873 | } else if (params.start && params.end) {
1874 | queryParams.set("start", params.start);
1875 | queryParams.set("end", params.end);
1876 | }
1877 |
1878 | if (params.projectId) {
1879 | queryParams.set("project", params.projectId);
1880 | }
1881 |
1882 | // Sort parameter transformation for API compatibility
1883 | let apiSort = params.sort;
1884 | // Skip transformation for equation fields - they should be passed as-is
1885 | if (params.sort?.includes("(") && !params.sort?.includes("equation|")) {
1886 | // Transform: count(field) -> count_field, count() -> count
1887 | // Use safer string manipulation to avoid ReDoS
1888 | const parenStart = params.sort.indexOf("(");
1889 | const parenEnd = params.sort.indexOf(")", parenStart);
1890 | if (parenStart !== -1 && parenEnd !== -1) {
1891 | const beforeParen = params.sort.substring(0, parenStart);
1892 | const insideParen = params.sort.substring(parenStart + 1, parenEnd);
1893 | const afterParen = params.sort.substring(parenEnd + 1);
1894 | const transformedInside = insideParen
1895 | ? `_${insideParen.replace(/\./g, "_")}`
1896 | : "";
1897 | apiSort = beforeParen + transformedInside + afterParen;
1898 | }
1899 | }
1900 | queryParams.set("sort", apiSort);
1901 |
1902 | // Add fields
1903 | for (const field of params.fields) {
1904 | queryParams.append("field", field);
1905 | }
1906 |
1907 | return queryParams;
1908 | }
1909 |
1910 | /**
1911 | * Builds query parameters for the modern EAP API (used by spans/logs datasets).
1912 | *
1913 | * Includes dataset-specific parameters like sampling for spans.
1914 | */
1915 | private buildEapApiQuery(params: {
1916 | query: string;
1917 | fields: string[];
1918 | limit: number;
1919 | projectId?: string;
1920 | dataset: "spans" | "ourlogs";
1921 | statsPeriod?: string;
1922 | start?: string;
1923 | end?: string;
1924 | sort: string;
1925 | }): URLSearchParams {
1926 | const queryParams = new URLSearchParams();
1927 |
1928 | // Basic parameters
1929 | queryParams.set("per_page", params.limit.toString());
1930 | queryParams.set("query", params.query);
1931 | queryParams.set("dataset", params.dataset);
1932 |
1933 | // Validate time parameters - can't use both relative and absolute
1934 | if (params.statsPeriod && (params.start || params.end)) {
1935 | throw new ApiValidationError(
1936 | "Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.",
1937 | );
1938 | }
1939 | if ((params.start && !params.end) || (!params.start && params.end)) {
1940 | throw new ApiValidationError(
1941 | "Both start and end parameters must be provided together for absolute time ranges.",
1942 | );
1943 | }
1944 | // Use either relative time (statsPeriod) or absolute time (start/end)
1945 | if (params.statsPeriod) {
1946 | queryParams.set("statsPeriod", params.statsPeriod);
1947 | } else if (params.start && params.end) {
1948 | queryParams.set("start", params.start);
1949 | queryParams.set("end", params.end);
1950 | }
1951 |
1952 | if (params.projectId) {
1953 | queryParams.set("project", params.projectId);
1954 | }
1955 |
1956 | // Dataset-specific parameters
1957 | if (params.dataset === "spans") {
1958 | queryParams.set("sampling", "NORMAL");
1959 | }
1960 |
1961 | // Sort parameter transformation for API compatibility
1962 | let apiSort = params.sort;
1963 | // Skip transformation for equation fields - they should be passed as-is
1964 | if (params.sort?.includes("(") && !params.sort?.includes("equation|")) {
1965 | // Transform: count(field) -> count_field, count() -> count
1966 | // Use safer string manipulation to avoid ReDoS
1967 | const parenStart = params.sort.indexOf("(");
1968 | const parenEnd = params.sort.indexOf(")", parenStart);
1969 | if (parenStart !== -1 && parenEnd !== -1) {
1970 | const beforeParen = params.sort.substring(0, parenStart);
1971 | const insideParen = params.sort.substring(parenStart + 1, parenEnd);
1972 | const afterParen = params.sort.substring(parenEnd + 1);
1973 | const transformedInside = insideParen
1974 | ? `_${insideParen.replace(/\./g, "_")}`
1975 | : "";
1976 | apiSort = beforeParen + transformedInside + afterParen;
1977 | }
1978 | }
1979 | queryParams.set("sort", apiSort);
1980 |
1981 | // Add fields
1982 | for (const field of params.fields) {
1983 | queryParams.append("field", field);
1984 | }
1985 |
1986 | return queryParams;
1987 | }
1988 |
1989 | /**
1990 | * Searches for events in Sentry using the unified events API.
1991 | * This method is used by the search_events tool for semantic search.
1992 | *
1993 | * Routes to the appropriate query builder based on dataset, even though
1994 | * the underlying API endpoint is the same. This separation makes the code
1995 | * cleaner and allows for future API divergence.
1996 | */
1997 | async searchEvents(
1998 | {
1999 | organizationSlug,
2000 | query,
2001 | fields,
2002 | limit = 10,
2003 | projectId,
2004 | dataset = "spans",
2005 | statsPeriod,
2006 | start,
2007 | end,
2008 | sort = "-timestamp",
2009 | }: {
2010 | organizationSlug: string;
2011 | query: string;
2012 | fields: string[];
2013 | limit?: number;
2014 | projectId?: string;
2015 | dataset?: "spans" | "errors" | "ourlogs";
2016 | statsPeriod?: string;
2017 | start?: string;
2018 | end?: string;
2019 | sort?: string;
2020 | },
2021 | opts?: RequestOptions,
2022 | ) {
2023 | let queryParams: URLSearchParams;
2024 |
2025 | if (dataset === "errors") {
2026 | // Use Discover API query builder
2027 | queryParams = this.buildDiscoverApiQuery({
2028 | query,
2029 | fields,
2030 | limit,
2031 | projectId,
2032 | statsPeriod,
2033 | start,
2034 | end,
2035 | sort,
2036 | });
2037 | } else {
2038 | // Use EAP API query builder for spans and logs
2039 | queryParams = this.buildEapApiQuery({
2040 | query,
2041 | fields,
2042 | limit,
2043 | projectId,
2044 | dataset,
2045 | statsPeriod,
2046 | start,
2047 | end,
2048 | sort,
2049 | });
2050 | }
2051 |
2052 | const apiUrl = `/organizations/${organizationSlug}/events/?${queryParams.toString()}`;
2053 | return await this.requestJSON(apiUrl, undefined, opts);
2054 | }
2055 |
2056 | // POST https://us.sentry.io/api/0/issues/5485083130/autofix/
2057 | async startAutofix(
2058 | {
2059 | organizationSlug,
2060 | issueId,
2061 | eventId,
2062 | instruction = "",
2063 | }: {
2064 | organizationSlug: string;
2065 | issueId: string;
2066 | eventId?: string;
2067 | instruction?: string;
2068 | },
2069 | opts?: RequestOptions,
2070 | ): Promise<AutofixRun> {
2071 | const body = await this.requestJSON(
2072 | `/organizations/${organizationSlug}/issues/${issueId}/autofix/`,
2073 | {
2074 | method: "POST",
2075 | body: JSON.stringify({
2076 | event_id: eventId,
2077 | instruction,
2078 | }),
2079 | },
2080 | opts,
2081 | );
2082 | return AutofixRunSchema.parse(body);
2083 | }
2084 |
2085 | // GET https://us.sentry.io/api/0/issues/5485083130/autofix/
2086 | async getAutofixState(
2087 | {
2088 | organizationSlug,
2089 | issueId,
2090 | }: {
2091 | organizationSlug: string;
2092 | issueId: string;
2093 | },
2094 | opts?: RequestOptions,
2095 | ): Promise<AutofixRunState> {
2096 | const body = await this.requestJSON(
2097 | `/organizations/${organizationSlug}/issues/${issueId}/autofix/`,
2098 | undefined,
2099 | opts,
2100 | );
2101 | return AutofixRunStateSchema.parse(body);
2102 | }
2103 |
2104 | /**
2105 | * Retrieves high-level metadata about a trace.
2106 | *
2107 | * Returns statistics including span counts, error counts, transaction
2108 | * breakdown, and operation type distribution for the specified trace.
2109 | *
2110 | * @param params Query parameters
2111 | * @param params.organizationSlug Organization identifier
2112 | * @param params.traceId Trace identifier (32-character hex string)
2113 | * @param params.statsPeriod Optional stats period (e.g., "14d", "7d")
2114 | * @param opts Request options
2115 | * @returns Trace metadata with statistics
2116 | *
2117 | * @example
2118 | * ```typescript
2119 | * const traceMeta = await apiService.getTraceMeta({
2120 | * organizationSlug: "my-org",
2121 | * traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a"
2122 | * });
2123 | * console.log(`Trace has ${traceMeta.span_count} spans`);
2124 | * ```
2125 | */
2126 | async getTraceMeta(
2127 | {
2128 | organizationSlug,
2129 | traceId,
2130 | statsPeriod = "14d",
2131 | }: {
2132 | organizationSlug: string;
2133 | traceId: string;
2134 | statsPeriod?: string;
2135 | },
2136 | opts?: RequestOptions,
2137 | ): Promise<TraceMeta> {
2138 | const queryParams = new URLSearchParams();
2139 | queryParams.set("statsPeriod", statsPeriod);
2140 |
2141 | const body = await this.requestJSON(
2142 | `/organizations/${organizationSlug}/trace-meta/${traceId}/?${queryParams.toString()}`,
2143 | undefined,
2144 | opts,
2145 | );
2146 | return TraceMetaSchema.parse(body);
2147 | }
2148 |
2149 | /**
2150 | * Retrieves the complete trace structure with all spans.
2151 | *
2152 | * Returns the hierarchical trace data including all spans, their timing
2153 | * information, operation details, and nested relationships.
2154 | *
2155 | * @param params Query parameters
2156 | * @param params.organizationSlug Organization identifier
2157 | * @param params.traceId Trace identifier (32-character hex string)
2158 | * @param params.limit Maximum number of spans to return (default: 1000)
2159 | * @param params.project Project filter (-1 for all projects)
2160 | * @param params.statsPeriod Optional stats period (e.g., "14d", "7d")
2161 | * @param opts Request options
2162 | * @returns Complete trace tree structure
2163 | *
2164 | * @example
2165 | * ```typescript
2166 | * const trace = await apiService.getTrace({
2167 | * organizationSlug: "my-org",
2168 | * traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
2169 | * limit: 1000
2170 | * });
2171 | * console.log(`Root spans: ${trace.length}`);
2172 | * ```
2173 | */
2174 | async getTrace(
2175 | {
2176 | organizationSlug,
2177 | traceId,
2178 | limit = 1000,
2179 | project = "-1",
2180 | statsPeriod = "14d",
2181 | }: {
2182 | organizationSlug: string;
2183 | traceId: string;
2184 | limit?: number;
2185 | project?: string;
2186 | statsPeriod?: string;
2187 | },
2188 | opts?: RequestOptions,
2189 | ): Promise<Trace> {
2190 | const queryParams = new URLSearchParams();
2191 | queryParams.set("limit", String(limit));
2192 | queryParams.set("project", project);
2193 | queryParams.set("statsPeriod", statsPeriod);
2194 |
2195 | const body = await this.requestJSON(
2196 | `/organizations/${organizationSlug}/trace/${traceId}/?${queryParams.toString()}`,
2197 | undefined,
2198 | opts,
2199 | );
2200 | return TraceSchema.parse(body);
2201 | }
2202 | }
2203 |
```