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 |
```