This is page 10 of 11. Use http://codebase.md/getsentry/sentry-mcp?lines=false&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
│ └── rules
├── .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
│ │ ├── constraint-do-analysis.md
│ │ ├── deployment.md
│ │ ├── mcpagent-architecture.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-agent.ts
│ │ │ │ │ ├── slug-validation.test.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
│ │ │ │ ├── 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.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/internal/formatting.ts:
--------------------------------------------------------------------------------
```typescript
/**
* LLM response formatting utilities for Sentry data.
*
* Converts Sentry API responses into structured markdown format optimized
* for LLM consumption. Handles stacktraces, event details, issue summaries,
* and contextual information with consistent formatting patterns.
*/
import type { z } from "zod";
import type {
Event,
Issue,
AutofixRunState,
Trace,
TraceSpan,
} from "../api-client/types";
import type {
ErrorEntrySchema,
ErrorEventSchema,
EventSchema,
FrameInterface,
RequestEntrySchema,
MessageEntrySchema,
ThreadsEntrySchema,
SentryApiService,
AutofixRunStepRootCauseAnalysisSchema,
} from "../api-client";
import {
getOutputForAutofixStep,
isTerminalStatus,
getStatusDisplayName,
} from "./tool-helpers/seer";
// Language detection mappings
const LANGUAGE_EXTENSIONS: Record<string, string> = {
".java": "java",
".py": "python",
".js": "javascript",
".jsx": "javascript",
".ts": "javascript",
".tsx": "javascript",
".rb": "ruby",
".php": "php",
};
const LANGUAGE_MODULE_PATTERNS: Array<[RegExp, string]> = [
[/^(java\.|com\.|org\.)/, "java"],
];
/**
* Detects the programming language of a stack frame based on the file extension.
* Falls back to the platform parameter if no filename is available or extension is unrecognized.
*
* @param frame - The stack frame containing file and location information
* @param platform - Optional platform hint to use as fallback
* @returns The detected language or platform fallback or "unknown"
*/
function detectLanguage(
frame: z.infer<typeof FrameInterface>,
platform?: string | null,
): string {
// Check filename extensions
if (frame.filename) {
const ext = frame.filename.toLowerCase().match(/\.[^.]+$/)?.[0];
if (ext && LANGUAGE_EXTENSIONS[ext]) {
return LANGUAGE_EXTENSIONS[ext];
}
}
// Check module patterns
if (frame.module) {
for (const [pattern, language] of LANGUAGE_MODULE_PATTERNS) {
if (pattern.test(frame.module)) {
return language;
}
}
}
// Fallback to platform or unknown
return platform || "unknown";
}
/**
* Formats a stack frame into a language-specific string representation.
* Different languages have different conventions for displaying stack traces.
*
* @param frame - The stack frame to format
* @param frameIndex - Optional frame index for languages that display frame numbers
* @param platform - Optional platform hint for language detection fallback
* @returns Formatted stack frame string
*/
export function formatFrameHeader(
frame: z.infer<typeof FrameInterface>,
frameIndex?: number,
platform?: string | null,
) {
const language = detectLanguage(frame, platform);
switch (language) {
case "java": {
// at com.example.ClassName.methodName(FileName.java:123)
const className = frame.module || "UnknownClass";
const method = frame.function || "<unknown>";
const source = frame.filename || "Unknown Source";
const location = frame.lineNo ? `:${frame.lineNo}` : "";
return `at ${className}.${method}(${source}${location})`;
}
case "python": {
// File "/path/to/file.py", line 42, in function_name
const file =
frame.filename || frame.absPath || frame.module || "<unknown>";
const func = frame.function || "<module>";
const line = frame.lineNo ? `, line ${frame.lineNo}` : "";
return ` File "${file}"${line}, in ${func}`;
}
case "javascript": {
// Original compact format: filename:line:col (function)
// This preserves backward compatibility
return `${[frame.filename, frame.lineNo, frame.colNo]
.filter((i) => !!i)
.join(":")}${frame.function ? ` (${frame.function})` : ""}`;
}
case "ruby": {
// from /path/to/file.rb:42:in `method_name'
const file = frame.filename || frame.module || "<unknown>";
const func = frame.function ? ` \`${frame.function}\`` : "";
const line = frame.lineNo ? `:${frame.lineNo}:in` : "";
return ` from ${file}${line}${func}`;
}
case "php": {
// #0 /path/to/file.php(42): functionName()
const file = frame.filename || "<unknown>";
const line = frame.lineNo ? `(${frame.lineNo})` : "";
const func = frame.function || "<unknown>";
const prefix = frameIndex !== undefined ? `#${frameIndex} ` : "";
return `${prefix}${file}${line}: ${func}()`;
}
default: {
// Generic format for unknown languages
const func = frame.function || "<unknown>";
const location = frame.filename || frame.module || "<unknown>";
const line = frame.lineNo ? `:${frame.lineNo}` : "";
const col = frame.colNo != null ? `:${frame.colNo}` : "";
return ` at ${func} (${location}${line}${col})`;
}
}
}
/**
* Formats a Sentry event into a structured markdown output.
* Includes error messages, stack traces, request info, and contextual data.
*
* @param event - The Sentry event to format
* @param options - Additional formatting context
* @returns Formatted markdown string
*/
export function formatEventOutput(
event: Event,
options?: {
performanceTrace?: Trace;
},
) {
let output = "";
// Look for the primary error information
const messageEntry = event.entries.find((e) => e.type === "message");
const exceptionEntry = event.entries.find((e) => e.type === "exception");
const threadsEntry = event.entries.find((e) => e.type === "threads");
const requestEntry = event.entries.find((e) => e.type === "request");
const spansEntry = event.entries.find((e) => e.type === "spans");
// Error message (if present)
if (messageEntry) {
output += formatMessageInterfaceOutput(
event,
messageEntry.data as z.infer<typeof MessageEntrySchema>,
);
}
// Stack trace (from exception or threads)
if (exceptionEntry) {
output += formatExceptionInterfaceOutput(
event,
exceptionEntry.data as z.infer<typeof ErrorEntrySchema>,
);
} else if (threadsEntry) {
output += formatThreadsInterfaceOutput(
event,
threadsEntry.data as z.infer<typeof ThreadsEntrySchema>,
);
}
// Request info (if HTTP error)
if (requestEntry) {
output += formatRequestInterfaceOutput(
event,
requestEntry.data as z.infer<typeof RequestEntrySchema>,
);
}
// Performance issue details (N+1 queries, etc.)
// Pass spans data for additional context even if we have evidence
if (event.type === "transaction") {
output += formatPerformanceIssueOutput(event, spansEntry?.data, options);
}
output += formatTags(event.tags);
output += formatContext(event.context);
output += formatContexts(event.contexts);
return output;
}
/**
* Extracts the context line matching the frame's line number for inline display.
* This is used in the full stacktrace view to show the actual line of code
* that caused the error inline with the stack frame.
*
* @param frame - The stack frame containing context lines
* @returns The line of code at the frame's line number, or empty string if not available
*/
function renderInlineContext(frame: z.infer<typeof FrameInterface>): string {
if (!frame.context?.length || !frame.lineNo) {
return "";
}
const contextLine = frame.context.find(([lineNo]) => lineNo === frame.lineNo);
return contextLine ? `\n${contextLine[1]}` : "";
}
/**
* Renders an enhanced view of a stack frame with context lines and variables.
* Used for the "Most Relevant Frame" section to provide detailed information
* about the most relevant application frame where the error occurred.
*
* @param frame - The stack frame to render with enhanced information
* @param event - The Sentry event containing platform information for language detection
* @returns Formatted string with frame header, context lines, and variables table
*/
function renderEnhancedFrame(
frame: z.infer<typeof FrameInterface>,
event: Event,
): string {
const parts: string[] = [];
parts.push("**Most Relevant Frame:**");
parts.push("─────────────────────");
parts.push(formatFrameHeader(frame, undefined, event.platform));
// Add context lines if available
if (frame.context?.length) {
const contextLines = renderContextLines(frame);
if (contextLines) {
parts.push("");
parts.push(contextLines);
}
}
// Add variables table if available
if (frame.vars && Object.keys(frame.vars).length > 0) {
parts.push("");
parts.push(renderVariablesTable(frame.vars));
}
return parts.join("\n");
}
function formatExceptionInterfaceOutput(
event: Event,
data: z.infer<typeof ErrorEntrySchema>,
) {
const parts: string[] = [];
// Handle both single exception (value) and chained exceptions (values)
const exceptions = data.values || (data.value ? [data.value] : []);
if (exceptions.length === 0) {
return "";
}
// For chained exceptions, they are typically ordered from innermost to outermost
// We'll render them in reverse order (outermost first) to match how they occurred
const isChained = exceptions.length > 1;
// Create a copy before reversing to avoid mutating the original array
[...exceptions].reverse().forEach((exception, index) => {
if (!exception) return;
// Add language-specific chain indicator for multiple exceptions
if (isChained && index > 0) {
parts.push("");
parts.push(
getExceptionChainMessage(
event.platform || null,
index,
exceptions.length,
),
);
parts.push("");
}
// Use the actual exception type and value as the heading
const exceptionTitle = `${exception.type}${exception.value ? `: ${exception.value}` : ""}`;
parts.push(index === 0 ? "### Error" : `### ${exceptionTitle}`);
parts.push("");
// Add the error details in a code block for the first exception
// to maintain backward compatibility
if (index === 0) {
parts.push("```");
parts.push(exceptionTitle);
parts.push("```");
parts.push("");
}
if (!exception.stacktrace || !exception.stacktrace.frames) {
parts.push("**Stacktrace:**");
parts.push("```");
parts.push("No stacktrace available");
parts.push("```");
return;
}
const frames = exception.stacktrace.frames;
// Only show enhanced frame for the first (outermost) exception to avoid overwhelming output
if (index === 0) {
const firstInAppFrame = findFirstInAppFrame(frames);
if (
firstInAppFrame &&
(firstInAppFrame.context?.length || firstInAppFrame.vars)
) {
parts.push(renderEnhancedFrame(firstInAppFrame, event));
parts.push("");
parts.push("**Full Stacktrace:**");
parts.push("────────────────");
} else {
parts.push("**Stacktrace:**");
}
} else {
parts.push("**Stacktrace:**");
}
parts.push("```");
parts.push(
frames
.map((frame) => {
const header = formatFrameHeader(frame, undefined, event.platform);
const context = renderInlineContext(frame);
return `${header}${context}`;
})
.join("\n"),
);
parts.push("```");
});
parts.push("");
parts.push("");
return parts.join("\n");
}
/**
* Get the appropriate exception chain message based on the platform
*/
function getExceptionChainMessage(
platform: string | null,
index: number,
totalExceptions: number,
): string {
// Default message for unknown platforms
const defaultMessage =
"**During handling of the above exception, another exception occurred:**";
if (!platform) {
return defaultMessage;
}
switch (platform.toLowerCase()) {
case "python":
// Python has two distinct messages, but without additional metadata
// we default to the implicit chaining message
return "**During handling of the above exception, another exception occurred:**";
case "java":
return "**Caused by:**";
case "csharp":
case "dotnet":
return "**---> Inner Exception:**";
case "ruby":
return "**Caused by:**";
case "go":
return "**Wrapped error:**";
case "rust":
return `**Caused by (${index}):**`;
default:
return defaultMessage;
}
}
function formatRequestInterfaceOutput(
event: Event,
data: z.infer<typeof RequestEntrySchema>,
) {
if (!data.method || !data.url) {
return "";
}
return `### HTTP Request\n\n**Method:** ${data.method}\n**URL:** ${data.url}\n\n`;
}
function formatMessageInterfaceOutput(
event: Event,
data: z.infer<typeof MessageEntrySchema>,
) {
if (!data.formatted && !data.message) {
return "";
}
const message = data.formatted || data.message || "";
return `### Error\n\n${"```"}\n${message}\n${"```"}\n\n`;
}
function formatThreadsInterfaceOutput(
event: Event,
data: z.infer<typeof ThreadsEntrySchema>,
) {
if (!data.values || data.values.length === 0) {
return "";
}
// Find the crashed thread only
const crashedThread = data.values.find((t) => t.crashed);
if (!crashedThread?.stacktrace?.frames) {
return "";
}
const parts: string[] = [];
// Include thread name if available
if (crashedThread.name) {
parts.push(`**Thread** (${crashedThread.name})`);
parts.push("");
}
const frames = crashedThread.stacktrace.frames;
// Find and format the first in-app frame with enhanced view
const firstInAppFrame = findFirstInAppFrame(frames);
if (
firstInAppFrame &&
(firstInAppFrame.context?.length || firstInAppFrame.vars)
) {
parts.push(renderEnhancedFrame(firstInAppFrame, event));
parts.push("");
parts.push("**Full Stacktrace:**");
parts.push("────────────────");
} else {
parts.push("**Stacktrace:**");
}
parts.push("```");
parts.push(
frames
.map((frame) => {
const header = formatFrameHeader(frame, undefined, event.platform);
const context = renderInlineContext(frame);
return `${header}${context}`;
})
.join("\n"),
);
parts.push("```");
parts.push("");
return parts.join("\n");
}
/**
* Renders surrounding source code context for a stack frame.
* Shows a window of code lines around the error line with visual indicators.
*
* @param frame - The stack frame containing context lines
* @param contextSize - Number of lines to show before and after the error line (default: 3)
* @returns Formatted context lines with line numbers and arrow indicator for the error line
*/
function renderContextLines(
frame: z.infer<typeof FrameInterface>,
contextSize = 3,
): string {
if (!frame.context || frame.context.length === 0 || !frame.lineNo) {
return "";
}
const lines: string[] = [];
const errorLine = frame.lineNo;
const maxLineNoWidth = Math.max(
...frame.context.map(([lineNo]) => lineNo.toString().length),
);
for (const [lineNo, code] of frame.context) {
const isErrorLine = lineNo === errorLine;
const lineNoStr = lineNo.toString().padStart(maxLineNoWidth, " ");
if (Math.abs(lineNo - errorLine) <= contextSize) {
if (isErrorLine) {
lines.push(` → ${lineNoStr} │ ${code}`);
} else {
lines.push(` ${lineNoStr} │ ${code}`);
}
}
}
return lines.join("\n");
}
/**
* Formats a variable value for display in the variables table.
* Handles different types appropriately and safely, converting complex objects
* to readable representations and handling edge cases like circular references.
*
* @param value - The variable value to format (can be any type)
* @param maxLength - Maximum length for stringified objects/arrays (default: 80)
* @returns Human-readable string representation of the value
*/
function formatVariableValue(value: unknown, maxLength = 80): string {
try {
if (typeof value === "string") {
return `"${value}"`;
}
if (value === null) {
return "null";
}
if (value === undefined) {
return "undefined";
}
if (typeof value === "object") {
const stringified = JSON.stringify(value);
if (stringified.length > maxLength) {
// Leave room for ", ...]" or ", ...}"
const truncateAt = maxLength - 6;
let truncated = stringified.substring(0, truncateAt);
// Find the last complete element by looking for the last comma
const lastComma = truncated.lastIndexOf(",");
if (lastComma > 0) {
truncated = truncated.substring(0, lastComma);
}
// Add the appropriate ending
if (Array.isArray(value)) {
return `${truncated}, ...]`;
}
return `${truncated}, ...}`;
}
return stringified;
}
return String(value);
} catch {
// Handle circular references or other stringify errors
return `<${typeof value}>`;
}
}
/**
* Renders a table of local variables in a tree-like format.
* Uses box-drawing characters to create a visual hierarchy of variables
* and their values at the point where the error occurred.
*
* @param vars - Object containing variable names as keys and their values
* @returns Formatted variables table with tree-style prefix characters
*/
function renderVariablesTable(vars: Record<string, unknown>): string {
const entries = Object.entries(vars);
if (entries.length === 0) {
return "";
}
const lines: string[] = ["Local Variables:"];
const lastIndex = entries.length - 1;
entries.forEach(([key, value], index) => {
const prefix = index === lastIndex ? "└─" : "├─";
const valueStr = formatVariableValue(value);
lines.push(`${prefix} ${key}: ${valueStr}`);
});
return lines.join("\n");
}
/**
* Finds the first application frame (in_app) in a stack trace.
* Searches from the bottom of the stack (oldest frame) to find the first
* frame that belongs to the user's application code rather than libraries.
*
* @param frames - Array of stack frames, typically in reverse chronological order
* @returns The first in-app frame found, or undefined if none exist
*/
function findFirstInAppFrame(
frames: z.infer<typeof FrameInterface>[],
): z.infer<typeof FrameInterface> | undefined {
// Frames are usually in reverse order (most recent first)
// We want the first in-app frame from the bottom
for (let i = frames.length - 1; i >= 0; i--) {
if (frames[i].inApp === true) {
return frames[i];
}
}
return undefined;
}
/**
* Constants for performance issue formatting
*/
const MAX_SPANS_IN_TREE = 10;
/**
* Safely parse a number from a string, returning a default if invalid
*/
function safeParseInt(value: unknown, defaultValue: number): number {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
return defaultValue;
}
/**
* Simplified span structure for rendering span trees in performance issues.
* This is a subset of the full span data focused on visualization needs.
*/
interface PerformanceSpan {
span_id: string;
op: string; // Operation type (e.g., "db.query", "http.client")
description: string; // Human-readable description of what the span did
duration: number; // Duration in milliseconds
is_n1_query: boolean; // Whether this span is part of the N+1 pattern
children: PerformanceSpan[];
level: number; // Nesting level for tree rendering
}
interface RawSpan {
span_id?: string;
id?: string;
op?: string;
description?: string;
timestamp?: number;
start_timestamp?: number;
duration?: number;
}
interface N1EvidenceData {
parentSpan?: string;
parentSpanIds?: string[];
repeatingSpansCompact?: string[];
repeatingSpans?: string[];
numberRepeatingSpans?: string; // API returns string even though it's a number
numPatternRepetitions?: number;
offenderSpanIds?: string[];
transactionName?: string;
[key: string]: unknown;
}
interface SlowDbEvidenceData {
parentSpan?: string;
[key: string]: unknown;
}
function normalizeSpanId(value: unknown): string | undefined {
if (typeof value === "string" && value) {
return value;
}
return undefined;
}
function getSpanIdentifier(span: RawSpan): string | undefined {
if (span.span_id !== undefined) {
return normalizeSpanId(span.span_id);
}
if (span.id !== undefined) {
return normalizeSpanId(span.id);
}
return undefined;
}
function getSpanDurationMs(span: RawSpan): number {
if (
typeof span.timestamp === "number" &&
typeof span.start_timestamp === "number"
) {
const deltaSeconds = span.timestamp - span.start_timestamp;
if (Number.isFinite(deltaSeconds) && deltaSeconds >= 0) {
return deltaSeconds * 1000;
}
}
// Trace APIs expose `duration` in milliseconds. Preserve fractional values.
if (typeof span.duration === "number" && Number.isFinite(span.duration)) {
return span.duration >= 0 ? span.duration : 0;
}
return 0;
}
function normalizeIdArray(values: unknown): string[] {
if (!Array.isArray(values)) {
return [];
}
return values
.map((value) => normalizeSpanId(value))
.filter((value): value is string => value !== undefined);
}
function isValidSpanArray(value: unknown): value is RawSpan[] {
return Array.isArray(value);
}
/**
* Get the repeating span descriptions from evidence data.
* Prefers repeatingSpansCompact (more concise) over repeatingSpans (verbose).
*/
function getRepeatingSpanLines(evidenceData: N1EvidenceData): string[] {
// Try compact version first (preferred for display)
if (
Array.isArray(evidenceData.repeatingSpansCompact) &&
evidenceData.repeatingSpansCompact.length > 0
) {
return evidenceData.repeatingSpansCompact
.map((s) => (typeof s === "string" ? s.trim() : ""))
.filter((s): s is string => s.length > 0);
}
// Fall back to full version
if (
Array.isArray(evidenceData.repeatingSpans) &&
evidenceData.repeatingSpans.length > 0
) {
return evidenceData.repeatingSpans
.map((s) => (typeof s === "string" ? s.trim() : ""))
.filter((s): s is string => s.length > 0);
}
return [];
}
function isTraceSpan(node: unknown): node is TraceSpan {
if (node === null || typeof node !== "object") {
return false;
}
const candidate = node as { event_type?: unknown; event_id?: unknown };
// Trace API returns spans with event_type: "span"
return (
candidate.event_type === "span" && typeof candidate.event_id === "string"
);
}
function buildTraceSpanTree(
trace: Trace,
parentSpanIds: string[],
offenderSpanIds: string[],
maxSpans: number,
): string[] {
const offenderSet = new Set(offenderSpanIds);
const spanMap = new Map<string, TraceSpan>();
function indexSpan(span: TraceSpan): void {
// Try to get span_id from additional_attributes, fall back to event_id
const spanId =
normalizeSpanId(span.additional_attributes?.span_id) || span.event_id;
if (spanId && spanId.length > 0) {
spanMap.set(spanId, span);
}
for (const child of span.children ?? []) {
if (isTraceSpan(child)) {
indexSpan(child);
}
}
}
for (const node of trace) {
if (isTraceSpan(node)) {
indexSpan(node);
}
}
const roots: PerformanceSpan[] = [];
const budget = { count: 0, limit: maxSpans };
// First, try to find parent spans
for (const parentId of parentSpanIds) {
const span = spanMap.get(parentId);
if (!span) {
continue;
}
const perfSpan = convertTraceSpanToPerformanceSpan(
span,
offenderSet,
budget,
0,
);
if (perfSpan) {
roots.push(perfSpan);
}
if (budget.count >= budget.limit) {
break;
}
}
// If no parent spans found, try to find offender spans directly
if (roots.length === 0 && offenderSpanIds.length > 0) {
for (const offenderId of offenderSpanIds) {
const span = spanMap.get(offenderId);
if (!span) {
continue;
}
const perfSpan = convertTraceSpanToPerformanceSpan(
span,
offenderSet,
budget,
0,
);
if (perfSpan) {
roots.push(perfSpan);
}
if (budget.count >= budget.limit) {
break;
}
}
}
if (roots.length === 0) {
return [];
}
return renderPerformanceSpanTree(roots);
}
function convertTraceSpanToPerformanceSpan(
span: TraceSpan,
offenderSet: Set<string>,
budget: { count: number; limit: number },
level: number,
): PerformanceSpan | null {
if (budget.count >= budget.limit) {
return null;
}
budget.count += 1;
// Get span ID from additional_attributes or fall back to event_id
const spanId =
normalizeSpanId(span.additional_attributes?.span_id) || span.event_id;
const performanceSpan: PerformanceSpan = {
span_id: spanId,
op: span.op || "unknown",
description: formatTraceSpanDescription(span),
duration: getTraceSpanDurationMs(span),
is_n1_query: offenderSet.has(spanId),
children: [],
level,
};
for (const child of span.children ?? []) {
if (!isTraceSpan(child)) {
continue;
}
if (budget.count >= budget.limit) {
break;
}
const childSpan = convertTraceSpanToPerformanceSpan(
child,
offenderSet,
budget,
level + 1,
);
if (childSpan) {
performanceSpan.children.push(childSpan);
}
if (budget.count >= budget.limit) {
break;
}
}
return performanceSpan;
}
function formatTraceSpanDescription(span: TraceSpan): string {
if (span.name && span.name.trim().length > 0) {
return span.name.trim();
}
if (span.description && span.description.trim().length > 0) {
return span.description.trim();
}
if (span.op && span.op.trim().length > 0) {
return span.op.trim();
}
return "unnamed";
}
function getTraceSpanDurationMs(span: TraceSpan): number {
if (typeof span.duration === "number" && span.duration >= 0) {
return span.duration;
}
if (
typeof (span as { end_timestamp?: number }).end_timestamp === "number" &&
typeof span.start_timestamp === "number"
) {
const deltaSeconds =
(span as { end_timestamp: number }).end_timestamp - span.start_timestamp;
if (Number.isFinite(deltaSeconds) && deltaSeconds >= 0) {
return deltaSeconds * 1000;
}
}
return 0;
}
function buildOffenderSummaries(
spans: RawSpan[],
offenderSpanIds: string[],
): string[] {
if (offenderSpanIds.length === 0) {
return [];
}
const spanMap = new Map<string, RawSpan>();
for (const span of spans) {
const identifier = getSpanIdentifier(span);
if (identifier) {
spanMap.set(identifier, span);
}
}
const summaries: string[] = [];
for (const offenderId of offenderSpanIds) {
const span = spanMap.get(offenderId);
if (span) {
const description = span.description || span.op || `Span ${offenderId}`;
const duration = getSpanDurationMs(span);
const durationLabel = duration > 0 ? ` (${Math.round(duration)}ms)` : "";
summaries.push(`${description}${durationLabel} [${offenderId}] [N+1]`);
} else {
summaries.push(`Span ${offenderId} [N+1]`);
}
}
return summaries;
}
/**
* Renders a hierarchical tree of performance spans using box-drawing characters.
* Highlights N+1 queries with a special indicator.
*
* @param spans - Array of selected performance spans
* @returns Array of formatted strings representing the tree
*/
function renderPerformanceSpanTree(spans: PerformanceSpan[]): string[] {
const lines: string[] = [];
function renderSpan(span: PerformanceSpan, prefix = "", isLast = true): void {
const connector = prefix === "" ? "" : isLast ? "└─ " : "├─ ";
const displayName = span.description?.trim() || span.op || "unnamed";
const shortId = span.span_id ? span.span_id.substring(0, 8) : "unknown";
const durationDisplay =
span.duration > 0 ? `${Math.round(span.duration)}ms` : "unknown";
const metadataParts: string[] = [shortId];
if (span.op && span.op !== "default") {
metadataParts.push(span.op);
}
metadataParts.push(durationDisplay);
const line = `${prefix}${connector}${displayName} [${metadataParts.join(
" · ",
)}]${span.is_n1_query ? " [N+1]" : ""}`;
lines.push(line);
// Render children
for (let i = 0; i < span.children.length; i++) {
const child = span.children[i];
const isLastChild = i === span.children.length - 1;
const childPrefix = prefix + (isLast ? " " : "│ ");
renderSpan(child, childPrefix, isLastChild);
}
}
for (let i = 0; i < spans.length; i++) {
const span = spans[i];
const isLastRoot = i === spans.length - 1;
renderSpan(span, "", isLastRoot);
}
return lines;
}
function selectN1QuerySpans(
spans: RawSpan[],
evidence: N1EvidenceData,
maxSpans = MAX_SPANS_IN_TREE,
): PerformanceSpan[] {
const selected: PerformanceSpan[] = [];
let spanCount = 0;
const offenderSpanIds = normalizeIdArray(evidence.offenderSpanIds);
const parentSpanIds = normalizeIdArray(evidence.parentSpanIds);
let parentSpan: PerformanceSpan | null = null;
if (parentSpanIds.length > 0) {
const parent = spans.find((span) => {
const identifier = getSpanIdentifier(span);
return identifier ? parentSpanIds.includes(identifier) : false;
});
if (parent) {
parentSpan = {
span_id: getSpanIdentifier(parent) ?? "unknown",
op: parent.op || "unknown",
description:
parent.description || evidence.parentSpan || "Parent Operation",
duration: getSpanDurationMs(parent),
is_n1_query: false,
children: [],
level: 0,
};
selected.push(parentSpan);
spanCount += 1;
}
}
if (offenderSpanIds.length > 0) {
const offenderSet = new Set(offenderSpanIds);
const offenderSpans = spans
.filter((span) => {
const identifier = getSpanIdentifier(span);
return identifier ? offenderSet.has(identifier) : false;
})
.slice(0, Math.max(0, maxSpans - spanCount));
for (const span of offenderSpans) {
const perfSpan: PerformanceSpan = {
span_id: getSpanIdentifier(span) ?? "unknown",
op: span.op || "db.query",
description: span.description || "Database Query",
duration: getSpanDurationMs(span),
is_n1_query: true,
children: [],
level: parentSpan ? 1 : 0,
};
if (parentSpan) {
parentSpan.children.push(perfSpan);
} else {
selected.push(perfSpan);
}
spanCount += 1;
if (spanCount >= maxSpans) {
break;
}
}
}
return selected;
}
/**
* Known Sentry performance issue types that we handle.
*
* NOTE: We intentionally only implement formatters for high-value performance issues
* that provide complex insights. Not all issue types need custom formatting - many
* can rely on the generic evidenceDisplay fields that Sentry provides.
*
* Currently fully implemented:
* - N+1 query detection (DB and API)
*
* Partially implemented:
* - Slow DB queries (shows parent span only)
*
* Not implemented (lower priority):
* - Asset-related issues (render blocking, uncompressed, large payloads)
* - File I/O issues
* - Consecutive queries
*/
const KNOWN_PERFORMANCE_ISSUE_TYPES = {
N_PLUS_ONE_DB_QUERIES: "performance_n_plus_one_db_queries",
N_PLUS_ONE_API_CALLS: "performance_n_plus_one_api_calls",
SLOW_DB_QUERY: "performance_slow_db_query",
RENDER_BLOCKING_ASSET: "performance_render_blocking_asset",
CONSECUTIVE_DB_QUERIES: "performance_consecutive_db_queries",
FILE_IO_MAIN_THREAD: "performance_file_io_main_thread",
M_N_PLUS_ONE_DB_QUERIES: "performance_m_n_plus_one_db_queries",
UNCOMPRESSED_ASSET: "performance_uncompressed_asset",
LARGE_HTTP_PAYLOAD: "performance_large_http_payload",
} as const;
/**
* Map numeric occurrence types to issue types (from Sentry's codebase).
*
* Sentry uses numeric type IDs internally in the occurrence data structure,
* but string issue types in the UI and other APIs. This mapping converts
* between them.
*
* Source: sentry/static/app/types/group.tsx in Sentry's codebase
* Range: 1xxx = transaction-based performance issues
* 2xxx = profile-based performance issues
*/
const OCCURRENCE_TYPE_TO_ISSUE_TYPE: Record<number, string> = {
1001: KNOWN_PERFORMANCE_ISSUE_TYPES.SLOW_DB_QUERY,
1004: KNOWN_PERFORMANCE_ISSUE_TYPES.RENDER_BLOCKING_ASSET,
1006: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES,
1906: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES, // Alternative ID for N+1 DB
1007: KNOWN_PERFORMANCE_ISSUE_TYPES.CONSECUTIVE_DB_QUERIES,
1008: KNOWN_PERFORMANCE_ISSUE_TYPES.FILE_IO_MAIN_THREAD,
1009: "performance_consecutive_http",
1010: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_API_CALLS,
1910: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_API_CALLS, // Alternative ID for N+1 API
1012: KNOWN_PERFORMANCE_ISSUE_TYPES.UNCOMPRESSED_ASSET,
1013: "performance_db_main_thread",
1015: KNOWN_PERFORMANCE_ISSUE_TYPES.LARGE_HTTP_PAYLOAD,
1016: "performance_http_overhead",
};
// Type alias currently unused but kept for potential future type safety
// type PerformanceIssueType = typeof KNOWN_PERFORMANCE_ISSUE_TYPES[keyof typeof KNOWN_PERFORMANCE_ISSUE_TYPES];
/**
* Formats N+1 query issue evidence data.
*
* N+1 queries are a common performance anti-pattern where code executes
* 1 query to get a list of items, then N additional queries (one per item)
* instead of using a single JOIN or batch query.
*
* Evidence fields we use:
* - parentSpan: The operation that triggered the N+1 queries
* - repeatingSpansCompact/repeatingSpans: The query pattern being repeated
* - numberRepeatingSpans: How many times the query was executed
* - offenderSpanIds: IDs of the actual span instances
* - parentSpanIds: IDs of parent spans for tree visualization
*/
function formatN1QueryEvidence(
evidenceData: N1EvidenceData,
spansData: unknown,
performanceTrace?: Trace,
): string {
const parts: string[] = [];
// Format parent span info if available
if (evidenceData.parentSpan) {
parts.push("**Parent Operation:**");
parts.push(`${evidenceData.parentSpan}`);
parts.push("");
}
// Format repeating spans (the N+1 queries)
const repeatingLines = getRepeatingSpanLines(evidenceData);
if (repeatingLines.length > 0) {
parts.push("### Repeated Database Queries");
parts.push("");
const queryCount = evidenceData.numberRepeatingSpans
? safeParseInt(evidenceData.numberRepeatingSpans, 0)
: evidenceData.numPatternRepetitions ||
evidenceData.offenderSpanIds?.length ||
0;
if (queryCount > 0) {
parts.push(`**Query executed ${queryCount} times:**`);
}
// Show the query pattern - if single line, render as SQL block; if multiple, as list
if (repeatingLines.length === 1) {
parts.push("```sql");
parts.push(repeatingLines[0]);
parts.push("```");
parts.push("");
} else {
parts.push("**Repeated operations:**");
for (const line of repeatingLines) {
parts.push(`- ${line}`);
}
parts.push("");
}
}
const parentSpanIds = normalizeIdArray(evidenceData.parentSpanIds);
const offenderSpanIds = normalizeIdArray(evidenceData.offenderSpanIds);
const traceLines = performanceTrace
? buildTraceSpanTree(
performanceTrace,
parentSpanIds,
offenderSpanIds,
MAX_SPANS_IN_TREE,
)
: [];
if (traceLines.length > 0) {
parts.push(`### Span Tree (Limited to ${MAX_SPANS_IN_TREE} spans)`);
parts.push("");
parts.push("```");
parts.push(...traceLines);
parts.push("```");
parts.push("");
} else {
const spanTree = isValidSpanArray(spansData)
? selectN1QuerySpans(spansData, evidenceData, MAX_SPANS_IN_TREE)
: [];
if (spanTree.length > 0) {
parts.push(`### Span Tree (Limited to ${MAX_SPANS_IN_TREE} spans)`);
parts.push("");
parts.push("```");
parts.push(...renderPerformanceSpanTree(spanTree));
parts.push("```");
parts.push("");
} else if (isValidSpanArray(spansData)) {
// Only show offender summaries if we have spans data but couldn't build a tree
const offenderSummaries = buildOffenderSummaries(
spansData as RawSpan[],
offenderSpanIds,
);
if (offenderSummaries.length > 0) {
parts.push("### Offending Spans");
parts.push("");
for (const summary of offenderSummaries) {
parts.push(`- ${summary}`);
}
}
}
}
return parts.join("\n");
}
/**
* Formats slow DB query issue evidence data.
*
* Currently only partially implemented - shows parent span information.
* Full implementation would show query duration, explain plan, etc.
*
* This is lower priority as the generic evidenceDisplay fields usually
* provide sufficient information for slow query issues.
*/
function formatSlowDbQueryEvidence(
evidenceData: SlowDbEvidenceData,
spansData: unknown,
): string {
const parts: string[] = [];
// Show parent span if available (generic field that applies to slow queries)
if (evidenceData.parentSpan) {
parts.push("**Parent Operation:**");
parts.push(`${evidenceData.parentSpan}`);
parts.push("");
}
// TODO: Implement slow query specific fields when we know the structure
// Potential fields: query duration, database name, query plan
console.warn(
"[formatSlowDbQueryEvidence] Evidence data rendering not yet fully implemented",
);
return parts.join("\n");
}
/**
* Formats performance issue details from transaction events based on the issue type.
*
* This is the main dispatcher for performance issue formatting. It:
* 1. Detects the issue type from occurrence data (numeric or string)
* 2. Calls the appropriate type-specific formatter if implemented
* 3. Falls back to generic evidenceDisplay fields for unimplemented types
* 4. Provides span analysis fallback for events without occurrence data
*
* The occurrence data structure comes from Sentry's performance issue detection
* and contains evidence about what triggered the issue.
*
* @param event - The transaction event containing performance issue data
* @param spansData - The spans data from the event entries
* @returns Formatted markdown string with performance issue details
*/
function formatPerformanceIssueOutput(
event: Event,
spansData: unknown,
options?: {
performanceTrace?: Trace;
},
): string {
const parts: string[] = [];
// Check if we have occurrence data
const occurrence = (event as any).occurrence;
if (!occurrence) {
return "";
}
// Get issue type - occurrence.type is numeric, issueType may be a string
let issueType: string | undefined;
if (typeof occurrence.type === "number") {
issueType = OCCURRENCE_TYPE_TO_ISSUE_TYPE[occurrence.type];
} else {
issueType = occurrence.issueType || occurrence.type;
}
const evidenceData = occurrence.evidenceData;
// Process evidence data based on known performance issue types
if (evidenceData) {
switch (issueType) {
case KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES:
case KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_API_CALLS:
case KNOWN_PERFORMANCE_ISSUE_TYPES.M_N_PLUS_ONE_DB_QUERIES: {
const result = formatN1QueryEvidence(
evidenceData,
spansData,
options?.performanceTrace,
);
if (result) parts.push(result);
break;
}
case KNOWN_PERFORMANCE_ISSUE_TYPES.SLOW_DB_QUERY: {
const result = formatSlowDbQueryEvidence(evidenceData, spansData);
if (result) parts.push(result);
break;
}
default:
// We don't implement formatters for all performance issue types.
// Many lower-priority issues (consecutive queries, asset issues, file I/O)
// work fine with just the generic evidenceDisplay fields below.
// Only high-value, complex issues like N+1 queries need custom formatting.
if (issueType) {
console.warn(
`[formatPerformanceIssueOutput] No custom formatter for issue type: ${issueType}`,
);
}
// Fall through to show generic evidence display below
}
}
// Show transaction name if available for any performance issue (generic field)
if (evidenceData?.transactionName) {
parts.push("**Transaction:**");
parts.push(`${evidenceData.transactionName}`);
parts.push("");
}
// Always show evidence display if available (this is generic and doesn't require type knowledge)
if (occurrence.evidenceDisplay?.length > 0) {
for (const display of occurrence.evidenceDisplay) {
if (display.important) {
parts.push(`**${display.name}:**`);
parts.push(`${display.value}`);
parts.push("");
}
}
}
return parts.length > 0 ? `${parts.join("\n")}\n` : "";
}
function formatTags(tags: z.infer<typeof EventSchema>["tags"]) {
if (!tags || tags.length === 0) {
return "";
}
return `### Tags\n\n${tags
.map((tag) => `**${tag.key}**: ${tag.value}`)
.join("\n")}\n\n`;
}
function formatContext(context: z.infer<typeof EventSchema>["context"]) {
if (!context || Object.keys(context).length === 0) {
return "";
}
return `### Extra Data\n\nAdditional data attached to this event.\n\n${Object.entries(
context,
)
.map(([key, value]) => {
return `**${key}**: ${JSON.stringify(value, undefined, 2)}`;
})
.join("\n")}\n\n`;
}
function formatContexts(contexts: z.infer<typeof EventSchema>["contexts"]) {
if (!contexts || Object.keys(contexts).length === 0) {
return "";
}
return `### Additional Context\n\nThese are additional context provided by the user when they're instrumenting their application.\n\n${Object.entries(
contexts,
)
.map(
([name, data]) =>
`**${name}**\n${Object.entries(data)
.filter(([key, _]) => key !== "type")
.map(([key, value]) => {
return `${key}: ${JSON.stringify(value, undefined, 2)}`;
})
.join("\n")}`,
)
.join("\n\n")}\n\n`;
}
/**
* Formats a brief Seer analysis summary for inclusion in issue details.
* Shows current status and high-level insights, prompting to use analyze_issue_with_seer for full details.
*
* @param autofixState - The autofix state containing Seer analysis data
* @param organizationSlug - The organization slug for the issue
* @param issueId - The issue ID (shortId)
* @returns Formatted markdown string with Seer summary, or empty string if no analysis exists
*/
function formatSeerSummary(
autofixState: AutofixRunState | undefined,
organizationSlug: string,
issueId: string,
): string {
if (!autofixState || !autofixState.autofix) {
return "";
}
const { autofix } = autofixState;
const parts: string[] = [];
parts.push("## Seer Analysis");
parts.push("");
// Show status first
const statusDisplay = getStatusDisplayName(autofix.status);
if (!isTerminalStatus(autofix.status)) {
parts.push(`**Status:** ${statusDisplay}`);
parts.push("");
}
// Show summary of what we have so far
if (autofix.steps.length > 0) {
const completedSteps = autofix.steps.filter(
(step) => step.status === "COMPLETED",
);
// Find the solution step if available
const solutionStep = completedSteps.find(
(step) => step.type === "solution",
);
if (solutionStep) {
// For solution steps, use the description directly
const solutionDescription = solutionStep.description;
if (
solutionDescription &&
typeof solutionDescription === "string" &&
solutionDescription.trim()
) {
parts.push("**Summary:**");
parts.push(solutionDescription.trim());
} else {
// Fallback to extracting from output if no description
const solutionOutput = getOutputForAutofixStep(solutionStep);
const lines = solutionOutput.split("\n");
const firstParagraph = lines.find(
(line) =>
line.trim().length > 50 &&
!line.startsWith("#") &&
!line.startsWith("*"),
);
if (firstParagraph) {
parts.push("**Summary:**");
parts.push(firstParagraph.trim());
}
}
} else if (completedSteps.length > 0) {
// Show what steps have been completed so far
const rootCauseStep = completedSteps.find(
(step) => step.type === "root_cause_analysis",
);
if (rootCauseStep) {
const typedStep = rootCauseStep as z.infer<
typeof AutofixRunStepRootCauseAnalysisSchema
>;
if (
typedStep.causes &&
typedStep.causes.length > 0 &&
typedStep.causes[0].description
) {
parts.push("**Root Cause Identified:**");
parts.push(typedStep.causes[0].description.trim());
}
} else {
// Show generic progress
parts.push(
`**Progress:** ${completedSteps.length} of ${autofix.steps.length} steps completed`,
);
}
}
} else {
// No steps yet - check for terminal states first
if (isTerminalStatus(autofix.status)) {
if (autofix.status === "FAILED" || autofix.status === "ERROR") {
parts.push("**Status:** Analysis failed.");
} else if (autofix.status === "CANCELLED") {
parts.push("**Status:** Analysis was cancelled.");
} else if (
autofix.status === "NEED_MORE_INFORMATION" ||
autofix.status === "WAITING_FOR_USER_RESPONSE"
) {
parts.push(
"**Status:** Analysis paused - additional information needed.",
);
}
} else {
parts.push("Analysis has started but no results yet.");
}
}
// Add specific messages for terminal states when steps exist
if (autofix.steps.length > 0 && isTerminalStatus(autofix.status)) {
if (autofix.status === "FAILED" || autofix.status === "ERROR") {
parts.push("");
parts.push("**Status:** Analysis failed.");
} else if (autofix.status === "CANCELLED") {
parts.push("");
parts.push("**Status:** Analysis was cancelled.");
} else if (
autofix.status === "NEED_MORE_INFORMATION" ||
autofix.status === "WAITING_FOR_USER_RESPONSE"
) {
parts.push("");
parts.push(
"**Status:** Analysis paused - additional information needed.",
);
}
}
// Always suggest using analyze_issue_with_seer for more details
parts.push("");
parts.push(
`**Note:** For detailed root cause analysis and solutions, call \`analyze_issue_with_seer(organizationSlug='${organizationSlug}', issueId='${issueId}')\``,
);
return `${parts.join("\n")}\n\n`;
}
/**
* Formats a Sentry issue with its latest event into comprehensive markdown output.
* Includes issue metadata, event details, and usage instructions.
*
* @param params - Object containing organization slug, issue, event, and API service
* @returns Formatted markdown string with complete issue information
*/
export function formatIssueOutput({
organizationSlug,
issue,
event,
apiService,
autofixState,
performanceTrace,
}: {
organizationSlug: string;
issue: Issue;
event: Event;
apiService: SentryApiService;
autofixState?: AutofixRunState;
performanceTrace?: Trace;
}) {
let output = `# Issue ${issue.shortId} in **${organizationSlug}**\n\n`;
// Check if this is a performance issue based on issueCategory or issueType
// Performance issues can have various categories like 'db_query' but issueType starts with 'performance_'
const isPerformanceIssue =
issue.issueType?.startsWith("performance_") ||
issue.issueCategory === "performance";
if (isPerformanceIssue && issue.metadata) {
// For performance issues, use metadata for better context
const issueTitle = issue.metadata.title || issue.title;
output += `**Description**: ${issueTitle}\n`;
if (issue.metadata.location) {
output += `**Location**: ${issue.metadata.location}\n`;
}
if (issue.metadata.value) {
output += `**Query Pattern**: \`${issue.metadata.value}\`\n`;
}
} else {
// For regular errors and other issues
output += `**Description**: ${issue.title}\n`;
output += `**Culprit**: ${issue.culprit}\n`;
}
output += `**First Seen**: ${new Date(issue.firstSeen).toISOString()}\n`;
output += `**Last Seen**: ${new Date(issue.lastSeen).toISOString()}\n`;
output += `**Occurrences**: ${issue.count}\n`;
output += `**Users Impacted**: ${issue.userCount}\n`;
output += `**Status**: ${issue.status}\n`;
output += `**Platform**: ${issue.platform}\n`;
output += `**Project**: ${issue.project.name}\n`;
output += `**URL**: ${apiService.getIssueUrl(organizationSlug, issue.shortId)}\n`;
output += "\n";
output += "## Event Details\n\n";
output += `**Event ID**: ${event.id}\n`;
// "default" type represents error events without exception data
if (event.type === "error" || event.type === "default") {
output += `**Occurred At**: ${new Date((event as z.infer<typeof ErrorEventSchema>).dateCreated).toISOString()}\n`;
}
if (event.message) {
output += `**Message**:\n${event.message}\n`;
}
output += "\n";
output += formatEventOutput(event, { performanceTrace });
// Add Seer context if available
if (autofixState) {
output += formatSeerSummary(autofixState, organizationSlug, issue.shortId);
}
output += "# Using this information\n\n";
output += `- You can reference the IssueID in commit messages (e.g. \`Fixes ${issue.shortId}\`) to automatically close the issue when the commit is merged.\n`;
output +=
"- The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code.\n";
return output;
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/formatting.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from "vitest";
import { formatEventOutput, formatFrameHeader } from "./formatting";
import type { Event } from "../api-client/types";
import {
EventBuilder,
createFrame,
frameFactories,
createStackTrace,
createExceptionValue,
createThread,
testEvents,
createFrameWithContext,
} from "./test-fixtures";
// Helper functions to reduce duplication in event creation
function createPythonExceptionEvent(
errorType: string,
errorMessage: string,
frames: any[],
): Event {
return new EventBuilder("python")
.withException(
createExceptionValue({
type: errorType,
value: errorMessage,
stacktrace: createStackTrace(frames),
}),
)
.build();
}
function createSimpleExceptionEvent(
platform: string,
errorType: string,
errorMessage: string,
frame: any,
): Event {
const builder = new EventBuilder(platform);
// Remove the contexts property to avoid "Additional Context" section
const event = builder
.withException(
createExceptionValue({
type: errorType,
value: errorMessage,
stacktrace: createStackTrace([frame]),
}),
)
.build();
// Remove contexts to match original test expectations
event.contexts = undefined;
return event;
}
describe("formatFrameHeader", () => {
it("uses platform as fallback when language detection fails", () => {
// Frame with no clear language indicators
const unknownFrame = {
filename: "/path/to/file.unknown",
function: "someFunction",
lineNo: 42,
};
// Without platform - should use generic format
expect(formatFrameHeader(unknownFrame)).toBe(
" at someFunction (/path/to/file.unknown:42)",
);
// With platform python - should use Python format
expect(formatFrameHeader(unknownFrame, undefined, "python")).toBe(
' File "/path/to/file.unknown", line 42, in someFunction',
);
// With platform java - should use Java format
expect(formatFrameHeader(unknownFrame, undefined, "java")).toBe(
"at UnknownClass.someFunction(/path/to/file.unknown:42)",
);
});
it("formats Java stack traces correctly", () => {
// With module and filename
const javaFrame1 = {
module: "com.example.ClassName",
function: "methodName",
filename: "ClassName.java",
lineNo: 123,
};
expect(formatFrameHeader(javaFrame1)).toBe(
"at com.example.ClassName.methodName(ClassName.java:123)",
);
// Without filename (common in Java) - needs platform hint
const javaFrame2 = {
module: "com.example.ClassName",
function: "methodName",
lineNo: 123,
};
expect(formatFrameHeader(javaFrame2, undefined, "java")).toBe(
"at com.example.ClassName.methodName(Unknown Source:123)",
);
});
it("formats Python stack traces correctly", () => {
const pythonFrame = {
filename: "/path/to/file.py",
function: "function_name",
lineNo: 42,
};
expect(formatFrameHeader(pythonFrame)).toBe(
' File "/path/to/file.py", line 42, in function_name',
);
// Module only (no filename) - needs platform hint
const pythonModuleFrame = {
module: "mymodule",
function: "function_name",
lineNo: 42,
};
expect(formatFrameHeader(pythonModuleFrame, undefined, "python")).toBe(
' File "mymodule", line 42, in function_name',
);
});
it("formats JavaScript stack traces correctly", () => {
// With column number
const jsFrame1 = {
filename: "/path/to/file.js",
function: "functionName",
lineNo: 10,
colNo: 15,
};
expect(formatFrameHeader(jsFrame1)).toBe(
"/path/to/file.js:10:15 (functionName)",
);
// Without column number but .js extension
const jsFrame2 = {
filename: "/path/to/file.js",
function: "functionName",
lineNo: 10,
};
expect(formatFrameHeader(jsFrame2)).toBe(
"/path/to/file.js:10 (functionName)",
);
// Anonymous function (no function name)
const jsFrame3 = {
filename: "/path/to/file.js",
lineNo: 10,
colNo: 15,
};
expect(formatFrameHeader(jsFrame3)).toBe("/path/to/file.js:10:15");
});
it("formats Ruby stack traces correctly", () => {
const rubyFrame = {
filename: "/path/to/file.rb",
function: "method_name",
lineNo: 42,
};
expect(formatFrameHeader(rubyFrame)).toBe(
" from /path/to/file.rb:42:in `method_name`",
);
// Without function name
const rubyFrame2 = {
filename: "/path/to/file.rb",
lineNo: 42,
};
expect(formatFrameHeader(rubyFrame2)).toBe(
" from /path/to/file.rb:42:in",
);
});
it("formats PHP stack traces correctly", () => {
// With frame index
const phpFrame1 = {
filename: "/path/to/file.php",
function: "functionName",
lineNo: 42,
};
expect(formatFrameHeader(phpFrame1, 0)).toBe(
"#0 /path/to/file.php(42): functionName()",
);
// Without frame index
const phpFrame2 = {
filename: "/path/to/file.php",
function: "functionName",
lineNo: 42,
};
expect(formatFrameHeader(phpFrame2)).toBe(
"/path/to/file.php(42): functionName()",
);
});
it("formats unknown languages with generic format", () => {
const unknownFrame = {
filename: "/path/to/file.unknown",
function: "someFunction",
lineNo: 42,
};
expect(formatFrameHeader(unknownFrame)).toBe(
" at someFunction (/path/to/file.unknown:42)",
);
});
it("prioritizes duck typing over platform when clear indicators exist", () => {
// Java file but platform says python - should use Java format
const javaFrame = {
filename: "Example.java",
module: "com.example.Example",
function: "doSomething",
lineNo: 42,
};
expect(formatFrameHeader(javaFrame, undefined, "python")).toBe(
"at com.example.Example.doSomething(Example.java:42)",
);
// Python file but platform says java - should use Python format
const pythonFrame = {
filename: "/app/example.py",
function: "do_something",
lineNo: 42,
};
expect(formatFrameHeader(pythonFrame, undefined, "java")).toBe(
' File "/app/example.py", line 42, in do_something',
);
});
});
describe("formatEventOutput", () => {
it("formats Java thread stack traces correctly", () => {
const event = testEvents.javaThreadError(
"Cannot use this function, please use update(String sql, PreparedStatementSetter pss) instead",
);
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
Cannot use this function, please use update(String sql, PreparedStatementSetter pss) instead
\`\`\`
**Thread** (CONTRACT_WORKER)
**Stacktrace:**
\`\`\`
at java.lang.Thread.run(Thread.java:833)
at com.citics.eqd.mq.aeron.AeronServer.lambda$start$3(AeronServer.java:110)
\`\`\`
"
`);
});
it("formats Python exception traces correctly", () => {
const event = testEvents.pythonException("Invalid value");
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
ValueError: Invalid value
\`\`\`
**Stacktrace:**
\`\`\`
File "/app/main.py", line 42, in process_data
File "/app/utils.py", line 15, in validate
\`\`\`
"
`);
});
it("should render enhanced in-app frame with context lines", () => {
const event = new EventBuilder("python")
.withException(
createExceptionValue({
type: "ValueError",
value: "Something went wrong",
stacktrace: createStackTrace([
createFrame({
filename: "/usr/lib/python3.8/json/__init__.py",
function: "loads",
lineNo: 357,
inApp: false,
}),
createFrameWithContext(
{
filename: "/app/services/payment.py",
function: "process_payment",
lineNo: 42,
},
[
[37, " def process_payment(self, amount, user_id):"],
[38, " user = self.get_user(user_id)"],
[39, " if not user:"],
[40, ' raise ValueError("User not found")'],
[41, " "],
[42, " balance = user.account.balance"],
[43, " if balance < amount:"],
[44, " raise InsufficientFundsError()"],
[45, " "],
[46, " transaction = Transaction(user, amount)"],
],
),
]),
}),
)
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
ValueError: Something went wrong
\`\`\`
**Most Relevant Frame:**
─────────────────────
File "/app/services/payment.py", line 42, in process_payment
39 │ if not user:
40 │ raise ValueError("User not found")
41 │
→ 42 │ balance = user.account.balance
43 │ if balance < amount:
44 │ raise InsufficientFundsError()
45 │
**Full Stacktrace:**
────────────────
\`\`\`
File "/usr/lib/python3.8/json/__init__.py", line 357, in loads
File "/app/services/payment.py", line 42, in process_payment
balance = user.account.balance
\`\`\`
"
`);
});
it("should render enhanced in-app frame with variables", () => {
const event = new EventBuilder("python")
.withException(
createExceptionValue({
type: "ValueError",
value: "Something went wrong",
stacktrace: createStackTrace([
createFrame({
filename: "/app/services/payment.py",
function: "process_payment",
lineNo: 42,
inApp: true,
vars: {
amount: 150.0,
user_id: "usr_123456",
user: null,
self: { type: "PaymentService", id: 1234 },
},
}),
]),
}),
)
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
ValueError: Something went wrong
\`\`\`
**Most Relevant Frame:**
─────────────────────
File "/app/services/payment.py", line 42, in process_payment
Local Variables:
├─ amount: 150
├─ user_id: "usr_123456"
├─ user: null
└─ self: {"type":"PaymentService","id":1234}
**Full Stacktrace:**
────────────────
\`\`\`
File "/app/services/payment.py", line 42, in process_payment
\`\`\`
"
`);
});
it("should handle frames without in-app or enhanced data", () => {
const event = new EventBuilder("python")
.withException(
createExceptionValue({
type: "ValueError",
value: "Something went wrong",
stacktrace: createStackTrace([
frameFactories.python({ lineNo: 10, function: "main" }),
]),
}),
)
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
ValueError: Something went wrong
\`\`\`
**Stacktrace:**
\`\`\`
File "/app/main.py", line 10, in main
\`\`\`
"
`);
});
it("should work with thread interface containing in-app frame", () => {
const event = new EventBuilder("java")
.withThread(
createThread({
id: 1,
crashed: true,
name: "main",
stacktrace: createStackTrace([
frameFactories.java({
module: "java.lang.Thread",
function: "run",
filename: "Thread.java",
lineNo: 748,
inApp: false,
}),
createFrameWithContext(
{
module: "com.example.PaymentService",
function: "processPayment",
filename: "PaymentService.java",
lineNo: 42,
},
[
[40, " User user = getUser(userId);"],
[41, " if (user == null) {"],
[42, " throw new UserNotFoundException(userId);"],
[43, " }"],
[44, " return user.getBalance();"],
],
{
userId: "12345",
user: null,
},
),
]),
}),
)
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"**Thread** (main)
**Most Relevant Frame:**
─────────────────────
at com.example.PaymentService.processPayment(PaymentService.java:42)
40 │ User user = getUser(userId);
41 │ if (user == null) {
→ 42 │ throw new UserNotFoundException(userId);
43 │ }
44 │ return user.getBalance();
Local Variables:
├─ userId: "12345"
└─ user: null
**Full Stacktrace:**
────────────────
\`\`\`
at java.lang.Thread.run(Thread.java:748)
at com.example.PaymentService.processPayment(PaymentService.java:42)
throw new UserNotFoundException(userId);
\`\`\`
"
`);
});
describe("Enhanced frame rendering variations", () => {
it("should handle Python format with enhanced frame", () => {
const event = createSimpleExceptionEvent(
"python",
"AttributeError",
"'NoneType' object has no attribute 'balance'",
createFrameWithContext(
{
filename: "/app/models/user.py",
function: "get_balance",
lineNo: 25,
},
[
[23, " def get_balance(self):"],
[24, " # This will fail if account is None"],
[25, " return self.account.balance"],
[26, ""],
[27, " def set_balance(self, amount):"],
],
{
self: { id: 123, account: null },
},
),
);
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
AttributeError: 'NoneType' object has no attribute 'balance'
\`\`\`
**Most Relevant Frame:**
─────────────────────
File "/app/models/user.py", line 25, in get_balance
23 │ def get_balance(self):
24 │ # This will fail if account is None
→ 25 │ return self.account.balance
26 │
27 │ def set_balance(self, amount):
Local Variables:
└─ self: {"id":123,"account":null}
**Full Stacktrace:**
────────────────
\`\`\`
File "/app/models/user.py", line 25, in get_balance
return self.account.balance
\`\`\`
"
`);
});
it("should handle JavaScript format with enhanced frame", () => {
const event = createSimpleExceptionEvent(
"javascript",
"TypeError",
"Cannot read property 'name' of undefined",
createFrameWithContext(
{
filename: "/src/components/UserProfile.tsx",
function: "UserProfile",
lineNo: 15,
colNo: 28,
},
[
[
13,
"export const UserProfile: React.FC<Props> = ({ userId }) => {",
],
[14, " const user = useUser(userId);"],
[15, " const displayName = user.profile.name;"],
[16, " "],
[17, " return ("],
],
{
userId: "usr_123",
user: undefined,
displayName: undefined,
},
),
);
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
TypeError: Cannot read property 'name' of undefined
\`\`\`
**Most Relevant Frame:**
─────────────────────
/src/components/UserProfile.tsx:15:28 (UserProfile)
13 │ export const UserProfile: React.FC<Props> = ({ userId }) => {
14 │ const user = useUser(userId);
→ 15 │ const displayName = user.profile.name;
16 │
17 │ return (
Local Variables:
├─ userId: "usr_123"
├─ user: undefined
└─ displayName: undefined
**Full Stacktrace:**
────────────────
\`\`\`
/src/components/UserProfile.tsx:15:28 (UserProfile)
const displayName = user.profile.name;
\`\`\`
"
`);
});
it("should handle Ruby format with enhanced frame", () => {
const event = new EventBuilder("ruby")
.withException(
createExceptionValue({
type: "NoMethodError",
value: "undefined method `charge' for nil:NilClass",
stacktrace: createStackTrace([
createFrameWithContext(
{
filename: "/app/services/payment_service.rb",
function: "process_payment",
lineNo: 8,
},
[
[6, " def process_payment(amount)"],
[7, " payment_method = user.payment_method"],
[8, " payment_method.charge(amount)"],
[9, " rescue => e"],
[10, " Rails.logger.error(e)"],
],
{
amount: 99.99,
payment_method: null,
},
),
]),
}),
)
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
NoMethodError: undefined method \`charge' for nil:NilClass
\`\`\`
**Most Relevant Frame:**
─────────────────────
from /app/services/payment_service.rb:8:in \`process_payment\`
6 │ def process_payment(amount)
7 │ payment_method = user.payment_method
→ 8 │ payment_method.charge(amount)
9 │ rescue => e
10 │ Rails.logger.error(e)
Local Variables:
├─ amount: 99.99
└─ payment_method: null
**Full Stacktrace:**
────────────────
\`\`\`
from /app/services/payment_service.rb:8:in \`process_payment\`
payment_method.charge(amount)
\`\`\`
"
`);
});
it("should handle PHP format with enhanced frame", () => {
const event = new EventBuilder("php")
.withException(
createExceptionValue({
type: "Error",
value: "Call to a member function getName() on null",
stacktrace: createStackTrace([
createFrameWithContext(
{
filename: "/var/www/app/User.php",
function: "getDisplayName",
lineNo: 45,
},
[
[43, " public function getDisplayName() {"],
[44, " $profile = $this->getProfile();"],
[45, " return $profile->getName();"],
[46, " }"],
],
{
profile: null,
},
),
]),
}),
)
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
Error: Call to a member function getName() on null
\`\`\`
**Most Relevant Frame:**
─────────────────────
/var/www/app/User.php(45): getDisplayName()
43 │ public function getDisplayName() {
44 │ $profile = $this->getProfile();
→ 45 │ return $profile->getName();
46 │ }
Local Variables:
└─ profile: null
**Full Stacktrace:**
────────────────
\`\`\`
/var/www/app/User.php(45): getDisplayName()
return $profile->getName();
\`\`\`
"
`);
});
it("should handle frame with context but no vars", () => {
const event = new EventBuilder("python")
.withException(
createExceptionValue({
type: "ValueError",
value: "Invalid configuration",
stacktrace: createStackTrace([
createFrameWithContext(
{
filename: "/app/config.py",
function: "load_config",
lineNo: 12,
},
[
[10, "def load_config():"],
[11, " if not os.path.exists(CONFIG_FILE):"],
[12, " raise ValueError('Invalid configuration')"],
[13, " with open(CONFIG_FILE) as f:"],
[14, " return json.load(f)"],
],
),
]),
}),
)
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
ValueError: Invalid configuration
\`\`\`
**Most Relevant Frame:**
─────────────────────
File "/app/config.py", line 12, in load_config
10 │ def load_config():
11 │ if not os.path.exists(CONFIG_FILE):
→ 12 │ raise ValueError('Invalid configuration')
13 │ with open(CONFIG_FILE) as f:
14 │ return json.load(f)
**Full Stacktrace:**
────────────────
\`\`\`
File "/app/config.py", line 12, in load_config
raise ValueError('Invalid configuration')
\`\`\`
"
`);
});
it("should handle frame with vars but no context", () => {
const event = createSimpleExceptionEvent(
"python",
"TypeError",
"unsupported operand type(s)",
createFrame({
filename: "/app/calculator.py",
function: "divide",
lineNo: 5,
inApp: true,
vars: {
numerator: 10,
denominator: "0",
result: undefined,
},
}),
);
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
TypeError: unsupported operand type(s)
\`\`\`
**Most Relevant Frame:**
─────────────────────
File "/app/calculator.py", line 5, in divide
Local Variables:
├─ numerator: 10
├─ denominator: "0"
└─ result: undefined
**Full Stacktrace:**
────────────────
\`\`\`
File "/app/calculator.py", line 5, in divide
\`\`\`
"
`);
});
it("should handle complex variable types", () => {
const event = createSimpleExceptionEvent(
"python",
"KeyError",
"'missing_key'",
createFrame({
filename: "/app/processor.py",
function: "process_data",
lineNo: 30,
inApp: true,
vars: {
string_var: "hello world",
number_var: 42,
float_var: 3.14,
bool_var: true,
null_var: null,
undefined_var: undefined,
array_var: [1, 2, 3],
object_var: { type: "User", id: 123 },
nested_object: {
user: { name: "John", age: 30 },
settings: { theme: "dark" },
},
empty_string: "",
zero: 0,
false_bool: false,
long_string:
"This is a very long string that should be handled properly in the output",
},
}),
);
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
KeyError: 'missing_key'
\`\`\`
**Most Relevant Frame:**
─────────────────────
File "/app/processor.py", line 30, in process_data
Local Variables:
├─ string_var: "hello world"
├─ number_var: 42
├─ float_var: 3.14
├─ bool_var: true
├─ null_var: null
├─ undefined_var: undefined
├─ array_var: [1,2,3]
├─ object_var: {"type":"User","id":123}
├─ nested_object: {"user":{"name":"John","age":30},"settings":{"theme":"dark"}}
├─ empty_string: ""
├─ zero: 0
├─ false_bool: false
└─ long_string: "This is a very long string that should be handled properly in the output"
**Full Stacktrace:**
────────────────
\`\`\`
File "/app/processor.py", line 30, in process_data
\`\`\`
"
`);
});
it("should truncate very long objects and arrays", () => {
const event = new EventBuilder("python")
.withException(
createExceptionValue({
type: "ValueError",
value: "Data processing error",
stacktrace: createStackTrace([
createFrame({
filename: "/app/processor.py",
function: "process_batch",
lineNo: 45,
inApp: true,
vars: {
small_array: [1, 2, 3],
large_array: Array(100)
.fill(0)
.map((_, i) => i),
small_object: { name: "test", value: 123 },
large_object: {
data: Array(50)
.fill(0)
.reduce(
(acc, _, i) => {
acc[`field${i}`] = `value${i}`;
return acc;
},
{} as Record<string, string>,
),
},
},
}),
]),
}),
)
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
ValueError: Data processing error
\`\`\`
**Most Relevant Frame:**
─────────────────────
File "/app/processor.py", line 45, in process_batch
Local Variables:
├─ small_array: [1,2,3]
├─ large_array: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26, ...]
├─ small_object: {"name":"test","value":123}
└─ large_object: {"data":{"field0":"value0","field1":"value1","field2":"value2", ...}
**Full Stacktrace:**
────────────────
\`\`\`
File "/app/processor.py", line 45, in process_batch
\`\`\`
"
`);
});
it("should show proper truncation format", () => {
const event = new EventBuilder("javascript")
.withException(
createExceptionValue({
type: "Error",
value: "Test error",
stacktrace: createStackTrace([
createFrame({
filename: "/app/test.js",
function: "test",
lineNo: 1,
inApp: true,
vars: {
shortArray: [1, 2, 3],
// This will be over 80 chars when stringified
longArray: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
],
shortObject: { a: 1, b: 2 },
// This will be over 80 chars when stringified
longObject: {
field1: "value1",
field2: "value2",
field3: "value3",
field4: "value4",
field5: "value5",
field6: "value6",
field7: "value7",
field8: "value8",
},
},
}),
]),
}),
)
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
Error: Test error
\`\`\`
**Most Relevant Frame:**
─────────────────────
/app/test.js:1 (test)
Local Variables:
├─ shortArray: [1,2,3]
├─ longArray: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27, ...]
├─ shortObject: {"a":1,"b":2}
└─ longObject: {"field1":"value1","field2":"value2","field3":"value3","field4":"value4", ...}
**Full Stacktrace:**
────────────────
\`\`\`
/app/test.js:1 (test)
\`\`\`
"
`);
});
it("should handle circular references gracefully", () => {
const circular: any = { name: "test" };
circular.self = circular;
const event = new EventBuilder("javascript")
.withException(
createExceptionValue({
type: "TypeError",
value: "Circular reference detected",
stacktrace: createStackTrace([
createFrame({
filename: "/app/utils.js",
function: "serialize",
lineNo: 10,
inApp: true,
vars: {
normal: { a: 1, b: 2 },
circular: circular,
},
}),
]),
}),
)
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
TypeError: Circular reference detected
\`\`\`
**Most Relevant Frame:**
─────────────────────
/app/utils.js:10 (serialize)
Local Variables:
├─ normal: {"a":1,"b":2}
└─ circular: <object>
**Full Stacktrace:**
────────────────
\`\`\`
/app/utils.js:10 (serialize)
\`\`\`
"
`);
});
it("should handle empty vars object", () => {
const event = createSimpleExceptionEvent(
"python",
"RuntimeError",
"Something went wrong",
createFrame({
filename: "/app/main.py",
function: "main",
lineNo: 1,
inApp: true,
vars: {},
}),
);
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
RuntimeError: Something went wrong
\`\`\`
**Most Relevant Frame:**
─────────────────────
File "/app/main.py", line 1, in main
**Full Stacktrace:**
────────────────
\`\`\`
File "/app/main.py", line 1, in main
\`\`\`
"
`);
});
it("should handle large context with proper windowing", () => {
const event = createSimpleExceptionEvent(
"python",
"IndexError",
"list index out of range",
createFrameWithContext(
{
filename: "/app/processor.py",
function: "process_items",
lineNo: 50,
},
[
[45, " # Setup phase"],
[46, " items = get_items()"],
[47, " results = []"],
[48, " "],
[49, " # This line causes the error"],
[50, " first_item = items[0]"],
[51, " "],
[52, " # Process items"],
[53, " for item in items:"],
[54, " results.append(process(item))"],
[55, " return results"],
],
{
items: [],
},
),
);
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
IndexError: list index out of range
\`\`\`
**Most Relevant Frame:**
─────────────────────
File "/app/processor.py", line 50, in process_items
47 │ results = []
48 │
49 │ # This line causes the error
→ 50 │ first_item = items[0]
51 │
52 │ # Process items
53 │ for item in items:
Local Variables:
└─ items: []
**Full Stacktrace:**
────────────────
\`\`\`
File "/app/processor.py", line 50, in process_items
first_item = items[0]
\`\`\`
"
`);
});
it("should handle context at beginning of file", () => {
const event = createSimpleExceptionEvent(
"python",
"ImportError",
"No module named 'missing_module'",
createFrameWithContext(
{
filename: "/app/startup.py",
function: "<module>",
lineNo: 2,
},
[
[1, "import os"],
[2, "import missing_module"],
[3, "import json"],
[4, ""],
[5, "def main():"],
],
),
);
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
ImportError: No module named 'missing_module'
\`\`\`
**Most Relevant Frame:**
─────────────────────
File "/app/startup.py", line 2, in <module>
1 │ import os
→ 2 │ import missing_module
3 │ import json
4 │
5 │ def main():
**Full Stacktrace:**
────────────────
\`\`\`
File "/app/startup.py", line 2, in <module>
import missing_module
\`\`\`
"
`);
});
});
describe("Chained exceptions", () => {
it("should render multiple chained exceptions", () => {
const event = new EventBuilder("python")
.withChainedExceptions([
createExceptionValue({
type: "KeyError",
value: "'user_id'",
stacktrace: createStackTrace([
createFrame({
filename: "/app/database.py",
function: "get_user",
lineNo: 15,
inApp: true,
}),
]),
}),
createExceptionValue({
type: "ValueError",
value: "User not found",
stacktrace: createStackTrace([
createFrame({
filename: "/app/services.py",
function: "process_user",
lineNo: 25,
inApp: true,
}),
]),
}),
createExceptionValue({
type: "HTTPError",
value: "500 Internal Server Error",
stacktrace: createStackTrace([
createFrame({
filename: "/app/handlers.py",
function: "handle_request",
lineNo: 42,
inApp: true,
}),
]),
}),
])
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
HTTPError: 500 Internal Server Error
\`\`\`
**Stacktrace:**
\`\`\`
File "/app/handlers.py", line 42, in handle_request
\`\`\`
**During handling of the above exception, another exception occurred:**
### ValueError: User not found
**Stacktrace:**
\`\`\`
File "/app/services.py", line 25, in process_user
\`\`\`
**During handling of the above exception, another exception occurred:**
### KeyError: 'user_id'
**Stacktrace:**
\`\`\`
File "/app/database.py", line 15, in get_user
\`\`\`
"
`);
});
it("should render chained exceptions with enhanced frame on outermost exception", () => {
const event = new EventBuilder("python")
.withChainedExceptions([
createExceptionValue({
type: "KeyError",
value: "'user_id'",
stacktrace: createStackTrace([
createFrameWithContext(
{
filename: "/app/database.py",
function: "get_user",
lineNo: 15,
inApp: true,
},
[
[13, "def get_user(data):"],
[14, " # This will fail if user_id is missing"],
[15, " user_id = data['user_id']"],
[16, " return db.find_user(user_id)"],
],
{
data: {},
},
),
]),
}),
createExceptionValue({
type: "ValueError",
value: "User not found",
stacktrace: createStackTrace([
createFrameWithContext(
{
filename: "/app/services.py",
function: "process_user",
lineNo: 25,
inApp: true,
},
[
[23, " try:"],
[24, " user = get_user(request_data)"],
[25, " except KeyError:"],
[26, " raise ValueError('User not found')"],
],
{
request_data: {},
},
),
]),
}),
])
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
ValueError: User not found
\`\`\`
**Most Relevant Frame:**
─────────────────────
File "/app/services.py", line 25, in process_user
23 │ try:
24 │ user = get_user(request_data)
→ 25 │ except KeyError:
26 │ raise ValueError('User not found')
Local Variables:
└─ request_data: {}
**Full Stacktrace:**
────────────────
\`\`\`
File "/app/services.py", line 25, in process_user
except KeyError:
\`\`\`
**During handling of the above exception, another exception occurred:**
### KeyError: 'user_id'
**Stacktrace:**
\`\`\`
File "/app/database.py", line 15, in get_user
user_id = data['user_id']
\`\`\`
"
`);
});
it("should handle single exception in values array (not chained)", () => {
const event = new EventBuilder("python")
.withChainedExceptions([
createExceptionValue({
type: "RuntimeError",
value: "Something went wrong",
stacktrace: createStackTrace([
createFrame({
filename: "/app/main.py",
function: "main",
lineNo: 10,
inApp: true,
}),
]),
}),
])
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
RuntimeError: Something went wrong
\`\`\`
**Stacktrace:**
\`\`\`
File "/app/main.py", line 10, in main
\`\`\`
"
`);
});
it("should use Java-style 'Caused by' for Java platform", () => {
const event = new EventBuilder("java")
.withChainedExceptions([
createExceptionValue({
type: "SQLException",
value: "Database connection failed",
stacktrace: createStackTrace([
frameFactories.java({
module: "com.example.db.DatabaseConnector",
function: "connect",
filename: "DatabaseConnector.java",
lineNo: 45,
}),
]),
}),
createExceptionValue({
type: "RuntimeException",
value: "Failed to initialize service",
stacktrace: createStackTrace([
frameFactories.java({
module: "com.example.service.UserService",
function: "initialize",
filename: "UserService.java",
lineNo: 23,
}),
]),
}),
])
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
RuntimeException: Failed to initialize service
\`\`\`
**Stacktrace:**
\`\`\`
at com.example.service.UserService.initialize(UserService.java:23)
\`\`\`
**Caused by:**
### SQLException: Database connection failed
**Stacktrace:**
\`\`\`
at com.example.db.DatabaseConnector.connect(DatabaseConnector.java:45)
\`\`\`
"
`);
});
it("should use C#-style arrow notation for dotnet platform", () => {
const event = new EventBuilder("csharp")
.withChainedExceptions([
createExceptionValue({
type: "ArgumentNullException",
value: "Value cannot be null. (Parameter 'userId')",
stacktrace: createStackTrace([
createFrame({
filename: "UserRepository.cs",
function: "GetUserById",
lineNo: 15,
inApp: true,
}),
]),
}),
createExceptionValue({
type: "ApplicationException",
value: "Failed to load user profile",
stacktrace: createStackTrace([
createFrame({
filename: "UserService.cs",
function: "LoadProfile",
lineNo: 42,
inApp: true,
}),
]),
}),
])
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
ApplicationException: Failed to load user profile
\`\`\`
**Stacktrace:**
\`\`\`
at LoadProfile (UserService.cs:42)
\`\`\`
**---> Inner Exception:**
### ArgumentNullException: Value cannot be null. (Parameter 'userId')
**Stacktrace:**
\`\`\`
at GetUserById (UserRepository.cs:15)
\`\`\`
"
`);
});
it("should handle child exception without stacktrace", () => {
const event = new EventBuilder("python")
.withChainedExceptions([
createExceptionValue({
type: "KeyError",
value: "'missing_key'",
// No stacktrace for child exception
stacktrace: undefined,
}),
createExceptionValue({
type: "ValueError",
value: "Data processing failed",
stacktrace: createStackTrace([
createFrame({
filename: "/app/processor.py",
function: "process_data",
lineNo: 42,
inApp: true,
}),
]),
}),
])
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"### Error
\`\`\`
ValueError: Data processing failed
\`\`\`
**Stacktrace:**
\`\`\`
File "/app/processor.py", line 42, in process_data
\`\`\`
**During handling of the above exception, another exception occurred:**
### KeyError: 'missing_key'
**Stacktrace:**
\`\`\`
No stacktrace available
\`\`\`
"
`);
});
});
describe("Performance issue formatting", () => {
it("should format N+1 query issue with evidence data", () => {
const event = new EventBuilder("python")
.withType("transaction")
.withOccurrence({
issueTitle: "N+1 Query",
culprit: "SELECT * FROM users WHERE id = %s",
type: 1006, // Performance issue type code
issueType: "performance_n_plus_one_db_queries",
evidenceData: {
parentSpanIds: ["span_123"],
parentSpan: "GET /api/users",
repeatingSpansCompact: ["SELECT * FROM users WHERE id = %s"],
numberRepeatingSpans: "5",
offenderSpanIds: [
"span_456",
"span_457",
"span_458",
"span_459",
"span_460",
],
transactionName: "/api/users",
op: "db",
},
evidenceDisplay: [
{
name: "Offending Spans",
value: "UserService.get_users",
important: true,
},
],
})
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"**Parent Operation:**
GET /api/users
### Repeated Database Queries
**Query executed 5 times:**
\`\`\`sql
SELECT * FROM users WHERE id = %s
\`\`\`
**Transaction:**
/api/users
**Offending Spans:**
UserService.get_users
"
`);
});
it("should format N+1 query issue with spans data fallback", () => {
const event = new EventBuilder("python")
.withType("transaction")
.withOccurrence({
issueTitle: "N+1 Query detected",
culprit: "database query",
})
.withEntry({
type: "spans",
data: [
{
op: "db.query",
description: "SELECT * FROM posts WHERE user_id = 1",
timestamp: 100.5,
start_timestamp: 100.0,
},
{
op: "db.query",
description: "SELECT * FROM posts WHERE user_id = 2",
timestamp: 101.0,
start_timestamp: 100.5,
},
{
op: "db.query",
description: "SELECT * FROM posts WHERE user_id = 3",
timestamp: 101.5,
start_timestamp: 101.0,
},
{
op: "http.client",
description: "GET /api/external",
timestamp: 102.0,
start_timestamp: 101.5,
},
],
})
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`""`);
});
it("should format transaction event with non-repeated database queries", () => {
const event = new EventBuilder("python")
.withType("transaction")
.withOccurrence({
issueTitle: "Slow DB Query",
culprit: "database",
})
.withEntry({
type: "spans",
data: [
{
op: "db.query",
description: "SELECT COUNT(*) FROM users",
timestamp: 100.5,
start_timestamp: 100.0,
},
{
op: "db.query",
description: "SELECT * FROM settings WHERE key = 'theme'",
timestamp: 101.0,
start_timestamp: 100.5,
},
],
})
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`""`);
});
it("should format evidence with operation and offenderSpanIds", () => {
const event = new EventBuilder("python")
.withType("transaction")
.withOccurrence({
issueTitle: "N+1 Query",
culprit: "database",
issueType: "performance_n_plus_one_db_queries",
evidenceData: {
op: "db",
offenderSpanIds: ["span_1", "span_2", "span_3", "span_4", "span_5"],
numberRepeatingSpans: "5",
},
})
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`""`);
});
it("should render span tree for N+1 queries with evidence and spans data", () => {
const event = new EventBuilder("python")
.withType("transaction")
.withOccurrence({
issueTitle: "N+1 Query",
culprit: "SELECT * FROM users WHERE id = %s",
issueType: "performance_n_plus_one_db_queries",
evidenceData: {
parentSpanIds: ["parent123"],
parentSpan: "GET /api/users",
offenderSpanIds: ["span1", "span2", "span3"],
repeatingSpansCompact: ["SELECT * FROM users WHERE id = %s"],
numberRepeatingSpans: "3",
op: "db",
},
})
.withEntry({
type: "spans",
data: [
{
span_id: "parent123",
op: "http.server",
description: "GET /users",
timestamp: 1722963600.25,
start_timestamp: 1722963600.0,
},
{
span_id: "span1",
op: "db.query",
description: "SELECT * FROM users WHERE id = 1",
timestamp: 1722963600.013,
start_timestamp: 1722963600.01,
},
{
span_id: "span2",
op: "db.query",
description: "SELECT * FROM users WHERE id = 2",
timestamp: 1722963600.018,
start_timestamp: 1722963600.014,
},
{
span_id: "span3",
op: "db.query",
description: "SELECT * FROM users WHERE id = 3",
timestamp: 1722963600.027,
start_timestamp: 1722963600.019,
},
],
})
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"**Parent Operation:**
GET /api/users
### Repeated Database Queries
**Query executed 3 times:**
\`\`\`sql
SELECT * FROM users WHERE id = %s
\`\`\`
### Span Tree (Limited to 10 spans)
\`\`\`
GET /users [parent12 · http.server · 250ms]
├─ SELECT * FROM users WHERE id = 1 [span1 · db.query · 3ms] [N+1]
├─ SELECT * FROM users WHERE id = 2 [span2 · db.query · 4ms] [N+1]
└─ SELECT * FROM users WHERE id = 3 [span3 · db.query · 8ms] [N+1]
\`\`\`
"
`);
});
it("should render span tree using duration fields when timestamps are missing", () => {
const event = new EventBuilder("python")
.withType("transaction")
.withOccurrence({
issueTitle: "N+1 Query",
issueType: "performance_n_plus_one_db_queries",
evidenceData: {
parentSpanIds: ["parentDur"],
parentSpan: "GET /api/durations",
offenderSpanIds: ["spanA", "spanB"],
repeatingSpansCompact: [
"SELECT * FROM durations WHERE bucket = %s",
],
numberRepeatingSpans: "2",
},
})
.withEntry({
type: "spans",
data: [
{
span_id: "parentDur",
op: "http.server",
description: "GET /durations",
duration: 1250,
},
{
span_id: "spanA",
op: "db.query",
description: "SELECT * FROM durations WHERE bucket = 'fast'",
duration: 0.5,
},
{
span_id: "spanB",
op: "db.query",
description: "SELECT * FROM durations WHERE bucket = 'slow'",
duration: 1500,
},
],
})
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"**Parent Operation:**
GET /api/durations
### Repeated Database Queries
**Query executed 2 times:**
\`\`\`sql
SELECT * FROM durations WHERE bucket = %s
\`\`\`
### Span Tree (Limited to 10 spans)
\`\`\`
GET /durations [parentDu · http.server · 1250ms]
├─ SELECT * FROM durations WHERE bucket = 'fast' [spanA · db.query · 1ms] [N+1]
└─ SELECT * FROM durations WHERE bucket = 'slow' [spanB · db.query · 1500ms] [N+1]
\`\`\`
"
`);
});
it("should handle transaction event without performance data", () => {
const event = new EventBuilder("python")
.withType("transaction")
.withOccurrence({
issueTitle: "Generic Performance Issue",
culprit: "slow endpoint",
})
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`""`);
});
it("should handle evidence data without repeating_spans", () => {
const event = new EventBuilder("python")
.withType("transaction")
.withOccurrence({
issueTitle: "Performance Issue",
culprit: "database",
issueType: "performance_slow_db_query", // A different type that we don't fully handle yet
evidenceData: {
parentSpan: "GET /api/data",
transactionName: "/api/data",
},
evidenceDisplay: [
{
name: "Source Location",
value: "DataService.fetch",
important: true,
},
],
})
.build();
const output = formatEventOutput(event);
expect(output).toMatchInlineSnapshot(`
"**Parent Operation:**
GET /api/data
**Transaction:**
/api/data
**Source Location:**
DataService.fetch
"
`);
});
});
});
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/api-client/client.ts:
--------------------------------------------------------------------------------
```typescript
import {
getIssueUrl as getIssueUrlUtil,
getTraceUrl as getTraceUrlUtil,
isSentryHost,
} from "../utils/url-utils";
import { logWarn } from "../telem/logging";
import {
OrganizationListSchema,
OrganizationSchema,
ClientKeySchema,
TeamListSchema,
TeamSchema,
ProjectListSchema,
ProjectSchema,
ReleaseListSchema,
IssueListSchema,
IssueSchema,
EventSchema,
EventAttachmentListSchema,
ErrorsSearchResponseSchema,
SpansSearchResponseSchema,
TagListSchema,
ApiErrorSchema,
ClientKeyListSchema,
AutofixRunSchema,
AutofixRunStateSchema,
TraceMetaSchema,
TraceSchema,
UserSchema,
UserRegionsSchema,
} from "./schema";
import { ConfigurationError } from "../errors";
import { createApiError, ApiNotFoundError, ApiValidationError } from "./errors";
import type {
AutofixRun,
AutofixRunState,
ClientKey,
ClientKeyList,
Event,
EventAttachment,
EventAttachmentList,
Issue,
IssueList,
OrganizationList,
Project,
ProjectList,
ReleaseList,
TagList,
Team,
TeamList,
Trace,
TraceMeta,
User,
} from "./types";
// TODO: this is shared - so ideally, for safety, it uses @sentry/core, but currently
// logger isnt exposed (or rather, it is, but its not the right logger)
// import { logger } from "@sentry/node";
/**
* Mapping of common network error codes to user-friendly messages.
* These help users understand and resolve connection issues.
*/
const NETWORK_ERROR_MESSAGES: Record<string, string> = {
EAI_AGAIN: "DNS temporarily unavailable. Check your internet connection.",
ENOTFOUND: "Hostname not found. Verify the URL is correct.",
ECONNREFUSED: "Connection refused. Ensure the service is accessible.",
ETIMEDOUT: "Connection timed out. Check network connectivity.",
ECONNRESET: "Connection reset. Try again in a moment.",
};
/**
* Custom error class for Sentry API responses.
*
* Provides enhanced error messages for LLM consumption and handles
* common API error scenarios with user-friendly messaging.
*
* @example
* ```typescript
* try {
* await apiService.listIssues({ organizationSlug: "invalid" });
* } catch (error) {
* if (error instanceof ApiError) {
* console.log(`API Error ${error.status}: ${error.message}`);
* }
* }
* ```
*/
type RequestOptions = {
host?: string;
};
/**
* Sentry API client service for interacting with Sentry's REST API.
*
* This service provides a comprehensive interface to Sentry's API endpoints,
* handling authentication, error processing, multi-region support, and
* response validation through Zod schemas.
*
* Key Features:
* - Multi-region support for Sentry SaaS and self-hosted instances
* - Automatic schema validation with Zod
* - Enhanced error handling with LLM-friendly messages
* - URL generation for Sentry resources (issues, traces)
* - Bearer token authentication
* - Always uses HTTPS for secure connections
*
* @example Basic Usage
* ```typescript
* const apiService = new SentryApiService({
* accessToken: "your-token",
* host: "sentry.io"
* });
*
* const orgs = await apiService.listOrganizations();
* const issues = await apiService.listIssues({
* organizationSlug: "my-org",
* query: "is:unresolved"
* });
* ```
*
* @example Multi-Region Support
* ```typescript
* // Self-hosted instance with hostname
* const selfHosted = new SentryApiService({
* accessToken: "token",
* host: "sentry.company.com"
* });
*
* // Regional endpoint override
* const issues = await apiService.listIssues(
* { organizationSlug: "org" },
* { host: "eu.sentry.io" }
* );
* ```
*/
export class SentryApiService {
private accessToken: string | null;
protected host: string;
protected apiPrefix: string;
/**
* Creates a new Sentry API service instance.
*
* Always uses HTTPS for secure connections.
*
* @param config Configuration object
* @param config.accessToken OAuth access token for authentication (optional for some endpoints)
* @param config.host Sentry hostname (e.g. "sentry.io", "sentry.example.com")
*/
constructor({
accessToken = null,
host = "sentry.io",
}: {
accessToken?: string | null;
host?: string;
}) {
this.accessToken = accessToken;
this.host = host;
this.apiPrefix = `https://${host}/api/0`;
}
/**
* Updates the host for API requests.
*
* Used for multi-region support or switching between Sentry instances.
* Always uses HTTPS protocol.
*
* @param host New hostname to use for API requests
*/
setHost(host: string) {
this.host = host;
this.apiPrefix = `https://${this.host}/api/0`;
}
/**
* Checks if the current host is Sentry SaaS (sentry.io).
*
* Used to determine API endpoint availability and URL formats.
* Self-hosted instances may not have all endpoints available.
*
* @returns True if using Sentry SaaS, false for self-hosted instances
*/
private isSaas(): boolean {
return isSentryHost(this.host);
}
/**
* Internal method for making authenticated requests to Sentry API.
*
* Handles:
* - Bearer token authentication
* - Error response parsing and enhancement
* - Multi-region host overrides
* - Fetch availability validation
*
* @param path API endpoint path (without /api/0 prefix)
* @param options Fetch options
* @param requestOptions Additional request configuration
* @returns Promise resolving to Response object
* @throws {ApiError} Enhanced API errors with user-friendly messages
* @throws {Error} Network or parsing errors
*/
private async request(
path: string,
options: RequestInit = {},
{ host }: { host?: string } = {},
): Promise<Response> {
const url = host
? `https://${host}/api/0${path}`
: `${this.apiPrefix}${path}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "Sentry MCP Server",
};
if (this.accessToken) {
headers.Authorization = `Bearer ${this.accessToken}`;
}
// Check if fetch is available, otherwise provide a helpful error message
if (typeof globalThis.fetch === "undefined") {
throw new ConfigurationError(
"fetch is not available. Please use Node.js >= 18 or ensure fetch is available in your environment.",
);
}
// logger.info(logger.fmt`[sentryApi] ${options.method || "GET"} ${url}`);
let response: Response;
try {
response = await fetch(url, {
...options,
headers,
});
} catch (error) {
// Extract the root cause from the error chain
let rootCause = error;
while (rootCause instanceof Error && rootCause.cause) {
rootCause = rootCause.cause;
}
const errorMessage =
rootCause instanceof Error ? rootCause.message : String(rootCause);
let friendlyMessage = `Unable to connect to ${url}`;
// Check if we have a specific message for this error
const errorCode = Object.keys(NETWORK_ERROR_MESSAGES).find((code) =>
errorMessage.includes(code),
);
if (errorCode) {
friendlyMessage += ` - ${NETWORK_ERROR_MESSAGES[errorCode]}`;
} else {
friendlyMessage += ` - ${errorMessage}`;
}
// DNS resolution failures and connection timeouts to custom hosts are configuration issues
if (
errorCode === "ENOTFOUND" ||
errorCode === "EAI_AGAIN" ||
errorCode === "ECONNREFUSED" ||
errorCode === "ETIMEDOUT" ||
errorMessage.includes("Connect Timeout Error")
) {
throw new ConfigurationError(friendlyMessage, { cause: error });
}
throw new Error(friendlyMessage, { cause: error });
}
// Handle error responses generically
if (!response.ok) {
const errorText = await response.text();
let parsed: unknown | undefined;
try {
parsed = JSON.parse(errorText);
} catch (error) {
// If we can't parse JSON, check if it's HTML (server error)
if (errorText.includes("<!DOCTYPE") || errorText.includes("<html")) {
logWarn("Received HTML error page instead of JSON", {
loggerScope: ["api", "client"],
extra: {
status: response.status,
statusText: response.statusText,
host: this.host,
path,
parseErrorMessage:
error instanceof Error ? error.message : String(error),
},
});
// HTML response instead of JSON typically indicates a server configuration issue
throw createApiError(
`Server error: Received HTML instead of JSON (${response.status} ${response.statusText}). This may indicate an invalid URL or server issue.`,
response.status,
errorText,
undefined,
);
}
logWarn("Failed to parse JSON error response", {
loggerScope: ["api", "client"],
extra: {
status: response.status,
statusText: response.statusText,
host: this.host,
path,
bodyPreview:
errorText.length > 256
? `${errorText.slice(0, 253)}…`
: errorText,
parseErrorMessage:
error instanceof Error ? error.message : String(error),
},
});
}
if (parsed) {
const { data, success, error } = ApiErrorSchema.safeParse(parsed);
if (success) {
// Use the new error factory to create the appropriate error type
throw createApiError(
data.detail,
response.status,
data.detail,
parsed,
);
}
logWarn("Failed to parse validated API error response", {
loggerScope: ["api", "client"],
extra: {
status: response.status,
statusText: response.statusText,
host: this.host,
path,
bodyPreview:
errorText.length > 256
? `${errorText.slice(0, 253)}…`
: errorText,
validationErrorMessage:
error instanceof Error ? error.message : String(error),
},
});
}
// Use the error factory to create the appropriate error type based on status
throw createApiError(
`API request failed: ${response.statusText}\n${errorText}`,
response.status,
errorText,
undefined,
);
}
return response;
}
/**
* Safely parses a JSON response, checking Content-Type header first.
*
* @param response The Response object from fetch
* @returns Promise resolving to the parsed JSON object
* @throws {Error} If response is not JSON or parsing fails
*/
private async parseJsonResponse(response: Response): Promise<unknown> {
// Handle case where response might not have all properties (e.g., in tests or promise chains)
if (!response.headers?.get) {
return response.json();
}
const contentType = response.headers.get("content-type");
// Check if the response is JSON
if (!contentType || !contentType.includes("application/json")) {
const responseText = await response.text();
// Check if it's HTML
if (
contentType?.includes("text/html") ||
responseText.includes("<!DOCTYPE") ||
responseText.includes("<html")
) {
// HTML when expecting JSON usually indicates authentication or routing issues
throw new Error(
`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.`,
);
}
// Generic non-JSON error
throw new Error(
`Expected JSON response but received ${contentType || "unknown content type"} ` +
`(${response.status} ${response.statusText})`,
);
}
try {
return await response.json();
} catch (error) {
// JSON parsing failure after successful response
throw new Error(
`Failed to parse JSON response: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Makes a request to the Sentry API and parses the JSON response.
*
* This is the primary method for API calls that expect JSON responses.
* It automatically validates Content-Type and provides helpful error messages
* for common issues like authentication failures or server errors.
*
* @param path API endpoint path (without /api/0 prefix)
* @param options Fetch options
* @param requestOptions Additional request configuration
* @returns Promise resolving to the parsed JSON response
* @throws {ApiError} Enhanced API errors with user-friendly messages
* @throws {Error} Network, parsing, or validation errors
*/
private async requestJSON(
path: string,
options: RequestInit = {},
requestOptions?: { host?: string },
): Promise<unknown> {
const response = await this.request(path, options, requestOptions);
return this.parseJsonResponse(response);
}
/**
* Generates a Sentry issue URL for browser navigation.
*
* Handles both SaaS (subdomain-based) and self-hosted URL formats.
* Always uses HTTPS protocol.
*
* @param organizationSlug Organization identifier
* @param issueId Issue identifier (short ID or numeric ID)
* @returns Full URL to the issue in Sentry UI
*
* @example
* ```typescript
* // SaaS: https://my-org.sentry.io/issues/PROJ-123
* apiService.getIssueUrl("my-org", "PROJ-123")
*
* // Self-hosted: https://sentry.company.com/organizations/my-org/issues/PROJ-123
* apiService.getIssueUrl("my-org", "PROJ-123")
* ```
*/
getIssueUrl(organizationSlug: string, issueId: string): string {
return getIssueUrlUtil(this.host, organizationSlug, issueId);
}
/**
* Generates a Sentry trace URL for performance investigation.
*
* Always uses HTTPS protocol.
*
* @param organizationSlug Organization identifier
* @param traceId Trace identifier (hex string)
* @returns Full HTTPS URL to the trace in Sentry UI
*
* @example
* ```typescript
* const traceUrl = apiService.getTraceUrl("my-org", "6a477f5b0f31ef7b6b9b5e1dea66c91d");
* // https://my-org.sentry.io/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d
* ```
*/
getTraceUrl(organizationSlug: string, traceId: string): string {
return getTraceUrlUtil(this.host, organizationSlug, traceId);
}
// ================================================================================
// URL BUILDERS FOR DIFFERENT SENTRY APIS
// ================================================================================
/**
* Builds a URL for the legacy Discover API (used by errors dataset).
*
* The Discover API is the older query interface that includes aggregate
* functions directly in the field list.
*
* @example
* // URL format: /explore/discover/homepage/?field=title&field=count_unique(user)
* buildDiscoverUrl("my-org", "level:error", "123", ["title", "count_unique(user)"], "-timestamp")
*/
private buildDiscoverUrl(params: {
organizationSlug: string;
query: string;
projectId?: string;
fields?: string[];
sort?: string;
statsPeriod?: string;
start?: string;
end?: string;
aggregateFunctions?: string[];
groupByFields?: string[];
}): string {
const {
organizationSlug,
query,
projectId,
fields,
sort,
statsPeriod,
start,
end,
aggregateFunctions,
groupByFields,
} = params;
const urlParams = new URLSearchParams();
// Discover API specific parameters
urlParams.set("dataset", "errors");
urlParams.set("queryDataset", "error-events");
urlParams.set("query", query);
if (projectId) {
urlParams.set("project", projectId);
}
// Discover API includes aggregate functions directly in field list
if (fields && fields.length > 0) {
for (const field of fields) {
urlParams.append("field", field);
}
} else {
// Default fields for Discover
urlParams.append("field", "title");
urlParams.append("field", "project");
urlParams.append("field", "user.display");
urlParams.append("field", "timestamp");
}
urlParams.set("sort", sort || "-timestamp");
// Add time parameters - either statsPeriod or start/end
if (start && end) {
urlParams.set("start", start);
urlParams.set("end", end);
} else {
urlParams.set("statsPeriod", statsPeriod || "24h");
}
// Check if this is an aggregate query
const isAggregate = (aggregateFunctions?.length ?? 0) > 0;
if (isAggregate) {
urlParams.set("mode", "aggregate");
// For aggregate queries in Discover, set yAxis to the first aggregate function
if (aggregateFunctions && aggregateFunctions.length > 0) {
urlParams.set("yAxis", aggregateFunctions[0]);
}
} else {
urlParams.set("yAxis", "count()");
}
// For SaaS instances, always use sentry.io for web UI URLs regardless of region
// Regional subdomains (e.g., us.sentry.io) are only for API endpoints
const webHost = this.isSaas() ? "sentry.io" : this.host;
const path = this.isSaas()
? `https://${organizationSlug}.${webHost}/explore/discover/homepage/`
: `https://${this.host}/organizations/${organizationSlug}/explore/discover/homepage/`;
return `${path}?${urlParams.toString()}`;
}
/**
* Builds a URL for the modern EAP (Event Analytics Platform) API used by spans/logs.
*
* The EAP API uses structured aggregate queries with separate aggregateField
* parameters containing JSON objects for groupBy and yAxes.
*
* @example
* // URL format: /explore/traces/?aggregateField={"groupBy":"span.op"}&aggregateField={"yAxes":["count()"]}
* buildEapUrl("my-org", "span.op:db", "123", ["span.op", "count()"], "-count()", ["count()"], ["span.op"])
*/
private buildEapUrl(params: {
organizationSlug: string;
query: string;
dataset: "spans" | "logs";
projectId?: string;
fields?: string[];
sort?: string;
statsPeriod?: string;
start?: string;
end?: string;
aggregateFunctions?: string[];
groupByFields?: string[];
}): string {
const {
organizationSlug,
query,
dataset,
projectId,
fields,
sort,
statsPeriod,
start,
end,
aggregateFunctions,
groupByFields,
} = params;
const urlParams = new URLSearchParams();
urlParams.set("query", query);
if (projectId) {
urlParams.set("project", projectId);
}
// Determine if this is an aggregate query
const isAggregateQuery =
(aggregateFunctions?.length ?? 0) > 0 ||
fields?.some((field) => field.includes("(") && field.includes(")")) ||
false;
if (isAggregateQuery) {
// EAP API uses structured aggregate parameters
if (
(aggregateFunctions?.length ?? 0) > 0 ||
(groupByFields?.length ?? 0) > 0
) {
// Add each groupBy field as a separate aggregateField parameter
if (groupByFields && groupByFields.length > 0) {
for (const field of groupByFields) {
urlParams.append(
"aggregateField",
JSON.stringify({ groupBy: field }),
);
}
}
// Add aggregate functions (yAxes)
if (aggregateFunctions && aggregateFunctions.length > 0) {
urlParams.append(
"aggregateField",
JSON.stringify({ yAxes: aggregateFunctions }),
);
}
} else {
// Fallback: parse fields to extract aggregate info
const parsedGroupByFields =
fields?.filter(
(field) => !field.includes("(") && !field.includes(")"),
) || [];
const parsedAggregateFunctions =
fields?.filter(
(field) => field.includes("(") && field.includes(")"),
) || [];
for (const field of parsedGroupByFields) {
urlParams.append(
"aggregateField",
JSON.stringify({ groupBy: field }),
);
}
if (parsedAggregateFunctions.length > 0) {
urlParams.append(
"aggregateField",
JSON.stringify({ yAxes: parsedAggregateFunctions }),
);
}
}
urlParams.set("mode", "aggregate");
} else {
// Non-aggregate query, add individual fields
if (fields && fields.length > 0) {
for (const field of fields) {
urlParams.append("field", field);
}
}
}
// Add sort parameter for all queries
if (sort) {
urlParams.set("sort", sort);
}
// Add time parameters - either statsPeriod or start/end
if (start && end) {
urlParams.set("start", start);
urlParams.set("end", end);
} else {
urlParams.set("statsPeriod", statsPeriod || "24h");
}
// Add table parameter for spans dataset (required for UI)
if (dataset === "spans") {
urlParams.set("table", "span");
}
const basePath = dataset === "logs" ? "logs" : "traces";
// For SaaS instances, always use sentry.io for web UI URLs regardless of region
// Regional subdomains (e.g., us.sentry.io) are only for API endpoints
const webHost = this.isSaas() ? "sentry.io" : this.host;
const path = this.isSaas()
? `https://${organizationSlug}.${webHost}/explore/${basePath}/`
: `https://${this.host}/organizations/${organizationSlug}/explore/${basePath}/`;
return `${path}?${urlParams.toString()}`;
}
/**
* Generates a Sentry events explorer URL for viewing search results.
*
* Routes to the appropriate API based on dataset:
* - Errors: Uses legacy Discover API
* - Spans/Logs: Uses modern EAP (Event Analytics Platform) API
*
* @param organizationSlug Organization identifier
* @param query Sentry search query
* @param projectId Optional project filter
* @param dataset Dataset type (spans, errors, or logs)
* @param fields Array of fields to include in results
* @param sort Sort parameter (e.g., "-timestamp", "-count()")
* @param aggregateFunctions Array of aggregate functions (only used for EAP datasets)
* @param groupByFields Array of fields to group by (only used for EAP datasets)
* @param statsPeriod Relative time period (e.g., "24h", "7d")
* @param start Absolute start time (ISO 8601)
* @param end Absolute end time (ISO 8601)
* @returns Full HTTPS URL to the events explorer in Sentry UI
*/
getEventsExplorerUrl(
organizationSlug: string,
query: string,
projectId?: string,
dataset: "spans" | "errors" | "logs" = "spans",
fields?: string[],
sort?: string,
aggregateFunctions?: string[],
groupByFields?: string[],
statsPeriod?: string,
start?: string,
end?: string,
): string {
if (dataset === "errors") {
// Route to legacy Discover API
return this.buildDiscoverUrl({
organizationSlug,
query,
projectId,
fields,
sort,
statsPeriod,
start,
end,
aggregateFunctions,
groupByFields,
});
}
// Route to modern EAP API (spans and logs)
return this.buildEapUrl({
organizationSlug,
query,
dataset,
projectId,
fields,
sort,
statsPeriod,
start,
end,
aggregateFunctions,
groupByFields,
});
}
/**
* Retrieves the authenticated user's profile information.
*
* @param opts Request options including host override
* @returns User profile data
* @throws {ApiError} If authentication fails or user not found
*/
async getAuthenticatedUser(opts?: RequestOptions): Promise<User> {
// Auth endpoints only exist on the main API server, never on regional endpoints
let authHost: string | undefined;
if (this.isSaas()) {
// For SaaS, always use the main sentry.io host, not regional hosts
// This handles cases like us.sentry.io, eu.sentry.io, etc.
authHost = "sentry.io";
}
// For self-hosted, use the configured host (authHost remains undefined)
const body = await this.requestJSON("/auth/", undefined, {
...opts,
host: authHost,
});
return UserSchema.parse(body);
}
/**
* Lists all organizations accessible to the authenticated user.
*
* Automatically handles multi-region queries by fetching from all
* available regions and combining results.
*
* @param params Query parameters
* @param params.query Search query to filter organizations by name/slug
* @param opts Request options
* @returns Array of organizations across all accessible regions (limited to 25 results)
*
* @example
* ```typescript
* const orgs = await apiService.listOrganizations();
* orgs.forEach(org => {
* // regionUrl present for Cloud Service, empty for self-hosted
* console.log(`${org.name} (${org.slug}) - ${org.links?.regionUrl || 'No region URL'}`);
* });
* ```
*/
async listOrganizations(
params?: { query?: string },
opts?: RequestOptions,
): Promise<OrganizationList> {
// Build query parameters
const queryParams = new URLSearchParams();
queryParams.set("per_page", "25");
if (params?.query) {
queryParams.set("query", params.query);
}
const queryString = queryParams.toString();
const path = `/organizations/?${queryString}`;
// For self-hosted instances, the regions endpoint doesn't exist
if (!this.isSaas()) {
const body = await this.requestJSON(path, undefined, opts);
return OrganizationListSchema.parse(body);
}
// For SaaS, try to use regions endpoint first
try {
// TODO: Sentry is currently not returning all orgs without hitting region endpoints
// The regions endpoint only exists on the main API server, not on regional endpoints
const regionsBody = await this.requestJSON(
"/users/me/regions/",
undefined,
{}, // Don't pass opts to ensure we use the main host
);
const regionData = UserRegionsSchema.parse(regionsBody);
const allOrganizations = (
await Promise.all(
regionData.regions.map(async (region) =>
this.requestJSON(path, undefined, {
...opts,
host: new URL(region.url).host,
}),
),
)
)
.map((data) => OrganizationListSchema.parse(data))
.reduce((acc, curr) => acc.concat(curr), []);
// Apply the limit after combining results from all regions
return allOrganizations.slice(0, 25);
} catch (error) {
// If regions endpoint fails (e.g., older self-hosted versions identifying as sentry.io),
// fall back to direct organizations endpoint
if (error instanceof ApiNotFoundError) {
// logger.info("Regions endpoint not found, falling back to direct organizations endpoint");
const body = await this.requestJSON(path, undefined, opts);
return OrganizationListSchema.parse(body);
}
// Re-throw other errors
throw error;
}
}
/**
* Gets a single organization by slug.
*
* @param organizationSlug Organization identifier
* @param opts Request options including host override
* @returns Organization data
*/
async getOrganization(organizationSlug: string, opts?: RequestOptions) {
const body = await this.requestJSON(
`/organizations/${organizationSlug}/`,
undefined,
opts,
);
return OrganizationSchema.parse(body);
}
/**
* Lists teams within an organization.
*
* @param organizationSlug Organization identifier
* @param params Query parameters
* @param params.query Search query to filter teams by name/slug
* @param opts Request options including host override
* @returns Array of teams in the organization (limited to 25 results)
*/
async listTeams(
organizationSlug: string,
params?: { query?: string },
opts?: RequestOptions,
): Promise<TeamList> {
const queryParams = new URLSearchParams();
queryParams.set("per_page", "25");
if (params?.query) {
queryParams.set("query", params.query);
}
const queryString = queryParams.toString();
const path = `/organizations/${organizationSlug}/teams/?${queryString}`;
const body = await this.requestJSON(path, undefined, opts);
return TeamListSchema.parse(body);
}
/**
* Creates a new team within an organization.
*
* @param params Team creation parameters
* @param params.organizationSlug Organization identifier
* @param params.name Team name
* @param opts Request options
* @returns Created team data
* @throws {ApiError} If team creation fails (e.g., name conflicts)
*/
async createTeam(
{
organizationSlug,
name,
}: {
organizationSlug: string;
name: string;
},
opts?: RequestOptions,
): Promise<Team> {
const body = await this.requestJSON(
`/organizations/${organizationSlug}/teams/`,
{
method: "POST",
body: JSON.stringify({ name }),
},
opts,
);
return TeamSchema.parse(body);
}
/**
* Lists projects within an organization.
*
* @param organizationSlug Organization identifier
* @param params Query parameters
* @param params.query Search query to filter projects by name/slug
* @param opts Request options
* @returns Array of projects in the organization (limited to 25 results)
*/
async listProjects(
organizationSlug: string,
params?: { query?: string },
opts?: RequestOptions,
): Promise<ProjectList> {
const queryParams = new URLSearchParams();
queryParams.set("per_page", "25");
if (params?.query) {
queryParams.set("query", params.query);
}
const queryString = queryParams.toString();
const path = `/organizations/${organizationSlug}/projects/?${queryString}`;
const body = await this.requestJSON(path, undefined, opts);
return ProjectListSchema.parse(body);
}
/**
* Gets a single project by slug or ID.
*
* @param params Project fetch parameters
* @param params.organizationSlug Organization identifier
* @param params.projectSlugOrId Project slug or numeric ID
* @param opts Request options
* @returns Project data
*/
async getProject(
{
organizationSlug,
projectSlugOrId,
}: {
organizationSlug: string;
projectSlugOrId: string;
},
opts?: RequestOptions,
): Promise<Project> {
const body = await this.requestJSON(
`/projects/${organizationSlug}/${projectSlugOrId}/`,
undefined,
opts,
);
return ProjectSchema.parse(body);
}
/**
* Creates a new project within a team.
*
* @param params Project creation parameters
* @param params.organizationSlug Organization identifier
* @param params.teamSlug Team identifier
* @param params.name Project name
* @param params.platform Platform identifier (e.g., "javascript", "python")
* @param opts Request options
* @returns Created project data
*/
async createProject(
{
organizationSlug,
teamSlug,
name,
platform,
}: {
organizationSlug: string;
teamSlug: string;
name: string;
platform?: string;
},
opts?: RequestOptions,
): Promise<Project> {
const body = await this.requestJSON(
`/teams/${organizationSlug}/${teamSlug}/projects/`,
{
method: "POST",
body: JSON.stringify({
name,
platform,
}),
},
opts,
);
return ProjectSchema.parse(body);
}
/**
* Updates an existing project's configuration.
*
* @param params Project update parameters
* @param params.organizationSlug Organization identifier
* @param params.projectSlug Current project identifier
* @param params.name New project name (optional)
* @param params.slug New project slug (optional)
* @param params.platform New platform identifier (optional)
* @param opts Request options
* @returns Updated project data
*/
async updateProject(
{
organizationSlug,
projectSlug,
name,
slug,
platform,
}: {
organizationSlug: string;
projectSlug: string;
name?: string;
slug?: string;
platform?: string;
},
opts?: RequestOptions,
): Promise<Project> {
const updateData: Record<string, any> = {};
if (name !== undefined) updateData.name = name;
if (slug !== undefined) updateData.slug = slug;
if (platform !== undefined) updateData.platform = platform;
const body = await this.requestJSON(
`/projects/${organizationSlug}/${projectSlug}/`,
{
method: "PUT",
body: JSON.stringify(updateData),
},
opts,
);
return ProjectSchema.parse(body);
}
/**
* Assigns a team to a project.
*
* @param params Assignment parameters
* @param params.organizationSlug Organization identifier
* @param params.projectSlug Project identifier
* @param params.teamSlug Team identifier to assign
* @param opts Request options
*/
async addTeamToProject(
{
organizationSlug,
projectSlug,
teamSlug,
}: {
organizationSlug: string;
projectSlug: string;
teamSlug: string;
},
opts?: RequestOptions,
): Promise<void> {
await this.request(
`/projects/${organizationSlug}/${projectSlug}/teams/${teamSlug}/`,
{
method: "POST",
body: JSON.stringify({}),
},
opts,
);
}
/**
* Creates a new client key (DSN) for a project.
*
* Client keys are used to identify and authenticate SDK requests to Sentry.
*
* @param params Key creation parameters
* @param params.organizationSlug Organization identifier
* @param params.projectSlug Project identifier
* @param params.name Human-readable name for the key (optional)
* @param opts Request options
* @returns Created client key with DSN information
*
* @example
* ```typescript
* const key = await apiService.createClientKey({
* organizationSlug: "my-org",
* projectSlug: "my-project",
* name: "Production"
* });
* console.log(`DSN: ${key.dsn.public}`);
* ```
*/
async createClientKey(
{
organizationSlug,
projectSlug,
name,
}: {
organizationSlug: string;
projectSlug: string;
name?: string;
},
opts?: RequestOptions,
): Promise<ClientKey> {
const body = await this.requestJSON(
`/projects/${organizationSlug}/${projectSlug}/keys/`,
{
method: "POST",
body: JSON.stringify({
name,
}),
},
opts,
);
return ClientKeySchema.parse(body);
}
/**
* Lists all client keys (DSNs) for a project.
*
* @param params Query parameters
* @param params.organizationSlug Organization identifier
* @param params.projectSlug Project identifier
* @param opts Request options
* @returns Array of client keys with DSN information
*/
async listClientKeys(
{
organizationSlug,
projectSlug,
}: {
organizationSlug: string;
projectSlug: string;
},
opts?: RequestOptions,
): Promise<ClientKeyList> {
const body = await this.requestJSON(
`/projects/${organizationSlug}/${projectSlug}/keys/`,
undefined,
opts,
);
return ClientKeyListSchema.parse(body);
}
/**
* Lists releases for an organization or specific project.
*
* @param params Query parameters
* @param params.organizationSlug Organization identifier
* @param params.projectSlug Project identifier (optional, scopes to specific project)
* @param params.query Search query for filtering releases
* @param opts Request options
* @returns Array of releases with deployment and commit information
*
* @example
* ```typescript
* // All releases for organization
* const releases = await apiService.listReleases({
* organizationSlug: "my-org"
* });
*
* // Search for specific version
* const filtered = await apiService.listReleases({
* organizationSlug: "my-org",
* query: "v1.2.3"
* });
* ```
*/
async listReleases(
{
organizationSlug,
projectSlug,
query,
}: {
organizationSlug: string;
projectSlug?: string;
query?: string;
},
opts?: RequestOptions,
): Promise<ReleaseList> {
const searchQuery = new URLSearchParams();
if (query) {
searchQuery.set("query", query);
}
const path = projectSlug
? `/projects/${organizationSlug}/${projectSlug}/releases/`
: `/organizations/${organizationSlug}/releases/`;
const body = await this.requestJSON(
searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path,
undefined,
opts,
);
return ReleaseListSchema.parse(body);
}
/**
* Lists available tags for search queries.
*
* Tags represent indexed fields that can be used in Sentry search queries.
*
* @param params Query parameters
* @param params.organizationSlug Organization identifier
* @param params.dataset Dataset to query tags for ("events", "errors" or "search_issues")
* @param params.project Numeric project ID to filter tags
* @param params.statsPeriod Time range for tag statistics (e.g., "24h", "7d")
* @param params.useCache Whether to use cached results
* @param params.useFlagsBackend Whether to use flags backend features
* @param opts Request options
* @returns Array of available tags with metadata
*
* @example
* ```typescript
* const tags = await apiService.listTags({
* organizationSlug: "my-org",
* dataset: "events",
* project: "123456",
* statsPeriod: "24h",
* useCache: true
* });
* tags.forEach(tag => console.log(`${tag.key}: ${tag.name}`));
* ```
*/
async listTags(
{
organizationSlug,
dataset,
project,
statsPeriod,
start,
end,
useCache,
useFlagsBackend,
}: {
organizationSlug: string;
dataset?: "events" | "errors" | "search_issues";
project?: string;
statsPeriod?: string;
start?: string;
end?: string;
useCache?: boolean;
useFlagsBackend?: boolean;
},
opts?: RequestOptions,
): Promise<TagList> {
const searchQuery = new URLSearchParams();
if (dataset) {
searchQuery.set("dataset", dataset);
}
if (project) {
searchQuery.set("project", project);
}
// Validate time parameters - can't use both relative and absolute
if (statsPeriod && (start || end)) {
throw new ApiValidationError(
"Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.",
);
}
if ((start && !end) || (!start && end)) {
throw new ApiValidationError(
"Both start and end parameters must be provided together for absolute time ranges.",
);
}
// Use either relative time (statsPeriod) or absolute time (start/end)
if (statsPeriod) {
searchQuery.set("statsPeriod", statsPeriod);
} else if (start && end) {
searchQuery.set("start", start);
searchQuery.set("end", end);
}
if (useCache !== undefined) {
searchQuery.set("useCache", useCache ? "1" : "0");
}
if (useFlagsBackend !== undefined) {
searchQuery.set("useFlagsBackend", useFlagsBackend ? "1" : "0");
}
const body = await this.requestJSON(
searchQuery.toString()
? `/organizations/${organizationSlug}/tags/?${searchQuery.toString()}`
: `/organizations/${organizationSlug}/tags/`,
undefined,
opts,
);
return TagListSchema.parse(body);
}
/**
* Lists trace item attributes available for search queries.
*
* Returns all available fields/attributes that can be used in event searches,
* including both built-in fields and custom tags.
*
* @param params Query parameters
* @param params.organizationSlug Organization identifier
* @param params.itemType Item type to query attributes for ("spans" or "logs")
* @param params.project Numeric project ID to filter attributes
* @param params.statsPeriod Time range for attribute statistics (e.g., "24h", "7d")
* @param opts Request options
* @returns Array of available attributes with metadata including type
*/
async listTraceItemAttributes(
{
organizationSlug,
itemType = "spans",
project,
statsPeriod,
start,
end,
}: {
organizationSlug: string;
itemType?: "spans" | "logs";
project?: string;
statsPeriod?: string;
start?: string;
end?: string;
},
opts?: RequestOptions,
): Promise<Array<{ key: string; name: string; type: "string" | "number" }>> {
// Fetch both string and number attributes
const [stringAttributes, numberAttributes] = await Promise.all([
this.fetchTraceItemAttributesByType(
organizationSlug,
itemType,
"string",
project,
statsPeriod,
start,
end,
opts,
),
this.fetchTraceItemAttributesByType(
organizationSlug,
itemType,
"number",
project,
statsPeriod,
start,
end,
opts,
),
]);
// Combine attributes with explicit type information
const allAttributes: Array<{
key: string;
name: string;
type: "string" | "number";
}> = [];
// Add string attributes
for (const attr of stringAttributes) {
allAttributes.push({
key: attr.key,
name: attr.name || attr.key,
type: "string",
});
}
// Add number attributes
for (const attr of numberAttributes) {
allAttributes.push({
key: attr.key,
name: attr.name || attr.key,
type: "number",
});
}
return allAttributes;
}
private async fetchTraceItemAttributesByType(
organizationSlug: string,
itemType: "spans" | "logs",
attributeType: "string" | "number",
project?: string,
statsPeriod?: string,
start?: string,
end?: string,
opts?: RequestOptions,
): Promise<any> {
const queryParams = new URLSearchParams();
queryParams.set("itemType", itemType);
queryParams.set("attributeType", attributeType);
if (project) {
queryParams.set("project", project);
}
// Validate time parameters - can't use both relative and absolute
if (statsPeriod && (start || end)) {
throw new ApiValidationError(
"Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.",
);
}
if ((start && !end) || (!start && end)) {
throw new ApiValidationError(
"Both start and end parameters must be provided together for absolute time ranges.",
);
}
// Use either relative time (statsPeriod) or absolute time (start/end)
if (statsPeriod) {
queryParams.set("statsPeriod", statsPeriod);
} else if (start && end) {
queryParams.set("start", start);
queryParams.set("end", end);
}
const url = `/organizations/${organizationSlug}/trace-items/attributes/?${queryParams.toString()}`;
const body = await this.requestJSON(url, undefined, opts);
return Array.isArray(body) ? body : [];
}
/**
* Lists issues within an organization or project.
*
* Issues represent groups of similar errors or problems in your application.
* Supports Sentry's powerful query syntax for filtering and sorting.
*
* @param params Query parameters
* @param params.organizationSlug Organization identifier
* @param params.projectSlug Project identifier (optional, scopes to specific project)
* @param params.query Sentry search query (e.g., "is:unresolved browser:chrome")
* @param params.sortBy Sort order ("user", "freq", "date", "new")
* @param opts Request options
* @returns Array of issues with metadata and statistics
*
* @example
* ```typescript
* // Recent unresolved issues
* const issues = await apiService.listIssues({
* organizationSlug: "my-org",
* query: "is:unresolved",
* sortBy: "date"
* });
*
* // High-frequency errors in specific project
* const critical = await apiService.listIssues({
* organizationSlug: "my-org",
* projectSlug: "backend",
* query: "level:error",
* sortBy: "freq"
* });
* ```
*/
async listIssues(
{
organizationSlug,
projectSlug,
query,
sortBy,
limit = 10,
}: {
organizationSlug: string;
projectSlug?: string;
query?: string | null;
sortBy?: "user" | "freq" | "date" | "new";
limit?: number;
},
opts?: RequestOptions,
): Promise<IssueList> {
const sentryQuery: string[] = [];
if (query) {
sentryQuery.push(query);
}
const queryParams = new URLSearchParams();
queryParams.set("per_page", String(limit));
if (sortBy) queryParams.set("sort", sortBy);
queryParams.set("statsPeriod", "24h");
queryParams.set("query", sentryQuery.join(" "));
queryParams.append("collapse", "unhandled");
const apiUrl = projectSlug
? `/projects/${organizationSlug}/${projectSlug}/issues/?${queryParams.toString()}`
: `/organizations/${organizationSlug}/issues/?${queryParams.toString()}`;
const body = await this.requestJSON(apiUrl, undefined, opts);
return IssueListSchema.parse(body);
}
async getIssue(
{
organizationSlug,
issueId,
}: {
organizationSlug: string;
issueId: string;
},
opts?: RequestOptions,
): Promise<Issue> {
const body = await this.requestJSON(
`/organizations/${organizationSlug}/issues/${issueId}/`,
undefined,
opts,
);
return IssueSchema.parse(body);
}
async getEventForIssue(
{
organizationSlug,
issueId,
eventId,
}: {
organizationSlug: string;
issueId: string;
eventId: string;
},
opts?: RequestOptions,
): Promise<Event> {
const body = await this.requestJSON(
`/organizations/${organizationSlug}/issues/${issueId}/events/${eventId}/`,
undefined,
opts,
);
const rawEvent = EventSchema.parse(body);
// Filter out unknown events - only return known error/default/transaction types
// "default" type represents error events without exception data
if (rawEvent.type === "error" || rawEvent.type === "default") {
return rawEvent as Event;
}
if (rawEvent.type === "transaction") {
return rawEvent as Event;
}
const eventType =
typeof rawEvent.type === "string" ? rawEvent.type : String(rawEvent.type);
throw new ApiValidationError(
`Unknown event type: ${eventType}`,
400,
`Only error, default, and transaction events are supported, got: ${eventType}`,
body,
);
}
async getLatestEventForIssue(
{
organizationSlug,
issueId,
}: {
organizationSlug: string;
issueId: string;
},
opts?: RequestOptions,
): Promise<Event> {
return this.getEventForIssue(
{
organizationSlug,
issueId,
eventId: "latest",
},
opts,
);
}
async listEventAttachments(
{
organizationSlug,
projectSlug,
eventId,
}: {
organizationSlug: string;
projectSlug: string;
eventId: string;
},
opts?: RequestOptions,
): Promise<EventAttachmentList> {
const body = await this.requestJSON(
`/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/`,
undefined,
opts,
);
return EventAttachmentListSchema.parse(body);
}
async getEventAttachment(
{
organizationSlug,
projectSlug,
eventId,
attachmentId,
}: {
organizationSlug: string;
projectSlug: string;
eventId: string;
attachmentId: string;
},
opts?: RequestOptions,
): Promise<{
attachment: EventAttachment;
downloadUrl: string;
filename: string;
blob: Blob;
}> {
// Get the attachment metadata first
const attachmentsData = await this.requestJSON(
`/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/`,
undefined,
opts,
);
const attachments = EventAttachmentListSchema.parse(attachmentsData);
const attachment = attachments.find((att) => att.id === attachmentId);
if (!attachment) {
throw new ApiNotFoundError(
`Attachment with ID ${attachmentId} not found for event ${eventId}`,
);
}
// Download the actual file content
const downloadUrl = `/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/${attachmentId}/?download=1`;
const downloadResponse = await this.request(
downloadUrl,
{
method: "GET",
headers: {
Accept: "application/octet-stream",
},
},
opts,
);
return {
attachment,
downloadUrl: downloadResponse.url,
filename: attachment.name,
blob: await downloadResponse.blob(),
};
}
async updateIssue(
{
organizationSlug,
issueId,
status,
assignedTo,
}: {
organizationSlug: string;
issueId: string;
status?: string;
assignedTo?: string;
},
opts?: RequestOptions,
): Promise<Issue> {
const updateData: Record<string, any> = {};
if (status !== undefined) updateData.status = status;
if (assignedTo !== undefined) updateData.assignedTo = assignedTo;
const body = await this.requestJSON(
`/organizations/${organizationSlug}/issues/${issueId}/`,
{
method: "PUT",
body: JSON.stringify(updateData),
},
opts,
);
return IssueSchema.parse(body);
}
// TODO: Sentry is not yet exposing a reasonable API to fetch trace data
// async getTrace({
// organizationSlug,
// traceId,
// }: {
// organizationSlug: string;
// traceId: string;
// }): Promise<z.infer<typeof SentryIssueSchema>> {
// const response = await this.request(
// `/organizations/${organizationSlug}/issues/${traceId}/`,
// );
// const body = await response.json();
// return SentryIssueSchema.parse(body);
// }
async searchErrors(
{
organizationSlug,
projectSlug,
filename,
transaction,
query,
sortBy = "last_seen",
}: {
organizationSlug: string;
projectSlug?: string;
filename?: string;
transaction?: string;
query?: string;
sortBy?: "last_seen" | "count";
},
opts?: RequestOptions,
) {
const sentryQuery: string[] = [];
if (filename) {
sentryQuery.push(`stack.filename:"*${filename.replace(/"/g, '\\"')}"`);
}
if (transaction) {
sentryQuery.push(`transaction:"${transaction.replace(/"/g, '\\"')}"`);
}
if (query) {
sentryQuery.push(query);
}
if (projectSlug) {
sentryQuery.push(`project:${projectSlug}`);
}
const queryParams = new URLSearchParams();
queryParams.set("dataset", "errors");
queryParams.set("per_page", "10");
queryParams.set(
"sort",
`-${sortBy === "last_seen" ? "last_seen" : "count"}`,
);
queryParams.set("statsPeriod", "24h");
queryParams.append("field", "issue");
queryParams.append("field", "title");
queryParams.append("field", "project");
queryParams.append("field", "last_seen()");
queryParams.append("field", "count()");
queryParams.set("query", sentryQuery.join(" "));
// if (projectSlug) queryParams.set("project", projectSlug);
const apiUrl = `/organizations/${organizationSlug}/events/?${queryParams.toString()}`;
const body = await this.requestJSON(apiUrl, undefined, opts);
// TODO(dcramer): If you're using an older version of Sentry this API had a breaking change
// meaning this endpoint will error.
return ErrorsSearchResponseSchema.parse(body).data;
}
async searchSpans(
{
organizationSlug,
projectSlug,
transaction,
query,
sortBy = "timestamp",
}: {
organizationSlug: string;
projectSlug?: string;
transaction?: string;
query?: string;
sortBy?: "timestamp" | "duration";
},
opts?: RequestOptions,
) {
const sentryQuery: string[] = ["is_transaction:true"];
if (transaction) {
sentryQuery.push(`transaction:"${transaction.replace(/"/g, '\\"')}"`);
}
if (query) {
sentryQuery.push(query);
}
if (projectSlug) {
sentryQuery.push(`project:${projectSlug}`);
}
const queryParams = new URLSearchParams();
queryParams.set("dataset", "spans");
queryParams.set("per_page", "10");
queryParams.set(
"sort",
`-${sortBy === "timestamp" ? "timestamp" : "span.duration"}`,
);
queryParams.set("allowAggregateConditions", "0");
queryParams.set("useRpc", "1");
queryParams.append("field", "id");
queryParams.append("field", "trace");
queryParams.append("field", "span.op");
queryParams.append("field", "span.description");
queryParams.append("field", "span.duration");
queryParams.append("field", "transaction");
queryParams.append("field", "project");
queryParams.append("field", "timestamp");
queryParams.set("query", sentryQuery.join(" "));
// if (projectSlug) queryParams.set("project", projectSlug);
const apiUrl = `/organizations/${organizationSlug}/events/?${queryParams.toString()}`;
const body = await this.requestJSON(apiUrl, undefined, opts);
return SpansSearchResponseSchema.parse(body).data;
}
// ================================================================================
// API QUERY BUILDERS FOR DIFFERENT SENTRY APIS
// ================================================================================
/**
* Builds query parameters for the legacy Discover API (primarily used by errors dataset).
*
* Note: While the API endpoint is the same for all datasets, we maintain separate
* builders to make future divergence easier and to keep the code organized.
*/
private buildDiscoverApiQuery(params: {
query: string;
fields: string[];
limit: number;
projectId?: string;
statsPeriod?: string;
start?: string;
end?: string;
sort: string;
}): URLSearchParams {
const queryParams = new URLSearchParams();
// Basic parameters
queryParams.set("per_page", params.limit.toString());
queryParams.set("query", params.query);
queryParams.set("dataset", "errors");
// Validate time parameters - can't use both relative and absolute
if (params.statsPeriod && (params.start || params.end)) {
throw new ApiValidationError(
"Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.",
);
}
if ((params.start && !params.end) || (!params.start && params.end)) {
throw new ApiValidationError(
"Both start and end parameters must be provided together for absolute time ranges.",
);
}
// Use either relative time (statsPeriod) or absolute time (start/end)
if (params.statsPeriod) {
queryParams.set("statsPeriod", params.statsPeriod);
} else if (params.start && params.end) {
queryParams.set("start", params.start);
queryParams.set("end", params.end);
}
if (params.projectId) {
queryParams.set("project", params.projectId);
}
// Sort parameter transformation for API compatibility
let apiSort = params.sort;
// Skip transformation for equation fields - they should be passed as-is
if (params.sort?.includes("(") && !params.sort?.includes("equation|")) {
// Transform: count(field) -> count_field, count() -> count
// Use safer string manipulation to avoid ReDoS
const parenStart = params.sort.indexOf("(");
const parenEnd = params.sort.indexOf(")", parenStart);
if (parenStart !== -1 && parenEnd !== -1) {
const beforeParen = params.sort.substring(0, parenStart);
const insideParen = params.sort.substring(parenStart + 1, parenEnd);
const afterParen = params.sort.substring(parenEnd + 1);
const transformedInside = insideParen
? `_${insideParen.replace(/\./g, "_")}`
: "";
apiSort = beforeParen + transformedInside + afterParen;
}
}
queryParams.set("sort", apiSort);
// Add fields
for (const field of params.fields) {
queryParams.append("field", field);
}
return queryParams;
}
/**
* Builds query parameters for the modern EAP API (used by spans/logs datasets).
*
* Includes dataset-specific parameters like sampling for spans.
*/
private buildEapApiQuery(params: {
query: string;
fields: string[];
limit: number;
projectId?: string;
dataset: "spans" | "ourlogs";
statsPeriod?: string;
start?: string;
end?: string;
sort: string;
}): URLSearchParams {
const queryParams = new URLSearchParams();
// Basic parameters
queryParams.set("per_page", params.limit.toString());
queryParams.set("query", params.query);
queryParams.set("dataset", params.dataset);
// Validate time parameters - can't use both relative and absolute
if (params.statsPeriod && (params.start || params.end)) {
throw new ApiValidationError(
"Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.",
);
}
if ((params.start && !params.end) || (!params.start && params.end)) {
throw new ApiValidationError(
"Both start and end parameters must be provided together for absolute time ranges.",
);
}
// Use either relative time (statsPeriod) or absolute time (start/end)
if (params.statsPeriod) {
queryParams.set("statsPeriod", params.statsPeriod);
} else if (params.start && params.end) {
queryParams.set("start", params.start);
queryParams.set("end", params.end);
}
if (params.projectId) {
queryParams.set("project", params.projectId);
}
// Dataset-specific parameters
if (params.dataset === "spans") {
queryParams.set("sampling", "NORMAL");
}
// Sort parameter transformation for API compatibility
let apiSort = params.sort;
// Skip transformation for equation fields - they should be passed as-is
if (params.sort?.includes("(") && !params.sort?.includes("equation|")) {
// Transform: count(field) -> count_field, count() -> count
// Use safer string manipulation to avoid ReDoS
const parenStart = params.sort.indexOf("(");
const parenEnd = params.sort.indexOf(")", parenStart);
if (parenStart !== -1 && parenEnd !== -1) {
const beforeParen = params.sort.substring(0, parenStart);
const insideParen = params.sort.substring(parenStart + 1, parenEnd);
const afterParen = params.sort.substring(parenEnd + 1);
const transformedInside = insideParen
? `_${insideParen.replace(/\./g, "_")}`
: "";
apiSort = beforeParen + transformedInside + afterParen;
}
}
queryParams.set("sort", apiSort);
// Add fields
for (const field of params.fields) {
queryParams.append("field", field);
}
return queryParams;
}
/**
* Searches for events in Sentry using the unified events API.
* This method is used by the search_events tool for semantic search.
*
* Routes to the appropriate query builder based on dataset, even though
* the underlying API endpoint is the same. This separation makes the code
* cleaner and allows for future API divergence.
*/
async searchEvents(
{
organizationSlug,
query,
fields,
limit = 10,
projectId,
dataset = "spans",
statsPeriod,
start,
end,
sort = "-timestamp",
}: {
organizationSlug: string;
query: string;
fields: string[];
limit?: number;
projectId?: string;
dataset?: "spans" | "errors" | "ourlogs";
statsPeriod?: string;
start?: string;
end?: string;
sort?: string;
},
opts?: RequestOptions,
) {
let queryParams: URLSearchParams;
if (dataset === "errors") {
// Use Discover API query builder
queryParams = this.buildDiscoverApiQuery({
query,
fields,
limit,
projectId,
statsPeriod,
start,
end,
sort,
});
} else {
// Use EAP API query builder for spans and logs
queryParams = this.buildEapApiQuery({
query,
fields,
limit,
projectId,
dataset,
statsPeriod,
start,
end,
sort,
});
}
const apiUrl = `/organizations/${organizationSlug}/events/?${queryParams.toString()}`;
return await this.requestJSON(apiUrl, undefined, opts);
}
// POST https://us.sentry.io/api/0/issues/5485083130/autofix/
async startAutofix(
{
organizationSlug,
issueId,
eventId,
instruction = "",
}: {
organizationSlug: string;
issueId: string;
eventId?: string;
instruction?: string;
},
opts?: RequestOptions,
): Promise<AutofixRun> {
const body = await this.requestJSON(
`/organizations/${organizationSlug}/issues/${issueId}/autofix/`,
{
method: "POST",
body: JSON.stringify({
event_id: eventId,
instruction,
}),
},
opts,
);
return AutofixRunSchema.parse(body);
}
// GET https://us.sentry.io/api/0/issues/5485083130/autofix/
async getAutofixState(
{
organizationSlug,
issueId,
}: {
organizationSlug: string;
issueId: string;
},
opts?: RequestOptions,
): Promise<AutofixRunState> {
const body = await this.requestJSON(
`/organizations/${organizationSlug}/issues/${issueId}/autofix/`,
undefined,
opts,
);
return AutofixRunStateSchema.parse(body);
}
/**
* Retrieves high-level metadata about a trace.
*
* Returns statistics including span counts, error counts, transaction
* breakdown, and operation type distribution for the specified trace.
*
* @param params Query parameters
* @param params.organizationSlug Organization identifier
* @param params.traceId Trace identifier (32-character hex string)
* @param params.statsPeriod Optional stats period (e.g., "14d", "7d")
* @param opts Request options
* @returns Trace metadata with statistics
*
* @example
* ```typescript
* const traceMeta = await apiService.getTraceMeta({
* organizationSlug: "my-org",
* traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a"
* });
* console.log(`Trace has ${traceMeta.span_count} spans`);
* ```
*/
async getTraceMeta(
{
organizationSlug,
traceId,
statsPeriod = "14d",
}: {
organizationSlug: string;
traceId: string;
statsPeriod?: string;
},
opts?: RequestOptions,
): Promise<TraceMeta> {
const queryParams = new URLSearchParams();
queryParams.set("statsPeriod", statsPeriod);
const body = await this.requestJSON(
`/organizations/${organizationSlug}/trace-meta/${traceId}/?${queryParams.toString()}`,
undefined,
opts,
);
return TraceMetaSchema.parse(body);
}
/**
* Retrieves the complete trace structure with all spans.
*
* Returns the hierarchical trace data including all spans, their timing
* information, operation details, and nested relationships.
*
* @param params Query parameters
* @param params.organizationSlug Organization identifier
* @param params.traceId Trace identifier (32-character hex string)
* @param params.limit Maximum number of spans to return (default: 1000)
* @param params.project Project filter (-1 for all projects)
* @param params.statsPeriod Optional stats period (e.g., "14d", "7d")
* @param opts Request options
* @returns Complete trace tree structure
*
* @example
* ```typescript
* const trace = await apiService.getTrace({
* organizationSlug: "my-org",
* traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
* limit: 1000
* });
* console.log(`Root spans: ${trace.length}`);
* ```
*/
async getTrace(
{
organizationSlug,
traceId,
limit = 1000,
project = "-1",
statsPeriod = "14d",
}: {
organizationSlug: string;
traceId: string;
limit?: number;
project?: string;
statsPeriod?: string;
},
opts?: RequestOptions,
): Promise<Trace> {
const queryParams = new URLSearchParams();
queryParams.set("limit", String(limit));
queryParams.set("project", project);
queryParams.set("statsPeriod", statsPeriod);
const body = await this.requestJSON(
`/organizations/${organizationSlug}/trace/${traceId}/?${queryParams.toString()}`,
undefined,
opts,
);
return TraceSchema.parse(body);
}
}
```