This is page 18 of 20. Use http://codebase.md/getsentry/sentry-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .claude
│ ├── agents
│ │ └── claude-optimizer.md
│ ├── commands
│ │ ├── gh-pr.md
│ │ └── gh-review.md
│ └── settings.json
├── .craft.yml
├── .cursor
│ └── mcp.json
├── .env.example
├── .github
│ └── workflows
│ ├── deploy.yml
│ ├── eval.yml
│ ├── merge-jobs.yml
│ ├── release.yml
│ ├── smoke-tests.yml
│ ├── test.yml
│ └── token-cost.yml
├── .gitignore
├── .mcp.json
├── .vscode
│ ├── extensions.json
│ ├── mcp.json
│ └── settings.json
├── AGENTS.md
├── benchmark-agent.sh
├── bin
│ └── bump-version.sh
├── biome.json
├── CLAUDE.md
├── codecov.yml
├── core
├── docs
│ ├── adding-tools.md
│ ├── api-patterns.md
│ ├── architecture.md
│ ├── cloudflare
│ │ ├── architecture.md
│ │ ├── oauth-architecture.md
│ │ └── overview.md
│ ├── coding-guidelines.md
│ ├── common-patterns.md
│ ├── error-handling.md
│ ├── github-actions.md
│ ├── llms
│ │ ├── document-scopes.md
│ │ ├── documentation-style-guide.md
│ │ └── README.md
│ ├── logging.md
│ ├── monitoring.md
│ ├── pr-management.md
│ ├── quality-checks.md
│ ├── README.md
│ ├── releases
│ │ ├── cloudflare.md
│ │ └── stdio.md
│ ├── search-events-api-patterns.md
│ ├── security.md
│ ├── specs
│ │ ├── README.md
│ │ ├── search-events.md
│ │ └── subpath-constraints.md
│ ├── testing-remote.md
│ ├── testing-stdio.md
│ ├── testing.md
│ └── token-cost-tracking.md
├── LICENSE.md
├── Makefile
├── package.json
├── packages
│ ├── mcp-cloudflare
│ │ ├── .env.example
│ │ ├── components.json
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── demo.cast
│ │ │ ├── favicon.ico
│ │ │ ├── flow-transparent.png
│ │ │ ├── flow.jpg
│ │ │ ├── keycap-⌘.png
│ │ │ ├── keycap-c.png
│ │ │ └── keycap-v.png
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── app.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── animation
│ │ │ │ │ │ ├── browser-ui
│ │ │ │ │ │ │ ├── BrowserWindow.tsx
│ │ │ │ │ │ │ ├── BrowserWindowIconSidebar.tsx
│ │ │ │ │ │ │ ├── DiffBlock.tsx
│ │ │ │ │ │ │ ├── IDEWindow.tsx
│ │ │ │ │ │ │ ├── IssueDetails.tsx
│ │ │ │ │ │ │ ├── keys-copy.tsx
│ │ │ │ │ │ │ ├── LoadingSquares.tsx
│ │ │ │ │ │ │ ├── RootCause.tsx
│ │ │ │ │ │ │ ├── seer-clipmask.tsx
│ │ │ │ │ │ │ ├── seer-noisefilter.tsx
│ │ │ │ │ │ │ ├── seer.tsx
│ │ │ │ │ │ │ └── WindowHeader.tsx
│ │ │ │ │ │ ├── BrowserAnimation.tsx
│ │ │ │ │ │ ├── DataWire.tsx
│ │ │ │ │ │ ├── dracula.css
│ │ │ │ │ │ ├── terminal-ui
│ │ │ │ │ │ │ ├── keys-paste.tsx
│ │ │ │ │ │ │ ├── SpeedDisplay.tsx
│ │ │ │ │ │ │ └── StepsList.tsx
│ │ │ │ │ │ ├── TerminalAnimation.tsx
│ │ │ │ │ │ └── tests.tsx
│ │ │ │ │ ├── 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
│ │ │ │ │ │ ├── install-tabs.tsx
│ │ │ │ │ │ ├── remote-setup.tsx
│ │ │ │ │ │ ├── setup-guide.tsx
│ │ │ │ │ │ └── stdio-setup.tsx
│ │ │ │ │ ├── getting-started.tsx
│ │ │ │ │ ├── hero
│ │ │ │ │ │ ├── header-divider.tsx
│ │ │ │ │ │ └── hero-block.tsx
│ │ │ │ │ ├── home-layout
│ │ │ │ │ │ ├── footer.tsx
│ │ │ │ │ │ └── sidebars.tsx
│ │ │ │ │ ├── ui
│ │ │ │ │ │ ├── accordion.tsx
│ │ │ │ │ │ ├── backdrop.tsx
│ │ │ │ │ │ ├── badge.tsx
│ │ │ │ │ │ ├── base.tsx
│ │ │ │ │ │ ├── button.tsx
│ │ │ │ │ │ ├── code-snippet.tsx
│ │ │ │ │ │ ├── header.tsx
│ │ │ │ │ │ ├── icon.tsx
│ │ │ │ │ │ ├── icons
│ │ │ │ │ │ │ ├── claude.tsx
│ │ │ │ │ │ │ ├── codex.tsx
│ │ │ │ │ │ │ ├── cursor.tsx
│ │ │ │ │ │ │ ├── gemini.tsx
│ │ │ │ │ │ │ ├── sentry.tsx
│ │ │ │ │ │ │ ├── vscode.tsx
│ │ │ │ │ │ │ ├── warp.tsx
│ │ │ │ │ │ │ ├── windsurf.tsx
│ │ │ │ │ │ │ └── zed.tsx
│ │ │ │ │ │ ├── interactive-markdown.tsx
│ │ │ │ │ │ ├── json-schema-params.tsx
│ │ │ │ │ │ ├── key-icon.tsx
│ │ │ │ │ │ ├── key-word.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
│ │ │ │ │ └── usecases
│ │ │ │ │ ├── fix-bugs.tsx
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── instrument.tsx
│ │ │ │ │ ├── search-things.tsx
│ │ │ │ │ └── search-visual.tsx
│ │ │ │ ├── contexts
│ │ │ │ │ └── auth-context.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ ├── use-endpoint-mode.ts
│ │ │ │ │ ├── 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
│ │ │ │ ├── utils
│ │ │ │ │ ├── chat-error-handler.ts
│ │ │ │ │ ├── cursor-deeplink.ts
│ │ │ │ │ └── index.ts
│ │ │ │ └── vite-env.d.ts
│ │ │ ├── constants.ts
│ │ │ ├── server
│ │ │ │ ├── app.test.ts
│ │ │ │ ├── app.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── lib
│ │ │ │ │ ├── approval-dialog.test.ts
│ │ │ │ │ ├── approval-dialog.ts
│ │ │ │ │ ├── constraint-utils.test.ts
│ │ │ │ │ ├── constraint-utils.ts
│ │ │ │ │ ├── html-utils.ts
│ │ │ │ │ ├── mcp-handler.test.ts
│ │ │ │ │ ├── mcp-handler.ts
│ │ │ │ │ └── slug-validation.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── oauth
│ │ │ │ │ ├── authorize.test.ts
│ │ │ │ │ ├── callback.test.ts
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── helpers.test.ts
│ │ │ │ │ ├── helpers.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── authorize.ts
│ │ │ │ │ │ ├── callback.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ └── state.ts
│ │ │ │ ├── routes
│ │ │ │ │ ├── chat-oauth.ts
│ │ │ │ │ ├── chat.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── metadata.ts
│ │ │ │ │ ├── search.test.ts
│ │ │ │ │ └── search.ts
│ │ │ │ ├── sentry.config.ts
│ │ │ │ ├── types
│ │ │ │ │ └── chat.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils
│ │ │ │ ├── auth-errors.ts
│ │ │ │ ├── client-ip.test.ts
│ │ │ │ ├── client-ip.ts
│ │ │ │ ├── rate-limiter.test.ts
│ │ │ │ └── rate-limiter.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-core
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── scripts
│ │ │ ├── generate-definitions.ts
│ │ │ ├── generate-otel-namespaces.ts
│ │ │ ├── measure-token-cost.ts
│ │ │ └── validate-skills-mapping.ts
│ │ ├── src
│ │ │ ├── api-client
│ │ │ │ ├── client.test.ts
│ │ │ │ ├── client.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.test.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── types.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── internal
│ │ │ │ ├── agents
│ │ │ │ │ ├── callEmbeddedAgent.ts
│ │ │ │ │ ├── openai-provider.test.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
│ │ │ ├── skillDefinitions.json
│ │ │ ├── skillDefinitions.ts
│ │ │ ├── skills.test.ts
│ │ │ ├── skills.ts
│ │ │ ├── telem
│ │ │ │ ├── index.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── sentry.test.ts
│ │ │ │ └── sentry.ts
│ │ │ ├── test-setup.ts
│ │ │ ├── test-utils
│ │ │ │ └── context.ts
│ │ │ ├── toolDefinitions.json
│ │ │ ├── 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
│ │ │ │ ├── use-sentry
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── handler.test.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── tool-wrapper.test.ts
│ │ │ │ │ └── tool-wrapper.ts
│ │ │ │ ├── whoami.test.ts
│ │ │ │ └── whoami.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
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── cli
│ │ │ │ ├── parse.test.ts
│ │ │ │ ├── parse.ts
│ │ │ │ ├── resolve.test.ts
│ │ │ │ ├── resolve.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── usage.ts
│ │ │ ├── index.ts
│ │ │ └── transports
│ │ │ └── stdio.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.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
│ │ │ │ ├── csp-event.json
│ │ │ │ ├── csp-issue.json
│ │ │ │ ├── default-event.json
│ │ │ │ ├── event-attachments.json
│ │ │ │ ├── event.json
│ │ │ │ ├── generic-event.json
│ │ │ │ ├── issue.json
│ │ │ │ ├── performance-event.json
│ │ │ │ ├── performance-issue.json
│ │ │ │ ├── project.json
│ │ │ │ ├── regressed-issue.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
│ │ │ │ ├── unknown-event.json
│ │ │ │ └── unsupported-issue.json
│ │ │ ├── fixtures.ts
│ │ │ ├── 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-core/src/internal/formatting.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import { formatEventOutput, formatFrameHeader } from "./formatting";
3 | import type { Event } from "../api-client/types";
4 | import {
5 | EventBuilder,
6 | createFrame,
7 | frameFactories,
8 | createStackTrace,
9 | createExceptionValue,
10 | createThread,
11 | testEvents,
12 | createFrameWithContext,
13 | } from "./test-fixtures";
14 |
15 | // Helper functions to reduce duplication in event creation
16 | function createPythonExceptionEvent(
17 | errorType: string,
18 | errorMessage: string,
19 | frames: any[],
20 | ): Event {
21 | return new EventBuilder("python")
22 | .withException(
23 | createExceptionValue({
24 | type: errorType,
25 | value: errorMessage,
26 | stacktrace: createStackTrace(frames),
27 | }),
28 | )
29 | .build();
30 | }
31 |
32 | function createSimpleExceptionEvent(
33 | platform: string,
34 | errorType: string,
35 | errorMessage: string,
36 | frame: any,
37 | ): Event {
38 | const builder = new EventBuilder(platform);
39 | // Remove the contexts property to avoid "Additional Context" section
40 | const event = builder
41 | .withException(
42 | createExceptionValue({
43 | type: errorType,
44 | value: errorMessage,
45 | stacktrace: createStackTrace([frame]),
46 | }),
47 | )
48 | .build();
49 | // Remove contexts to match original test expectations
50 | event.contexts = undefined;
51 | return event;
52 | }
53 |
54 | describe("formatFrameHeader", () => {
55 | it("uses platform as fallback when language detection fails", () => {
56 | // Frame with no clear language indicators
57 | const unknownFrame = {
58 | filename: "/path/to/file.unknown",
59 | function: "someFunction",
60 | lineNo: 42,
61 | };
62 |
63 | // Without platform - should use generic format
64 | expect(formatFrameHeader(unknownFrame)).toBe(
65 | " at someFunction (/path/to/file.unknown:42)",
66 | );
67 |
68 | // With platform python - should use Python format
69 | expect(formatFrameHeader(unknownFrame, undefined, "python")).toBe(
70 | ' File "/path/to/file.unknown", line 42, in someFunction',
71 | );
72 |
73 | // With platform java - should use Java format
74 | expect(formatFrameHeader(unknownFrame, undefined, "java")).toBe(
75 | "at UnknownClass.someFunction(/path/to/file.unknown:42)",
76 | );
77 | });
78 | it("formats Java stack traces correctly", () => {
79 | // With module and filename
80 | const javaFrame1 = {
81 | module: "com.example.ClassName",
82 | function: "methodName",
83 | filename: "ClassName.java",
84 | lineNo: 123,
85 | };
86 | expect(formatFrameHeader(javaFrame1)).toBe(
87 | "at com.example.ClassName.methodName(ClassName.java:123)",
88 | );
89 |
90 | // Without filename (common in Java) - needs platform hint
91 | const javaFrame2 = {
92 | module: "com.example.ClassName",
93 | function: "methodName",
94 | lineNo: 123,
95 | };
96 | expect(formatFrameHeader(javaFrame2, undefined, "java")).toBe(
97 | "at com.example.ClassName.methodName(Unknown Source:123)",
98 | );
99 | });
100 |
101 | it("formats Python stack traces correctly", () => {
102 | const pythonFrame = {
103 | filename: "/path/to/file.py",
104 | function: "function_name",
105 | lineNo: 42,
106 | };
107 | expect(formatFrameHeader(pythonFrame)).toBe(
108 | ' File "/path/to/file.py", line 42, in function_name',
109 | );
110 |
111 | // Module only (no filename) - needs platform hint
112 | const pythonModuleFrame = {
113 | module: "mymodule",
114 | function: "function_name",
115 | lineNo: 42,
116 | };
117 | expect(formatFrameHeader(pythonModuleFrame, undefined, "python")).toBe(
118 | ' File "mymodule", line 42, in function_name',
119 | );
120 | });
121 |
122 | it("formats JavaScript stack traces correctly", () => {
123 | // With column number
124 | const jsFrame1 = {
125 | filename: "/path/to/file.js",
126 | function: "functionName",
127 | lineNo: 10,
128 | colNo: 15,
129 | };
130 | expect(formatFrameHeader(jsFrame1)).toBe(
131 | "/path/to/file.js:10:15 (functionName)",
132 | );
133 |
134 | // Without column number but .js extension
135 | const jsFrame2 = {
136 | filename: "/path/to/file.js",
137 | function: "functionName",
138 | lineNo: 10,
139 | };
140 | expect(formatFrameHeader(jsFrame2)).toBe(
141 | "/path/to/file.js:10 (functionName)",
142 | );
143 |
144 | // Anonymous function (no function name)
145 | const jsFrame3 = {
146 | filename: "/path/to/file.js",
147 | lineNo: 10,
148 | colNo: 15,
149 | };
150 | expect(formatFrameHeader(jsFrame3)).toBe("/path/to/file.js:10:15");
151 | });
152 |
153 | it("formats Ruby stack traces correctly", () => {
154 | const rubyFrame = {
155 | filename: "/path/to/file.rb",
156 | function: "method_name",
157 | lineNo: 42,
158 | };
159 | expect(formatFrameHeader(rubyFrame)).toBe(
160 | " from /path/to/file.rb:42:in `method_name`",
161 | );
162 |
163 | // Without function name
164 | const rubyFrame2 = {
165 | filename: "/path/to/file.rb",
166 | lineNo: 42,
167 | };
168 | expect(formatFrameHeader(rubyFrame2)).toBe(
169 | " from /path/to/file.rb:42:in",
170 | );
171 | });
172 |
173 | it("formats PHP stack traces correctly", () => {
174 | // With frame index
175 | const phpFrame1 = {
176 | filename: "/path/to/file.php",
177 | function: "functionName",
178 | lineNo: 42,
179 | };
180 | expect(formatFrameHeader(phpFrame1, 0)).toBe(
181 | "#0 /path/to/file.php(42): functionName()",
182 | );
183 |
184 | // Without frame index
185 | const phpFrame2 = {
186 | filename: "/path/to/file.php",
187 | function: "functionName",
188 | lineNo: 42,
189 | };
190 | expect(formatFrameHeader(phpFrame2)).toBe(
191 | "/path/to/file.php(42): functionName()",
192 | );
193 | });
194 |
195 | it("formats unknown languages with generic format", () => {
196 | const unknownFrame = {
197 | filename: "/path/to/file.unknown",
198 | function: "someFunction",
199 | lineNo: 42,
200 | };
201 | expect(formatFrameHeader(unknownFrame)).toBe(
202 | " at someFunction (/path/to/file.unknown:42)",
203 | );
204 | });
205 |
206 | it("prioritizes duck typing over platform when clear indicators exist", () => {
207 | // Java file but platform says python - should use Java format
208 | const javaFrame = {
209 | filename: "Example.java",
210 | module: "com.example.Example",
211 | function: "doSomething",
212 | lineNo: 42,
213 | };
214 | expect(formatFrameHeader(javaFrame, undefined, "python")).toBe(
215 | "at com.example.Example.doSomething(Example.java:42)",
216 | );
217 |
218 | // Python file but platform says java - should use Python format
219 | const pythonFrame = {
220 | filename: "/app/example.py",
221 | function: "do_something",
222 | lineNo: 42,
223 | };
224 | expect(formatFrameHeader(pythonFrame, undefined, "java")).toBe(
225 | ' File "/app/example.py", line 42, in do_something',
226 | );
227 | });
228 | });
229 |
230 | describe("formatEventOutput", () => {
231 | it("formats Java thread stack traces correctly", () => {
232 | const event = testEvents.javaThreadError(
233 | "Cannot use this function, please use update(String sql, PreparedStatementSetter pss) instead",
234 | );
235 |
236 | const output = formatEventOutput(event);
237 |
238 | expect(output).toMatchInlineSnapshot(`
239 | "### Error
240 |
241 | \`\`\`
242 | Cannot use this function, please use update(String sql, PreparedStatementSetter pss) instead
243 | \`\`\`
244 |
245 | **Thread** (CONTRACT_WORKER)
246 |
247 | **Stacktrace:**
248 | \`\`\`
249 | at java.lang.Thread.run(Thread.java:833)
250 | at com.citics.eqd.mq.aeron.AeronServer.lambda$start$3(AeronServer.java:110)
251 | \`\`\`
252 | "
253 | `);
254 | });
255 |
256 | it("formats Python exception traces correctly", () => {
257 | const event = testEvents.pythonException("Invalid value");
258 |
259 | const output = formatEventOutput(event);
260 |
261 | expect(output).toMatchInlineSnapshot(`
262 | "### Error
263 |
264 | \`\`\`
265 | ValueError: Invalid value
266 | \`\`\`
267 |
268 | **Stacktrace:**
269 | \`\`\`
270 | File "/app/main.py", line 42, in process_data
271 | File "/app/utils.py", line 15, in validate
272 | \`\`\`
273 |
274 | "
275 | `);
276 | });
277 |
278 | it("should render enhanced in-app frame with context lines", () => {
279 | const event = new EventBuilder("python")
280 | .withException(
281 | createExceptionValue({
282 | type: "ValueError",
283 | value: "Something went wrong",
284 | stacktrace: createStackTrace([
285 | createFrame({
286 | filename: "/usr/lib/python3.8/json/__init__.py",
287 | function: "loads",
288 | lineNo: 357,
289 | inApp: false,
290 | }),
291 | createFrameWithContext(
292 | {
293 | filename: "/app/services/payment.py",
294 | function: "process_payment",
295 | lineNo: 42,
296 | },
297 | [
298 | [37, " def process_payment(self, amount, user_id):"],
299 | [38, " user = self.get_user(user_id)"],
300 | [39, " if not user:"],
301 | [40, ' raise ValueError("User not found")'],
302 | [41, " "],
303 | [42, " balance = user.account.balance"],
304 | [43, " if balance < amount:"],
305 | [44, " raise InsufficientFundsError()"],
306 | [45, " "],
307 | [46, " transaction = Transaction(user, amount)"],
308 | ],
309 | ),
310 | ]),
311 | }),
312 | )
313 | .build();
314 |
315 | const output = formatEventOutput(event);
316 |
317 | expect(output).toMatchInlineSnapshot(`
318 | "### Error
319 |
320 | \`\`\`
321 | ValueError: Something went wrong
322 | \`\`\`
323 |
324 | **Most Relevant Frame:**
325 | ─────────────────────
326 | File "/app/services/payment.py", line 42, in process_payment
327 |
328 | 39 │ if not user:
329 | 40 │ raise ValueError("User not found")
330 | 41 │
331 | → 42 │ balance = user.account.balance
332 | 43 │ if balance < amount:
333 | 44 │ raise InsufficientFundsError()
334 | 45 │
335 |
336 | **Full Stacktrace:**
337 | ────────────────
338 | \`\`\`
339 | File "/usr/lib/python3.8/json/__init__.py", line 357, in loads
340 | File "/app/services/payment.py", line 42, in process_payment
341 | balance = user.account.balance
342 | \`\`\`
343 |
344 | "
345 | `);
346 | });
347 |
348 | it("should render enhanced in-app frame with variables", () => {
349 | const event = new EventBuilder("python")
350 | .withException(
351 | createExceptionValue({
352 | type: "ValueError",
353 | value: "Something went wrong",
354 | stacktrace: createStackTrace([
355 | createFrame({
356 | filename: "/app/services/payment.py",
357 | function: "process_payment",
358 | lineNo: 42,
359 | inApp: true,
360 | vars: {
361 | amount: 150.0,
362 | user_id: "usr_123456",
363 | user: null,
364 | self: { type: "PaymentService", id: 1234 },
365 | },
366 | }),
367 | ]),
368 | }),
369 | )
370 | .build();
371 |
372 | const output = formatEventOutput(event);
373 |
374 | expect(output).toMatchInlineSnapshot(`
375 | "### Error
376 |
377 | \`\`\`
378 | ValueError: Something went wrong
379 | \`\`\`
380 |
381 | **Most Relevant Frame:**
382 | ─────────────────────
383 | File "/app/services/payment.py", line 42, in process_payment
384 |
385 | Local Variables:
386 | ├─ amount: 150
387 | ├─ user_id: "usr_123456"
388 | ├─ user: null
389 | └─ self: {"type":"PaymentService","id":1234}
390 |
391 | **Full Stacktrace:**
392 | ────────────────
393 | \`\`\`
394 | File "/app/services/payment.py", line 42, in process_payment
395 | \`\`\`
396 |
397 | "
398 | `);
399 | });
400 |
401 | it("should handle frames without in-app or enhanced data", () => {
402 | const event = new EventBuilder("python")
403 | .withException(
404 | createExceptionValue({
405 | type: "ValueError",
406 | value: "Something went wrong",
407 | stacktrace: createStackTrace([
408 | frameFactories.python({ lineNo: 10, function: "main" }),
409 | ]),
410 | }),
411 | )
412 | .build();
413 |
414 | const output = formatEventOutput(event);
415 |
416 | expect(output).toMatchInlineSnapshot(`
417 | "### Error
418 |
419 | \`\`\`
420 | ValueError: Something went wrong
421 | \`\`\`
422 |
423 | **Stacktrace:**
424 | \`\`\`
425 | File "/app/main.py", line 10, in main
426 | \`\`\`
427 |
428 | "
429 | `);
430 | });
431 |
432 | it("should work with thread interface containing in-app frame", () => {
433 | const event = new EventBuilder("java")
434 | .withThread(
435 | createThread({
436 | id: 1,
437 | crashed: true,
438 | name: "main",
439 | stacktrace: createStackTrace([
440 | frameFactories.java({
441 | module: "java.lang.Thread",
442 | function: "run",
443 | filename: "Thread.java",
444 | lineNo: 748,
445 | inApp: false,
446 | }),
447 | createFrameWithContext(
448 | {
449 | module: "com.example.PaymentService",
450 | function: "processPayment",
451 | filename: "PaymentService.java",
452 | lineNo: 42,
453 | },
454 | [
455 | [40, " User user = getUser(userId);"],
456 | [41, " if (user == null) {"],
457 | [42, " throw new UserNotFoundException(userId);"],
458 | [43, " }"],
459 | [44, " return user.getBalance();"],
460 | ],
461 | {
462 | userId: "12345",
463 | user: null,
464 | },
465 | ),
466 | ]),
467 | }),
468 | )
469 | .build();
470 |
471 | const output = formatEventOutput(event);
472 |
473 | expect(output).toMatchInlineSnapshot(`
474 | "**Thread** (main)
475 |
476 | **Most Relevant Frame:**
477 | ─────────────────────
478 | at com.example.PaymentService.processPayment(PaymentService.java:42)
479 |
480 | 40 │ User user = getUser(userId);
481 | 41 │ if (user == null) {
482 | → 42 │ throw new UserNotFoundException(userId);
483 | 43 │ }
484 | 44 │ return user.getBalance();
485 |
486 | Local Variables:
487 | ├─ userId: "12345"
488 | └─ user: null
489 |
490 | **Full Stacktrace:**
491 | ────────────────
492 | \`\`\`
493 | at java.lang.Thread.run(Thread.java:748)
494 | at com.example.PaymentService.processPayment(PaymentService.java:42)
495 | throw new UserNotFoundException(userId);
496 | \`\`\`
497 | "
498 | `);
499 | });
500 |
501 | describe("Enhanced frame rendering variations", () => {
502 | it("should handle Python format with enhanced frame", () => {
503 | const event = createSimpleExceptionEvent(
504 | "python",
505 | "AttributeError",
506 | "'NoneType' object has no attribute 'balance'",
507 | createFrameWithContext(
508 | {
509 | filename: "/app/models/user.py",
510 | function: "get_balance",
511 | lineNo: 25,
512 | },
513 | [
514 | [23, " def get_balance(self):"],
515 | [24, " # This will fail if account is None"],
516 | [25, " return self.account.balance"],
517 | [26, ""],
518 | [27, " def set_balance(self, amount):"],
519 | ],
520 | {
521 | self: { id: 123, account: null },
522 | },
523 | ),
524 | );
525 |
526 | const output = formatEventOutput(event);
527 |
528 | expect(output).toMatchInlineSnapshot(`
529 | "### Error
530 |
531 | \`\`\`
532 | AttributeError: 'NoneType' object has no attribute 'balance'
533 | \`\`\`
534 |
535 | **Most Relevant Frame:**
536 | ─────────────────────
537 | File "/app/models/user.py", line 25, in get_balance
538 |
539 | 23 │ def get_balance(self):
540 | 24 │ # This will fail if account is None
541 | → 25 │ return self.account.balance
542 | 26 │
543 | 27 │ def set_balance(self, amount):
544 |
545 | Local Variables:
546 | └─ self: {"id":123,"account":null}
547 |
548 | **Full Stacktrace:**
549 | ────────────────
550 | \`\`\`
551 | File "/app/models/user.py", line 25, in get_balance
552 | return self.account.balance
553 | \`\`\`
554 |
555 | "
556 | `);
557 | });
558 |
559 | it("should handle JavaScript format with enhanced frame", () => {
560 | const event = createSimpleExceptionEvent(
561 | "javascript",
562 | "TypeError",
563 | "Cannot read property 'name' of undefined",
564 | createFrameWithContext(
565 | {
566 | filename: "/src/components/UserProfile.tsx",
567 | function: "UserProfile",
568 | lineNo: 15,
569 | colNo: 28,
570 | },
571 | [
572 | [
573 | 13,
574 | "export const UserProfile: React.FC<Props> = ({ userId }) => {",
575 | ],
576 | [14, " const user = useUser(userId);"],
577 | [15, " const displayName = user.profile.name;"],
578 | [16, " "],
579 | [17, " return ("],
580 | ],
581 | {
582 | userId: "usr_123",
583 | user: undefined,
584 | displayName: undefined,
585 | },
586 | ),
587 | );
588 |
589 | const output = formatEventOutput(event);
590 |
591 | expect(output).toMatchInlineSnapshot(`
592 | "### Error
593 |
594 | \`\`\`
595 | TypeError: Cannot read property 'name' of undefined
596 | \`\`\`
597 |
598 | **Most Relevant Frame:**
599 | ─────────────────────
600 | /src/components/UserProfile.tsx:15:28 (UserProfile)
601 |
602 | 13 │ export const UserProfile: React.FC<Props> = ({ userId }) => {
603 | 14 │ const user = useUser(userId);
604 | → 15 │ const displayName = user.profile.name;
605 | 16 │
606 | 17 │ return (
607 |
608 | Local Variables:
609 | ├─ userId: "usr_123"
610 | ├─ user: undefined
611 | └─ displayName: undefined
612 |
613 | **Full Stacktrace:**
614 | ────────────────
615 | \`\`\`
616 | /src/components/UserProfile.tsx:15:28 (UserProfile)
617 | const displayName = user.profile.name;
618 | \`\`\`
619 |
620 | "
621 | `);
622 | });
623 |
624 | it("should handle Ruby format with enhanced frame", () => {
625 | const event = new EventBuilder("ruby")
626 | .withException(
627 | createExceptionValue({
628 | type: "NoMethodError",
629 | value: "undefined method `charge' for nil:NilClass",
630 | stacktrace: createStackTrace([
631 | createFrameWithContext(
632 | {
633 | filename: "/app/services/payment_service.rb",
634 | function: "process_payment",
635 | lineNo: 8,
636 | },
637 | [
638 | [6, " def process_payment(amount)"],
639 | [7, " payment_method = user.payment_method"],
640 | [8, " payment_method.charge(amount)"],
641 | [9, " rescue => e"],
642 | [10, " Rails.logger.error(e)"],
643 | ],
644 | {
645 | amount: 99.99,
646 | payment_method: null,
647 | },
648 | ),
649 | ]),
650 | }),
651 | )
652 | .build();
653 |
654 | const output = formatEventOutput(event);
655 |
656 | expect(output).toMatchInlineSnapshot(`
657 | "### Error
658 |
659 | \`\`\`
660 | NoMethodError: undefined method \`charge' for nil:NilClass
661 | \`\`\`
662 |
663 | **Most Relevant Frame:**
664 | ─────────────────────
665 | from /app/services/payment_service.rb:8:in \`process_payment\`
666 |
667 | 6 │ def process_payment(amount)
668 | 7 │ payment_method = user.payment_method
669 | → 8 │ payment_method.charge(amount)
670 | 9 │ rescue => e
671 | 10 │ Rails.logger.error(e)
672 |
673 | Local Variables:
674 | ├─ amount: 99.99
675 | └─ payment_method: null
676 |
677 | **Full Stacktrace:**
678 | ────────────────
679 | \`\`\`
680 | from /app/services/payment_service.rb:8:in \`process_payment\`
681 | payment_method.charge(amount)
682 | \`\`\`
683 |
684 | "
685 | `);
686 | });
687 |
688 | it("should handle PHP format with enhanced frame", () => {
689 | const event = new EventBuilder("php")
690 | .withException(
691 | createExceptionValue({
692 | type: "Error",
693 | value: "Call to a member function getName() on null",
694 | stacktrace: createStackTrace([
695 | createFrameWithContext(
696 | {
697 | filename: "/var/www/app/User.php",
698 | function: "getDisplayName",
699 | lineNo: 45,
700 | },
701 | [
702 | [43, " public function getDisplayName() {"],
703 | [44, " $profile = $this->getProfile();"],
704 | [45, " return $profile->getName();"],
705 | [46, " }"],
706 | ],
707 | {
708 | profile: null,
709 | },
710 | ),
711 | ]),
712 | }),
713 | )
714 | .build();
715 |
716 | const output = formatEventOutput(event);
717 |
718 | expect(output).toMatchInlineSnapshot(`
719 | "### Error
720 |
721 | \`\`\`
722 | Error: Call to a member function getName() on null
723 | \`\`\`
724 |
725 | **Most Relevant Frame:**
726 | ─────────────────────
727 | /var/www/app/User.php(45): getDisplayName()
728 |
729 | 43 │ public function getDisplayName() {
730 | 44 │ $profile = $this->getProfile();
731 | → 45 │ return $profile->getName();
732 | 46 │ }
733 |
734 | Local Variables:
735 | └─ profile: null
736 |
737 | **Full Stacktrace:**
738 | ────────────────
739 | \`\`\`
740 | /var/www/app/User.php(45): getDisplayName()
741 | return $profile->getName();
742 | \`\`\`
743 |
744 | "
745 | `);
746 | });
747 |
748 | it("should handle frame with context but no vars", () => {
749 | const event = new EventBuilder("python")
750 | .withException(
751 | createExceptionValue({
752 | type: "ValueError",
753 | value: "Invalid configuration",
754 | stacktrace: createStackTrace([
755 | createFrameWithContext(
756 | {
757 | filename: "/app/config.py",
758 | function: "load_config",
759 | lineNo: 12,
760 | },
761 | [
762 | [10, "def load_config():"],
763 | [11, " if not os.path.exists(CONFIG_FILE):"],
764 | [12, " raise ValueError('Invalid configuration')"],
765 | [13, " with open(CONFIG_FILE) as f:"],
766 | [14, " return json.load(f)"],
767 | ],
768 | ),
769 | ]),
770 | }),
771 | )
772 | .build();
773 |
774 | const output = formatEventOutput(event);
775 |
776 | expect(output).toMatchInlineSnapshot(`
777 | "### Error
778 |
779 | \`\`\`
780 | ValueError: Invalid configuration
781 | \`\`\`
782 |
783 | **Most Relevant Frame:**
784 | ─────────────────────
785 | File "/app/config.py", line 12, in load_config
786 |
787 | 10 │ def load_config():
788 | 11 │ if not os.path.exists(CONFIG_FILE):
789 | → 12 │ raise ValueError('Invalid configuration')
790 | 13 │ with open(CONFIG_FILE) as f:
791 | 14 │ return json.load(f)
792 |
793 | **Full Stacktrace:**
794 | ────────────────
795 | \`\`\`
796 | File "/app/config.py", line 12, in load_config
797 | raise ValueError('Invalid configuration')
798 | \`\`\`
799 |
800 | "
801 | `);
802 | });
803 |
804 | it("should handle frame with vars but no context", () => {
805 | const event = createSimpleExceptionEvent(
806 | "python",
807 | "TypeError",
808 | "unsupported operand type(s)",
809 | createFrame({
810 | filename: "/app/calculator.py",
811 | function: "divide",
812 | lineNo: 5,
813 | inApp: true,
814 | vars: {
815 | numerator: 10,
816 | denominator: "0",
817 | result: undefined,
818 | },
819 | }),
820 | );
821 |
822 | const output = formatEventOutput(event);
823 |
824 | expect(output).toMatchInlineSnapshot(`
825 | "### Error
826 |
827 | \`\`\`
828 | TypeError: unsupported operand type(s)
829 | \`\`\`
830 |
831 | **Most Relevant Frame:**
832 | ─────────────────────
833 | File "/app/calculator.py", line 5, in divide
834 |
835 | Local Variables:
836 | ├─ numerator: 10
837 | ├─ denominator: "0"
838 | └─ result: undefined
839 |
840 | **Full Stacktrace:**
841 | ────────────────
842 | \`\`\`
843 | File "/app/calculator.py", line 5, in divide
844 | \`\`\`
845 |
846 | "
847 | `);
848 | });
849 |
850 | it("should handle complex variable types", () => {
851 | const event = createSimpleExceptionEvent(
852 | "python",
853 | "KeyError",
854 | "'missing_key'",
855 | createFrame({
856 | filename: "/app/processor.py",
857 | function: "process_data",
858 | lineNo: 30,
859 | inApp: true,
860 | vars: {
861 | string_var: "hello world",
862 | number_var: 42,
863 | float_var: 3.14,
864 | bool_var: true,
865 | null_var: null,
866 | undefined_var: undefined,
867 | array_var: [1, 2, 3],
868 | object_var: { type: "User", id: 123 },
869 | nested_object: {
870 | user: { name: "John", age: 30 },
871 | settings: { theme: "dark" },
872 | },
873 | empty_string: "",
874 | zero: 0,
875 | false_bool: false,
876 | long_string:
877 | "This is a very long string that should be handled properly in the output",
878 | },
879 | }),
880 | );
881 |
882 | const output = formatEventOutput(event);
883 |
884 | expect(output).toMatchInlineSnapshot(`
885 | "### Error
886 |
887 | \`\`\`
888 | KeyError: 'missing_key'
889 | \`\`\`
890 |
891 | **Most Relevant Frame:**
892 | ─────────────────────
893 | File "/app/processor.py", line 30, in process_data
894 |
895 | Local Variables:
896 | ├─ string_var: "hello world"
897 | ├─ number_var: 42
898 | ├─ float_var: 3.14
899 | ├─ bool_var: true
900 | ├─ null_var: null
901 | ├─ undefined_var: undefined
902 | ├─ array_var: [1,2,3]
903 | ├─ object_var: {"type":"User","id":123}
904 | ├─ nested_object: {"user":{"name":"John","age":30},"settings":{"theme":"dark"}}
905 | ├─ empty_string: ""
906 | ├─ zero: 0
907 | ├─ false_bool: false
908 | └─ long_string: "This is a very long string that should be handled properly in the output"
909 |
910 | **Full Stacktrace:**
911 | ────────────────
912 | \`\`\`
913 | File "/app/processor.py", line 30, in process_data
914 | \`\`\`
915 |
916 | "
917 | `);
918 | });
919 |
920 | it("should truncate very long objects and arrays", () => {
921 | const event = new EventBuilder("python")
922 | .withException(
923 | createExceptionValue({
924 | type: "ValueError",
925 | value: "Data processing error",
926 | stacktrace: createStackTrace([
927 | createFrame({
928 | filename: "/app/processor.py",
929 | function: "process_batch",
930 | lineNo: 45,
931 | inApp: true,
932 | vars: {
933 | small_array: [1, 2, 3],
934 | large_array: Array(100)
935 | .fill(0)
936 | .map((_, i) => i),
937 | small_object: { name: "test", value: 123 },
938 | large_object: {
939 | data: Array(50)
940 | .fill(0)
941 | .reduce(
942 | (acc, _, i) => {
943 | acc[`field${i}`] = `value${i}`;
944 | return acc;
945 | },
946 | {} as Record<string, string>,
947 | ),
948 | },
949 | },
950 | }),
951 | ]),
952 | }),
953 | )
954 | .build();
955 |
956 | const output = formatEventOutput(event);
957 |
958 | expect(output).toMatchInlineSnapshot(`
959 | "### Error
960 |
961 | \`\`\`
962 | ValueError: Data processing error
963 | \`\`\`
964 |
965 | **Most Relevant Frame:**
966 | ─────────────────────
967 | File "/app/processor.py", line 45, in process_batch
968 |
969 | Local Variables:
970 | ├─ small_array: [1,2,3]
971 | ├─ 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, ...]
972 | ├─ small_object: {"name":"test","value":123}
973 | └─ large_object: {"data":{"field0":"value0","field1":"value1","field2":"value2", ...}
974 |
975 | **Full Stacktrace:**
976 | ────────────────
977 | \`\`\`
978 | File "/app/processor.py", line 45, in process_batch
979 | \`\`\`
980 |
981 | "
982 | `);
983 | });
984 |
985 | it("should show proper truncation format", () => {
986 | const event = new EventBuilder("javascript")
987 | .withException(
988 | createExceptionValue({
989 | type: "Error",
990 | value: "Test error",
991 | stacktrace: createStackTrace([
992 | createFrame({
993 | filename: "/app/test.js",
994 | function: "test",
995 | lineNo: 1,
996 | inApp: true,
997 | vars: {
998 | shortArray: [1, 2, 3],
999 | // This will be over 80 chars when stringified
1000 | longArray: [
1001 | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
1002 | 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
1003 | ],
1004 | shortObject: { a: 1, b: 2 },
1005 | // This will be over 80 chars when stringified
1006 | longObject: {
1007 | field1: "value1",
1008 | field2: "value2",
1009 | field3: "value3",
1010 | field4: "value4",
1011 | field5: "value5",
1012 | field6: "value6",
1013 | field7: "value7",
1014 | field8: "value8",
1015 | },
1016 | },
1017 | }),
1018 | ]),
1019 | }),
1020 | )
1021 | .build();
1022 |
1023 | const output = formatEventOutput(event);
1024 |
1025 | expect(output).toMatchInlineSnapshot(`
1026 | "### Error
1027 |
1028 | \`\`\`
1029 | Error: Test error
1030 | \`\`\`
1031 |
1032 | **Most Relevant Frame:**
1033 | ─────────────────────
1034 | /app/test.js:1 (test)
1035 |
1036 | Local Variables:
1037 | ├─ shortArray: [1,2,3]
1038 | ├─ 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, ...]
1039 | ├─ shortObject: {"a":1,"b":2}
1040 | └─ longObject: {"field1":"value1","field2":"value2","field3":"value3","field4":"value4", ...}
1041 |
1042 | **Full Stacktrace:**
1043 | ────────────────
1044 | \`\`\`
1045 | /app/test.js:1 (test)
1046 | \`\`\`
1047 |
1048 | "
1049 | `);
1050 | });
1051 |
1052 | it("should handle circular references gracefully", () => {
1053 | const circular: any = { name: "test" };
1054 | circular.self = circular;
1055 |
1056 | const event = new EventBuilder("javascript")
1057 | .withException(
1058 | createExceptionValue({
1059 | type: "TypeError",
1060 | value: "Circular reference detected",
1061 | stacktrace: createStackTrace([
1062 | createFrame({
1063 | filename: "/app/utils.js",
1064 | function: "serialize",
1065 | lineNo: 10,
1066 | inApp: true,
1067 | vars: {
1068 | normal: { a: 1, b: 2 },
1069 | circular: circular,
1070 | },
1071 | }),
1072 | ]),
1073 | }),
1074 | )
1075 | .build();
1076 |
1077 | const output = formatEventOutput(event);
1078 |
1079 | expect(output).toMatchInlineSnapshot(`
1080 | "### Error
1081 |
1082 | \`\`\`
1083 | TypeError: Circular reference detected
1084 | \`\`\`
1085 |
1086 | **Most Relevant Frame:**
1087 | ─────────────────────
1088 | /app/utils.js:10 (serialize)
1089 |
1090 | Local Variables:
1091 | ├─ normal: {"a":1,"b":2}
1092 | └─ circular: <object>
1093 |
1094 | **Full Stacktrace:**
1095 | ────────────────
1096 | \`\`\`
1097 | /app/utils.js:10 (serialize)
1098 | \`\`\`
1099 |
1100 | "
1101 | `);
1102 | });
1103 |
1104 | it("should handle empty vars object", () => {
1105 | const event = createSimpleExceptionEvent(
1106 | "python",
1107 | "RuntimeError",
1108 | "Something went wrong",
1109 | createFrame({
1110 | filename: "/app/main.py",
1111 | function: "main",
1112 | lineNo: 1,
1113 | inApp: true,
1114 | vars: {},
1115 | }),
1116 | );
1117 |
1118 | const output = formatEventOutput(event);
1119 |
1120 | expect(output).toMatchInlineSnapshot(`
1121 | "### Error
1122 |
1123 | \`\`\`
1124 | RuntimeError: Something went wrong
1125 | \`\`\`
1126 |
1127 | **Most Relevant Frame:**
1128 | ─────────────────────
1129 | File "/app/main.py", line 1, in main
1130 |
1131 | **Full Stacktrace:**
1132 | ────────────────
1133 | \`\`\`
1134 | File "/app/main.py", line 1, in main
1135 | \`\`\`
1136 |
1137 | "
1138 | `);
1139 | });
1140 |
1141 | it("should handle large context with proper windowing", () => {
1142 | const event = createSimpleExceptionEvent(
1143 | "python",
1144 | "IndexError",
1145 | "list index out of range",
1146 | createFrameWithContext(
1147 | {
1148 | filename: "/app/processor.py",
1149 | function: "process_items",
1150 | lineNo: 50,
1151 | },
1152 | [
1153 | [45, " # Setup phase"],
1154 | [46, " items = get_items()"],
1155 | [47, " results = []"],
1156 | [48, " "],
1157 | [49, " # This line causes the error"],
1158 | [50, " first_item = items[0]"],
1159 | [51, " "],
1160 | [52, " # Process items"],
1161 | [53, " for item in items:"],
1162 | [54, " results.append(process(item))"],
1163 | [55, " return results"],
1164 | ],
1165 | {
1166 | items: [],
1167 | },
1168 | ),
1169 | );
1170 |
1171 | const output = formatEventOutput(event);
1172 |
1173 | expect(output).toMatchInlineSnapshot(`
1174 | "### Error
1175 |
1176 | \`\`\`
1177 | IndexError: list index out of range
1178 | \`\`\`
1179 |
1180 | **Most Relevant Frame:**
1181 | ─────────────────────
1182 | File "/app/processor.py", line 50, in process_items
1183 |
1184 | 47 │ results = []
1185 | 48 │
1186 | 49 │ # This line causes the error
1187 | → 50 │ first_item = items[0]
1188 | 51 │
1189 | 52 │ # Process items
1190 | 53 │ for item in items:
1191 |
1192 | Local Variables:
1193 | └─ items: []
1194 |
1195 | **Full Stacktrace:**
1196 | ────────────────
1197 | \`\`\`
1198 | File "/app/processor.py", line 50, in process_items
1199 | first_item = items[0]
1200 | \`\`\`
1201 |
1202 | "
1203 | `);
1204 | });
1205 |
1206 | it("should handle context at beginning of file", () => {
1207 | const event = createSimpleExceptionEvent(
1208 | "python",
1209 | "ImportError",
1210 | "No module named 'missing_module'",
1211 | createFrameWithContext(
1212 | {
1213 | filename: "/app/startup.py",
1214 | function: "<module>",
1215 | lineNo: 2,
1216 | },
1217 | [
1218 | [1, "import os"],
1219 | [2, "import missing_module"],
1220 | [3, "import json"],
1221 | [4, ""],
1222 | [5, "def main():"],
1223 | ],
1224 | ),
1225 | );
1226 |
1227 | const output = formatEventOutput(event);
1228 |
1229 | expect(output).toMatchInlineSnapshot(`
1230 | "### Error
1231 |
1232 | \`\`\`
1233 | ImportError: No module named 'missing_module'
1234 | \`\`\`
1235 |
1236 | **Most Relevant Frame:**
1237 | ─────────────────────
1238 | File "/app/startup.py", line 2, in <module>
1239 |
1240 | 1 │ import os
1241 | → 2 │ import missing_module
1242 | 3 │ import json
1243 | 4 │
1244 | 5 │ def main():
1245 |
1246 | **Full Stacktrace:**
1247 | ────────────────
1248 | \`\`\`
1249 | File "/app/startup.py", line 2, in <module>
1250 | import missing_module
1251 | \`\`\`
1252 |
1253 | "
1254 | `);
1255 | });
1256 | });
1257 |
1258 | describe("Chained exceptions", () => {
1259 | it("should render multiple chained exceptions", () => {
1260 | const event = new EventBuilder("python")
1261 | .withChainedExceptions([
1262 | createExceptionValue({
1263 | type: "KeyError",
1264 | value: "'user_id'",
1265 | stacktrace: createStackTrace([
1266 | createFrame({
1267 | filename: "/app/database.py",
1268 | function: "get_user",
1269 | lineNo: 15,
1270 | inApp: true,
1271 | }),
1272 | ]),
1273 | }),
1274 | createExceptionValue({
1275 | type: "ValueError",
1276 | value: "User not found",
1277 | stacktrace: createStackTrace([
1278 | createFrame({
1279 | filename: "/app/services.py",
1280 | function: "process_user",
1281 | lineNo: 25,
1282 | inApp: true,
1283 | }),
1284 | ]),
1285 | }),
1286 | createExceptionValue({
1287 | type: "HTTPError",
1288 | value: "500 Internal Server Error",
1289 | stacktrace: createStackTrace([
1290 | createFrame({
1291 | filename: "/app/handlers.py",
1292 | function: "handle_request",
1293 | lineNo: 42,
1294 | inApp: true,
1295 | }),
1296 | ]),
1297 | }),
1298 | ])
1299 | .build();
1300 |
1301 | const output = formatEventOutput(event);
1302 |
1303 | expect(output).toMatchInlineSnapshot(`
1304 | "### Error
1305 |
1306 | \`\`\`
1307 | HTTPError: 500 Internal Server Error
1308 | \`\`\`
1309 |
1310 | **Stacktrace:**
1311 | \`\`\`
1312 | File "/app/handlers.py", line 42, in handle_request
1313 | \`\`\`
1314 |
1315 | **During handling of the above exception, another exception occurred:**
1316 |
1317 | ### ValueError: User not found
1318 |
1319 | **Stacktrace:**
1320 | \`\`\`
1321 | File "/app/services.py", line 25, in process_user
1322 | \`\`\`
1323 |
1324 | **During handling of the above exception, another exception occurred:**
1325 |
1326 | ### KeyError: 'user_id'
1327 |
1328 | **Stacktrace:**
1329 | \`\`\`
1330 | File "/app/database.py", line 15, in get_user
1331 | \`\`\`
1332 |
1333 | "
1334 | `);
1335 | });
1336 |
1337 | it("should render chained exceptions with enhanced frame on outermost exception", () => {
1338 | const event = new EventBuilder("python")
1339 | .withChainedExceptions([
1340 | createExceptionValue({
1341 | type: "KeyError",
1342 | value: "'user_id'",
1343 | stacktrace: createStackTrace([
1344 | createFrameWithContext(
1345 | {
1346 | filename: "/app/database.py",
1347 | function: "get_user",
1348 | lineNo: 15,
1349 | inApp: true,
1350 | },
1351 | [
1352 | [13, "def get_user(data):"],
1353 | [14, " # This will fail if user_id is missing"],
1354 | [15, " user_id = data['user_id']"],
1355 | [16, " return db.find_user(user_id)"],
1356 | ],
1357 | {
1358 | data: {},
1359 | },
1360 | ),
1361 | ]),
1362 | }),
1363 | createExceptionValue({
1364 | type: "ValueError",
1365 | value: "User not found",
1366 | stacktrace: createStackTrace([
1367 | createFrameWithContext(
1368 | {
1369 | filename: "/app/services.py",
1370 | function: "process_user",
1371 | lineNo: 25,
1372 | inApp: true,
1373 | },
1374 | [
1375 | [23, " try:"],
1376 | [24, " user = get_user(request_data)"],
1377 | [25, " except KeyError:"],
1378 | [26, " raise ValueError('User not found')"],
1379 | ],
1380 | {
1381 | request_data: {},
1382 | },
1383 | ),
1384 | ]),
1385 | }),
1386 | ])
1387 | .build();
1388 |
1389 | const output = formatEventOutput(event);
1390 |
1391 | expect(output).toMatchInlineSnapshot(`
1392 | "### Error
1393 |
1394 | \`\`\`
1395 | ValueError: User not found
1396 | \`\`\`
1397 |
1398 | **Most Relevant Frame:**
1399 | ─────────────────────
1400 | File "/app/services.py", line 25, in process_user
1401 |
1402 | 23 │ try:
1403 | 24 │ user = get_user(request_data)
1404 | → 25 │ except KeyError:
1405 | 26 │ raise ValueError('User not found')
1406 |
1407 | Local Variables:
1408 | └─ request_data: {}
1409 |
1410 | **Full Stacktrace:**
1411 | ────────────────
1412 | \`\`\`
1413 | File "/app/services.py", line 25, in process_user
1414 | except KeyError:
1415 | \`\`\`
1416 |
1417 | **During handling of the above exception, another exception occurred:**
1418 |
1419 | ### KeyError: 'user_id'
1420 |
1421 | **Stacktrace:**
1422 | \`\`\`
1423 | File "/app/database.py", line 15, in get_user
1424 | user_id = data['user_id']
1425 | \`\`\`
1426 |
1427 | "
1428 | `);
1429 | });
1430 |
1431 | it("should handle single exception in values array (not chained)", () => {
1432 | const event = new EventBuilder("python")
1433 | .withChainedExceptions([
1434 | createExceptionValue({
1435 | type: "RuntimeError",
1436 | value: "Something went wrong",
1437 | stacktrace: createStackTrace([
1438 | createFrame({
1439 | filename: "/app/main.py",
1440 | function: "main",
1441 | lineNo: 10,
1442 | inApp: true,
1443 | }),
1444 | ]),
1445 | }),
1446 | ])
1447 | .build();
1448 |
1449 | const output = formatEventOutput(event);
1450 |
1451 | expect(output).toMatchInlineSnapshot(`
1452 | "### Error
1453 |
1454 | \`\`\`
1455 | RuntimeError: Something went wrong
1456 | \`\`\`
1457 |
1458 | **Stacktrace:**
1459 | \`\`\`
1460 | File "/app/main.py", line 10, in main
1461 | \`\`\`
1462 |
1463 | "
1464 | `);
1465 | });
1466 |
1467 | it("should use Java-style 'Caused by' for Java platform", () => {
1468 | const event = new EventBuilder("java")
1469 | .withChainedExceptions([
1470 | createExceptionValue({
1471 | type: "SQLException",
1472 | value: "Database connection failed",
1473 | stacktrace: createStackTrace([
1474 | frameFactories.java({
1475 | module: "com.example.db.DatabaseConnector",
1476 | function: "connect",
1477 | filename: "DatabaseConnector.java",
1478 | lineNo: 45,
1479 | }),
1480 | ]),
1481 | }),
1482 | createExceptionValue({
1483 | type: "RuntimeException",
1484 | value: "Failed to initialize service",
1485 | stacktrace: createStackTrace([
1486 | frameFactories.java({
1487 | module: "com.example.service.UserService",
1488 | function: "initialize",
1489 | filename: "UserService.java",
1490 | lineNo: 23,
1491 | }),
1492 | ]),
1493 | }),
1494 | ])
1495 | .build();
1496 |
1497 | const output = formatEventOutput(event);
1498 |
1499 | expect(output).toMatchInlineSnapshot(`
1500 | "### Error
1501 |
1502 | \`\`\`
1503 | RuntimeException: Failed to initialize service
1504 | \`\`\`
1505 |
1506 | **Stacktrace:**
1507 | \`\`\`
1508 | at com.example.service.UserService.initialize(UserService.java:23)
1509 | \`\`\`
1510 |
1511 | **Caused by:**
1512 |
1513 | ### SQLException: Database connection failed
1514 |
1515 | **Stacktrace:**
1516 | \`\`\`
1517 | at com.example.db.DatabaseConnector.connect(DatabaseConnector.java:45)
1518 | \`\`\`
1519 |
1520 | "
1521 | `);
1522 | });
1523 |
1524 | it("should use C#-style arrow notation for dotnet platform", () => {
1525 | const event = new EventBuilder("csharp")
1526 | .withChainedExceptions([
1527 | createExceptionValue({
1528 | type: "ArgumentNullException",
1529 | value: "Value cannot be null. (Parameter 'userId')",
1530 | stacktrace: createStackTrace([
1531 | createFrame({
1532 | filename: "UserRepository.cs",
1533 | function: "GetUserById",
1534 | lineNo: 15,
1535 | inApp: true,
1536 | }),
1537 | ]),
1538 | }),
1539 | createExceptionValue({
1540 | type: "ApplicationException",
1541 | value: "Failed to load user profile",
1542 | stacktrace: createStackTrace([
1543 | createFrame({
1544 | filename: "UserService.cs",
1545 | function: "LoadProfile",
1546 | lineNo: 42,
1547 | inApp: true,
1548 | }),
1549 | ]),
1550 | }),
1551 | ])
1552 | .build();
1553 |
1554 | const output = formatEventOutput(event);
1555 |
1556 | expect(output).toMatchInlineSnapshot(`
1557 | "### Error
1558 |
1559 | \`\`\`
1560 | ApplicationException: Failed to load user profile
1561 | \`\`\`
1562 |
1563 | **Stacktrace:**
1564 | \`\`\`
1565 | at LoadProfile (UserService.cs:42)
1566 | \`\`\`
1567 |
1568 | **---> Inner Exception:**
1569 |
1570 | ### ArgumentNullException: Value cannot be null. (Parameter 'userId')
1571 |
1572 | **Stacktrace:**
1573 | \`\`\`
1574 | at GetUserById (UserRepository.cs:15)
1575 | \`\`\`
1576 |
1577 | "
1578 | `);
1579 | });
1580 |
1581 | it("should handle child exception without stacktrace", () => {
1582 | const event = new EventBuilder("python")
1583 | .withChainedExceptions([
1584 | createExceptionValue({
1585 | type: "KeyError",
1586 | value: "'missing_key'",
1587 | // No stacktrace for child exception
1588 | stacktrace: undefined,
1589 | }),
1590 | createExceptionValue({
1591 | type: "ValueError",
1592 | value: "Data processing failed",
1593 | stacktrace: createStackTrace([
1594 | createFrame({
1595 | filename: "/app/processor.py",
1596 | function: "process_data",
1597 | lineNo: 42,
1598 | inApp: true,
1599 | }),
1600 | ]),
1601 | }),
1602 | ])
1603 | .build();
1604 |
1605 | const output = formatEventOutput(event);
1606 |
1607 | expect(output).toMatchInlineSnapshot(`
1608 | "### Error
1609 |
1610 | \`\`\`
1611 | ValueError: Data processing failed
1612 | \`\`\`
1613 |
1614 | **Stacktrace:**
1615 | \`\`\`
1616 | File "/app/processor.py", line 42, in process_data
1617 | \`\`\`
1618 |
1619 | **During handling of the above exception, another exception occurred:**
1620 |
1621 | ### KeyError: 'missing_key'
1622 |
1623 | **Stacktrace:**
1624 | \`\`\`
1625 | No stacktrace available
1626 | \`\`\`
1627 |
1628 | "
1629 | `);
1630 | });
1631 | });
1632 |
1633 | describe("Performance issue formatting", () => {
1634 | it("should format N+1 query issue with evidence data", () => {
1635 | const event = new EventBuilder("python")
1636 | .withType("transaction")
1637 | .withOccurrence({
1638 | issueTitle: "N+1 Query",
1639 | culprit: "SELECT * FROM users WHERE id = %s",
1640 | type: 1006, // Performance issue type code
1641 | issueType: "performance_n_plus_one_db_queries",
1642 | evidenceData: {
1643 | parentSpanIds: ["span_123"],
1644 | parentSpan: "GET /api/users",
1645 | repeatingSpansCompact: ["SELECT * FROM users WHERE id = %s"],
1646 | numberRepeatingSpans: "5",
1647 | offenderSpanIds: [
1648 | "span_456",
1649 | "span_457",
1650 | "span_458",
1651 | "span_459",
1652 | "span_460",
1653 | ],
1654 | transactionName: "/api/users",
1655 | op: "db",
1656 | },
1657 | evidenceDisplay: [
1658 | {
1659 | name: "Offending Spans",
1660 | value: "UserService.get_users",
1661 | important: true,
1662 | },
1663 | ],
1664 | })
1665 | .build();
1666 |
1667 | const output = formatEventOutput(event);
1668 |
1669 | expect(output).toMatchInlineSnapshot(`
1670 | "**Parent Operation:**
1671 | GET /api/users
1672 |
1673 | ### Repeated Database Queries
1674 |
1675 | **Query executed 5 times:**
1676 | \`\`\`sql
1677 | SELECT * FROM users WHERE id = %s
1678 | \`\`\`
1679 |
1680 | **Transaction:**
1681 | /api/users
1682 |
1683 | **Offending Spans:**
1684 | UserService.get_users
1685 |
1686 | "
1687 | `);
1688 | });
1689 |
1690 | it("should format N+1 query issue with spans data fallback", () => {
1691 | const event = new EventBuilder("python")
1692 | .withType("transaction")
1693 | .withOccurrence({
1694 | issueTitle: "N+1 Query detected",
1695 | culprit: "database query",
1696 | })
1697 | .withEntry({
1698 | type: "spans",
1699 | data: [
1700 | {
1701 | op: "db.query",
1702 | description: "SELECT * FROM posts WHERE user_id = 1",
1703 | timestamp: 100.5,
1704 | start_timestamp: 100.0,
1705 | },
1706 | {
1707 | op: "db.query",
1708 | description: "SELECT * FROM posts WHERE user_id = 2",
1709 | timestamp: 101.0,
1710 | start_timestamp: 100.5,
1711 | },
1712 | {
1713 | op: "db.query",
1714 | description: "SELECT * FROM posts WHERE user_id = 3",
1715 | timestamp: 101.5,
1716 | start_timestamp: 101.0,
1717 | },
1718 | {
1719 | op: "http.client",
1720 | description: "GET /api/external",
1721 | timestamp: 102.0,
1722 | start_timestamp: 101.5,
1723 | },
1724 | ],
1725 | })
1726 | .build();
1727 |
1728 | const output = formatEventOutput(event);
1729 |
1730 | expect(output).toMatchInlineSnapshot(`""`);
1731 | });
1732 |
1733 | it("should format transaction event with non-repeated database queries", () => {
1734 | const event = new EventBuilder("python")
1735 | .withType("transaction")
1736 | .withOccurrence({
1737 | issueTitle: "Slow DB Query",
1738 | culprit: "database",
1739 | })
1740 | .withEntry({
1741 | type: "spans",
1742 | data: [
1743 | {
1744 | op: "db.query",
1745 | description: "SELECT COUNT(*) FROM users",
1746 | timestamp: 100.5,
1747 | start_timestamp: 100.0,
1748 | },
1749 | {
1750 | op: "db.query",
1751 | description: "SELECT * FROM settings WHERE key = 'theme'",
1752 | timestamp: 101.0,
1753 | start_timestamp: 100.5,
1754 | },
1755 | ],
1756 | })
1757 | .build();
1758 |
1759 | const output = formatEventOutput(event);
1760 |
1761 | expect(output).toMatchInlineSnapshot(`""`);
1762 | });
1763 |
1764 | it("should format evidence with operation and offenderSpanIds", () => {
1765 | const event = new EventBuilder("python")
1766 | .withType("transaction")
1767 | .withOccurrence({
1768 | issueTitle: "N+1 Query",
1769 | culprit: "database",
1770 | issueType: "performance_n_plus_one_db_queries",
1771 | evidenceData: {
1772 | op: "db",
1773 | offenderSpanIds: ["span_1", "span_2", "span_3", "span_4", "span_5"],
1774 | numberRepeatingSpans: "5",
1775 | },
1776 | })
1777 | .build();
1778 |
1779 | const output = formatEventOutput(event);
1780 |
1781 | expect(output).toMatchInlineSnapshot(`""`);
1782 | });
1783 |
1784 | it("should render span tree for N+1 queries with evidence and spans data", () => {
1785 | const event = new EventBuilder("python")
1786 | .withType("transaction")
1787 | .withOccurrence({
1788 | issueTitle: "N+1 Query",
1789 | culprit: "SELECT * FROM users WHERE id = %s",
1790 | issueType: "performance_n_plus_one_db_queries",
1791 | evidenceData: {
1792 | parentSpanIds: ["parent123"],
1793 | parentSpan: "GET /api/users",
1794 | offenderSpanIds: ["span1", "span2", "span3"],
1795 | repeatingSpansCompact: ["SELECT * FROM users WHERE id = %s"],
1796 | numberRepeatingSpans: "3",
1797 | op: "db",
1798 | },
1799 | })
1800 | .withEntry({
1801 | type: "spans",
1802 | data: [
1803 | {
1804 | span_id: "parent123",
1805 | op: "http.server",
1806 | description: "GET /users",
1807 | timestamp: 1722963600.25,
1808 | start_timestamp: 1722963600.0,
1809 | },
1810 | {
1811 | span_id: "span1",
1812 | op: "db.query",
1813 | description: "SELECT * FROM users WHERE id = 1",
1814 | timestamp: 1722963600.013,
1815 | start_timestamp: 1722963600.01,
1816 | },
1817 | {
1818 | span_id: "span2",
1819 | op: "db.query",
1820 | description: "SELECT * FROM users WHERE id = 2",
1821 | timestamp: 1722963600.018,
1822 | start_timestamp: 1722963600.014,
1823 | },
1824 | {
1825 | span_id: "span3",
1826 | op: "db.query",
1827 | description: "SELECT * FROM users WHERE id = 3",
1828 | timestamp: 1722963600.027,
1829 | start_timestamp: 1722963600.019,
1830 | },
1831 | ],
1832 | })
1833 | .build();
1834 |
1835 | const output = formatEventOutput(event);
1836 |
1837 | expect(output).toMatchInlineSnapshot(`
1838 | "**Parent Operation:**
1839 | GET /api/users
1840 |
1841 | ### Repeated Database Queries
1842 |
1843 | **Query executed 3 times:**
1844 | \`\`\`sql
1845 | SELECT * FROM users WHERE id = %s
1846 | \`\`\`
1847 |
1848 | ### Span Tree (Limited to 10 spans)
1849 |
1850 | \`\`\`
1851 | GET /users [parent12 · http.server · 250ms]
1852 | ├─ SELECT * FROM users WHERE id = 1 [span1 · db.query · 3ms] [N+1]
1853 | ├─ SELECT * FROM users WHERE id = 2 [span2 · db.query · 4ms] [N+1]
1854 | └─ SELECT * FROM users WHERE id = 3 [span3 · db.query · 8ms] [N+1]
1855 | \`\`\`
1856 |
1857 | "
1858 | `);
1859 | });
1860 |
1861 | it("should render span tree using duration fields when timestamps are missing", () => {
1862 | const event = new EventBuilder("python")
1863 | .withType("transaction")
1864 | .withOccurrence({
1865 | issueTitle: "N+1 Query",
1866 | issueType: "performance_n_plus_one_db_queries",
1867 | evidenceData: {
1868 | parentSpanIds: ["parentDur"],
1869 | parentSpan: "GET /api/durations",
1870 | offenderSpanIds: ["spanA", "spanB"],
1871 | repeatingSpansCompact: [
1872 | "SELECT * FROM durations WHERE bucket = %s",
1873 | ],
1874 | numberRepeatingSpans: "2",
1875 | },
1876 | })
1877 | .withEntry({
1878 | type: "spans",
1879 | data: [
1880 | {
1881 | span_id: "parentDur",
1882 | op: "http.server",
1883 | description: "GET /durations",
1884 | duration: 1250,
1885 | },
1886 | {
1887 | span_id: "spanA",
1888 | op: "db.query",
1889 | description: "SELECT * FROM durations WHERE bucket = 'fast'",
1890 | duration: 0.5,
1891 | },
1892 | {
1893 | span_id: "spanB",
1894 | op: "db.query",
1895 | description: "SELECT * FROM durations WHERE bucket = 'slow'",
1896 | duration: 1500,
1897 | },
1898 | ],
1899 | })
1900 | .build();
1901 |
1902 | const output = formatEventOutput(event);
1903 |
1904 | expect(output).toMatchInlineSnapshot(`
1905 | "**Parent Operation:**
1906 | GET /api/durations
1907 |
1908 | ### Repeated Database Queries
1909 |
1910 | **Query executed 2 times:**
1911 | \`\`\`sql
1912 | SELECT * FROM durations WHERE bucket = %s
1913 | \`\`\`
1914 |
1915 | ### Span Tree (Limited to 10 spans)
1916 |
1917 | \`\`\`
1918 | GET /durations [parentDu · http.server · 1250ms]
1919 | ├─ SELECT * FROM durations WHERE bucket = 'fast' [spanA · db.query · 1ms] [N+1]
1920 | └─ SELECT * FROM durations WHERE bucket = 'slow' [spanB · db.query · 1500ms] [N+1]
1921 | \`\`\`
1922 |
1923 | "
1924 | `);
1925 | });
1926 |
1927 | it("should handle transaction event without performance data", () => {
1928 | const event = new EventBuilder("python")
1929 | .withType("transaction")
1930 | .withOccurrence({
1931 | issueTitle: "Generic Performance Issue",
1932 | culprit: "slow endpoint",
1933 | })
1934 | .build();
1935 |
1936 | const output = formatEventOutput(event);
1937 |
1938 | expect(output).toMatchInlineSnapshot(`""`);
1939 | });
1940 |
1941 | it("should handle evidence data without repeating_spans", () => {
1942 | const event = new EventBuilder("python")
1943 | .withType("transaction")
1944 | .withOccurrence({
1945 | issueTitle: "Performance Issue",
1946 | culprit: "database",
1947 | issueType: "performance_slow_db_query", // A different type that we don't fully handle yet
1948 | evidenceData: {
1949 | parentSpan: "GET /api/data",
1950 | transactionName: "/api/data",
1951 | },
1952 | evidenceDisplay: [
1953 | {
1954 | name: "Source Location",
1955 | value: "DataService.fetch",
1956 | important: true,
1957 | },
1958 | ],
1959 | })
1960 | .build();
1961 |
1962 | const output = formatEventOutput(event);
1963 |
1964 | expect(output).toMatchInlineSnapshot(`
1965 | "**Parent Operation:**
1966 | GET /api/data
1967 |
1968 | **Transaction:**
1969 | /api/data
1970 |
1971 | **Source Location:**
1972 | DataService.fetch
1973 |
1974 | "
1975 | `);
1976 | });
1977 | });
1978 | });
1979 |
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/internal/formatting.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * LLM response formatting utilities for Sentry data.
3 | *
4 | * Converts Sentry API responses into structured markdown format optimized
5 | * for LLM consumption. Handles stacktraces, event details, issue summaries,
6 | * and contextual information with consistent formatting patterns.
7 | */
8 | import type { z } from "zod";
9 | import type {
10 | Event,
11 | Issue,
12 | AutofixRunState,
13 | Trace,
14 | TraceSpan,
15 | GenericEvent,
16 | } from "../api-client/types";
17 | import type {
18 | ErrorEntrySchema,
19 | ErrorEventSchema,
20 | DefaultEventSchema,
21 | GenericEventSchema,
22 | EventSchema,
23 | FrameInterface,
24 | RequestEntrySchema,
25 | MessageEntrySchema,
26 | ThreadsEntrySchema,
27 | SentryApiService,
28 | AutofixRunStepRootCauseAnalysisSchema,
29 | } from "../api-client";
30 | import {
31 | getOutputForAutofixStep,
32 | isTerminalStatus,
33 | getStatusDisplayName,
34 | } from "./tool-helpers/seer";
35 | import { logIssue } from "../telem/logging";
36 |
37 | // Language detection mappings
38 | const LANGUAGE_EXTENSIONS: Record<string, string> = {
39 | ".java": "java",
40 | ".py": "python",
41 | ".js": "javascript",
42 | ".jsx": "javascript",
43 | ".ts": "javascript",
44 | ".tsx": "javascript",
45 | ".rb": "ruby",
46 | ".php": "php",
47 | };
48 |
49 | const LANGUAGE_MODULE_PATTERNS: Array<[RegExp, string]> = [
50 | [/^(java\.|com\.|org\.)/, "java"],
51 | ];
52 |
53 | /**
54 | * Detects the programming language of a stack frame based on the file extension.
55 | * Falls back to the platform parameter if no filename is available or extension is unrecognized.
56 | *
57 | * @param frame - The stack frame containing file and location information
58 | * @param platform - Optional platform hint to use as fallback
59 | * @returns The detected language or platform fallback or "unknown"
60 | */
61 | function detectLanguage(
62 | frame: z.infer<typeof FrameInterface>,
63 | platform?: string | null,
64 | ): string {
65 | // Check filename extensions
66 | if (frame.filename) {
67 | const ext = frame.filename.toLowerCase().match(/\.[^.]+$/)?.[0];
68 | if (ext && LANGUAGE_EXTENSIONS[ext]) {
69 | return LANGUAGE_EXTENSIONS[ext];
70 | }
71 | }
72 |
73 | // Check module patterns
74 | if (frame.module) {
75 | for (const [pattern, language] of LANGUAGE_MODULE_PATTERNS) {
76 | if (pattern.test(frame.module)) {
77 | return language;
78 | }
79 | }
80 | }
81 |
82 | // Fallback to platform or unknown
83 | return platform || "unknown";
84 | }
85 |
86 | /**
87 | * Formats a stack frame into a language-specific string representation.
88 | * Different languages have different conventions for displaying stack traces.
89 | *
90 | * @param frame - The stack frame to format
91 | * @param frameIndex - Optional frame index for languages that display frame numbers
92 | * @param platform - Optional platform hint for language detection fallback
93 | * @returns Formatted stack frame string
94 | */
95 | export function formatFrameHeader(
96 | frame: z.infer<typeof FrameInterface>,
97 | frameIndex?: number,
98 | platform?: string | null,
99 | ) {
100 | const language = detectLanguage(frame, platform);
101 |
102 | switch (language) {
103 | case "java": {
104 | // at com.example.ClassName.methodName(FileName.java:123)
105 | const className = frame.module || "UnknownClass";
106 | const method = frame.function || "<unknown>";
107 | const source = frame.filename || "Unknown Source";
108 | const location = frame.lineNo ? `:${frame.lineNo}` : "";
109 | return `at ${className}.${method}(${source}${location})`;
110 | }
111 |
112 | case "python": {
113 | // File "/path/to/file.py", line 42, in function_name
114 | const file =
115 | frame.filename || frame.absPath || frame.module || "<unknown>";
116 | const func = frame.function || "<module>";
117 | const line = frame.lineNo ? `, line ${frame.lineNo}` : "";
118 | return ` File "${file}"${line}, in ${func}`;
119 | }
120 |
121 | case "javascript": {
122 | // Original compact format: filename:line:col (function)
123 | // This preserves backward compatibility
124 | return `${[frame.filename, frame.lineNo, frame.colNo]
125 | .filter((i) => !!i)
126 | .join(":")}${frame.function ? ` (${frame.function})` : ""}`;
127 | }
128 |
129 | case "ruby": {
130 | // from /path/to/file.rb:42:in `method_name'
131 | const file = frame.filename || frame.module || "<unknown>";
132 | const func = frame.function ? ` \`${frame.function}\`` : "";
133 | const line = frame.lineNo ? `:${frame.lineNo}:in` : "";
134 | return ` from ${file}${line}${func}`;
135 | }
136 |
137 | case "php": {
138 | // #0 /path/to/file.php(42): functionName()
139 | const file = frame.filename || "<unknown>";
140 | const line = frame.lineNo ? `(${frame.lineNo})` : "";
141 | const func = frame.function || "<unknown>";
142 | const prefix = frameIndex !== undefined ? `#${frameIndex} ` : "";
143 | return `${prefix}${file}${line}: ${func}()`;
144 | }
145 |
146 | default: {
147 | // Generic format for unknown languages
148 | const func = frame.function || "<unknown>";
149 | const location = frame.filename || frame.module || "<unknown>";
150 | const line = frame.lineNo ? `:${frame.lineNo}` : "";
151 | const col = frame.colNo != null ? `:${frame.colNo}` : "";
152 | return ` at ${func} (${location}${line}${col})`;
153 | }
154 | }
155 | }
156 |
157 | /**
158 | * Formats a Sentry event into a structured markdown output.
159 | * Includes error messages, stack traces, request info, and contextual data.
160 | *
161 | * @param event - The Sentry event to format
162 | * @param options - Additional formatting context
163 | * @returns Formatted markdown string
164 | */
165 | export function formatEventOutput(
166 | event: Event,
167 | options?: {
168 | performanceTrace?: Trace;
169 | },
170 | ) {
171 | let output = "";
172 |
173 | // Check if entries exist (may be undefined for unsupported event types)
174 | if (!event.entries || !Array.isArray(event.entries)) {
175 | // For unsupported event types, just show tags and contexts
176 | output += formatTags(event.tags);
177 | output += formatContext(event.context);
178 | output += formatContexts(event.contexts);
179 | return output;
180 | }
181 |
182 | // Look for the primary error information
183 | const messageEntry = event.entries.find((e) => e.type === "message");
184 | const exceptionEntry = event.entries.find((e) => e.type === "exception");
185 | const threadsEntry = event.entries.find((e) => e.type === "threads");
186 | const requestEntry = event.entries.find((e) => e.type === "request");
187 | const spansEntry = event.entries.find((e) => e.type === "spans");
188 | const cspEntry = event.entries.find((e) => e.type === "csp");
189 |
190 | // Error message (if present)
191 | if (messageEntry) {
192 | output += formatMessageInterfaceOutput(
193 | event,
194 | messageEntry.data as z.infer<typeof MessageEntrySchema>,
195 | );
196 | }
197 |
198 | // Stack trace (from exception or threads)
199 | if (exceptionEntry) {
200 | output += formatExceptionInterfaceOutput(
201 | event,
202 | exceptionEntry.data as z.infer<typeof ErrorEntrySchema>,
203 | );
204 | } else if (threadsEntry) {
205 | output += formatThreadsInterfaceOutput(
206 | event,
207 | threadsEntry.data as z.infer<typeof ThreadsEntrySchema>,
208 | );
209 | }
210 |
211 | // Request info (if HTTP error)
212 | if (requestEntry) {
213 | output += formatRequestInterfaceOutput(
214 | event,
215 | requestEntry.data as z.infer<typeof RequestEntrySchema>,
216 | );
217 | }
218 |
219 | // CSP violation details
220 | if (cspEntry) {
221 | output += formatCspInterfaceOutput(event, cspEntry.data);
222 | }
223 |
224 | // Performance issue details (N+1 queries, etc.)
225 | // Pass spans data for additional context even if we have evidence
226 | if (event.type === "transaction") {
227 | output += formatPerformanceIssueOutput(event, spansEntry?.data, options);
228 | }
229 |
230 | // Generic events (performance regressions, metric-based issues)
231 | // These have occurrence data with evidenceDisplay that needs formatting
232 | if (event.type === "generic") {
233 | output += formatGenericEventOutput(event);
234 | }
235 |
236 | output += formatTags(event.tags);
237 | output += formatContext(event.context);
238 | output += formatContexts(event.contexts);
239 | return output;
240 | }
241 |
242 | /**
243 | * Extracts the context line matching the frame's line number for inline display.
244 | * This is used in the full stacktrace view to show the actual line of code
245 | * that caused the error inline with the stack frame.
246 | *
247 | * @param frame - The stack frame containing context lines
248 | * @returns The line of code at the frame's line number, or empty string if not available
249 | */
250 | function renderInlineContext(frame: z.infer<typeof FrameInterface>): string {
251 | if (!frame.context?.length || !frame.lineNo) {
252 | return "";
253 | }
254 |
255 | const contextLine = frame.context.find(([lineNo]) => lineNo === frame.lineNo);
256 | return contextLine ? `\n${contextLine[1]}` : "";
257 | }
258 |
259 | /**
260 | * Renders an enhanced view of a stack frame with context lines and variables.
261 | * Used for the "Most Relevant Frame" section to provide detailed information
262 | * about the most relevant application frame where the error occurred.
263 | *
264 | * @param frame - The stack frame to render with enhanced information
265 | * @param event - The Sentry event containing platform information for language detection
266 | * @returns Formatted string with frame header, context lines, and variables table
267 | */
268 | function renderEnhancedFrame(
269 | frame: z.infer<typeof FrameInterface>,
270 | event: Event,
271 | ): string {
272 | const parts: string[] = [];
273 |
274 | parts.push("**Most Relevant Frame:**");
275 | parts.push("─────────────────────");
276 | parts.push(formatFrameHeader(frame, undefined, event.platform));
277 |
278 | // Add context lines if available
279 | if (frame.context?.length) {
280 | const contextLines = renderContextLines(frame);
281 | if (contextLines) {
282 | parts.push("");
283 | parts.push(contextLines);
284 | }
285 | }
286 |
287 | // Add variables table if available
288 | if (frame.vars && Object.keys(frame.vars).length > 0) {
289 | parts.push("");
290 | parts.push(renderVariablesTable(frame.vars));
291 | }
292 |
293 | return parts.join("\n");
294 | }
295 |
296 | function formatExceptionInterfaceOutput(
297 | event: Event,
298 | data: z.infer<typeof ErrorEntrySchema>,
299 | ) {
300 | const parts: string[] = [];
301 |
302 | // Handle both single exception (value) and chained exceptions (values)
303 | const exceptions = data.values || (data.value ? [data.value] : []);
304 |
305 | if (exceptions.length === 0) {
306 | return "";
307 | }
308 |
309 | // For chained exceptions, they are typically ordered from innermost to outermost
310 | // We'll render them in reverse order (outermost first) to match how they occurred
311 | const isChained = exceptions.length > 1;
312 |
313 | // Create a copy before reversing to avoid mutating the original array
314 | [...exceptions].reverse().forEach((exception, index) => {
315 | if (!exception) return;
316 |
317 | // Add language-specific chain indicator for multiple exceptions
318 | if (isChained && index > 0) {
319 | parts.push("");
320 | parts.push(
321 | getExceptionChainMessage(
322 | event.platform || null,
323 | index,
324 | exceptions.length,
325 | ),
326 | );
327 | parts.push("");
328 | }
329 |
330 | // Use the actual exception type and value as the heading
331 | const exceptionTitle = `${exception.type}${exception.value ? `: ${exception.value}` : ""}`;
332 |
333 | parts.push(index === 0 ? "### Error" : `### ${exceptionTitle}`);
334 | parts.push("");
335 |
336 | // Add the error details in a code block for the first exception
337 | // to maintain backward compatibility
338 | if (index === 0) {
339 | parts.push("```");
340 | parts.push(exceptionTitle);
341 | parts.push("```");
342 | parts.push("");
343 | }
344 |
345 | if (!exception.stacktrace || !exception.stacktrace.frames) {
346 | parts.push("**Stacktrace:**");
347 | parts.push("```");
348 | parts.push("No stacktrace available");
349 | parts.push("```");
350 | return;
351 | }
352 |
353 | const frames = exception.stacktrace.frames;
354 |
355 | // Only show enhanced frame for the first (outermost) exception to avoid overwhelming output
356 | if (index === 0) {
357 | const firstInAppFrame = findFirstInAppFrame(frames);
358 | if (
359 | firstInAppFrame &&
360 | (firstInAppFrame.context?.length || firstInAppFrame.vars)
361 | ) {
362 | parts.push(renderEnhancedFrame(firstInAppFrame, event));
363 | parts.push("");
364 | parts.push("**Full Stacktrace:**");
365 | parts.push("────────────────");
366 | } else {
367 | parts.push("**Stacktrace:**");
368 | }
369 | } else {
370 | parts.push("**Stacktrace:**");
371 | }
372 |
373 | parts.push("```");
374 | parts.push(
375 | frames
376 | .map((frame) => {
377 | const header = formatFrameHeader(frame, undefined, event.platform);
378 | const context = renderInlineContext(frame);
379 | return `${header}${context}`;
380 | })
381 | .join("\n"),
382 | );
383 | parts.push("```");
384 | });
385 |
386 | parts.push("");
387 | parts.push("");
388 |
389 | return parts.join("\n");
390 | }
391 |
392 | /**
393 | * Get the appropriate exception chain message based on the platform
394 | */
395 | function getExceptionChainMessage(
396 | platform: string | null,
397 | index: number,
398 | totalExceptions: number,
399 | ): string {
400 | // Default message for unknown platforms
401 | const defaultMessage =
402 | "**During handling of the above exception, another exception occurred:**";
403 |
404 | if (!platform) {
405 | return defaultMessage;
406 | }
407 |
408 | switch (platform.toLowerCase()) {
409 | case "python":
410 | // Python has two distinct messages, but without additional metadata
411 | // we default to the implicit chaining message
412 | return "**During handling of the above exception, another exception occurred:**";
413 |
414 | case "java":
415 | return "**Caused by:**";
416 |
417 | case "csharp":
418 | case "dotnet":
419 | return "**---> Inner Exception:**";
420 |
421 | case "ruby":
422 | return "**Caused by:**";
423 |
424 | case "go":
425 | return "**Wrapped error:**";
426 |
427 | case "rust":
428 | return `**Caused by (${index}):**`;
429 |
430 | default:
431 | return defaultMessage;
432 | }
433 | }
434 |
435 | function formatCspInterfaceOutput(event: Event, data: any) {
436 | if (!data) {
437 | return "";
438 | }
439 |
440 | const parts: string[] = [];
441 | parts.push("### CSP Violation");
442 | parts.push("");
443 |
444 | if (data.blocked_uri) {
445 | parts.push(`**Blocked URI**: ${data.blocked_uri}`);
446 | }
447 |
448 | if (data.violated_directive) {
449 | parts.push(`**Violated Directive**: ${data.violated_directive}`);
450 | }
451 |
452 | if (data.effective_directive) {
453 | parts.push(`**Effective Directive**: ${data.effective_directive}`);
454 | }
455 |
456 | if (data.document_uri) {
457 | parts.push(`**Document URI**: ${data.document_uri}`);
458 | }
459 |
460 | if (data.source_file) {
461 | parts.push(`**Source File**: ${data.source_file}`);
462 | if (data.line_number) {
463 | parts.push(`**Line Number**: ${data.line_number}`);
464 | }
465 | }
466 |
467 | if (data.disposition) {
468 | parts.push(`**Disposition**: ${data.disposition}`);
469 | }
470 |
471 | if (data.original_policy) {
472 | parts.push("");
473 | parts.push("**Original Policy:**");
474 | parts.push("```");
475 | parts.push(data.original_policy);
476 | parts.push("```");
477 | }
478 |
479 | parts.push("");
480 | parts.push("");
481 |
482 | return parts.join("\n");
483 | }
484 |
485 | function formatRequestInterfaceOutput(
486 | event: Event,
487 | data: z.infer<typeof RequestEntrySchema>,
488 | ) {
489 | if (!data.method || !data.url) {
490 | return "";
491 | }
492 | return `### HTTP Request\n\n**Method:** ${data.method}\n**URL:** ${data.url}\n\n`;
493 | }
494 |
495 | function formatMessageInterfaceOutput(
496 | event: Event,
497 | data: z.infer<typeof MessageEntrySchema>,
498 | ) {
499 | if (!data.formatted && !data.message) {
500 | return "";
501 | }
502 | const message = data.formatted || data.message || "";
503 | return `### Error\n\n${"```"}\n${message}\n${"```"}\n\n`;
504 | }
505 |
506 | function formatThreadsInterfaceOutput(
507 | event: Event,
508 | data: z.infer<typeof ThreadsEntrySchema>,
509 | ) {
510 | if (!data.values || data.values.length === 0) {
511 | return "";
512 | }
513 |
514 | // Find the crashed thread only
515 | const crashedThread = data.values.find((t) => t.crashed);
516 |
517 | if (!crashedThread?.stacktrace?.frames) {
518 | return "";
519 | }
520 |
521 | const parts: string[] = [];
522 |
523 | // Include thread name if available
524 | if (crashedThread.name) {
525 | parts.push(`**Thread** (${crashedThread.name})`);
526 | parts.push("");
527 | }
528 |
529 | const frames = crashedThread.stacktrace.frames;
530 |
531 | // Find and format the first in-app frame with enhanced view
532 | const firstInAppFrame = findFirstInAppFrame(frames);
533 | if (
534 | firstInAppFrame &&
535 | (firstInAppFrame.context?.length || firstInAppFrame.vars)
536 | ) {
537 | parts.push(renderEnhancedFrame(firstInAppFrame, event));
538 | parts.push("");
539 | parts.push("**Full Stacktrace:**");
540 | parts.push("────────────────");
541 | } else {
542 | parts.push("**Stacktrace:**");
543 | }
544 |
545 | parts.push("```");
546 | parts.push(
547 | frames
548 | .map((frame) => {
549 | const header = formatFrameHeader(frame, undefined, event.platform);
550 | const context = renderInlineContext(frame);
551 | return `${header}${context}`;
552 | })
553 | .join("\n"),
554 | );
555 | parts.push("```");
556 | parts.push("");
557 |
558 | return parts.join("\n");
559 | }
560 |
561 | /**
562 | * Renders surrounding source code context for a stack frame.
563 | * Shows a window of code lines around the error line with visual indicators.
564 | *
565 | * @param frame - The stack frame containing context lines
566 | * @param contextSize - Number of lines to show before and after the error line (default: 3)
567 | * @returns Formatted context lines with line numbers and arrow indicator for the error line
568 | */
569 | function renderContextLines(
570 | frame: z.infer<typeof FrameInterface>,
571 | contextSize = 3,
572 | ): string {
573 | if (!frame.context || frame.context.length === 0 || !frame.lineNo) {
574 | return "";
575 | }
576 |
577 | const lines: string[] = [];
578 | const errorLine = frame.lineNo;
579 | const maxLineNoWidth = Math.max(
580 | ...frame.context.map(([lineNo]) => lineNo.toString().length),
581 | );
582 |
583 | for (const [lineNo, code] of frame.context) {
584 | const isErrorLine = lineNo === errorLine;
585 | const lineNoStr = lineNo.toString().padStart(maxLineNoWidth, " ");
586 |
587 | if (Math.abs(lineNo - errorLine) <= contextSize) {
588 | if (isErrorLine) {
589 | lines.push(` → ${lineNoStr} │ ${code}`);
590 | } else {
591 | lines.push(` ${lineNoStr} │ ${code}`);
592 | }
593 | }
594 | }
595 |
596 | return lines.join("\n");
597 | }
598 |
599 | /**
600 | * Formats a variable value for display in the variables table.
601 | * Handles different types appropriately and safely, converting complex objects
602 | * to readable representations and handling edge cases like circular references.
603 | *
604 | * @param value - The variable value to format (can be any type)
605 | * @param maxLength - Maximum length for stringified objects/arrays (default: 80)
606 | * @returns Human-readable string representation of the value
607 | */
608 | function formatVariableValue(value: unknown, maxLength = 80): string {
609 | try {
610 | if (typeof value === "string") {
611 | return `"${value}"`;
612 | }
613 | if (value === null) {
614 | return "null";
615 | }
616 | if (value === undefined) {
617 | return "undefined";
618 | }
619 | if (typeof value === "object") {
620 | const stringified = JSON.stringify(value);
621 | if (stringified.length > maxLength) {
622 | // Leave room for ", ...]" or ", ...}"
623 | const truncateAt = maxLength - 6;
624 | let truncated = stringified.substring(0, truncateAt);
625 |
626 | // Find the last complete element by looking for the last comma
627 | const lastComma = truncated.lastIndexOf(",");
628 | if (lastComma > 0) {
629 | truncated = truncated.substring(0, lastComma);
630 | }
631 |
632 | // Add the appropriate ending
633 | if (Array.isArray(value)) {
634 | return `${truncated}, ...]`;
635 | }
636 | return `${truncated}, ...}`;
637 | }
638 | return stringified;
639 | }
640 | return String(value);
641 | } catch {
642 | // Handle circular references or other stringify errors
643 | return `<${typeof value}>`;
644 | }
645 | }
646 |
647 | /**
648 | * Renders a table of local variables in a tree-like format.
649 | * Uses box-drawing characters to create a visual hierarchy of variables
650 | * and their values at the point where the error occurred.
651 | *
652 | * @param vars - Object containing variable names as keys and their values
653 | * @returns Formatted variables table with tree-style prefix characters
654 | */
655 | function renderVariablesTable(vars: Record<string, unknown>): string {
656 | const entries = Object.entries(vars);
657 | if (entries.length === 0) {
658 | return "";
659 | }
660 |
661 | const lines: string[] = ["Local Variables:"];
662 | const lastIndex = entries.length - 1;
663 |
664 | entries.forEach(([key, value], index) => {
665 | const prefix = index === lastIndex ? "└─" : "├─";
666 | const valueStr = formatVariableValue(value);
667 | lines.push(`${prefix} ${key}: ${valueStr}`);
668 | });
669 |
670 | return lines.join("\n");
671 | }
672 |
673 | /**
674 | * Finds the first application frame (in_app) in a stack trace.
675 | * Searches from the bottom of the stack (oldest frame) to find the first
676 | * frame that belongs to the user's application code rather than libraries.
677 | *
678 | * @param frames - Array of stack frames, typically in reverse chronological order
679 | * @returns The first in-app frame found, or undefined if none exist
680 | */
681 | function findFirstInAppFrame(
682 | frames: z.infer<typeof FrameInterface>[],
683 | ): z.infer<typeof FrameInterface> | undefined {
684 | // Frames are usually in reverse order (most recent first)
685 | // We want the first in-app frame from the bottom
686 | for (let i = frames.length - 1; i >= 0; i--) {
687 | if (frames[i].inApp === true) {
688 | return frames[i];
689 | }
690 | }
691 | return undefined;
692 | }
693 |
694 | /**
695 | * Constants for performance issue formatting
696 | */
697 | const MAX_SPANS_IN_TREE = 10;
698 |
699 | /**
700 | * Safely parse a number from a string, returning a default if invalid
701 | */
702 | function safeParseInt(value: unknown, defaultValue: number): number {
703 | if (typeof value === "number") return value;
704 | if (typeof value === "string") {
705 | const parsed = Number.parseInt(value, 10);
706 | return Number.isNaN(parsed) ? defaultValue : parsed;
707 | }
708 | return defaultValue;
709 | }
710 |
711 | /**
712 | * Simplified span structure for rendering span trees in performance issues.
713 | * This is a subset of the full span data focused on visualization needs.
714 | */
715 | interface PerformanceSpan {
716 | span_id: string;
717 | op: string; // Operation type (e.g., "db.query", "http.client")
718 | description: string; // Human-readable description of what the span did
719 | duration: number; // Duration in milliseconds
720 | is_n1_query: boolean; // Whether this span is part of the N+1 pattern
721 | children: PerformanceSpan[];
722 | level: number; // Nesting level for tree rendering
723 | }
724 |
725 | interface RawSpan {
726 | span_id?: string;
727 | id?: string;
728 | op?: string;
729 | description?: string;
730 | timestamp?: number;
731 | start_timestamp?: number;
732 | duration?: number;
733 | }
734 |
735 | interface N1EvidenceData {
736 | parentSpan?: string;
737 | parentSpanIds?: string[];
738 | repeatingSpansCompact?: string[];
739 | repeatingSpans?: string[];
740 | numberRepeatingSpans?: string; // API returns string even though it's a number
741 | numPatternRepetitions?: number;
742 | offenderSpanIds?: string[];
743 | transactionName?: string;
744 | [key: string]: unknown;
745 | }
746 |
747 | interface SlowDbEvidenceData {
748 | parentSpan?: string;
749 | [key: string]: unknown;
750 | }
751 |
752 | function normalizeSpanId(value: unknown): string | undefined {
753 | if (typeof value === "string" && value) {
754 | return value;
755 | }
756 | return undefined;
757 | }
758 |
759 | function getSpanIdentifier(span: RawSpan): string | undefined {
760 | if (span.span_id !== undefined) {
761 | return normalizeSpanId(span.span_id);
762 | }
763 | if (span.id !== undefined) {
764 | return normalizeSpanId(span.id);
765 | }
766 | return undefined;
767 | }
768 |
769 | function getSpanDurationMs(span: RawSpan): number {
770 | if (
771 | typeof span.timestamp === "number" &&
772 | typeof span.start_timestamp === "number"
773 | ) {
774 | const deltaSeconds = span.timestamp - span.start_timestamp;
775 | if (Number.isFinite(deltaSeconds) && deltaSeconds >= 0) {
776 | return deltaSeconds * 1000;
777 | }
778 | }
779 |
780 | // Trace APIs expose `duration` in milliseconds. Preserve fractional values.
781 | if (typeof span.duration === "number" && Number.isFinite(span.duration)) {
782 | return span.duration >= 0 ? span.duration : 0;
783 | }
784 |
785 | return 0;
786 | }
787 |
788 | function normalizeIdArray(values: unknown): string[] {
789 | if (!Array.isArray(values)) {
790 | return [];
791 | }
792 |
793 | return values
794 | .map((value) => normalizeSpanId(value))
795 | .filter((value): value is string => value !== undefined);
796 | }
797 |
798 | function isValidSpanArray(value: unknown): value is RawSpan[] {
799 | return Array.isArray(value);
800 | }
801 |
802 | /**
803 | * Get the repeating span descriptions from evidence data.
804 | * Prefers repeatingSpansCompact (more concise) over repeatingSpans (verbose).
805 | */
806 | function getRepeatingSpanLines(evidenceData: N1EvidenceData): string[] {
807 | // Try compact version first (preferred for display)
808 | if (
809 | Array.isArray(evidenceData.repeatingSpansCompact) &&
810 | evidenceData.repeatingSpansCompact.length > 0
811 | ) {
812 | return evidenceData.repeatingSpansCompact
813 | .map((s) => (typeof s === "string" ? s.trim() : ""))
814 | .filter((s): s is string => s.length > 0);
815 | }
816 |
817 | // Fall back to full version
818 | if (
819 | Array.isArray(evidenceData.repeatingSpans) &&
820 | evidenceData.repeatingSpans.length > 0
821 | ) {
822 | return evidenceData.repeatingSpans
823 | .map((s) => (typeof s === "string" ? s.trim() : ""))
824 | .filter((s): s is string => s.length > 0);
825 | }
826 |
827 | return [];
828 | }
829 |
830 | function isTraceSpan(node: unknown): node is TraceSpan {
831 | if (node === null || typeof node !== "object") {
832 | return false;
833 | }
834 | const candidate = node as { event_type?: unknown; event_id?: unknown };
835 | // Trace API returns spans with event_type: "span"
836 | return (
837 | candidate.event_type === "span" && typeof candidate.event_id === "string"
838 | );
839 | }
840 |
841 | function buildTraceSpanTree(
842 | trace: Trace,
843 | parentSpanIds: string[],
844 | offenderSpanIds: string[],
845 | maxSpans: number,
846 | ): string[] {
847 | const offenderSet = new Set(offenderSpanIds);
848 | const spanMap = new Map<string, TraceSpan>();
849 |
850 | function indexSpan(span: TraceSpan): void {
851 | // Try to get span_id from additional_attributes, fall back to event_id
852 | const spanId =
853 | normalizeSpanId(span.additional_attributes?.span_id) || span.event_id;
854 | if (spanId && spanId.length > 0) {
855 | spanMap.set(spanId, span);
856 | }
857 | for (const child of span.children ?? []) {
858 | if (isTraceSpan(child)) {
859 | indexSpan(child);
860 | }
861 | }
862 | }
863 |
864 | for (const node of trace) {
865 | if (isTraceSpan(node)) {
866 | indexSpan(node);
867 | }
868 | }
869 |
870 | const roots: PerformanceSpan[] = [];
871 | const budget = { count: 0, limit: maxSpans };
872 |
873 | // First, try to find parent spans
874 | for (const parentId of parentSpanIds) {
875 | const span = spanMap.get(parentId);
876 | if (!span) {
877 | continue;
878 | }
879 | const perfSpan = convertTraceSpanToPerformanceSpan(
880 | span,
881 | offenderSet,
882 | budget,
883 | 0,
884 | );
885 | if (perfSpan) {
886 | roots.push(perfSpan);
887 | }
888 | if (budget.count >= budget.limit) {
889 | break;
890 | }
891 | }
892 |
893 | // If no parent spans found, try to find offender spans directly
894 | if (roots.length === 0 && offenderSpanIds.length > 0) {
895 | for (const offenderId of offenderSpanIds) {
896 | const span = spanMap.get(offenderId);
897 | if (!span) {
898 | continue;
899 | }
900 | const perfSpan = convertTraceSpanToPerformanceSpan(
901 | span,
902 | offenderSet,
903 | budget,
904 | 0,
905 | );
906 | if (perfSpan) {
907 | roots.push(perfSpan);
908 | }
909 | if (budget.count >= budget.limit) {
910 | break;
911 | }
912 | }
913 | }
914 |
915 | if (roots.length === 0) {
916 | return [];
917 | }
918 |
919 | return renderPerformanceSpanTree(roots);
920 | }
921 |
922 | function convertTraceSpanToPerformanceSpan(
923 | span: TraceSpan,
924 | offenderSet: Set<string>,
925 | budget: { count: number; limit: number },
926 | level: number,
927 | ): PerformanceSpan | null {
928 | if (budget.count >= budget.limit) {
929 | return null;
930 | }
931 |
932 | budget.count += 1;
933 |
934 | // Get span ID from additional_attributes or fall back to event_id
935 | const spanId =
936 | normalizeSpanId(span.additional_attributes?.span_id) || span.event_id;
937 |
938 | const performanceSpan: PerformanceSpan = {
939 | span_id: spanId,
940 | op: span.op || "unknown",
941 | description: formatTraceSpanDescription(span),
942 | duration: getTraceSpanDurationMs(span),
943 | is_n1_query: offenderSet.has(spanId),
944 | children: [],
945 | level,
946 | };
947 |
948 | for (const child of span.children ?? []) {
949 | if (!isTraceSpan(child)) {
950 | continue;
951 | }
952 | if (budget.count >= budget.limit) {
953 | break;
954 | }
955 | const childSpan = convertTraceSpanToPerformanceSpan(
956 | child,
957 | offenderSet,
958 | budget,
959 | level + 1,
960 | );
961 | if (childSpan) {
962 | performanceSpan.children.push(childSpan);
963 | }
964 | if (budget.count >= budget.limit) {
965 | break;
966 | }
967 | }
968 |
969 | return performanceSpan;
970 | }
971 |
972 | function formatTraceSpanDescription(span: TraceSpan): string {
973 | if (span.name && span.name.trim().length > 0) {
974 | return span.name.trim();
975 | }
976 | if (span.description && span.description.trim().length > 0) {
977 | return span.description.trim();
978 | }
979 | if (span.op && span.op.trim().length > 0) {
980 | return span.op.trim();
981 | }
982 | return "unnamed";
983 | }
984 |
985 | function getTraceSpanDurationMs(span: TraceSpan): number {
986 | if (typeof span.duration === "number" && span.duration >= 0) {
987 | return span.duration;
988 | }
989 | if (
990 | typeof (span as { end_timestamp?: number }).end_timestamp === "number" &&
991 | typeof span.start_timestamp === "number"
992 | ) {
993 | const deltaSeconds =
994 | (span as { end_timestamp: number }).end_timestamp - span.start_timestamp;
995 | if (Number.isFinite(deltaSeconds) && deltaSeconds >= 0) {
996 | return deltaSeconds * 1000;
997 | }
998 | }
999 | return 0;
1000 | }
1001 |
1002 | function buildOffenderSummaries(
1003 | spans: RawSpan[],
1004 | offenderSpanIds: string[],
1005 | ): string[] {
1006 | if (offenderSpanIds.length === 0) {
1007 | return [];
1008 | }
1009 |
1010 | const spanMap = new Map<string, RawSpan>();
1011 | for (const span of spans) {
1012 | const identifier = getSpanIdentifier(span);
1013 | if (identifier) {
1014 | spanMap.set(identifier, span);
1015 | }
1016 | }
1017 |
1018 | const summaries: string[] = [];
1019 | for (const offenderId of offenderSpanIds) {
1020 | const span = spanMap.get(offenderId);
1021 | if (span) {
1022 | const description = span.description || span.op || `Span ${offenderId}`;
1023 | const duration = getSpanDurationMs(span);
1024 | const durationLabel = duration > 0 ? ` (${Math.round(duration)}ms)` : "";
1025 | summaries.push(`${description}${durationLabel} [${offenderId}] [N+1]`);
1026 | } else {
1027 | summaries.push(`Span ${offenderId} [N+1]`);
1028 | }
1029 | }
1030 |
1031 | return summaries;
1032 | }
1033 |
1034 | /**
1035 | * Renders a hierarchical tree of performance spans using box-drawing characters.
1036 | * Highlights N+1 queries with a special indicator.
1037 | *
1038 | * @param spans - Array of selected performance spans
1039 | * @returns Array of formatted strings representing the tree
1040 | */
1041 | function renderPerformanceSpanTree(spans: PerformanceSpan[]): string[] {
1042 | const lines: string[] = [];
1043 |
1044 | function renderSpan(span: PerformanceSpan, prefix = "", isLast = true): void {
1045 | const connector = prefix === "" ? "" : isLast ? "└─ " : "├─ ";
1046 |
1047 | const displayName = span.description?.trim() || span.op || "unnamed";
1048 | const shortId = span.span_id ? span.span_id.substring(0, 8) : "unknown";
1049 | const durationDisplay =
1050 | span.duration > 0 ? `${Math.round(span.duration)}ms` : "unknown";
1051 |
1052 | const metadataParts: string[] = [shortId];
1053 | if (span.op && span.op !== "default") {
1054 | metadataParts.push(span.op);
1055 | }
1056 | metadataParts.push(durationDisplay);
1057 |
1058 | const line = `${prefix}${connector}${displayName} [${metadataParts.join(
1059 | " · ",
1060 | )}]${span.is_n1_query ? " [N+1]" : ""}`;
1061 | lines.push(line);
1062 |
1063 | // Render children
1064 | for (let i = 0; i < span.children.length; i++) {
1065 | const child = span.children[i];
1066 | const isLastChild = i === span.children.length - 1;
1067 | const childPrefix = prefix + (isLast ? " " : "│ ");
1068 | renderSpan(child, childPrefix, isLastChild);
1069 | }
1070 | }
1071 |
1072 | for (let i = 0; i < spans.length; i++) {
1073 | const span = spans[i];
1074 | const isLastRoot = i === spans.length - 1;
1075 | renderSpan(span, "", isLastRoot);
1076 | }
1077 |
1078 | return lines;
1079 | }
1080 |
1081 | function selectN1QuerySpans(
1082 | spans: RawSpan[],
1083 | evidence: N1EvidenceData,
1084 | maxSpans = MAX_SPANS_IN_TREE,
1085 | ): PerformanceSpan[] {
1086 | const selected: PerformanceSpan[] = [];
1087 | let spanCount = 0;
1088 |
1089 | const offenderSpanIds = normalizeIdArray(evidence.offenderSpanIds);
1090 | const parentSpanIds = normalizeIdArray(evidence.parentSpanIds);
1091 |
1092 | let parentSpan: PerformanceSpan | null = null;
1093 | if (parentSpanIds.length > 0) {
1094 | const parent = spans.find((span) => {
1095 | const identifier = getSpanIdentifier(span);
1096 | return identifier ? parentSpanIds.includes(identifier) : false;
1097 | });
1098 |
1099 | if (parent) {
1100 | parentSpan = {
1101 | span_id: getSpanIdentifier(parent) ?? "unknown",
1102 | op: parent.op || "unknown",
1103 | description:
1104 | parent.description || evidence.parentSpan || "Parent Operation",
1105 | duration: getSpanDurationMs(parent),
1106 | is_n1_query: false,
1107 | children: [],
1108 | level: 0,
1109 | };
1110 | selected.push(parentSpan);
1111 | spanCount += 1;
1112 | }
1113 | }
1114 |
1115 | if (offenderSpanIds.length > 0) {
1116 | const offenderSet = new Set(offenderSpanIds);
1117 | const offenderSpans = spans
1118 | .filter((span) => {
1119 | const identifier = getSpanIdentifier(span);
1120 | return identifier ? offenderSet.has(identifier) : false;
1121 | })
1122 | .slice(0, Math.max(0, maxSpans - spanCount));
1123 |
1124 | for (const span of offenderSpans) {
1125 | const perfSpan: PerformanceSpan = {
1126 | span_id: getSpanIdentifier(span) ?? "unknown",
1127 | op: span.op || "db.query",
1128 | description: span.description || "Database Query",
1129 | duration: getSpanDurationMs(span),
1130 | is_n1_query: true,
1131 | children: [],
1132 | level: parentSpan ? 1 : 0,
1133 | };
1134 |
1135 | if (parentSpan) {
1136 | parentSpan.children.push(perfSpan);
1137 | } else {
1138 | selected.push(perfSpan);
1139 | }
1140 |
1141 | spanCount += 1;
1142 | if (spanCount >= maxSpans) {
1143 | break;
1144 | }
1145 | }
1146 | }
1147 |
1148 | return selected;
1149 | }
1150 |
1151 | /**
1152 | * Known Sentry performance issue types that we handle.
1153 | *
1154 | * NOTE: We intentionally only implement formatters for high-value performance issues
1155 | * that provide complex insights. Not all issue types need custom formatting - many
1156 | * can rely on the generic evidenceDisplay fields that Sentry provides.
1157 | *
1158 | * Currently fully implemented:
1159 | * - N+1 query detection (DB and API)
1160 | *
1161 | * Partially implemented:
1162 | * - Slow DB queries (shows parent span only)
1163 | *
1164 | * Not implemented (lower priority):
1165 | * - Asset-related issues (render blocking, uncompressed, large payloads)
1166 | * - File I/O issues
1167 | * - Consecutive queries
1168 | */
1169 | const KNOWN_PERFORMANCE_ISSUE_TYPES = {
1170 | N_PLUS_ONE_DB_QUERIES: "performance_n_plus_one_db_queries",
1171 | N_PLUS_ONE_API_CALLS: "performance_n_plus_one_api_calls",
1172 | SLOW_DB_QUERY: "performance_slow_db_query",
1173 | RENDER_BLOCKING_ASSET: "performance_render_blocking_asset",
1174 | CONSECUTIVE_DB_QUERIES: "performance_consecutive_db_queries",
1175 | FILE_IO_MAIN_THREAD: "performance_file_io_main_thread",
1176 | M_N_PLUS_ONE_DB_QUERIES: "performance_m_n_plus_one_db_queries",
1177 | UNCOMPRESSED_ASSET: "performance_uncompressed_asset",
1178 | LARGE_HTTP_PAYLOAD: "performance_large_http_payload",
1179 | } as const;
1180 |
1181 | /**
1182 | * Map numeric occurrence types to issue types (from Sentry's codebase).
1183 | *
1184 | * Sentry uses numeric type IDs internally in the occurrence data structure,
1185 | * but string issue types in the UI and other APIs. This mapping converts
1186 | * between them.
1187 | *
1188 | * Source: sentry/static/app/types/group.tsx in Sentry's codebase
1189 | * Range: 1xxx = transaction-based performance issues
1190 | * 2xxx = profile-based performance issues
1191 | */
1192 | const OCCURRENCE_TYPE_TO_ISSUE_TYPE: Record<number, string> = {
1193 | 1001: KNOWN_PERFORMANCE_ISSUE_TYPES.SLOW_DB_QUERY,
1194 | 1004: KNOWN_PERFORMANCE_ISSUE_TYPES.RENDER_BLOCKING_ASSET,
1195 | 1006: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES,
1196 | 1906: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES, // Alternative ID for N+1 DB
1197 | 1007: KNOWN_PERFORMANCE_ISSUE_TYPES.CONSECUTIVE_DB_QUERIES,
1198 | 1008: KNOWN_PERFORMANCE_ISSUE_TYPES.FILE_IO_MAIN_THREAD,
1199 | 1009: "performance_consecutive_http",
1200 | 1010: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_API_CALLS,
1201 | 1910: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_API_CALLS, // Alternative ID for N+1 API
1202 | 1012: KNOWN_PERFORMANCE_ISSUE_TYPES.UNCOMPRESSED_ASSET,
1203 | 1013: "performance_db_main_thread",
1204 | 1015: KNOWN_PERFORMANCE_ISSUE_TYPES.LARGE_HTTP_PAYLOAD,
1205 | 1016: "performance_http_overhead",
1206 | };
1207 |
1208 | // Type alias currently unused but kept for potential future type safety
1209 | // type PerformanceIssueType = typeof KNOWN_PERFORMANCE_ISSUE_TYPES[keyof typeof KNOWN_PERFORMANCE_ISSUE_TYPES];
1210 |
1211 | /**
1212 | * Formats N+1 query issue evidence data.
1213 | *
1214 | * N+1 queries are a common performance anti-pattern where code executes
1215 | * 1 query to get a list of items, then N additional queries (one per item)
1216 | * instead of using a single JOIN or batch query.
1217 | *
1218 | * Evidence fields we use:
1219 | * - parentSpan: The operation that triggered the N+1 queries
1220 | * - repeatingSpansCompact/repeatingSpans: The query pattern being repeated
1221 | * - numberRepeatingSpans: How many times the query was executed
1222 | * - offenderSpanIds: IDs of the actual span instances
1223 | * - parentSpanIds: IDs of parent spans for tree visualization
1224 | */
1225 | function formatN1QueryEvidence(
1226 | evidenceData: N1EvidenceData,
1227 | spansData: unknown,
1228 | performanceTrace?: Trace,
1229 | ): string {
1230 | const parts: string[] = [];
1231 |
1232 | // Format parent span info if available
1233 | if (evidenceData.parentSpan) {
1234 | parts.push("**Parent Operation:**");
1235 | parts.push(`${evidenceData.parentSpan}`);
1236 | parts.push("");
1237 | }
1238 |
1239 | // Format repeating spans (the N+1 queries)
1240 | const repeatingLines = getRepeatingSpanLines(evidenceData);
1241 | if (repeatingLines.length > 0) {
1242 | parts.push("### Repeated Database Queries");
1243 | parts.push("");
1244 |
1245 | const queryCount = evidenceData.numberRepeatingSpans
1246 | ? safeParseInt(evidenceData.numberRepeatingSpans, 0)
1247 | : evidenceData.numPatternRepetitions ||
1248 | evidenceData.offenderSpanIds?.length ||
1249 | 0;
1250 |
1251 | if (queryCount > 0) {
1252 | parts.push(`**Query executed ${queryCount} times:**`);
1253 | }
1254 |
1255 | // Show the query pattern - if single line, render as SQL block; if multiple, as list
1256 | if (repeatingLines.length === 1) {
1257 | parts.push("```sql");
1258 | parts.push(repeatingLines[0]);
1259 | parts.push("```");
1260 | parts.push("");
1261 | } else {
1262 | parts.push("**Repeated operations:**");
1263 | for (const line of repeatingLines) {
1264 | parts.push(`- ${line}`);
1265 | }
1266 | parts.push("");
1267 | }
1268 | }
1269 |
1270 | const parentSpanIds = normalizeIdArray(evidenceData.parentSpanIds);
1271 | const offenderSpanIds = normalizeIdArray(evidenceData.offenderSpanIds);
1272 |
1273 | const traceLines = performanceTrace
1274 | ? buildTraceSpanTree(
1275 | performanceTrace,
1276 | parentSpanIds,
1277 | offenderSpanIds,
1278 | MAX_SPANS_IN_TREE,
1279 | )
1280 | : [];
1281 |
1282 | if (traceLines.length > 0) {
1283 | parts.push(`### Span Tree (Limited to ${MAX_SPANS_IN_TREE} spans)`);
1284 | parts.push("");
1285 | parts.push("```");
1286 | parts.push(...traceLines);
1287 | parts.push("```");
1288 | parts.push("");
1289 | } else {
1290 | const spanTree = isValidSpanArray(spansData)
1291 | ? selectN1QuerySpans(spansData, evidenceData, MAX_SPANS_IN_TREE)
1292 | : [];
1293 |
1294 | if (spanTree.length > 0) {
1295 | parts.push(`### Span Tree (Limited to ${MAX_SPANS_IN_TREE} spans)`);
1296 | parts.push("");
1297 | parts.push("```");
1298 | parts.push(...renderPerformanceSpanTree(spanTree));
1299 | parts.push("```");
1300 | parts.push("");
1301 | } else if (isValidSpanArray(spansData)) {
1302 | // Only show offender summaries if we have spans data but couldn't build a tree
1303 | const offenderSummaries = buildOffenderSummaries(
1304 | spansData as RawSpan[],
1305 | offenderSpanIds,
1306 | );
1307 |
1308 | if (offenderSummaries.length > 0) {
1309 | parts.push("### Offending Spans");
1310 | parts.push("");
1311 | for (const summary of offenderSummaries) {
1312 | parts.push(`- ${summary}`);
1313 | }
1314 | }
1315 | }
1316 | }
1317 |
1318 | return parts.join("\n");
1319 | }
1320 |
1321 | /**
1322 | * Formats slow DB query issue evidence data.
1323 | *
1324 | * Currently only partially implemented - shows parent span information.
1325 | * Full implementation would show query duration, explain plan, etc.
1326 | *
1327 | * This is lower priority as the generic evidenceDisplay fields usually
1328 | * provide sufficient information for slow query issues.
1329 | */
1330 | function formatSlowDbQueryEvidence(
1331 | evidenceData: SlowDbEvidenceData,
1332 | spansData: unknown,
1333 | ): string {
1334 | const parts: string[] = [];
1335 |
1336 | // Show parent span if available (generic field that applies to slow queries)
1337 | if (evidenceData.parentSpan) {
1338 | parts.push("**Parent Operation:**");
1339 | parts.push(`${evidenceData.parentSpan}`);
1340 | parts.push("");
1341 | }
1342 |
1343 | // TODO: Implement slow query specific fields when we know the structure
1344 | // Potential fields: query duration, database name, query plan
1345 | console.warn(
1346 | "[formatSlowDbQueryEvidence] Evidence data rendering not yet fully implemented",
1347 | );
1348 |
1349 | return parts.join("\n");
1350 | }
1351 |
1352 | /**
1353 | * Formats performance issue details from transaction events based on the issue type.
1354 | *
1355 | * This is the main dispatcher for performance issue formatting. It:
1356 | * 1. Detects the issue type from occurrence data (numeric or string)
1357 | * 2. Calls the appropriate type-specific formatter if implemented
1358 | * 3. Falls back to generic evidenceDisplay fields for unimplemented types
1359 | * 4. Provides span analysis fallback for events without occurrence data
1360 | *
1361 | * The occurrence data structure comes from Sentry's performance issue detection
1362 | * and contains evidence about what triggered the issue.
1363 | *
1364 | * @param event - The transaction event containing performance issue data
1365 | * @param spansData - The spans data from the event entries
1366 | * @returns Formatted markdown string with performance issue details
1367 | */
1368 | function formatPerformanceIssueOutput(
1369 | event: Event,
1370 | spansData: unknown,
1371 | options?: {
1372 | performanceTrace?: Trace;
1373 | },
1374 | ): string {
1375 | const parts: string[] = [];
1376 |
1377 | // Check if we have occurrence data
1378 | const occurrence = (event as any).occurrence;
1379 | if (!occurrence) {
1380 | return "";
1381 | }
1382 |
1383 | // Get issue type - occurrence.type is numeric, issueType may be a string
1384 | let issueType: string | undefined;
1385 | if (typeof occurrence.type === "number") {
1386 | issueType = OCCURRENCE_TYPE_TO_ISSUE_TYPE[occurrence.type];
1387 | } else {
1388 | issueType = occurrence.issueType || occurrence.type;
1389 | }
1390 |
1391 | const evidenceData = occurrence.evidenceData;
1392 |
1393 | // Process evidence data based on known performance issue types
1394 | if (evidenceData) {
1395 | switch (issueType) {
1396 | case KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES:
1397 | case KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_API_CALLS:
1398 | case KNOWN_PERFORMANCE_ISSUE_TYPES.M_N_PLUS_ONE_DB_QUERIES: {
1399 | const result = formatN1QueryEvidence(
1400 | evidenceData,
1401 | spansData,
1402 | options?.performanceTrace,
1403 | );
1404 | if (result) parts.push(result);
1405 | break;
1406 | }
1407 |
1408 | case KNOWN_PERFORMANCE_ISSUE_TYPES.SLOW_DB_QUERY: {
1409 | const result = formatSlowDbQueryEvidence(evidenceData, spansData);
1410 | if (result) parts.push(result);
1411 | break;
1412 | }
1413 |
1414 | default:
1415 | // We don't implement formatters for all performance issue types.
1416 | // Many lower-priority issues (consecutive queries, asset issues, file I/O)
1417 | // work fine with just the generic evidenceDisplay fields below.
1418 | // Only high-value, complex issues like N+1 queries need custom formatting.
1419 | if (issueType) {
1420 | console.warn(
1421 | `[formatPerformanceIssueOutput] No custom formatter for issue type: ${issueType}`,
1422 | );
1423 | }
1424 | // Fall through to show generic evidence display below
1425 | }
1426 | }
1427 |
1428 | // Show transaction name if available for any performance issue (generic field)
1429 | if (evidenceData?.transactionName) {
1430 | parts.push("**Transaction:**");
1431 | parts.push(`${evidenceData.transactionName}`);
1432 | parts.push("");
1433 | }
1434 |
1435 | // Always show evidence display if available (this is generic and doesn't require type knowledge)
1436 | if (occurrence.evidenceDisplay?.length > 0) {
1437 | for (const display of occurrence.evidenceDisplay) {
1438 | if (display.important) {
1439 | parts.push(`**${display.name}:**`);
1440 | parts.push(`${display.value}`);
1441 | parts.push("");
1442 | }
1443 | }
1444 | }
1445 |
1446 | return parts.length > 0 ? `${parts.join("\n")}\n` : "";
1447 | }
1448 |
1449 | /**
1450 | * Formats generic event output (performance regressions, metric-based issues).
1451 | * Generic events don't have traditional error entries, but have occurrence data
1452 | * with evidenceDisplay showing regression details, metric changes, etc.
1453 | */
1454 | function formatGenericEventOutput(event: Event): string {
1455 | const parts: string[] = [];
1456 |
1457 | // Only generic events have occurrence data
1458 | if (event.type !== "generic") {
1459 | return "";
1460 | }
1461 |
1462 | // Type assertion after guard - we know it's a GenericEvent
1463 | const genericEvent = event as GenericEvent;
1464 | const occurrence = genericEvent.occurrence;
1465 | if (!occurrence) {
1466 | return "";
1467 | }
1468 |
1469 | // Add a section header for performance regression details
1470 | const evidenceData = occurrence.evidenceData;
1471 | if (evidenceData) {
1472 | parts.push("### Performance Regression Details");
1473 | parts.push("");
1474 | }
1475 |
1476 | // Show evidence display items (regression details, metric changes, etc.)
1477 | if (occurrence.evidenceDisplay && occurrence.evidenceDisplay.length > 0) {
1478 | for (const display of occurrence.evidenceDisplay) {
1479 | if (display.important) {
1480 | parts.push(`**${display.name}:**`);
1481 | parts.push(`${display.value}`);
1482 | parts.push("");
1483 | }
1484 | }
1485 | }
1486 |
1487 | return parts.length > 0 ? `${parts.join("\n")}\n` : "";
1488 | }
1489 |
1490 | function formatTags(tags: z.infer<typeof EventSchema>["tags"]) {
1491 | if (!tags || tags.length === 0) {
1492 | return "";
1493 | }
1494 | return `### Tags\n\n${tags
1495 | .map((tag) => `**${tag.key}**: ${tag.value}`)
1496 | .join("\n")}\n\n`;
1497 | }
1498 |
1499 | function formatContext(context: z.infer<typeof EventSchema>["context"]) {
1500 | if (!context || Object.keys(context).length === 0) {
1501 | return "";
1502 | }
1503 | return `### Extra Data\n\nAdditional data attached to this event.\n\n${Object.entries(
1504 | context,
1505 | )
1506 | .map(([key, value]) => {
1507 | return `**${key}**: ${JSON.stringify(value, undefined, 2)}`;
1508 | })
1509 | .join("\n")}\n\n`;
1510 | }
1511 |
1512 | function formatContexts(contexts: z.infer<typeof EventSchema>["contexts"]) {
1513 | if (!contexts || Object.keys(contexts).length === 0) {
1514 | return "";
1515 | }
1516 | return `### Additional Context\n\nThese are additional context provided by the user when they're instrumenting their application.\n\n${Object.entries(
1517 | contexts,
1518 | )
1519 | .map(
1520 | ([name, data]) =>
1521 | `**${name}**\n${Object.entries(data)
1522 | .filter(([key, _]) => key !== "type")
1523 | .map(([key, value]) => {
1524 | return `${key}: ${JSON.stringify(value, undefined, 2)}`;
1525 | })
1526 | .join("\n")}`,
1527 | )
1528 | .join("\n\n")}\n\n`;
1529 | }
1530 |
1531 | /**
1532 | * Formats a brief Seer analysis summary for inclusion in issue details.
1533 | * Shows current status and high-level insights, prompting to use analyze_issue_with_seer for full details.
1534 | *
1535 | * @param autofixState - The autofix state containing Seer analysis data
1536 | * @param organizationSlug - The organization slug for the issue
1537 | * @param issueId - The issue ID (shortId)
1538 | * @returns Formatted markdown string with Seer summary, or empty string if no analysis exists
1539 | */
1540 | function formatSeerSummary(
1541 | autofixState: AutofixRunState | undefined,
1542 | organizationSlug: string,
1543 | issueId: string,
1544 | ): string {
1545 | if (!autofixState || !autofixState.autofix) {
1546 | return "";
1547 | }
1548 |
1549 | const { autofix } = autofixState;
1550 | const parts: string[] = [];
1551 |
1552 | parts.push("## Seer Analysis");
1553 | parts.push("");
1554 |
1555 | // Show status first
1556 | const statusDisplay = getStatusDisplayName(autofix.status);
1557 | if (!isTerminalStatus(autofix.status)) {
1558 | parts.push(`**Status:** ${statusDisplay}`);
1559 | parts.push("");
1560 | }
1561 |
1562 | // Show summary of what we have so far
1563 | if (autofix.steps.length > 0) {
1564 | const completedSteps = autofix.steps.filter(
1565 | (step) => step.status === "COMPLETED",
1566 | );
1567 |
1568 | // Find the solution step if available
1569 | const solutionStep = completedSteps.find(
1570 | (step) => step.type === "solution",
1571 | );
1572 |
1573 | if (solutionStep) {
1574 | // For solution steps, use the description directly
1575 | const solutionDescription = solutionStep.description;
1576 | if (
1577 | solutionDescription &&
1578 | typeof solutionDescription === "string" &&
1579 | solutionDescription.trim()
1580 | ) {
1581 | parts.push("**Summary:**");
1582 | parts.push(solutionDescription.trim());
1583 | } else {
1584 | // Fallback to extracting from output if no description
1585 | const solutionOutput = getOutputForAutofixStep(solutionStep);
1586 | const lines = solutionOutput.split("\n");
1587 | const firstParagraph = lines.find(
1588 | (line) =>
1589 | line.trim().length > 50 &&
1590 | !line.startsWith("#") &&
1591 | !line.startsWith("*"),
1592 | );
1593 | if (firstParagraph) {
1594 | parts.push("**Summary:**");
1595 | parts.push(firstParagraph.trim());
1596 | }
1597 | }
1598 | } else if (completedSteps.length > 0) {
1599 | // Show what steps have been completed so far
1600 | const rootCauseStep = completedSteps.find(
1601 | (step) => step.type === "root_cause_analysis",
1602 | );
1603 |
1604 | if (rootCauseStep) {
1605 | const typedStep = rootCauseStep as z.infer<
1606 | typeof AutofixRunStepRootCauseAnalysisSchema
1607 | >;
1608 | if (
1609 | typedStep.causes &&
1610 | typedStep.causes.length > 0 &&
1611 | typedStep.causes[0].description
1612 | ) {
1613 | parts.push("**Root Cause Identified:**");
1614 | parts.push(typedStep.causes[0].description.trim());
1615 | }
1616 | } else {
1617 | // Show generic progress
1618 | parts.push(
1619 | `**Progress:** ${completedSteps.length} of ${autofix.steps.length} steps completed`,
1620 | );
1621 | }
1622 | }
1623 | } else {
1624 | // No steps yet - check for terminal states first
1625 | if (isTerminalStatus(autofix.status)) {
1626 | if (autofix.status === "FAILED" || autofix.status === "ERROR") {
1627 | parts.push("**Status:** Analysis failed.");
1628 | } else if (autofix.status === "CANCELLED") {
1629 | parts.push("**Status:** Analysis was cancelled.");
1630 | } else if (
1631 | autofix.status === "NEED_MORE_INFORMATION" ||
1632 | autofix.status === "WAITING_FOR_USER_RESPONSE"
1633 | ) {
1634 | parts.push(
1635 | "**Status:** Analysis paused - additional information needed.",
1636 | );
1637 | }
1638 | } else {
1639 | parts.push("Analysis has started but no results yet.");
1640 | }
1641 | }
1642 |
1643 | // Add specific messages for terminal states when steps exist
1644 | if (autofix.steps.length > 0 && isTerminalStatus(autofix.status)) {
1645 | if (autofix.status === "FAILED" || autofix.status === "ERROR") {
1646 | parts.push("");
1647 | parts.push("**Status:** Analysis failed.");
1648 | } else if (autofix.status === "CANCELLED") {
1649 | parts.push("");
1650 | parts.push("**Status:** Analysis was cancelled.");
1651 | } else if (
1652 | autofix.status === "NEED_MORE_INFORMATION" ||
1653 | autofix.status === "WAITING_FOR_USER_RESPONSE"
1654 | ) {
1655 | parts.push("");
1656 | parts.push(
1657 | "**Status:** Analysis paused - additional information needed.",
1658 | );
1659 | }
1660 | }
1661 |
1662 | // Always suggest using analyze_issue_with_seer for more details
1663 | parts.push("");
1664 | parts.push(
1665 | `**Note:** For detailed root cause analysis and solutions, call \`analyze_issue_with_seer(organizationSlug='${organizationSlug}', issueId='${issueId}')\``,
1666 | );
1667 |
1668 | return `${parts.join("\n")}\n\n`;
1669 | }
1670 |
1671 | /**
1672 | * Formats a Sentry issue with its latest event into comprehensive markdown output.
1673 | * Includes issue metadata, event details, and usage instructions.
1674 | *
1675 | * @param params - Object containing organization slug, issue, event, and API service
1676 | * @returns Formatted markdown string with complete issue information
1677 | */
1678 | export function formatIssueOutput({
1679 | organizationSlug,
1680 | issue,
1681 | event,
1682 | apiService,
1683 | autofixState,
1684 | performanceTrace,
1685 | }: {
1686 | organizationSlug: string;
1687 | issue: Issue;
1688 | event: Event;
1689 | apiService: SentryApiService;
1690 | autofixState?: AutofixRunState;
1691 | performanceTrace?: Trace;
1692 | }) {
1693 | let output = `# Issue ${issue.shortId} in **${organizationSlug}**\n\n`;
1694 |
1695 | // Check if this is a performance issue based on issueCategory or issueType
1696 | // Performance issues can have various categories like 'db_query' but issueType starts with 'performance_'
1697 | const isPerformanceIssue =
1698 | issue.issueType?.startsWith("performance_") ||
1699 | issue.issueCategory === "performance";
1700 |
1701 | if (isPerformanceIssue && issue.metadata) {
1702 | // For performance issues, use metadata for better context
1703 | const issueTitle = issue.metadata.title || issue.title;
1704 | output += `**Description**: ${issueTitle}\n`;
1705 |
1706 | if (issue.metadata.location) {
1707 | output += `**Location**: ${issue.metadata.location}\n`;
1708 | }
1709 | if (issue.metadata.value) {
1710 | output += `**Query Pattern**: \`${issue.metadata.value}\`\n`;
1711 | }
1712 | } else {
1713 | // For regular errors and other issues
1714 | output += `**Description**: ${issue.title}\n`;
1715 | output += `**Culprit**: ${issue.culprit}\n`;
1716 | }
1717 |
1718 | output += `**First Seen**: ${new Date(issue.firstSeen).toISOString()}\n`;
1719 | output += `**Last Seen**: ${new Date(issue.lastSeen).toISOString()}\n`;
1720 | output += `**Occurrences**: ${issue.count}\n`;
1721 | output += `**Users Impacted**: ${issue.userCount}\n`;
1722 | output += `**Status**: ${issue.status}\n`;
1723 |
1724 | // Add substatus if present (e.g., "regressed" for performance regressions)
1725 | if (issue.substatus) {
1726 | output += `**Substatus**: ${issue.substatus}\n`;
1727 | }
1728 |
1729 | // Add assignee information if assigned
1730 | if (issue.assignedTo) {
1731 | if (typeof issue.assignedTo === "string") {
1732 | output += `**Assigned To**: ${issue.assignedTo}\n`;
1733 | } else {
1734 | const assignee = issue.assignedTo;
1735 | const type = assignee.type === "team" ? "Team" : "User";
1736 | output += `**Assigned To**: ${assignee.name} (${type})\n`;
1737 | }
1738 | }
1739 |
1740 | // Add issue type and category for performance/metric issues
1741 | if (issue.issueType) {
1742 | output += `**Issue Type**: ${issue.issueType}\n`;
1743 | }
1744 | if (issue.issueCategory) {
1745 | output += `**Issue Category**: ${issue.issueCategory}\n`;
1746 | }
1747 |
1748 | output += `**Platform**: ${issue.platform}\n`;
1749 | output += `**Project**: ${issue.project.name}\n`;
1750 | output += `**URL**: ${apiService.getIssueUrl(organizationSlug, issue.shortId)}\n`;
1751 | output += "\n";
1752 | output += "## Event Details\n\n";
1753 |
1754 | // Check if this is an unsupported event type
1755 | // Event type union is: ErrorEvent | DefaultEvent | TransactionEvent | GenericEvent | CspEvent
1756 | // But in practice we may have other types returned as UnknownEvent
1757 | const eventType = event.type;
1758 | const isUnsupportedType =
1759 | eventType !== "error" &&
1760 | eventType !== "default" &&
1761 | eventType !== "transaction" &&
1762 | eventType !== "generic" &&
1763 | eventType !== "csp";
1764 |
1765 | if (isUnsupportedType) {
1766 | // Log to Sentry for tracking new/unknown event types
1767 | const sentryEventId = logIssue(
1768 | `Unsupported event type encountered: ${String(eventType)}`,
1769 | {
1770 | contexts: {
1771 | event: {
1772 | event_id: event.id,
1773 | event_type: eventType,
1774 | issue_id: issue.id,
1775 | issue_short_id: issue.shortId,
1776 | organization_slug: organizationSlug,
1777 | project_slug: issue.project.slug,
1778 | },
1779 | },
1780 | },
1781 | );
1782 |
1783 | output += `⚠️ **Warning**: Unsupported event type "${String(eventType)}"\n\n`;
1784 | output += "This event type is not yet fully supported by the MCP server. ";
1785 | output += "Only basic issue information is shown above.\n\n";
1786 | output +=
1787 | "**Please report this**: Open a GitHub issue at https://github.com/getsentry/sentry-mcp/issues/new ";
1788 | output += `and include Event ID **${event.id}**`;
1789 | if (sentryEventId) {
1790 | output += ` and Sentry Event ID **${sentryEventId}**`;
1791 | }
1792 | output += " to help us add support for this event type.\n";
1793 |
1794 | // For unsupported event types, return early without trying to render event details
1795 | return output;
1796 | }
1797 |
1798 | output += `**Event ID**: ${event.id}\n`;
1799 | output += `**Type**: ${event.type}\n`;
1800 | // "default" type represents error events without exception data
1801 | // "generic" type represents performance regressions and metric-based issues
1802 | // "csp" type represents Content Security Policy violations
1803 | if (
1804 | event.type === "error" ||
1805 | event.type === "default" ||
1806 | event.type === "generic" ||
1807 | event.type === "csp"
1808 | ) {
1809 | const typedEvent = event as
1810 | | z.infer<typeof ErrorEventSchema>
1811 | | z.infer<typeof DefaultEventSchema>
1812 | | z.infer<typeof GenericEventSchema>
1813 | | any; // CSP events don't have a schema yet
1814 | if (typedEvent.dateCreated) {
1815 | output += `**Occurred At**: ${new Date(typedEvent.dateCreated).toISOString()}\n`;
1816 | }
1817 | }
1818 | if (event.message) {
1819 | output += `**Message**:\n${event.message}\n`;
1820 | }
1821 | output += "\n";
1822 | output += formatEventOutput(event, { performanceTrace });
1823 |
1824 | // Add Seer context if available
1825 | if (autofixState) {
1826 | output += formatSeerSummary(autofixState, organizationSlug, issue.shortId);
1827 | }
1828 |
1829 | output += "# Using this information\n\n";
1830 | 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`;
1831 | output +=
1832 | "- The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code.\n";
1833 | return output;
1834 | }
1835 |
```