#
tokens: 42326/50000 2/408 files (page 13/15)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 13 of 15. Use http://codebase.md/getsentry/sentry-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .claude
│   ├── agents
│   │   └── claude-optimizer.md
│   ├── commands
│   │   ├── gh-pr.md
│   │   └── gh-review.md
│   └── settings.json
├── .craft.yml
├── .cursor
│   └── mcp.json
├── .env.example
├── .github
│   └── workflows
│       ├── deploy.yml
│       ├── eval.yml
│       ├── merge-jobs.yml
│       ├── release.yml
│       ├── smoke-tests.yml
│       └── test.yml
├── .gitignore
├── .mcp.json
├── .vscode
│   ├── extensions.json
│   ├── mcp.json
│   └── settings.json
├── AGENTS.md
├── bin
│   └── bump-version.sh
├── biome.json
├── CLAUDE.md
├── codecov.yml
├── core
├── docs
│   ├── adding-tools.mdc
│   ├── api-patterns.mdc
│   ├── architecture.mdc
│   ├── cloudflare
│   │   ├── architecture.md
│   │   ├── deployment.md
│   │   ├── oauth-architecture.md
│   │   └── overview.md
│   ├── coding-guidelines.mdc
│   ├── common-patterns.mdc
│   ├── cursor.mdc
│   ├── deployment.mdc
│   ├── error-handling.mdc
│   ├── github-actions.mdc
│   ├── llms
│   │   ├── document-scopes.mdc
│   │   ├── documentation-style-guide.mdc
│   │   └── README.md
│   ├── logging.mdc
│   ├── monitoring.mdc
│   ├── permissions-and-scopes.md
│   ├── pr-management.mdc
│   ├── quality-checks.mdc
│   ├── README.md
│   ├── search-events-api-patterns.md
│   ├── security.mdc
│   ├── specs
│   │   ├── README.md
│   │   ├── search-events.md
│   │   └── subpath-constraints.md
│   └── testing.mdc
├── LICENSE.md
├── Makefile
├── package.json
├── packages
│   ├── mcp-cloudflare
│   │   ├── .env.example
│   │   ├── components.json
│   │   ├── index.html
│   │   ├── package.json
│   │   ├── public
│   │   │   ├── favicon.ico
│   │   │   ├── flow-transparent.png
│   │   │   └── flow.jpg
│   │   ├── src
│   │   │   ├── client
│   │   │   │   ├── app.tsx
│   │   │   │   ├── components
│   │   │   │   │   ├── chat
│   │   │   │   │   │   ├── auth-form.tsx
│   │   │   │   │   │   ├── chat-input.tsx
│   │   │   │   │   │   ├── chat-message.tsx
│   │   │   │   │   │   ├── chat-messages.tsx
│   │   │   │   │   │   ├── chat-ui.tsx
│   │   │   │   │   │   ├── chat.tsx
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── tool-invocation.tsx
│   │   │   │   │   │   └── types.ts
│   │   │   │   │   ├── fragments
│   │   │   │   │   │   ├── remote-setup.tsx
│   │   │   │   │   │   ├── setup-guide.tsx
│   │   │   │   │   │   └── stdio-setup.tsx
│   │   │   │   │   └── ui
│   │   │   │   │       ├── accordion.tsx
│   │   │   │   │       ├── backdrop.tsx
│   │   │   │   │       ├── base.tsx
│   │   │   │   │       ├── button.tsx
│   │   │   │   │       ├── code-snippet.tsx
│   │   │   │   │       ├── header.tsx
│   │   │   │   │       ├── icon.tsx
│   │   │   │   │       ├── icons
│   │   │   │   │       │   └── sentry.tsx
│   │   │   │   │       ├── interactive-markdown.tsx
│   │   │   │   │       ├── json-schema-params.tsx
│   │   │   │   │       ├── markdown.tsx
│   │   │   │   │       ├── note.tsx
│   │   │   │   │       ├── prose.tsx
│   │   │   │   │       ├── section.tsx
│   │   │   │   │       ├── slash-command-actions.tsx
│   │   │   │   │       ├── slash-command-text.tsx
│   │   │   │   │       ├── sliding-panel.tsx
│   │   │   │   │       ├── template-vars.tsx
│   │   │   │   │       ├── tool-actions.tsx
│   │   │   │   │       └── typewriter.tsx
│   │   │   │   ├── contexts
│   │   │   │   │   └── auth-context.tsx
│   │   │   │   ├── hooks
│   │   │   │   │   ├── use-mcp-metadata.ts
│   │   │   │   │   ├── use-persisted-chat.ts
│   │   │   │   │   ├── use-scroll-lock.ts
│   │   │   │   │   └── use-streaming-simulation.ts
│   │   │   │   ├── index.css
│   │   │   │   ├── instrument.ts
│   │   │   │   ├── lib
│   │   │   │   │   └── utils.ts
│   │   │   │   ├── main.tsx
│   │   │   │   ├── pages
│   │   │   │   │   └── home.tsx
│   │   │   │   ├── utils
│   │   │   │   │   ├── chat-error-handler.ts
│   │   │   │   │   └── index.ts
│   │   │   │   └── vite-env.d.ts
│   │   │   ├── constants.ts
│   │   │   ├── server
│   │   │   │   ├── app.test.ts
│   │   │   │   ├── app.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── lib
│   │   │   │   │   ├── approval-dialog.test.ts
│   │   │   │   │   ├── approval-dialog.ts
│   │   │   │   │   ├── constraint-utils.test.ts
│   │   │   │   │   ├── constraint-utils.ts
│   │   │   │   │   ├── html-utils.ts
│   │   │   │   │   ├── mcp-handler.test.ts
│   │   │   │   │   ├── mcp-handler.ts
│   │   │   │   │   └── slug-validation.ts
│   │   │   │   ├── logging.ts
│   │   │   │   ├── oauth
│   │   │   │   │   ├── authorize.test.ts
│   │   │   │   │   ├── callback.test.ts
│   │   │   │   │   ├── constants.ts
│   │   │   │   │   ├── helpers.test.ts
│   │   │   │   │   ├── helpers.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── routes
│   │   │   │   │   │   ├── authorize.ts
│   │   │   │   │   │   ├── callback.ts
│   │   │   │   │   │   └── index.ts
│   │   │   │   │   └── state.ts
│   │   │   │   ├── routes
│   │   │   │   │   ├── chat-oauth.ts
│   │   │   │   │   ├── chat.ts
│   │   │   │   │   ├── mcp.ts
│   │   │   │   │   ├── metadata.ts
│   │   │   │   │   ├── search.test.ts
│   │   │   │   │   └── search.ts
│   │   │   │   ├── sentry.config.ts
│   │   │   │   ├── types
│   │   │   │   │   └── chat.ts
│   │   │   │   ├── types.ts
│   │   │   │   └── utils
│   │   │   │       └── auth-errors.ts
│   │   │   └── test-setup.ts
│   │   ├── tsconfig.client.json
│   │   ├── tsconfig.json
│   │   ├── tsconfig.node.json
│   │   ├── tsconfig.server.json
│   │   ├── vite.config.ts
│   │   ├── vitest.config.ts
│   │   ├── worker-configuration.d.ts
│   │   ├── wrangler.canary.jsonc
│   │   └── wrangler.jsonc
│   ├── mcp-server
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── scripts
│   │   │   ├── generate-definitions.ts
│   │   │   └── generate-otel-namespaces.ts
│   │   ├── src
│   │   │   ├── api-client
│   │   │   │   ├── client.test.ts
│   │   │   │   ├── client.ts
│   │   │   │   ├── errors.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── schema.ts
│   │   │   │   └── types.ts
│   │   │   ├── cli
│   │   │   │   ├── parse.test.ts
│   │   │   │   ├── parse.ts
│   │   │   │   ├── resolve.test.ts
│   │   │   │   ├── resolve.ts
│   │   │   │   ├── types.ts
│   │   │   │   └── usage.ts
│   │   │   ├── constants.ts
│   │   │   ├── errors.test.ts
│   │   │   ├── errors.ts
│   │   │   ├── index.ts
│   │   │   ├── internal
│   │   │   │   ├── agents
│   │   │   │   │   ├── callEmbeddedAgent.ts
│   │   │   │   │   ├── openai-provider.ts
│   │   │   │   │   └── tools
│   │   │   │   │       ├── data
│   │   │   │   │       │   ├── __namespaces.json
│   │   │   │   │       │   ├── android.json
│   │   │   │   │       │   ├── app.json
│   │   │   │   │       │   ├── artifact.json
│   │   │   │   │       │   ├── aspnetcore.json
│   │   │   │   │       │   ├── aws.json
│   │   │   │   │       │   ├── azure.json
│   │   │   │   │       │   ├── browser.json
│   │   │   │   │       │   ├── cassandra.json
│   │   │   │   │       │   ├── cicd.json
│   │   │   │   │       │   ├── CLAUDE.md
│   │   │   │   │       │   ├── client.json
│   │   │   │   │       │   ├── cloud.json
│   │   │   │   │       │   ├── cloudevents.json
│   │   │   │   │       │   ├── cloudfoundry.json
│   │   │   │   │       │   ├── code.json
│   │   │   │   │       │   ├── container.json
│   │   │   │   │       │   ├── cpu.json
│   │   │   │   │       │   ├── cpython.json
│   │   │   │   │       │   ├── database.json
│   │   │   │   │       │   ├── db.json
│   │   │   │   │       │   ├── deployment.json
│   │   │   │   │       │   ├── destination.json
│   │   │   │   │       │   ├── device.json
│   │   │   │   │       │   ├── disk.json
│   │   │   │   │       │   ├── dns.json
│   │   │   │   │       │   ├── dotnet.json
│   │   │   │   │       │   ├── elasticsearch.json
│   │   │   │   │       │   ├── enduser.json
│   │   │   │   │       │   ├── error.json
│   │   │   │   │       │   ├── faas.json
│   │   │   │   │       │   ├── feature_flags.json
│   │   │   │   │       │   ├── file.json
│   │   │   │   │       │   ├── gcp.json
│   │   │   │   │       │   ├── gen_ai.json
│   │   │   │   │       │   ├── geo.json
│   │   │   │   │       │   ├── go.json
│   │   │   │   │       │   ├── graphql.json
│   │   │   │   │       │   ├── hardware.json
│   │   │   │   │       │   ├── heroku.json
│   │   │   │   │       │   ├── host.json
│   │   │   │   │       │   ├── http.json
│   │   │   │   │       │   ├── ios.json
│   │   │   │   │       │   ├── jvm.json
│   │   │   │   │       │   ├── k8s.json
│   │   │   │   │       │   ├── linux.json
│   │   │   │   │       │   ├── log.json
│   │   │   │   │       │   ├── mcp.json
│   │   │   │   │       │   ├── messaging.json
│   │   │   │   │       │   ├── network.json
│   │   │   │   │       │   ├── nodejs.json
│   │   │   │   │       │   ├── oci.json
│   │   │   │   │       │   ├── opentracing.json
│   │   │   │   │       │   ├── os.json
│   │   │   │   │       │   ├── otel.json
│   │   │   │   │       │   ├── peer.json
│   │   │   │   │       │   ├── process.json
│   │   │   │   │       │   ├── profile.json
│   │   │   │   │       │   ├── rpc.json
│   │   │   │   │       │   ├── server.json
│   │   │   │   │       │   ├── service.json
│   │   │   │   │       │   ├── session.json
│   │   │   │   │       │   ├── signalr.json
│   │   │   │   │       │   ├── source.json
│   │   │   │   │       │   ├── system.json
│   │   │   │   │       │   ├── telemetry.json
│   │   │   │   │       │   ├── test.json
│   │   │   │   │       │   ├── thread.json
│   │   │   │   │       │   ├── tls.json
│   │   │   │   │       │   ├── url.json
│   │   │   │   │       │   ├── user.json
│   │   │   │   │       │   ├── v8js.json
│   │   │   │   │       │   ├── vcs.json
│   │   │   │   │       │   ├── webengine.json
│   │   │   │   │       │   └── zos.json
│   │   │   │   │       ├── dataset-fields.test.ts
│   │   │   │   │       ├── dataset-fields.ts
│   │   │   │   │       ├── otel-semantics.test.ts
│   │   │   │   │       ├── otel-semantics.ts
│   │   │   │   │       ├── utils.ts
│   │   │   │   │       ├── whoami.test.ts
│   │   │   │   │       └── whoami.ts
│   │   │   │   ├── constraint-helpers.test.ts
│   │   │   │   ├── constraint-helpers.ts
│   │   │   │   ├── context-storage.ts
│   │   │   │   ├── error-handling.ts
│   │   │   │   ├── fetch-utils.test.ts
│   │   │   │   ├── fetch-utils.ts
│   │   │   │   ├── formatting.test.ts
│   │   │   │   ├── formatting.ts
│   │   │   │   ├── issue-helpers.test.ts
│   │   │   │   ├── issue-helpers.ts
│   │   │   │   ├── test-fixtures.ts
│   │   │   │   └── tool-helpers
│   │   │   │       ├── api.test.ts
│   │   │   │       ├── api.ts
│   │   │   │       ├── define.ts
│   │   │   │       ├── enhance-error.ts
│   │   │   │       ├── formatting.ts
│   │   │   │       ├── issue.ts
│   │   │   │       ├── seer.test.ts
│   │   │   │       ├── seer.ts
│   │   │   │       ├── validate-region-url.test.ts
│   │   │   │       └── validate-region-url.ts
│   │   │   ├── permissions.parseScopes.test.ts
│   │   │   ├── permissions.ts
│   │   │   ├── schema.ts
│   │   │   ├── server-context.test.ts
│   │   │   ├── server.ts
│   │   │   ├── telem
│   │   │   │   ├── index.ts
│   │   │   │   ├── logging.ts
│   │   │   │   ├── sentry.test.ts
│   │   │   │   └── sentry.ts
│   │   │   ├── test-setup.ts
│   │   │   ├── test-utils
│   │   │   │   └── context.ts
│   │   │   ├── toolDefinitions.ts
│   │   │   ├── tools
│   │   │   │   ├── analyze-issue-with-seer.test.ts
│   │   │   │   ├── analyze-issue-with-seer.ts
│   │   │   │   ├── create-dsn.test.ts
│   │   │   │   ├── create-dsn.ts
│   │   │   │   ├── create-project.test.ts
│   │   │   │   ├── create-project.ts
│   │   │   │   ├── create-team.test.ts
│   │   │   │   ├── create-team.ts
│   │   │   │   ├── find-dsns.test.ts
│   │   │   │   ├── find-dsns.ts
│   │   │   │   ├── find-organizations.test.ts
│   │   │   │   ├── find-organizations.ts
│   │   │   │   ├── find-projects.test.ts
│   │   │   │   ├── find-projects.ts
│   │   │   │   ├── find-releases.test.ts
│   │   │   │   ├── find-releases.ts
│   │   │   │   ├── find-teams.test.ts
│   │   │   │   ├── find-teams.ts
│   │   │   │   ├── get-doc.test.ts
│   │   │   │   ├── get-doc.ts
│   │   │   │   ├── get-event-attachment.test.ts
│   │   │   │   ├── get-event-attachment.ts
│   │   │   │   ├── get-issue-details.test.ts
│   │   │   │   ├── get-issue-details.ts
│   │   │   │   ├── get-trace-details.test.ts
│   │   │   │   ├── get-trace-details.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── search-docs.test.ts
│   │   │   │   ├── search-docs.ts
│   │   │   │   ├── search-events
│   │   │   │   │   ├── agent.ts
│   │   │   │   │   ├── CLAUDE.md
│   │   │   │   │   ├── config.ts
│   │   │   │   │   ├── formatters.ts
│   │   │   │   │   ├── handler.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── utils.test.ts
│   │   │   │   │   └── utils.ts
│   │   │   │   ├── search-events.test.ts
│   │   │   │   ├── search-issues
│   │   │   │   │   ├── agent.ts
│   │   │   │   │   ├── CLAUDE.md
│   │   │   │   │   ├── config.ts
│   │   │   │   │   ├── formatters.ts
│   │   │   │   │   ├── handler.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── README.md
│   │   │   │   ├── tools.test.ts
│   │   │   │   ├── types.ts
│   │   │   │   ├── update-issue.test.ts
│   │   │   │   ├── update-issue.ts
│   │   │   │   ├── update-project.test.ts
│   │   │   │   ├── update-project.ts
│   │   │   │   ├── whoami.test.ts
│   │   │   │   └── whoami.ts
│   │   │   ├── transports
│   │   │   │   └── stdio.ts
│   │   │   ├── types.ts
│   │   │   ├── utils
│   │   │   │   ├── slug-validation.test.ts
│   │   │   │   ├── slug-validation.ts
│   │   │   │   ├── url-utils.test.ts
│   │   │   │   └── url-utils.ts
│   │   │   └── version.ts
│   │   ├── tsconfig.json
│   │   ├── tsdown.config.ts
│   │   └── vitest.config.ts
│   ├── mcp-server-evals
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── bin
│   │   │   │   └── start-mock-stdio.ts
│   │   │   ├── evals
│   │   │   │   ├── autofix.eval.ts
│   │   │   │   ├── create-dsn.eval.ts
│   │   │   │   ├── create-project.eval.ts
│   │   │   │   ├── create-team.eval.ts
│   │   │   │   ├── get-issue.eval.ts
│   │   │   │   ├── get-trace-details.eval.ts
│   │   │   │   ├── list-dsns.eval.ts
│   │   │   │   ├── list-issues.eval.ts
│   │   │   │   ├── list-organizations.eval.ts
│   │   │   │   ├── list-projects.eval.ts
│   │   │   │   ├── list-releases.eval.ts
│   │   │   │   ├── list-tags.eval.ts
│   │   │   │   ├── list-teams.eval.ts
│   │   │   │   ├── search-docs.eval.ts
│   │   │   │   ├── search-events-agent.eval.ts
│   │   │   │   ├── search-events.eval.ts
│   │   │   │   ├── search-issues-agent.eval.ts
│   │   │   │   ├── search-issues.eval.ts
│   │   │   │   ├── update-issue.eval.ts
│   │   │   │   ├── update-project.eval.ts
│   │   │   │   └── utils
│   │   │   │       ├── fixtures.ts
│   │   │   │       ├── index.ts
│   │   │   │       ├── runner.ts
│   │   │   │       ├── structuredOutputScorer.ts
│   │   │   │       └── toolPredictionScorer.ts
│   │   │   └── setup-env.ts
│   │   ├── tsconfig.json
│   │   └── vitest.config.ts
│   ├── mcp-server-mocks
│   │   ├── package.json
│   │   ├── src
│   │   │   ├── fixtures
│   │   │   │   ├── autofix-state.json
│   │   │   │   ├── event-attachments.json
│   │   │   │   ├── event.json
│   │   │   │   ├── issue.json
│   │   │   │   ├── performance-event.json
│   │   │   │   ├── project.json
│   │   │   │   ├── tags.json
│   │   │   │   ├── team.json
│   │   │   │   ├── trace-event.json
│   │   │   │   ├── trace-items-attributes-logs-number.json
│   │   │   │   ├── trace-items-attributes-logs-string.json
│   │   │   │   ├── trace-items-attributes-spans-number.json
│   │   │   │   ├── trace-items-attributes-spans-string.json
│   │   │   │   ├── trace-items-attributes.json
│   │   │   │   ├── trace-meta-with-nulls.json
│   │   │   │   ├── trace-meta.json
│   │   │   │   ├── trace-mixed.json
│   │   │   │   └── trace.json
│   │   │   ├── index.ts
│   │   │   └── utils.ts
│   │   ├── tsconfig.json
│   │   └── tsdown.config.ts
│   ├── mcp-server-tsconfig
│   │   ├── package.json
│   │   ├── tsconfig.base.json
│   │   └── tsconfig.vite.json
│   ├── mcp-test-client
│   │   ├── .env.test
│   │   ├── .gitignore
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── agent.ts
│   │   │   ├── auth
│   │   │   │   ├── config.ts
│   │   │   │   └── oauth.ts
│   │   │   ├── constants.ts
│   │   │   ├── index.ts
│   │   │   ├── logger.test.ts
│   │   │   ├── logger.ts
│   │   │   ├── mcp-test-client-remote.ts
│   │   │   ├── mcp-test-client.ts
│   │   │   ├── types.ts
│   │   │   └── version.ts
│   │   ├── tsconfig.json
│   │   ├── tsdown.config.ts
│   │   └── vitest.config.ts
│   └── smoke-tests
│       ├── package.json
│       ├── src
│       │   └── smoke.test.ts
│       └── vitest.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── scripts
│   └── check-doc-links.mjs
├── turbo.json
└── vitest.workspace.ts
```

# Files

--------------------------------------------------------------------------------
/packages/mcp-server/src/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 | } from "../api-client/types";
  16 | import type {
  17 |   ErrorEntrySchema,
  18 |   ErrorEventSchema,
  19 |   EventSchema,
  20 |   FrameInterface,
  21 |   RequestEntrySchema,
  22 |   MessageEntrySchema,
  23 |   ThreadsEntrySchema,
  24 |   SentryApiService,
  25 |   AutofixRunStepRootCauseAnalysisSchema,
  26 | } from "../api-client";
  27 | import {
  28 |   getOutputForAutofixStep,
  29 |   isTerminalStatus,
  30 |   getStatusDisplayName,
  31 | } from "./tool-helpers/seer";
  32 | 
  33 | // Language detection mappings
  34 | const LANGUAGE_EXTENSIONS: Record<string, string> = {
  35 |   ".java": "java",
  36 |   ".py": "python",
  37 |   ".js": "javascript",
  38 |   ".jsx": "javascript",
  39 |   ".ts": "javascript",
  40 |   ".tsx": "javascript",
  41 |   ".rb": "ruby",
  42 |   ".php": "php",
  43 | };
  44 | 
  45 | const LANGUAGE_MODULE_PATTERNS: Array<[RegExp, string]> = [
  46 |   [/^(java\.|com\.|org\.)/, "java"],
  47 | ];
  48 | 
  49 | /**
  50 |  * Detects the programming language of a stack frame based on the file extension.
  51 |  * Falls back to the platform parameter if no filename is available or extension is unrecognized.
  52 |  *
  53 |  * @param frame - The stack frame containing file and location information
  54 |  * @param platform - Optional platform hint to use as fallback
  55 |  * @returns The detected language or platform fallback or "unknown"
  56 |  */
  57 | function detectLanguage(
  58 |   frame: z.infer<typeof FrameInterface>,
  59 |   platform?: string | null,
  60 | ): string {
  61 |   // Check filename extensions
  62 |   if (frame.filename) {
  63 |     const ext = frame.filename.toLowerCase().match(/\.[^.]+$/)?.[0];
  64 |     if (ext && LANGUAGE_EXTENSIONS[ext]) {
  65 |       return LANGUAGE_EXTENSIONS[ext];
  66 |     }
  67 |   }
  68 | 
  69 |   // Check module patterns
  70 |   if (frame.module) {
  71 |     for (const [pattern, language] of LANGUAGE_MODULE_PATTERNS) {
  72 |       if (pattern.test(frame.module)) {
  73 |         return language;
  74 |       }
  75 |     }
  76 |   }
  77 | 
  78 |   // Fallback to platform or unknown
  79 |   return platform || "unknown";
  80 | }
  81 | 
  82 | /**
  83 |  * Formats a stack frame into a language-specific string representation.
  84 |  * Different languages have different conventions for displaying stack traces.
  85 |  *
  86 |  * @param frame - The stack frame to format
  87 |  * @param frameIndex - Optional frame index for languages that display frame numbers
  88 |  * @param platform - Optional platform hint for language detection fallback
  89 |  * @returns Formatted stack frame string
  90 |  */
  91 | export function formatFrameHeader(
  92 |   frame: z.infer<typeof FrameInterface>,
  93 |   frameIndex?: number,
  94 |   platform?: string | null,
  95 | ) {
  96 |   const language = detectLanguage(frame, platform);
  97 | 
  98 |   switch (language) {
  99 |     case "java": {
 100 |       // at com.example.ClassName.methodName(FileName.java:123)
 101 |       const className = frame.module || "UnknownClass";
 102 |       const method = frame.function || "<unknown>";
 103 |       const source = frame.filename || "Unknown Source";
 104 |       const location = frame.lineNo ? `:${frame.lineNo}` : "";
 105 |       return `at ${className}.${method}(${source}${location})`;
 106 |     }
 107 | 
 108 |     case "python": {
 109 |       // File "/path/to/file.py", line 42, in function_name
 110 |       const file =
 111 |         frame.filename || frame.absPath || frame.module || "<unknown>";
 112 |       const func = frame.function || "<module>";
 113 |       const line = frame.lineNo ? `, line ${frame.lineNo}` : "";
 114 |       return `  File "${file}"${line}, in ${func}`;
 115 |     }
 116 | 
 117 |     case "javascript": {
 118 |       // Original compact format: filename:line:col (function)
 119 |       // This preserves backward compatibility
 120 |       return `${[frame.filename, frame.lineNo, frame.colNo]
 121 |         .filter((i) => !!i)
 122 |         .join(":")}${frame.function ? ` (${frame.function})` : ""}`;
 123 |     }
 124 | 
 125 |     case "ruby": {
 126 |       // from /path/to/file.rb:42:in `method_name'
 127 |       const file = frame.filename || frame.module || "<unknown>";
 128 |       const func = frame.function ? ` \`${frame.function}\`` : "";
 129 |       const line = frame.lineNo ? `:${frame.lineNo}:in` : "";
 130 |       return `    from ${file}${line}${func}`;
 131 |     }
 132 | 
 133 |     case "php": {
 134 |       // #0 /path/to/file.php(42): functionName()
 135 |       const file = frame.filename || "<unknown>";
 136 |       const line = frame.lineNo ? `(${frame.lineNo})` : "";
 137 |       const func = frame.function || "<unknown>";
 138 |       const prefix = frameIndex !== undefined ? `#${frameIndex} ` : "";
 139 |       return `${prefix}${file}${line}: ${func}()`;
 140 |     }
 141 | 
 142 |     default: {
 143 |       // Generic format for unknown languages
 144 |       const func = frame.function || "<unknown>";
 145 |       const location = frame.filename || frame.module || "<unknown>";
 146 |       const line = frame.lineNo ? `:${frame.lineNo}` : "";
 147 |       const col = frame.colNo != null ? `:${frame.colNo}` : "";
 148 |       return `    at ${func} (${location}${line}${col})`;
 149 |     }
 150 |   }
 151 | }
 152 | 
 153 | /**
 154 |  * Formats a Sentry event into a structured markdown output.
 155 |  * Includes error messages, stack traces, request info, and contextual data.
 156 |  *
 157 |  * @param event - The Sentry event to format
 158 |  * @param options - Additional formatting context
 159 |  * @returns Formatted markdown string
 160 |  */
 161 | export function formatEventOutput(
 162 |   event: Event,
 163 |   options?: {
 164 |     performanceTrace?: Trace;
 165 |   },
 166 | ) {
 167 |   let output = "";
 168 | 
 169 |   // Look for the primary error information
 170 |   const messageEntry = event.entries.find((e) => e.type === "message");
 171 |   const exceptionEntry = event.entries.find((e) => e.type === "exception");
 172 |   const threadsEntry = event.entries.find((e) => e.type === "threads");
 173 |   const requestEntry = event.entries.find((e) => e.type === "request");
 174 |   const spansEntry = event.entries.find((e) => e.type === "spans");
 175 | 
 176 |   // Error message (if present)
 177 |   if (messageEntry) {
 178 |     output += formatMessageInterfaceOutput(
 179 |       event,
 180 |       messageEntry.data as z.infer<typeof MessageEntrySchema>,
 181 |     );
 182 |   }
 183 | 
 184 |   // Stack trace (from exception or threads)
 185 |   if (exceptionEntry) {
 186 |     output += formatExceptionInterfaceOutput(
 187 |       event,
 188 |       exceptionEntry.data as z.infer<typeof ErrorEntrySchema>,
 189 |     );
 190 |   } else if (threadsEntry) {
 191 |     output += formatThreadsInterfaceOutput(
 192 |       event,
 193 |       threadsEntry.data as z.infer<typeof ThreadsEntrySchema>,
 194 |     );
 195 |   }
 196 | 
 197 |   // Request info (if HTTP error)
 198 |   if (requestEntry) {
 199 |     output += formatRequestInterfaceOutput(
 200 |       event,
 201 |       requestEntry.data as z.infer<typeof RequestEntrySchema>,
 202 |     );
 203 |   }
 204 | 
 205 |   // Performance issue details (N+1 queries, etc.)
 206 |   // Pass spans data for additional context even if we have evidence
 207 |   if (event.type === "transaction") {
 208 |     output += formatPerformanceIssueOutput(event, spansEntry?.data, options);
 209 |   }
 210 | 
 211 |   output += formatTags(event.tags);
 212 |   output += formatContext(event.context);
 213 |   output += formatContexts(event.contexts);
 214 |   return output;
 215 | }
 216 | 
 217 | /**
 218 |  * Extracts the context line matching the frame's line number for inline display.
 219 |  * This is used in the full stacktrace view to show the actual line of code
 220 |  * that caused the error inline with the stack frame.
 221 |  *
 222 |  * @param frame - The stack frame containing context lines
 223 |  * @returns The line of code at the frame's line number, or empty string if not available
 224 |  */
 225 | function renderInlineContext(frame: z.infer<typeof FrameInterface>): string {
 226 |   if (!frame.context?.length || !frame.lineNo) {
 227 |     return "";
 228 |   }
 229 | 
 230 |   const contextLine = frame.context.find(([lineNo]) => lineNo === frame.lineNo);
 231 |   return contextLine ? `\n${contextLine[1]}` : "";
 232 | }
 233 | 
 234 | /**
 235 |  * Renders an enhanced view of a stack frame with context lines and variables.
 236 |  * Used for the "Most Relevant Frame" section to provide detailed information
 237 |  * about the most relevant application frame where the error occurred.
 238 |  *
 239 |  * @param frame - The stack frame to render with enhanced information
 240 |  * @param event - The Sentry event containing platform information for language detection
 241 |  * @returns Formatted string with frame header, context lines, and variables table
 242 |  */
 243 | function renderEnhancedFrame(
 244 |   frame: z.infer<typeof FrameInterface>,
 245 |   event: Event,
 246 | ): string {
 247 |   const parts: string[] = [];
 248 | 
 249 |   parts.push("**Most Relevant Frame:**");
 250 |   parts.push("─────────────────────");
 251 |   parts.push(formatFrameHeader(frame, undefined, event.platform));
 252 | 
 253 |   // Add context lines if available
 254 |   if (frame.context?.length) {
 255 |     const contextLines = renderContextLines(frame);
 256 |     if (contextLines) {
 257 |       parts.push("");
 258 |       parts.push(contextLines);
 259 |     }
 260 |   }
 261 | 
 262 |   // Add variables table if available
 263 |   if (frame.vars && Object.keys(frame.vars).length > 0) {
 264 |     parts.push("");
 265 |     parts.push(renderVariablesTable(frame.vars));
 266 |   }
 267 | 
 268 |   return parts.join("\n");
 269 | }
 270 | 
 271 | function formatExceptionInterfaceOutput(
 272 |   event: Event,
 273 |   data: z.infer<typeof ErrorEntrySchema>,
 274 | ) {
 275 |   const parts: string[] = [];
 276 | 
 277 |   // Handle both single exception (value) and chained exceptions (values)
 278 |   const exceptions = data.values || (data.value ? [data.value] : []);
 279 | 
 280 |   if (exceptions.length === 0) {
 281 |     return "";
 282 |   }
 283 | 
 284 |   // For chained exceptions, they are typically ordered from innermost to outermost
 285 |   // We'll render them in reverse order (outermost first) to match how they occurred
 286 |   const isChained = exceptions.length > 1;
 287 | 
 288 |   // Create a copy before reversing to avoid mutating the original array
 289 |   [...exceptions].reverse().forEach((exception, index) => {
 290 |     if (!exception) return;
 291 | 
 292 |     // Add language-specific chain indicator for multiple exceptions
 293 |     if (isChained && index > 0) {
 294 |       parts.push("");
 295 |       parts.push(
 296 |         getExceptionChainMessage(
 297 |           event.platform || null,
 298 |           index,
 299 |           exceptions.length,
 300 |         ),
 301 |       );
 302 |       parts.push("");
 303 |     }
 304 | 
 305 |     // Use the actual exception type and value as the heading
 306 |     const exceptionTitle = `${exception.type}${exception.value ? `: ${exception.value}` : ""}`;
 307 | 
 308 |     parts.push(index === 0 ? "### Error" : `### ${exceptionTitle}`);
 309 |     parts.push("");
 310 | 
 311 |     // Add the error details in a code block for the first exception
 312 |     // to maintain backward compatibility
 313 |     if (index === 0) {
 314 |       parts.push("```");
 315 |       parts.push(exceptionTitle);
 316 |       parts.push("```");
 317 |       parts.push("");
 318 |     }
 319 | 
 320 |     if (!exception.stacktrace || !exception.stacktrace.frames) {
 321 |       parts.push("**Stacktrace:**");
 322 |       parts.push("```");
 323 |       parts.push("No stacktrace available");
 324 |       parts.push("```");
 325 |       return;
 326 |     }
 327 | 
 328 |     const frames = exception.stacktrace.frames;
 329 | 
 330 |     // Only show enhanced frame for the first (outermost) exception to avoid overwhelming output
 331 |     if (index === 0) {
 332 |       const firstInAppFrame = findFirstInAppFrame(frames);
 333 |       if (
 334 |         firstInAppFrame &&
 335 |         (firstInAppFrame.context?.length || firstInAppFrame.vars)
 336 |       ) {
 337 |         parts.push(renderEnhancedFrame(firstInAppFrame, event));
 338 |         parts.push("");
 339 |         parts.push("**Full Stacktrace:**");
 340 |         parts.push("────────────────");
 341 |       } else {
 342 |         parts.push("**Stacktrace:**");
 343 |       }
 344 |     } else {
 345 |       parts.push("**Stacktrace:**");
 346 |     }
 347 | 
 348 |     parts.push("```");
 349 |     parts.push(
 350 |       frames
 351 |         .map((frame) => {
 352 |           const header = formatFrameHeader(frame, undefined, event.platform);
 353 |           const context = renderInlineContext(frame);
 354 |           return `${header}${context}`;
 355 |         })
 356 |         .join("\n"),
 357 |     );
 358 |     parts.push("```");
 359 |   });
 360 | 
 361 |   parts.push("");
 362 |   parts.push("");
 363 | 
 364 |   return parts.join("\n");
 365 | }
 366 | 
 367 | /**
 368 |  * Get the appropriate exception chain message based on the platform
 369 |  */
 370 | function getExceptionChainMessage(
 371 |   platform: string | null,
 372 |   index: number,
 373 |   totalExceptions: number,
 374 | ): string {
 375 |   // Default message for unknown platforms
 376 |   const defaultMessage =
 377 |     "**During handling of the above exception, another exception occurred:**";
 378 | 
 379 |   if (!platform) {
 380 |     return defaultMessage;
 381 |   }
 382 | 
 383 |   switch (platform.toLowerCase()) {
 384 |     case "python":
 385 |       // Python has two distinct messages, but without additional metadata
 386 |       // we default to the implicit chaining message
 387 |       return "**During handling of the above exception, another exception occurred:**";
 388 | 
 389 |     case "java":
 390 |       return "**Caused by:**";
 391 | 
 392 |     case "csharp":
 393 |     case "dotnet":
 394 |       return "**---> Inner Exception:**";
 395 | 
 396 |     case "ruby":
 397 |       return "**Caused by:**";
 398 | 
 399 |     case "go":
 400 |       return "**Wrapped error:**";
 401 | 
 402 |     case "rust":
 403 |       return `**Caused by (${index}):**`;
 404 | 
 405 |     default:
 406 |       return defaultMessage;
 407 |   }
 408 | }
 409 | 
 410 | function formatRequestInterfaceOutput(
 411 |   event: Event,
 412 |   data: z.infer<typeof RequestEntrySchema>,
 413 | ) {
 414 |   if (!data.method || !data.url) {
 415 |     return "";
 416 |   }
 417 |   return `### HTTP Request\n\n**Method:** ${data.method}\n**URL:** ${data.url}\n\n`;
 418 | }
 419 | 
 420 | function formatMessageInterfaceOutput(
 421 |   event: Event,
 422 |   data: z.infer<typeof MessageEntrySchema>,
 423 | ) {
 424 |   if (!data.formatted && !data.message) {
 425 |     return "";
 426 |   }
 427 |   const message = data.formatted || data.message || "";
 428 |   return `### Error\n\n${"```"}\n${message}\n${"```"}\n\n`;
 429 | }
 430 | 
 431 | function formatThreadsInterfaceOutput(
 432 |   event: Event,
 433 |   data: z.infer<typeof ThreadsEntrySchema>,
 434 | ) {
 435 |   if (!data.values || data.values.length === 0) {
 436 |     return "";
 437 |   }
 438 | 
 439 |   // Find the crashed thread only
 440 |   const crashedThread = data.values.find((t) => t.crashed);
 441 | 
 442 |   if (!crashedThread?.stacktrace?.frames) {
 443 |     return "";
 444 |   }
 445 | 
 446 |   const parts: string[] = [];
 447 | 
 448 |   // Include thread name if available
 449 |   if (crashedThread.name) {
 450 |     parts.push(`**Thread** (${crashedThread.name})`);
 451 |     parts.push("");
 452 |   }
 453 | 
 454 |   const frames = crashedThread.stacktrace.frames;
 455 | 
 456 |   // Find and format the first in-app frame with enhanced view
 457 |   const firstInAppFrame = findFirstInAppFrame(frames);
 458 |   if (
 459 |     firstInAppFrame &&
 460 |     (firstInAppFrame.context?.length || firstInAppFrame.vars)
 461 |   ) {
 462 |     parts.push(renderEnhancedFrame(firstInAppFrame, event));
 463 |     parts.push("");
 464 |     parts.push("**Full Stacktrace:**");
 465 |     parts.push("────────────────");
 466 |   } else {
 467 |     parts.push("**Stacktrace:**");
 468 |   }
 469 | 
 470 |   parts.push("```");
 471 |   parts.push(
 472 |     frames
 473 |       .map((frame) => {
 474 |         const header = formatFrameHeader(frame, undefined, event.platform);
 475 |         const context = renderInlineContext(frame);
 476 |         return `${header}${context}`;
 477 |       })
 478 |       .join("\n"),
 479 |   );
 480 |   parts.push("```");
 481 |   parts.push("");
 482 | 
 483 |   return parts.join("\n");
 484 | }
 485 | 
 486 | /**
 487 |  * Renders surrounding source code context for a stack frame.
 488 |  * Shows a window of code lines around the error line with visual indicators.
 489 |  *
 490 |  * @param frame - The stack frame containing context lines
 491 |  * @param contextSize - Number of lines to show before and after the error line (default: 3)
 492 |  * @returns Formatted context lines with line numbers and arrow indicator for the error line
 493 |  */
 494 | function renderContextLines(
 495 |   frame: z.infer<typeof FrameInterface>,
 496 |   contextSize = 3,
 497 | ): string {
 498 |   if (!frame.context || frame.context.length === 0 || !frame.lineNo) {
 499 |     return "";
 500 |   }
 501 | 
 502 |   const lines: string[] = [];
 503 |   const errorLine = frame.lineNo;
 504 |   const maxLineNoWidth = Math.max(
 505 |     ...frame.context.map(([lineNo]) => lineNo.toString().length),
 506 |   );
 507 | 
 508 |   for (const [lineNo, code] of frame.context) {
 509 |     const isErrorLine = lineNo === errorLine;
 510 |     const lineNoStr = lineNo.toString().padStart(maxLineNoWidth, " ");
 511 | 
 512 |     if (Math.abs(lineNo - errorLine) <= contextSize) {
 513 |       if (isErrorLine) {
 514 |         lines.push(`  → ${lineNoStr} │ ${code}`);
 515 |       } else {
 516 |         lines.push(`    ${lineNoStr} │ ${code}`);
 517 |       }
 518 |     }
 519 |   }
 520 | 
 521 |   return lines.join("\n");
 522 | }
 523 | 
 524 | /**
 525 |  * Formats a variable value for display in the variables table.
 526 |  * Handles different types appropriately and safely, converting complex objects
 527 |  * to readable representations and handling edge cases like circular references.
 528 |  *
 529 |  * @param value - The variable value to format (can be any type)
 530 |  * @param maxLength - Maximum length for stringified objects/arrays (default: 80)
 531 |  * @returns Human-readable string representation of the value
 532 |  */
 533 | function formatVariableValue(value: unknown, maxLength = 80): string {
 534 |   try {
 535 |     if (typeof value === "string") {
 536 |       return `"${value}"`;
 537 |     }
 538 |     if (value === null) {
 539 |       return "null";
 540 |     }
 541 |     if (value === undefined) {
 542 |       return "undefined";
 543 |     }
 544 |     if (typeof value === "object") {
 545 |       const stringified = JSON.stringify(value);
 546 |       if (stringified.length > maxLength) {
 547 |         // Leave room for ", ...]" or ", ...}"
 548 |         const truncateAt = maxLength - 6;
 549 |         let truncated = stringified.substring(0, truncateAt);
 550 | 
 551 |         // Find the last complete element by looking for the last comma
 552 |         const lastComma = truncated.lastIndexOf(",");
 553 |         if (lastComma > 0) {
 554 |           truncated = truncated.substring(0, lastComma);
 555 |         }
 556 | 
 557 |         // Add the appropriate ending
 558 |         if (Array.isArray(value)) {
 559 |           return `${truncated}, ...]`;
 560 |         }
 561 |         return `${truncated}, ...}`;
 562 |       }
 563 |       return stringified;
 564 |     }
 565 |     return String(value);
 566 |   } catch {
 567 |     // Handle circular references or other stringify errors
 568 |     return `<${typeof value}>`;
 569 |   }
 570 | }
 571 | 
 572 | /**
 573 |  * Renders a table of local variables in a tree-like format.
 574 |  * Uses box-drawing characters to create a visual hierarchy of variables
 575 |  * and their values at the point where the error occurred.
 576 |  *
 577 |  * @param vars - Object containing variable names as keys and their values
 578 |  * @returns Formatted variables table with tree-style prefix characters
 579 |  */
 580 | function renderVariablesTable(vars: Record<string, unknown>): string {
 581 |   const entries = Object.entries(vars);
 582 |   if (entries.length === 0) {
 583 |     return "";
 584 |   }
 585 | 
 586 |   const lines: string[] = ["Local Variables:"];
 587 |   const lastIndex = entries.length - 1;
 588 | 
 589 |   entries.forEach(([key, value], index) => {
 590 |     const prefix = index === lastIndex ? "└─" : "├─";
 591 |     const valueStr = formatVariableValue(value);
 592 |     lines.push(`${prefix} ${key}: ${valueStr}`);
 593 |   });
 594 | 
 595 |   return lines.join("\n");
 596 | }
 597 | 
 598 | /**
 599 |  * Finds the first application frame (in_app) in a stack trace.
 600 |  * Searches from the bottom of the stack (oldest frame) to find the first
 601 |  * frame that belongs to the user's application code rather than libraries.
 602 |  *
 603 |  * @param frames - Array of stack frames, typically in reverse chronological order
 604 |  * @returns The first in-app frame found, or undefined if none exist
 605 |  */
 606 | function findFirstInAppFrame(
 607 |   frames: z.infer<typeof FrameInterface>[],
 608 | ): z.infer<typeof FrameInterface> | undefined {
 609 |   // Frames are usually in reverse order (most recent first)
 610 |   // We want the first in-app frame from the bottom
 611 |   for (let i = frames.length - 1; i >= 0; i--) {
 612 |     if (frames[i].inApp === true) {
 613 |       return frames[i];
 614 |     }
 615 |   }
 616 |   return undefined;
 617 | }
 618 | 
 619 | /**
 620 |  * Constants for performance issue formatting
 621 |  */
 622 | const MAX_SPANS_IN_TREE = 10;
 623 | 
 624 | /**
 625 |  * Safely parse a number from a string, returning a default if invalid
 626 |  */
 627 | function safeParseInt(value: unknown, defaultValue: number): number {
 628 |   if (typeof value === "number") return value;
 629 |   if (typeof value === "string") {
 630 |     const parsed = Number.parseInt(value, 10);
 631 |     return Number.isNaN(parsed) ? defaultValue : parsed;
 632 |   }
 633 |   return defaultValue;
 634 | }
 635 | 
 636 | /**
 637 |  * Simplified span structure for rendering span trees in performance issues.
 638 |  * This is a subset of the full span data focused on visualization needs.
 639 |  */
 640 | interface PerformanceSpan {
 641 |   span_id: string;
 642 |   op: string; // Operation type (e.g., "db.query", "http.client")
 643 |   description: string; // Human-readable description of what the span did
 644 |   duration: number; // Duration in milliseconds
 645 |   is_n1_query: boolean; // Whether this span is part of the N+1 pattern
 646 |   children: PerformanceSpan[];
 647 |   level: number; // Nesting level for tree rendering
 648 | }
 649 | 
 650 | interface RawSpan {
 651 |   span_id?: string;
 652 |   id?: string;
 653 |   op?: string;
 654 |   description?: string;
 655 |   timestamp?: number;
 656 |   start_timestamp?: number;
 657 |   duration?: number;
 658 | }
 659 | 
 660 | interface N1EvidenceData {
 661 |   parentSpan?: string;
 662 |   parentSpanIds?: string[];
 663 |   repeatingSpansCompact?: string[];
 664 |   repeatingSpans?: string[];
 665 |   numberRepeatingSpans?: string; // API returns string even though it's a number
 666 |   numPatternRepetitions?: number;
 667 |   offenderSpanIds?: string[];
 668 |   transactionName?: string;
 669 |   [key: string]: unknown;
 670 | }
 671 | 
 672 | interface SlowDbEvidenceData {
 673 |   parentSpan?: string;
 674 |   [key: string]: unknown;
 675 | }
 676 | 
 677 | function normalizeSpanId(value: unknown): string | undefined {
 678 |   if (typeof value === "string" && value) {
 679 |     return value;
 680 |   }
 681 |   return undefined;
 682 | }
 683 | 
 684 | function getSpanIdentifier(span: RawSpan): string | undefined {
 685 |   if (span.span_id !== undefined) {
 686 |     return normalizeSpanId(span.span_id);
 687 |   }
 688 |   if (span.id !== undefined) {
 689 |     return normalizeSpanId(span.id);
 690 |   }
 691 |   return undefined;
 692 | }
 693 | 
 694 | function getSpanDurationMs(span: RawSpan): number {
 695 |   if (
 696 |     typeof span.timestamp === "number" &&
 697 |     typeof span.start_timestamp === "number"
 698 |   ) {
 699 |     const deltaSeconds = span.timestamp - span.start_timestamp;
 700 |     if (Number.isFinite(deltaSeconds) && deltaSeconds >= 0) {
 701 |       return deltaSeconds * 1000;
 702 |     }
 703 |   }
 704 | 
 705 |   // Trace APIs expose `duration` in milliseconds. Preserve fractional values.
 706 |   if (typeof span.duration === "number" && Number.isFinite(span.duration)) {
 707 |     return span.duration >= 0 ? span.duration : 0;
 708 |   }
 709 | 
 710 |   return 0;
 711 | }
 712 | 
 713 | function normalizeIdArray(values: unknown): string[] {
 714 |   if (!Array.isArray(values)) {
 715 |     return [];
 716 |   }
 717 | 
 718 |   return values
 719 |     .map((value) => normalizeSpanId(value))
 720 |     .filter((value): value is string => value !== undefined);
 721 | }
 722 | 
 723 | function isValidSpanArray(value: unknown): value is RawSpan[] {
 724 |   return Array.isArray(value);
 725 | }
 726 | 
 727 | /**
 728 |  * Get the repeating span descriptions from evidence data.
 729 |  * Prefers repeatingSpansCompact (more concise) over repeatingSpans (verbose).
 730 |  */
 731 | function getRepeatingSpanLines(evidenceData: N1EvidenceData): string[] {
 732 |   // Try compact version first (preferred for display)
 733 |   if (
 734 |     Array.isArray(evidenceData.repeatingSpansCompact) &&
 735 |     evidenceData.repeatingSpansCompact.length > 0
 736 |   ) {
 737 |     return evidenceData.repeatingSpansCompact
 738 |       .map((s) => (typeof s === "string" ? s.trim() : ""))
 739 |       .filter((s): s is string => s.length > 0);
 740 |   }
 741 | 
 742 |   // Fall back to full version
 743 |   if (
 744 |     Array.isArray(evidenceData.repeatingSpans) &&
 745 |     evidenceData.repeatingSpans.length > 0
 746 |   ) {
 747 |     return evidenceData.repeatingSpans
 748 |       .map((s) => (typeof s === "string" ? s.trim() : ""))
 749 |       .filter((s): s is string => s.length > 0);
 750 |   }
 751 | 
 752 |   return [];
 753 | }
 754 | 
 755 | function isTraceSpan(node: unknown): node is TraceSpan {
 756 |   if (node === null || typeof node !== "object") {
 757 |     return false;
 758 |   }
 759 |   const candidate = node as { event_type?: unknown; event_id?: unknown };
 760 |   // Trace API returns spans with event_type: "span"
 761 |   return (
 762 |     candidate.event_type === "span" && typeof candidate.event_id === "string"
 763 |   );
 764 | }
 765 | 
 766 | function buildTraceSpanTree(
 767 |   trace: Trace,
 768 |   parentSpanIds: string[],
 769 |   offenderSpanIds: string[],
 770 |   maxSpans: number,
 771 | ): string[] {
 772 |   const offenderSet = new Set(offenderSpanIds);
 773 |   const spanMap = new Map<string, TraceSpan>();
 774 | 
 775 |   function indexSpan(span: TraceSpan): void {
 776 |     // Try to get span_id from additional_attributes, fall back to event_id
 777 |     const spanId =
 778 |       normalizeSpanId(span.additional_attributes?.span_id) || span.event_id;
 779 |     if (spanId && spanId.length > 0) {
 780 |       spanMap.set(spanId, span);
 781 |     }
 782 |     for (const child of span.children ?? []) {
 783 |       if (isTraceSpan(child)) {
 784 |         indexSpan(child);
 785 |       }
 786 |     }
 787 |   }
 788 | 
 789 |   for (const node of trace) {
 790 |     if (isTraceSpan(node)) {
 791 |       indexSpan(node);
 792 |     }
 793 |   }
 794 | 
 795 |   const roots: PerformanceSpan[] = [];
 796 |   const budget = { count: 0, limit: maxSpans };
 797 | 
 798 |   // First, try to find parent spans
 799 |   for (const parentId of parentSpanIds) {
 800 |     const span = spanMap.get(parentId);
 801 |     if (!span) {
 802 |       continue;
 803 |     }
 804 |     const perfSpan = convertTraceSpanToPerformanceSpan(
 805 |       span,
 806 |       offenderSet,
 807 |       budget,
 808 |       0,
 809 |     );
 810 |     if (perfSpan) {
 811 |       roots.push(perfSpan);
 812 |     }
 813 |     if (budget.count >= budget.limit) {
 814 |       break;
 815 |     }
 816 |   }
 817 | 
 818 |   // If no parent spans found, try to find offender spans directly
 819 |   if (roots.length === 0 && offenderSpanIds.length > 0) {
 820 |     for (const offenderId of offenderSpanIds) {
 821 |       const span = spanMap.get(offenderId);
 822 |       if (!span) {
 823 |         continue;
 824 |       }
 825 |       const perfSpan = convertTraceSpanToPerformanceSpan(
 826 |         span,
 827 |         offenderSet,
 828 |         budget,
 829 |         0,
 830 |       );
 831 |       if (perfSpan) {
 832 |         roots.push(perfSpan);
 833 |       }
 834 |       if (budget.count >= budget.limit) {
 835 |         break;
 836 |       }
 837 |     }
 838 |   }
 839 | 
 840 |   if (roots.length === 0) {
 841 |     return [];
 842 |   }
 843 | 
 844 |   return renderPerformanceSpanTree(roots);
 845 | }
 846 | 
 847 | function convertTraceSpanToPerformanceSpan(
 848 |   span: TraceSpan,
 849 |   offenderSet: Set<string>,
 850 |   budget: { count: number; limit: number },
 851 |   level: number,
 852 | ): PerformanceSpan | null {
 853 |   if (budget.count >= budget.limit) {
 854 |     return null;
 855 |   }
 856 | 
 857 |   budget.count += 1;
 858 | 
 859 |   // Get span ID from additional_attributes or fall back to event_id
 860 |   const spanId =
 861 |     normalizeSpanId(span.additional_attributes?.span_id) || span.event_id;
 862 | 
 863 |   const performanceSpan: PerformanceSpan = {
 864 |     span_id: spanId,
 865 |     op: span.op || "unknown",
 866 |     description: formatTraceSpanDescription(span),
 867 |     duration: getTraceSpanDurationMs(span),
 868 |     is_n1_query: offenderSet.has(spanId),
 869 |     children: [],
 870 |     level,
 871 |   };
 872 | 
 873 |   for (const child of span.children ?? []) {
 874 |     if (!isTraceSpan(child)) {
 875 |       continue;
 876 |     }
 877 |     if (budget.count >= budget.limit) {
 878 |       break;
 879 |     }
 880 |     const childSpan = convertTraceSpanToPerformanceSpan(
 881 |       child,
 882 |       offenderSet,
 883 |       budget,
 884 |       level + 1,
 885 |     );
 886 |     if (childSpan) {
 887 |       performanceSpan.children.push(childSpan);
 888 |     }
 889 |     if (budget.count >= budget.limit) {
 890 |       break;
 891 |     }
 892 |   }
 893 | 
 894 |   return performanceSpan;
 895 | }
 896 | 
 897 | function formatTraceSpanDescription(span: TraceSpan): string {
 898 |   if (span.name && span.name.trim().length > 0) {
 899 |     return span.name.trim();
 900 |   }
 901 |   if (span.description && span.description.trim().length > 0) {
 902 |     return span.description.trim();
 903 |   }
 904 |   if (span.op && span.op.trim().length > 0) {
 905 |     return span.op.trim();
 906 |   }
 907 |   return "unnamed";
 908 | }
 909 | 
 910 | function getTraceSpanDurationMs(span: TraceSpan): number {
 911 |   if (typeof span.duration === "number" && span.duration >= 0) {
 912 |     return span.duration;
 913 |   }
 914 |   if (
 915 |     typeof (span as { end_timestamp?: number }).end_timestamp === "number" &&
 916 |     typeof span.start_timestamp === "number"
 917 |   ) {
 918 |     const deltaSeconds =
 919 |       (span as { end_timestamp: number }).end_timestamp - span.start_timestamp;
 920 |     if (Number.isFinite(deltaSeconds) && deltaSeconds >= 0) {
 921 |       return deltaSeconds * 1000;
 922 |     }
 923 |   }
 924 |   return 0;
 925 | }
 926 | 
 927 | function buildOffenderSummaries(
 928 |   spans: RawSpan[],
 929 |   offenderSpanIds: string[],
 930 | ): string[] {
 931 |   if (offenderSpanIds.length === 0) {
 932 |     return [];
 933 |   }
 934 | 
 935 |   const spanMap = new Map<string, RawSpan>();
 936 |   for (const span of spans) {
 937 |     const identifier = getSpanIdentifier(span);
 938 |     if (identifier) {
 939 |       spanMap.set(identifier, span);
 940 |     }
 941 |   }
 942 | 
 943 |   const summaries: string[] = [];
 944 |   for (const offenderId of offenderSpanIds) {
 945 |     const span = spanMap.get(offenderId);
 946 |     if (span) {
 947 |       const description = span.description || span.op || `Span ${offenderId}`;
 948 |       const duration = getSpanDurationMs(span);
 949 |       const durationLabel = duration > 0 ? ` (${Math.round(duration)}ms)` : "";
 950 |       summaries.push(`${description}${durationLabel} [${offenderId}] [N+1]`);
 951 |     } else {
 952 |       summaries.push(`Span ${offenderId} [N+1]`);
 953 |     }
 954 |   }
 955 | 
 956 |   return summaries;
 957 | }
 958 | 
 959 | /**
 960 |  * Renders a hierarchical tree of performance spans using box-drawing characters.
 961 |  * Highlights N+1 queries with a special indicator.
 962 |  *
 963 |  * @param spans - Array of selected performance spans
 964 |  * @returns Array of formatted strings representing the tree
 965 |  */
 966 | function renderPerformanceSpanTree(spans: PerformanceSpan[]): string[] {
 967 |   const lines: string[] = [];
 968 | 
 969 |   function renderSpan(span: PerformanceSpan, prefix = "", isLast = true): void {
 970 |     const connector = prefix === "" ? "" : isLast ? "└─ " : "├─ ";
 971 | 
 972 |     const displayName = span.description?.trim() || span.op || "unnamed";
 973 |     const shortId = span.span_id ? span.span_id.substring(0, 8) : "unknown";
 974 |     const durationDisplay =
 975 |       span.duration > 0 ? `${Math.round(span.duration)}ms` : "unknown";
 976 | 
 977 |     const metadataParts: string[] = [shortId];
 978 |     if (span.op && span.op !== "default") {
 979 |       metadataParts.push(span.op);
 980 |     }
 981 |     metadataParts.push(durationDisplay);
 982 | 
 983 |     const line = `${prefix}${connector}${displayName} [${metadataParts.join(
 984 |       " · ",
 985 |     )}]${span.is_n1_query ? " [N+1]" : ""}`;
 986 |     lines.push(line);
 987 | 
 988 |     // Render children
 989 |     for (let i = 0; i < span.children.length; i++) {
 990 |       const child = span.children[i];
 991 |       const isLastChild = i === span.children.length - 1;
 992 |       const childPrefix = prefix + (isLast ? "   " : "│  ");
 993 |       renderSpan(child, childPrefix, isLastChild);
 994 |     }
 995 |   }
 996 | 
 997 |   for (let i = 0; i < spans.length; i++) {
 998 |     const span = spans[i];
 999 |     const isLastRoot = i === spans.length - 1;
1000 |     renderSpan(span, "", isLastRoot);
1001 |   }
1002 | 
1003 |   return lines;
1004 | }
1005 | 
1006 | function selectN1QuerySpans(
1007 |   spans: RawSpan[],
1008 |   evidence: N1EvidenceData,
1009 |   maxSpans = MAX_SPANS_IN_TREE,
1010 | ): PerformanceSpan[] {
1011 |   const selected: PerformanceSpan[] = [];
1012 |   let spanCount = 0;
1013 | 
1014 |   const offenderSpanIds = normalizeIdArray(evidence.offenderSpanIds);
1015 |   const parentSpanIds = normalizeIdArray(evidence.parentSpanIds);
1016 | 
1017 |   let parentSpan: PerformanceSpan | null = null;
1018 |   if (parentSpanIds.length > 0) {
1019 |     const parent = spans.find((span) => {
1020 |       const identifier = getSpanIdentifier(span);
1021 |       return identifier ? parentSpanIds.includes(identifier) : false;
1022 |     });
1023 | 
1024 |     if (parent) {
1025 |       parentSpan = {
1026 |         span_id: getSpanIdentifier(parent) ?? "unknown",
1027 |         op: parent.op || "unknown",
1028 |         description:
1029 |           parent.description || evidence.parentSpan || "Parent Operation",
1030 |         duration: getSpanDurationMs(parent),
1031 |         is_n1_query: false,
1032 |         children: [],
1033 |         level: 0,
1034 |       };
1035 |       selected.push(parentSpan);
1036 |       spanCount += 1;
1037 |     }
1038 |   }
1039 | 
1040 |   if (offenderSpanIds.length > 0) {
1041 |     const offenderSet = new Set(offenderSpanIds);
1042 |     const offenderSpans = spans
1043 |       .filter((span) => {
1044 |         const identifier = getSpanIdentifier(span);
1045 |         return identifier ? offenderSet.has(identifier) : false;
1046 |       })
1047 |       .slice(0, Math.max(0, maxSpans - spanCount));
1048 | 
1049 |     for (const span of offenderSpans) {
1050 |       const perfSpan: PerformanceSpan = {
1051 |         span_id: getSpanIdentifier(span) ?? "unknown",
1052 |         op: span.op || "db.query",
1053 |         description: span.description || "Database Query",
1054 |         duration: getSpanDurationMs(span),
1055 |         is_n1_query: true,
1056 |         children: [],
1057 |         level: parentSpan ? 1 : 0,
1058 |       };
1059 | 
1060 |       if (parentSpan) {
1061 |         parentSpan.children.push(perfSpan);
1062 |       } else {
1063 |         selected.push(perfSpan);
1064 |       }
1065 | 
1066 |       spanCount += 1;
1067 |       if (spanCount >= maxSpans) {
1068 |         break;
1069 |       }
1070 |     }
1071 |   }
1072 | 
1073 |   return selected;
1074 | }
1075 | 
1076 | /**
1077 |  * Known Sentry performance issue types that we handle.
1078 |  *
1079 |  * NOTE: We intentionally only implement formatters for high-value performance issues
1080 |  * that provide complex insights. Not all issue types need custom formatting - many
1081 |  * can rely on the generic evidenceDisplay fields that Sentry provides.
1082 |  *
1083 |  * Currently fully implemented:
1084 |  * - N+1 query detection (DB and API)
1085 |  *
1086 |  * Partially implemented:
1087 |  * - Slow DB queries (shows parent span only)
1088 |  *
1089 |  * Not implemented (lower priority):
1090 |  * - Asset-related issues (render blocking, uncompressed, large payloads)
1091 |  * - File I/O issues
1092 |  * - Consecutive queries
1093 |  */
1094 | const KNOWN_PERFORMANCE_ISSUE_TYPES = {
1095 |   N_PLUS_ONE_DB_QUERIES: "performance_n_plus_one_db_queries",
1096 |   N_PLUS_ONE_API_CALLS: "performance_n_plus_one_api_calls",
1097 |   SLOW_DB_QUERY: "performance_slow_db_query",
1098 |   RENDER_BLOCKING_ASSET: "performance_render_blocking_asset",
1099 |   CONSECUTIVE_DB_QUERIES: "performance_consecutive_db_queries",
1100 |   FILE_IO_MAIN_THREAD: "performance_file_io_main_thread",
1101 |   M_N_PLUS_ONE_DB_QUERIES: "performance_m_n_plus_one_db_queries",
1102 |   UNCOMPRESSED_ASSET: "performance_uncompressed_asset",
1103 |   LARGE_HTTP_PAYLOAD: "performance_large_http_payload",
1104 | } as const;
1105 | 
1106 | /**
1107 |  * Map numeric occurrence types to issue types (from Sentry's codebase).
1108 |  *
1109 |  * Sentry uses numeric type IDs internally in the occurrence data structure,
1110 |  * but string issue types in the UI and other APIs. This mapping converts
1111 |  * between them.
1112 |  *
1113 |  * Source: sentry/static/app/types/group.tsx in Sentry's codebase
1114 |  * Range: 1xxx = transaction-based performance issues
1115 |  *        2xxx = profile-based performance issues
1116 |  */
1117 | const OCCURRENCE_TYPE_TO_ISSUE_TYPE: Record<number, string> = {
1118 |   1001: KNOWN_PERFORMANCE_ISSUE_TYPES.SLOW_DB_QUERY,
1119 |   1004: KNOWN_PERFORMANCE_ISSUE_TYPES.RENDER_BLOCKING_ASSET,
1120 |   1006: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES,
1121 |   1906: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES, // Alternative ID for N+1 DB
1122 |   1007: KNOWN_PERFORMANCE_ISSUE_TYPES.CONSECUTIVE_DB_QUERIES,
1123 |   1008: KNOWN_PERFORMANCE_ISSUE_TYPES.FILE_IO_MAIN_THREAD,
1124 |   1009: "performance_consecutive_http",
1125 |   1010: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_API_CALLS,
1126 |   1910: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_API_CALLS, // Alternative ID for N+1 API
1127 |   1012: KNOWN_PERFORMANCE_ISSUE_TYPES.UNCOMPRESSED_ASSET,
1128 |   1013: "performance_db_main_thread",
1129 |   1015: KNOWN_PERFORMANCE_ISSUE_TYPES.LARGE_HTTP_PAYLOAD,
1130 |   1016: "performance_http_overhead",
1131 | };
1132 | 
1133 | // Type alias currently unused but kept for potential future type safety
1134 | // type PerformanceIssueType = typeof KNOWN_PERFORMANCE_ISSUE_TYPES[keyof typeof KNOWN_PERFORMANCE_ISSUE_TYPES];
1135 | 
1136 | /**
1137 |  * Formats N+1 query issue evidence data.
1138 |  *
1139 |  * N+1 queries are a common performance anti-pattern where code executes
1140 |  * 1 query to get a list of items, then N additional queries (one per item)
1141 |  * instead of using a single JOIN or batch query.
1142 |  *
1143 |  * Evidence fields we use:
1144 |  * - parentSpan: The operation that triggered the N+1 queries
1145 |  * - repeatingSpansCompact/repeatingSpans: The query pattern being repeated
1146 |  * - numberRepeatingSpans: How many times the query was executed
1147 |  * - offenderSpanIds: IDs of the actual span instances
1148 |  * - parentSpanIds: IDs of parent spans for tree visualization
1149 |  */
1150 | function formatN1QueryEvidence(
1151 |   evidenceData: N1EvidenceData,
1152 |   spansData: unknown,
1153 |   performanceTrace?: Trace,
1154 | ): string {
1155 |   const parts: string[] = [];
1156 | 
1157 |   // Format parent span info if available
1158 |   if (evidenceData.parentSpan) {
1159 |     parts.push("**Parent Operation:**");
1160 |     parts.push(`${evidenceData.parentSpan}`);
1161 |     parts.push("");
1162 |   }
1163 | 
1164 |   // Format repeating spans (the N+1 queries)
1165 |   const repeatingLines = getRepeatingSpanLines(evidenceData);
1166 |   if (repeatingLines.length > 0) {
1167 |     parts.push("### Repeated Database Queries");
1168 |     parts.push("");
1169 | 
1170 |     const queryCount = evidenceData.numberRepeatingSpans
1171 |       ? safeParseInt(evidenceData.numberRepeatingSpans, 0)
1172 |       : evidenceData.numPatternRepetitions ||
1173 |         evidenceData.offenderSpanIds?.length ||
1174 |         0;
1175 | 
1176 |     if (queryCount > 0) {
1177 |       parts.push(`**Query executed ${queryCount} times:**`);
1178 |     }
1179 | 
1180 |     // Show the query pattern - if single line, render as SQL block; if multiple, as list
1181 |     if (repeatingLines.length === 1) {
1182 |       parts.push("```sql");
1183 |       parts.push(repeatingLines[0]);
1184 |       parts.push("```");
1185 |       parts.push("");
1186 |     } else {
1187 |       parts.push("**Repeated operations:**");
1188 |       for (const line of repeatingLines) {
1189 |         parts.push(`- ${line}`);
1190 |       }
1191 |       parts.push("");
1192 |     }
1193 |   }
1194 | 
1195 |   const parentSpanIds = normalizeIdArray(evidenceData.parentSpanIds);
1196 |   const offenderSpanIds = normalizeIdArray(evidenceData.offenderSpanIds);
1197 | 
1198 |   const traceLines = performanceTrace
1199 |     ? buildTraceSpanTree(
1200 |         performanceTrace,
1201 |         parentSpanIds,
1202 |         offenderSpanIds,
1203 |         MAX_SPANS_IN_TREE,
1204 |       )
1205 |     : [];
1206 | 
1207 |   if (traceLines.length > 0) {
1208 |     parts.push(`### Span Tree (Limited to ${MAX_SPANS_IN_TREE} spans)`);
1209 |     parts.push("");
1210 |     parts.push("```");
1211 |     parts.push(...traceLines);
1212 |     parts.push("```");
1213 |     parts.push("");
1214 |   } else {
1215 |     const spanTree = isValidSpanArray(spansData)
1216 |       ? selectN1QuerySpans(spansData, evidenceData, MAX_SPANS_IN_TREE)
1217 |       : [];
1218 | 
1219 |     if (spanTree.length > 0) {
1220 |       parts.push(`### Span Tree (Limited to ${MAX_SPANS_IN_TREE} spans)`);
1221 |       parts.push("");
1222 |       parts.push("```");
1223 |       parts.push(...renderPerformanceSpanTree(spanTree));
1224 |       parts.push("```");
1225 |       parts.push("");
1226 |     } else if (isValidSpanArray(spansData)) {
1227 |       // Only show offender summaries if we have spans data but couldn't build a tree
1228 |       const offenderSummaries = buildOffenderSummaries(
1229 |         spansData as RawSpan[],
1230 |         offenderSpanIds,
1231 |       );
1232 | 
1233 |       if (offenderSummaries.length > 0) {
1234 |         parts.push("### Offending Spans");
1235 |         parts.push("");
1236 |         for (const summary of offenderSummaries) {
1237 |           parts.push(`- ${summary}`);
1238 |         }
1239 |       }
1240 |     }
1241 |   }
1242 | 
1243 |   return parts.join("\n");
1244 | }
1245 | 
1246 | /**
1247 |  * Formats slow DB query issue evidence data.
1248 |  *
1249 |  * Currently only partially implemented - shows parent span information.
1250 |  * Full implementation would show query duration, explain plan, etc.
1251 |  *
1252 |  * This is lower priority as the generic evidenceDisplay fields usually
1253 |  * provide sufficient information for slow query issues.
1254 |  */
1255 | function formatSlowDbQueryEvidence(
1256 |   evidenceData: SlowDbEvidenceData,
1257 |   spansData: unknown,
1258 | ): string {
1259 |   const parts: string[] = [];
1260 | 
1261 |   // Show parent span if available (generic field that applies to slow queries)
1262 |   if (evidenceData.parentSpan) {
1263 |     parts.push("**Parent Operation:**");
1264 |     parts.push(`${evidenceData.parentSpan}`);
1265 |     parts.push("");
1266 |   }
1267 | 
1268 |   // TODO: Implement slow query specific fields when we know the structure
1269 |   // Potential fields: query duration, database name, query plan
1270 |   console.warn(
1271 |     "[formatSlowDbQueryEvidence] Evidence data rendering not yet fully implemented",
1272 |   );
1273 | 
1274 |   return parts.join("\n");
1275 | }
1276 | 
1277 | /**
1278 |  * Formats performance issue details from transaction events based on the issue type.
1279 |  *
1280 |  * This is the main dispatcher for performance issue formatting. It:
1281 |  * 1. Detects the issue type from occurrence data (numeric or string)
1282 |  * 2. Calls the appropriate type-specific formatter if implemented
1283 |  * 3. Falls back to generic evidenceDisplay fields for unimplemented types
1284 |  * 4. Provides span analysis fallback for events without occurrence data
1285 |  *
1286 |  * The occurrence data structure comes from Sentry's performance issue detection
1287 |  * and contains evidence about what triggered the issue.
1288 |  *
1289 |  * @param event - The transaction event containing performance issue data
1290 |  * @param spansData - The spans data from the event entries
1291 |  * @returns Formatted markdown string with performance issue details
1292 |  */
1293 | function formatPerformanceIssueOutput(
1294 |   event: Event,
1295 |   spansData: unknown,
1296 |   options?: {
1297 |     performanceTrace?: Trace;
1298 |   },
1299 | ): string {
1300 |   const parts: string[] = [];
1301 | 
1302 |   // Check if we have occurrence data
1303 |   const occurrence = (event as any).occurrence;
1304 |   if (!occurrence) {
1305 |     return "";
1306 |   }
1307 | 
1308 |   // Get issue type - occurrence.type is numeric, issueType may be a string
1309 |   let issueType: string | undefined;
1310 |   if (typeof occurrence.type === "number") {
1311 |     issueType = OCCURRENCE_TYPE_TO_ISSUE_TYPE[occurrence.type];
1312 |   } else {
1313 |     issueType = occurrence.issueType || occurrence.type;
1314 |   }
1315 | 
1316 |   const evidenceData = occurrence.evidenceData;
1317 | 
1318 |   // Process evidence data based on known performance issue types
1319 |   if (evidenceData) {
1320 |     switch (issueType) {
1321 |       case KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES:
1322 |       case KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_API_CALLS:
1323 |       case KNOWN_PERFORMANCE_ISSUE_TYPES.M_N_PLUS_ONE_DB_QUERIES: {
1324 |         const result = formatN1QueryEvidence(
1325 |           evidenceData,
1326 |           spansData,
1327 |           options?.performanceTrace,
1328 |         );
1329 |         if (result) parts.push(result);
1330 |         break;
1331 |       }
1332 | 
1333 |       case KNOWN_PERFORMANCE_ISSUE_TYPES.SLOW_DB_QUERY: {
1334 |         const result = formatSlowDbQueryEvidence(evidenceData, spansData);
1335 |         if (result) parts.push(result);
1336 |         break;
1337 |       }
1338 | 
1339 |       default:
1340 |         // We don't implement formatters for all performance issue types.
1341 |         // Many lower-priority issues (consecutive queries, asset issues, file I/O)
1342 |         // work fine with just the generic evidenceDisplay fields below.
1343 |         // Only high-value, complex issues like N+1 queries need custom formatting.
1344 |         if (issueType) {
1345 |           console.warn(
1346 |             `[formatPerformanceIssueOutput] No custom formatter for issue type: ${issueType}`,
1347 |           );
1348 |         }
1349 |       // Fall through to show generic evidence display below
1350 |     }
1351 |   }
1352 | 
1353 |   // Show transaction name if available for any performance issue (generic field)
1354 |   if (evidenceData?.transactionName) {
1355 |     parts.push("**Transaction:**");
1356 |     parts.push(`${evidenceData.transactionName}`);
1357 |     parts.push("");
1358 |   }
1359 | 
1360 |   // Always show evidence display if available (this is generic and doesn't require type knowledge)
1361 |   if (occurrence.evidenceDisplay?.length > 0) {
1362 |     for (const display of occurrence.evidenceDisplay) {
1363 |       if (display.important) {
1364 |         parts.push(`**${display.name}:**`);
1365 |         parts.push(`${display.value}`);
1366 |         parts.push("");
1367 |       }
1368 |     }
1369 |   }
1370 | 
1371 |   return parts.length > 0 ? `${parts.join("\n")}\n` : "";
1372 | }
1373 | 
1374 | function formatTags(tags: z.infer<typeof EventSchema>["tags"]) {
1375 |   if (!tags || tags.length === 0) {
1376 |     return "";
1377 |   }
1378 |   return `### Tags\n\n${tags
1379 |     .map((tag) => `**${tag.key}**: ${tag.value}`)
1380 |     .join("\n")}\n\n`;
1381 | }
1382 | 
1383 | function formatContext(context: z.infer<typeof EventSchema>["context"]) {
1384 |   if (!context || Object.keys(context).length === 0) {
1385 |     return "";
1386 |   }
1387 |   return `### Extra Data\n\nAdditional data attached to this event.\n\n${Object.entries(
1388 |     context,
1389 |   )
1390 |     .map(([key, value]) => {
1391 |       return `**${key}**: ${JSON.stringify(value, undefined, 2)}`;
1392 |     })
1393 |     .join("\n")}\n\n`;
1394 | }
1395 | 
1396 | function formatContexts(contexts: z.infer<typeof EventSchema>["contexts"]) {
1397 |   if (!contexts || Object.keys(contexts).length === 0) {
1398 |     return "";
1399 |   }
1400 |   return `### Additional Context\n\nThese are additional context provided by the user when they're instrumenting their application.\n\n${Object.entries(
1401 |     contexts,
1402 |   )
1403 |     .map(
1404 |       ([name, data]) =>
1405 |         `**${name}**\n${Object.entries(data)
1406 |           .filter(([key, _]) => key !== "type")
1407 |           .map(([key, value]) => {
1408 |             return `${key}: ${JSON.stringify(value, undefined, 2)}`;
1409 |           })
1410 |           .join("\n")}`,
1411 |     )
1412 |     .join("\n\n")}\n\n`;
1413 | }
1414 | 
1415 | /**
1416 |  * Formats a brief Seer analysis summary for inclusion in issue details.
1417 |  * Shows current status and high-level insights, prompting to use analyze_issue_with_seer for full details.
1418 |  *
1419 |  * @param autofixState - The autofix state containing Seer analysis data
1420 |  * @param organizationSlug - The organization slug for the issue
1421 |  * @param issueId - The issue ID (shortId)
1422 |  * @returns Formatted markdown string with Seer summary, or empty string if no analysis exists
1423 |  */
1424 | function formatSeerSummary(
1425 |   autofixState: AutofixRunState | undefined,
1426 |   organizationSlug: string,
1427 |   issueId: string,
1428 | ): string {
1429 |   if (!autofixState || !autofixState.autofix) {
1430 |     return "";
1431 |   }
1432 | 
1433 |   const { autofix } = autofixState;
1434 |   const parts: string[] = [];
1435 | 
1436 |   parts.push("## Seer Analysis");
1437 |   parts.push("");
1438 | 
1439 |   // Show status first
1440 |   const statusDisplay = getStatusDisplayName(autofix.status);
1441 |   if (!isTerminalStatus(autofix.status)) {
1442 |     parts.push(`**Status:** ${statusDisplay}`);
1443 |     parts.push("");
1444 |   }
1445 | 
1446 |   // Show summary of what we have so far
1447 |   if (autofix.steps.length > 0) {
1448 |     const completedSteps = autofix.steps.filter(
1449 |       (step) => step.status === "COMPLETED",
1450 |     );
1451 | 
1452 |     // Find the solution step if available
1453 |     const solutionStep = completedSteps.find(
1454 |       (step) => step.type === "solution",
1455 |     );
1456 | 
1457 |     if (solutionStep) {
1458 |       // For solution steps, use the description directly
1459 |       const solutionDescription = solutionStep.description;
1460 |       if (
1461 |         solutionDescription &&
1462 |         typeof solutionDescription === "string" &&
1463 |         solutionDescription.trim()
1464 |       ) {
1465 |         parts.push("**Summary:**");
1466 |         parts.push(solutionDescription.trim());
1467 |       } else {
1468 |         // Fallback to extracting from output if no description
1469 |         const solutionOutput = getOutputForAutofixStep(solutionStep);
1470 |         const lines = solutionOutput.split("\n");
1471 |         const firstParagraph = lines.find(
1472 |           (line) =>
1473 |             line.trim().length > 50 &&
1474 |             !line.startsWith("#") &&
1475 |             !line.startsWith("*"),
1476 |         );
1477 |         if (firstParagraph) {
1478 |           parts.push("**Summary:**");
1479 |           parts.push(firstParagraph.trim());
1480 |         }
1481 |       }
1482 |     } else if (completedSteps.length > 0) {
1483 |       // Show what steps have been completed so far
1484 |       const rootCauseStep = completedSteps.find(
1485 |         (step) => step.type === "root_cause_analysis",
1486 |       );
1487 | 
1488 |       if (rootCauseStep) {
1489 |         const typedStep = rootCauseStep as z.infer<
1490 |           typeof AutofixRunStepRootCauseAnalysisSchema
1491 |         >;
1492 |         if (
1493 |           typedStep.causes &&
1494 |           typedStep.causes.length > 0 &&
1495 |           typedStep.causes[0].description
1496 |         ) {
1497 |           parts.push("**Root Cause Identified:**");
1498 |           parts.push(typedStep.causes[0].description.trim());
1499 |         }
1500 |       } else {
1501 |         // Show generic progress
1502 |         parts.push(
1503 |           `**Progress:** ${completedSteps.length} of ${autofix.steps.length} steps completed`,
1504 |         );
1505 |       }
1506 |     }
1507 |   } else {
1508 |     // No steps yet - check for terminal states first
1509 |     if (isTerminalStatus(autofix.status)) {
1510 |       if (autofix.status === "FAILED" || autofix.status === "ERROR") {
1511 |         parts.push("**Status:** Analysis failed.");
1512 |       } else if (autofix.status === "CANCELLED") {
1513 |         parts.push("**Status:** Analysis was cancelled.");
1514 |       } else if (
1515 |         autofix.status === "NEED_MORE_INFORMATION" ||
1516 |         autofix.status === "WAITING_FOR_USER_RESPONSE"
1517 |       ) {
1518 |         parts.push(
1519 |           "**Status:** Analysis paused - additional information needed.",
1520 |         );
1521 |       }
1522 |     } else {
1523 |       parts.push("Analysis has started but no results yet.");
1524 |     }
1525 |   }
1526 | 
1527 |   // Add specific messages for terminal states when steps exist
1528 |   if (autofix.steps.length > 0 && isTerminalStatus(autofix.status)) {
1529 |     if (autofix.status === "FAILED" || autofix.status === "ERROR") {
1530 |       parts.push("");
1531 |       parts.push("**Status:** Analysis failed.");
1532 |     } else if (autofix.status === "CANCELLED") {
1533 |       parts.push("");
1534 |       parts.push("**Status:** Analysis was cancelled.");
1535 |     } else if (
1536 |       autofix.status === "NEED_MORE_INFORMATION" ||
1537 |       autofix.status === "WAITING_FOR_USER_RESPONSE"
1538 |     ) {
1539 |       parts.push("");
1540 |       parts.push(
1541 |         "**Status:** Analysis paused - additional information needed.",
1542 |       );
1543 |     }
1544 |   }
1545 | 
1546 |   // Always suggest using analyze_issue_with_seer for more details
1547 |   parts.push("");
1548 |   parts.push(
1549 |     `**Note:** For detailed root cause analysis and solutions, call \`analyze_issue_with_seer(organizationSlug='${organizationSlug}', issueId='${issueId}')\``,
1550 |   );
1551 | 
1552 |   return `${parts.join("\n")}\n\n`;
1553 | }
1554 | 
1555 | /**
1556 |  * Formats a Sentry issue with its latest event into comprehensive markdown output.
1557 |  * Includes issue metadata, event details, and usage instructions.
1558 |  *
1559 |  * @param params - Object containing organization slug, issue, event, and API service
1560 |  * @returns Formatted markdown string with complete issue information
1561 |  */
1562 | export function formatIssueOutput({
1563 |   organizationSlug,
1564 |   issue,
1565 |   event,
1566 |   apiService,
1567 |   autofixState,
1568 |   performanceTrace,
1569 | }: {
1570 |   organizationSlug: string;
1571 |   issue: Issue;
1572 |   event: Event;
1573 |   apiService: SentryApiService;
1574 |   autofixState?: AutofixRunState;
1575 |   performanceTrace?: Trace;
1576 | }) {
1577 |   let output = `# Issue ${issue.shortId} in **${organizationSlug}**\n\n`;
1578 | 
1579 |   // Check if this is a performance issue based on issueCategory or issueType
1580 |   // Performance issues can have various categories like 'db_query' but issueType starts with 'performance_'
1581 |   const isPerformanceIssue =
1582 |     issue.issueType?.startsWith("performance_") ||
1583 |     issue.issueCategory === "performance";
1584 | 
1585 |   if (isPerformanceIssue && issue.metadata) {
1586 |     // For performance issues, use metadata for better context
1587 |     const issueTitle = issue.metadata.title || issue.title;
1588 |     output += `**Description**: ${issueTitle}\n`;
1589 | 
1590 |     if (issue.metadata.location) {
1591 |       output += `**Location**: ${issue.metadata.location}\n`;
1592 |     }
1593 |     if (issue.metadata.value) {
1594 |       output += `**Query Pattern**: \`${issue.metadata.value}\`\n`;
1595 |     }
1596 |   } else {
1597 |     // For regular errors and other issues
1598 |     output += `**Description**: ${issue.title}\n`;
1599 |     output += `**Culprit**: ${issue.culprit}\n`;
1600 |   }
1601 | 
1602 |   output += `**First Seen**: ${new Date(issue.firstSeen).toISOString()}\n`;
1603 |   output += `**Last Seen**: ${new Date(issue.lastSeen).toISOString()}\n`;
1604 |   output += `**Occurrences**: ${issue.count}\n`;
1605 |   output += `**Users Impacted**: ${issue.userCount}\n`;
1606 |   output += `**Status**: ${issue.status}\n`;
1607 |   output += `**Platform**: ${issue.platform}\n`;
1608 |   output += `**Project**: ${issue.project.name}\n`;
1609 |   output += `**URL**: ${apiService.getIssueUrl(organizationSlug, issue.shortId)}\n`;
1610 |   output += "\n";
1611 |   output += "## Event Details\n\n";
1612 |   output += `**Event ID**: ${event.id}\n`;
1613 |   // "default" type represents error events without exception data
1614 |   if (event.type === "error" || event.type === "default") {
1615 |     output += `**Occurred At**: ${new Date((event as z.infer<typeof ErrorEventSchema>).dateCreated).toISOString()}\n`;
1616 |   }
1617 |   if (event.message) {
1618 |     output += `**Message**:\n${event.message}\n`;
1619 |   }
1620 |   output += "\n";
1621 |   output += formatEventOutput(event, { performanceTrace });
1622 | 
1623 |   // Add Seer context if available
1624 |   if (autofixState) {
1625 |     output += formatSeerSummary(autofixState, organizationSlug, issue.shortId);
1626 |   }
1627 | 
1628 |   output += "# Using this information\n\n";
1629 |   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`;
1630 |   output +=
1631 |     "- The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code.\n";
1632 |   return output;
1633 | }
1634 | 
```

--------------------------------------------------------------------------------
/packages/mcp-server/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 | 
```
Page 13/15FirstPrevNextLast