#
tokens: 45582/50000 3/408 files (page 10/11)
lines: off (toggle) GitHub
raw markdown copy
This is page 10 of 11. Use http://codebase.md/getsentry/sentry-mcp?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);
  }
}

```
Page 10/11FirstPrevNextLast