From fdd8e613b40299b8e4cea84719e00b1648d20a92 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 19:19:39 +0000 Subject: [PATCH 01/23] analytics: add Sentry Error Intelligence dashboard --- .../analytics/app/pages/adhoc/registry.ts | 13 +- .../adhoc/sentry-errors/ErrorGroupsPanel.tsx | 186 +++++ .../adhoc/sentry-errors/IssueSparkline.tsx | 46 ++ .../app/pages/adhoc/sentry-errors/index.tsx | 732 ++++++++++++++++++ 4 files changed, 976 insertions(+), 1 deletion(-) create mode 100644 templates/analytics/app/pages/adhoc/sentry-errors/ErrorGroupsPanel.tsx create mode 100644 templates/analytics/app/pages/adhoc/sentry-errors/IssueSparkline.tsx create mode 100644 templates/analytics/app/pages/adhoc/sentry-errors/index.tsx diff --git a/templates/analytics/app/pages/adhoc/registry.ts b/templates/analytics/app/pages/adhoc/registry.ts index c97381cb7f..1b56150f6b 100644 --- a/templates/analytics/app/pages/adhoc/registry.ts +++ b/templates/analytics/app/pages/adhoc/registry.ts @@ -63,7 +63,17 @@ export interface DashboardMeta { // Add new dashboards here. Each entry needs a matching file in this directory. // REQUIRED FIELDS: id, name, author, lastUpdated -export const dashboards: DashboardMeta[] = []; +export const dashboards: DashboardMeta[] = [ + { + id: "sentry-errors", + name: "Sentry Error Intelligence", + description: + "Top 10 errors, escalating issues, related error groups, and project breakdown", + author: "Liam DeBeasi", + lastUpdated: "2025-05-12", + dateCreated: "2025-05-12", + }, +]; const HIDDEN_KEY = "hidden-dashboards"; @@ -143,6 +153,7 @@ export const dashboardComponents: Record< > = { explorer: lazy(() => import("./explorer")), "explorer-dashboard": lazy(() => import("./explorer-dashboard")), + "sentry-errors": lazy(() => import("./sentry-errors")), }; // Validate all dashboards at module load time diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/ErrorGroupsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/ErrorGroupsPanel.tsx new file mode 100644 index 0000000000..3182c197d8 --- /dev/null +++ b/templates/analytics/app/pages/adhoc/sentry-errors/ErrorGroupsPanel.tsx @@ -0,0 +1,186 @@ +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + IconChevronDown, + IconChevronRight, + IconExternalLink, +} from "@tabler/icons-react"; +import type { SentryIssue } from "./index"; + +interface ErrorGroup { + type: string; + issues: SentryIssue[]; + totalEvents: number; + totalUsers: number; + worstLevel: string; +} + +function levelOrder(level: string): number { + return { fatal: 0, error: 1, warning: 2, info: 3, debug: 4 }[level] ?? 5; +} + +function levelColor(level: string): string { + switch (level) { + case "fatal": + return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800"; + case "error": + return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400 border-orange-200 dark:border-orange-800"; + case "warning": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800"; + default: + return "bg-muted text-muted-foreground border-border"; + } +} + +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +function timeAgo(date: string): string { + const ms = Date.now() - new Date(date).getTime(); + const mins = Math.floor(ms / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +function GroupRow({ group }: { group: ErrorGroup }) { + const [expanded, setExpanded] = useState(false); + return ( +
+ + + {expanded && ( +
+ {group.issues.map((issue) => ( + + + {issue.level} + +
+

+ {issue.metadata.value ?? issue.title} +

+

+ {issue.project.name} · {timeAgo(issue.lastSeen)} ·{" "} + {formatCount(parseInt(issue.count, 10))} events +

+
+ +
+ ))} +
+ )} +
+ ); +} + +interface ErrorGroupsPanelProps { + issues: SentryIssue[]; + isLoading: boolean; +} + +export function ErrorGroupsPanel({ issues, isLoading }: ErrorGroupsPanelProps) { + const groups = useMemo((): ErrorGroup[] => { + const map = new Map(); + for (const issue of issues) { + const type = issue.metadata.type ?? issue.type ?? "Unknown"; + const arr = map.get(type) ?? []; + arr.push(issue); + map.set(type, arr); + } + return [...map.entries()] + .map(([type, issueList]) => { + const totalEvents = issueList.reduce( + (s, i) => s + parseInt(i.count, 10), + 0, + ); + const totalUsers = issueList.reduce((s, i) => s + i.userCount, 0); + const worstLevel = issueList.reduce((worst, i) => { + return levelOrder(i.level) < levelOrder(worst) ? i.level : worst; + }, "debug"); + return { type, issues: issueList, totalEvents, totalUsers, worstLevel }; + }) + .sort((a, b) => b.totalEvents - a.totalEvents); + }, [issues]); + + if (isLoading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+ ); + } + + if (!groups.length) { + return ( +
+

No error groups found

+
+ ); + } + + return ( + +
+ {groups.map((group) => ( + + ))} +
+
+ ); +} diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/IssueSparkline.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/IssueSparkline.tsx new file mode 100644 index 0000000000..d1285d3119 --- /dev/null +++ b/templates/analytics/app/pages/adhoc/sentry-errors/IssueSparkline.tsx @@ -0,0 +1,46 @@ +interface IssueSparklineProps { + data: number[][]; + escalating?: boolean; +} + +export function IssueSparkline({ data, escalating }: IssueSparklineProps) { + if (!data.length) return null; + + const values = data.map(([, v]) => v); + const max = Math.max(...values, 1); + const height = 24; + const width = 120; + const barW = Math.max(2, Math.floor(width / values.length) - 1); + const gap = Math.max(1, Math.floor(width / values.length) - barW); + + return ( + + ); +} diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx new file mode 100644 index 0000000000..91400c1928 --- /dev/null +++ b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx @@ -0,0 +1,732 @@ +import { useState, useMemo } from "react"; +import { useActionQuery } from "@agent-native/core/client"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { + IconBug, + IconUsers, + IconAlertTriangle, + IconTrendingUp, + IconChevronRight, + IconChevronDown, + IconExternalLink, + IconRefresh, + IconClock, + IconCopy, + IconCircleCheck, + IconLink, +} from "@tabler/icons-react"; +import { IssueSparkline } from "./IssueSparkline"; +import { ErrorGroupsPanel } from "./ErrorGroupsPanel"; + +// ---- Types ------------------------------------------------------------------ + +export interface SentryIssue { + id: string; + shortId: string; + title: string; + culprit: string; + permalink: string; + level: "fatal" | "error" | "warning" | "info" | "debug"; + status: string; + platform: string; + project: { id: string; name: string; slug: string }; + type: string; + metadata: { + type?: string; + value?: string; + filename?: string; + function?: string; + }; + count: string; + userCount: number; + firstSeen: string; + lastSeen: string; + stats?: Record; +} + +interface SentryProject { + id: string; + slug: string; + name: string; + platform: string | null; +} + +type StatsPeriod = "24h" | "7d" | "14d" | "30d"; + +// ---- Helpers ---------------------------------------------------------------- + +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +function timeAgo(date: string): string { + const ms = Date.now() - new Date(date).getTime(); + const mins = Math.floor(ms / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +function isEscalating(issue: SentryIssue): boolean { + const stats = issue.stats?.["24h"]; + if (!stats || stats.length < 4) return false; + const recent = stats.slice(-4).reduce((s, [, v]) => s + v, 0); + const earlier = stats.slice(-8, -4).reduce((s, [, v]) => s + v, 0); + return recent > earlier * 1.5 && recent > 0; +} + +function levelColor(level: string): string { + switch (level) { + case "fatal": + return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800"; + case "error": + return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400 border-orange-200 dark:border-orange-800"; + case "warning": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800"; + default: + return "bg-muted text-muted-foreground border-border"; + } +} + +// ---- Sub-components --------------------------------------------------------- + +function StatCard({ + label, + value, + icon: Icon, + accent, + isLoading, +}: { + label: string; + value: string | number; + icon: React.ComponentType<{ className?: string }>; + accent?: string; + isLoading?: boolean; +}) { + return ( + + +
+
+

{label}

+ {isLoading ? ( + + ) : ( +

+ {value} +

+ )} +
+
+ +
+
+
+
+ ); +} + +function IssueRow({ + issue, + rank, + isSelected, + escalating, + onSelect, +}: { + issue: SentryIssue; + rank: number; + isSelected: boolean; + escalating: boolean; + onSelect: () => void; +}) { + const count = parseInt(issue.count, 10); + return ( + + ); +} + +function IssueDetail({ issue }: { issue: SentryIssue }) { + const count = parseInt(issue.count, 10); + return ( +
+
+
+

Total events

+

{formatCount(count)}

+
+
+

Affected users

+

{issue.userCount}

+
+
+

First seen

+

+ {timeAgo(issue.firstSeen)} +

+
+
+

Last seen

+

+ {timeAgo(issue.lastSeen)} +

+
+
+ + {issue.culprit && ( +
+

Culprit

+ + {issue.culprit} + +
+ )} + + {issue.metadata.filename && ( +
+

Location

+ + {issue.metadata.filename} + {issue.metadata.function && ` · ${issue.metadata.function}`} + +
+ )} + +
+ e.stopPropagation()} + className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline" + > + + Open in Sentry + + · + + {issue.shortId} + +
+
+ ); +} + +function EmptyState({ message }: { message: string }) { + return ( +
+
+ +
+

{message}

+
+ ); +} + +function ErrorState({ message }: { message: string }) { + return ( +
+
+ +
+

Could not load Sentry data

+

{message}

+

+ Check Settings → Data sources and + ensure SENTRY_AUTH_TOKEN is + configured. +

+
+ ); +} + +function IssueListSkeleton() { + return ( +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ +
+ + + +
+ +
+ ))} +
+ ); +} + +// ---- Main Component --------------------------------------------------------- + +export default function SentryErrorsDashboard() { + const [period, setPeriod] = useState("7d"); + const [selectedIssueId, setSelectedIssueId] = useState(null); + const [activeTab, setActiveTab] = useState<"top10" | "escalating" | "groups">( + "top10", + ); + + const issuesQuery = useActionQuery("sentry", { + mode: "issues", + statsPeriod: period, + query: "is:unresolved", + }); + + const statsQuery = useActionQuery("sentry", { + mode: "stats", + statsPeriod: period, + }); + + const issues: SentryIssue[] = useMemo( + () => (issuesQuery.data as { issues?: SentryIssue[] })?.issues ?? [], + [issuesQuery.data], + ); + + const top10 = useMemo( + () => + [...issues] + .sort((a, b) => parseInt(b.count, 10) - parseInt(a.count, 10)) + .slice(0, 10), + [issues], + ); + + const escalating = useMemo( + () => issues.filter(isEscalating).slice(0, 10), + [issues], + ); + + const totalEvents = useMemo( + () => issues.reduce((s, i) => s + parseInt(i.count, 10), 0), + [issues], + ); + + const totalUsers = useMemo( + () => issues.reduce((s, i) => s + i.userCount, 0), + [issues], + ); + + const displayedIssues = + activeTab === "top10" + ? top10 + : activeTab === "escalating" + ? escalating + : []; + + const isLoading = issuesQuery.isLoading; + const error = issuesQuery.error as Error | null; + + function toggleIssue(id: string) { + setSelectedIssueId((prev) => (prev === id ? null : id)); + } + + return ( +
+ {/* Header */} +
+
+

+ + Sentry Error Intelligence +

+

+ Top errors, escalating issues, and related error groups +

+
+
+ setPeriod(v as StatsPeriod)} + > + + + 24h + + + 7d + + + 14d + + + 30d + + + + +
+
+ + {/* Stat Cards */} +
+ + + + 0 + ? "text-rose-600 dark:text-rose-400" + : undefined + } + isLoading={isLoading} + /> +
+ + {/* Main Content */} + {error ? ( + + + + ) : ( +
+ {/* Issue List */} + + + + setActiveTab(v as "top10" | "escalating" | "groups") + } + > + + + Top 10 Errors + + + + Escalating + {escalating.length > 0 && !isLoading && ( + + {escalating.length} + + )} + + + Error Groups + + + + + +
+ {activeTab === "groups" ? ( + + ) : isLoading ? ( + + ) : displayedIssues.length === 0 ? ( + + ) : ( + +
+ {displayedIssues.map((issue, i) => { + const escalating_ = isEscalating(issue); + return ( +
+ toggleIssue(issue.id)} + /> + {selectedIssueId === issue.id && ( + + )} +
+ ); + })} +
+
+ )} +
+
+ + {/* Right Panel */} +
+ {/* Related Issues panel */} + i.id === selectedIssueId) ?? null) + : null + } + isLoading={isLoading} + /> + + {/* Project Breakdown */} + +
+
+ )} +
+ ); +} + +// ---- Related Issues Panel --------------------------------------------------- + +function RelatedIssuesPanel({ + issues, + selectedIssue, + isLoading, +}: { + issues: SentryIssue[]; + selectedIssue: SentryIssue | null; + isLoading: boolean; +}) { + const related = useMemo(() => { + if (!selectedIssue) return []; + const selectedType = selectedIssue.metadata.type ?? ""; + const selectedCulprit = selectedIssue.culprit; + return issues + .filter( + (i) => + i.id !== selectedIssue.id && + (i.culprit === selectedCulprit || + (selectedType && i.metadata.type === selectedType)), + ) + .slice(0, 5); + }, [issues, selectedIssue]); + + return ( + + + + + Related Issues + + + + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : !selectedIssue ? ( +

+ Select an issue to see related errors +

+ ) : related.length === 0 ? ( +

+ No related issues found +

+ ) : ( + + )} +
+
+ ); +} + +// ---- Project Breakdown Panel ------------------------------------------------ + +function ProjectBreakdownPanel({ + issues, + isLoading, +}: { + issues: SentryIssue[]; + isLoading: boolean; +}) { + const projectCounts = useMemo(() => { + const map = new Map< + string, + { name: string; count: number; events: number } + >(); + for (const issue of issues) { + const key = issue.project.slug; + const existing = map.get(key) ?? { + name: issue.project.name, + count: 0, + events: 0, + }; + map.set(key, { + name: issue.project.name, + count: existing.count + 1, + events: existing.events + parseInt(issue.count, 10), + }); + } + return [...map.values()].sort((a, b) => b.events - a.events).slice(0, 6); + }, [issues]); + + const maxEvents = projectCounts[0]?.events ?? 1; + + return ( + + + + + By Project + + + + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : projectCounts.length === 0 ? ( +

+ No projects +

+ ) : ( + projectCounts.map((proj) => ( +
+
+ + {proj.name} + + + {formatCount(proj.events)} + +
+
+
+
+
+ )) + )} + + + ); +} From 129e25f49752772240b40357ff4c9afcc8a1ab3c Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 19:30:58 +0000 Subject: [PATCH 02/23] fix(analytics): handle Sentry API credential errors in dashboard --- .../app/pages/adhoc/sentry-errors/index.tsx | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx index 91400c1928..bf3f89e915 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx @@ -348,9 +348,27 @@ export default function SentryErrorsDashboard() { statsPeriod: period, }); + const rawData = issuesQuery.data as + | { issues?: SentryIssue[] } + | { error: string; message?: string } + | null; + + // Action returns 200 with { error: "missing_api_key", message: "..." } when + // credentials aren't configured — treat it the same as a fetch error. + const dataError: string | null = useMemo(() => { + if (issuesQuery.error) return (issuesQuery.error as Error).message; + if (rawData && "error" in rawData) { + return ( + (rawData as { message?: string }).message ?? + String((rawData as { error: string }).error) + ); + } + return null; + }, [issuesQuery.error, rawData]); + const issues: SentryIssue[] = useMemo( - () => (issuesQuery.data as { issues?: SentryIssue[] })?.issues ?? [], - [issuesQuery.data], + () => (rawData && "issues" in rawData ? (rawData.issues ?? []) : []), + [rawData], ); const top10 = useMemo( @@ -384,7 +402,7 @@ export default function SentryErrorsDashboard() { : []; const isLoading = issuesQuery.isLoading; - const error = issuesQuery.error as Error | null; + const error = dataError; function toggleIssue(id: string) { setSelectedIssueId((prev) => (prev === id ? null : id)); @@ -474,7 +492,7 @@ export default function SentryErrorsDashboard() { {/* Main Content */} {error ? ( - + ) : (
From 0350690ce81a98622a89ac17125a640a0ff17322 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 19:48:11 +0000 Subject: [PATCH 03/23] analytics: add Sentry org slug to required env vars --- templates/analytics/app/lib/data-sources.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/templates/analytics/app/lib/data-sources.ts b/templates/analytics/app/lib/data-sources.ts index be05286e27..5217b97ca6 100644 --- a/templates/analytics/app/lib/data-sources.ts +++ b/templates/analytics/app/lib/data-sources.ts @@ -548,7 +548,7 @@ export const dataSources: DataSource[] = [ description: "Error tracking and performance monitoring", category: "engineering", icon: IconBug, - envKeys: ["SENTRY_AUTH_TOKEN"], + envKeys: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG_SLUG"], docsUrl: "https://docs.sentry.io/api/", walkthroughSteps: [ { @@ -566,6 +566,14 @@ export const dataSources: DataSource[] = [ inputPlaceholder: "sntrys_...", inputType: "password", }, + { + title: "Enter your Organization Slug", + description: + "Your Sentry organization slug (e.g. my-company). Find it in your Sentry URL: sentry.io/organizations//", + inputKey: "SENTRY_ORG_SLUG", + inputLabel: "Organization Slug", + inputPlaceholder: "my-company", + }, ], }, { From 4e524ae9c1018eb7548fc0863a5fba8091151f25 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 19:52:35 +0000 Subject: [PATCH 04/23] analytics: add Slack mention search for Sentry errors --- .../sentry-errors/SlackMentionsPanel.tsx | 283 ++++++++++++++++++ .../app/pages/adhoc/sentry-errors/index.tsx | 3 + 2 files changed, 286 insertions(+) create mode 100644 templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx new file mode 100644 index 0000000000..8d18bb909e --- /dev/null +++ b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx @@ -0,0 +1,283 @@ +import { useState } from "react"; +import { useActionQuery } from "@agent-native/core/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + IconBrandSlack, + IconSearch, + IconMessage, + IconAlertTriangle, + IconExternalLink, + IconRefresh, +} from "@tabler/icons-react"; +import type { SentryIssue } from "./index"; + +// ---- Types ------------------------------------------------------------------ + +interface SlackMessage { + type: string; + user?: string; + username?: string; + text: string; + ts: string; + thread_ts?: string; + reply_count?: number; + channel?: { id: string; name: string }; + permalink?: string; +} + +interface SlackUser { + id: string; + name: string; + real_name: string; + profile: { display_name: string }; +} + +// ---- Helpers ---------------------------------------------------------------- + +function tsToDate(ts: string): string { + const ms = parseFloat(ts) * 1000; + const d = new Date(ms); + const now = Date.now(); + const diff = now - ms; + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 7) return `${days}d ago`; + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function resolveUsername( + msg: SlackMessage, + users: Record, +): string { + if (msg.user && users[msg.user]) { + const u = users[msg.user]; + return u.profile.display_name || u.real_name || u.name; + } + return msg.username ?? "Unknown"; +} + +function stripSlackFormatting(text: string): string { + return text + .replace(/<@[A-Z0-9]+>/g, "@user") + .replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1") + .replace(/<([^>|]+)\|([^>]+)>/g, "$2") + .replace(/<([^>]+)>/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/_([^_]+)_/g, "$1") + .replace(/`([^`]+)`/g, "$1"); +} + +function buildSearchQuery(issue: SentryIssue): string { + const parts: string[] = []; + if (issue.metadata.type) parts.push(issue.metadata.type); + if (issue.metadata.value) { + const trimmed = issue.metadata.value.slice(0, 60).trim(); + if (trimmed) parts.push(trimmed); + } + if (!parts.length) parts.push(issue.title.slice(0, 80)); + return parts.join(" ").replace(/['"]/g, "").trim(); +} + +// ---- Main Component --------------------------------------------------------- + +interface SlackMentionsPanelProps { + issue: SentryIssue; +} + +export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) { + const defaultQuery = buildSearchQuery(issue); + const [query, setQuery] = useState(defaultQuery); + const [activeQuery, setActiveQuery] = useState(null); + const [editingQuery, setEditingQuery] = useState(false); + + const searchQuery = useActionQuery( + "slack-messages", + { mode: "search", query: activeQuery ?? "" }, + { enabled: !!activeQuery }, + ); + + const rawData = searchQuery.data as { + messages?: SlackMessage[]; + users?: Record; + total?: number; + error?: string; + } | null; + + const messages = + rawData && "messages" in rawData ? (rawData.messages ?? []) : []; + const users: Record = + rawData && "users" in rawData ? (rawData.users ?? {}) : {}; + const dataError = + (rawData && "error" in rawData ? rawData.error : null) ?? + (searchQuery.error ? (searchQuery.error as Error).message : null); + + function handleSearch() { + const q = query.trim(); + if (!q) return; + setActiveQuery(q); + setEditingQuery(false); + if (activeQuery === q) searchQuery.refetch(); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") handleSearch(); + } + + // Not yet triggered + if (!activeQuery) { + return ( +
+
+
+ + Search Slack for mentions of this error +
+ +
+ {/* Query preview */} +

+ "{query}" +

+
+ ); + } + + return ( +
+ {/* Header row */} +
+
+ + Slack mentions + {!searchQuery.isLoading && !dataError && ( + + ({messages.length} result{messages.length !== 1 ? "s" : ""}) + + )} +
+ +
+ + {/* Query editor */} + {editingQuery ? ( +
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + className="h-7 text-xs font-mono" + autoFocus + /> + +
+ ) : ( + + )} + + {/* Results */} + {searchQuery.isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) : dataError ? ( +
+ + {dataError} +
+ ) : messages.length === 0 ? ( +
+ + No Slack messages found for this query +
+ ) : ( +
+ {messages.map((msg) => { + const name = resolveUsername(msg, users); + const text = stripSlackFormatting(msg.text); + return ( +
+
+ {name[0] ?? "?"} +
+
+
+ {name} + + {tsToDate(msg.ts)} + + {msg.channel && ( + + #{msg.channel.name} + + )} + {msg.reply_count ? ( + + {msg.reply_count} repl + {msg.reply_count !== 1 ? "ies" : "y"} + + ) : null} +
+

+ {text} +

+
+ {msg.permalink && ( + + + + )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx index bf3f89e915..1376e00517 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx @@ -23,6 +23,7 @@ import { } from "@tabler/icons-react"; import { IssueSparkline } from "./IssueSparkline"; import { ErrorGroupsPanel } from "./ErrorGroupsPanel"; +import { SlackMentionsPanel } from "./SlackMentionsPanel"; // ---- Types ------------------------------------------------------------------ @@ -278,6 +279,8 @@ function IssueDetail({ issue }: { issue: SentryIssue }) { {issue.shortId}
+ +
); } From 354ca1d757ec735a7e231941eaa692adbc8d8069 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 19:56:53 +0000 Subject: [PATCH 05/23] fix(analytics): make SlackMentionsPanel interactive with mutation --- .../sentry-errors/SlackMentionsPanel.tsx | 190 +++++++++--------- 1 file changed, 99 insertions(+), 91 deletions(-) diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx index 8d18bb909e..bfe7486c52 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useActionQuery } from "@agent-native/core/client"; +import { useActionMutation } from "@agent-native/core/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; @@ -10,6 +10,7 @@ import { IconAlertTriangle, IconExternalLink, IconRefresh, + IconX, } from "@tabler/icons-react"; import type { SentryIssue } from "./index"; @@ -34,13 +35,18 @@ interface SlackUser { profile: { display_name: string }; } +interface SlackSearchResult { + messages?: SlackMessage[]; + users?: Record; + total?: number; + error?: string; +} + // ---- Helpers ---------------------------------------------------------------- function tsToDate(ts: string): string { const ms = parseFloat(ts) * 1000; - const d = new Date(ms); - const now = Date.now(); - const diff = now - ms; + const diff = Date.now() - ms; const mins = Math.floor(diff / 60_000); if (mins < 1) return "just now"; if (mins < 60) return `${mins}m ago`; @@ -48,7 +54,10 @@ function tsToDate(ts: string): string { if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); if (days < 7) return `${days}d ago`; - return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return new Date(ms).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); } function resolveUsername( @@ -75,12 +84,18 @@ function stripSlackFormatting(text: string): string { function buildSearchQuery(issue: SentryIssue): string { const parts: string[] = []; + // Issue short ID (e.g. "AIR-LAYOUT-1176") is the most specific search term + if (issue.shortId) parts.push(issue.shortId); + // Error type is meaningful (e.g. "McpError", "TypeError") if (issue.metadata.type) parts.push(issue.metadata.type); + // Truncated error value gives context if (issue.metadata.value) { - const trimmed = issue.metadata.value.slice(0, 60).trim(); - if (trimmed) parts.push(trimmed); + const trimmed = issue.metadata.value.slice(0, 50).trim(); + if (trimmed && !parts.some((p) => trimmed.includes(p))) { + parts.push(trimmed); + } } - if (!parts.length) parts.push(issue.title.slice(0, 80)); + if (parts.length === 0) parts.push(issue.title.slice(0, 80)); return parts.join(" ").replace(/['"]/g, "").trim(); } @@ -93,146 +108,139 @@ interface SlackMentionsPanelProps { export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) { const defaultQuery = buildSearchQuery(issue); const [query, setQuery] = useState(defaultQuery); - const [activeQuery, setActiveQuery] = useState(null); const [editingQuery, setEditingQuery] = useState(false); + const [result, setResult] = useState(null); + const [searched, setSearched] = useState(false); - const searchQuery = useActionQuery( - "slack-messages", - { mode: "search", query: activeQuery ?? "" }, - { enabled: !!activeQuery }, - ); - - const rawData = searchQuery.data as { - messages?: SlackMessage[]; - users?: Record; - total?: number; - error?: string; - } | null; + const mutation = useActionMutation("slack-messages"); - const messages = - rawData && "messages" in rawData ? (rawData.messages ?? []) : []; - const users: Record = - rawData && "users" in rawData ? (rawData.users ?? {}) : {}; + const messages = result?.messages ?? []; + const users: Record = result?.users ?? {}; const dataError = - (rawData && "error" in rawData ? rawData.error : null) ?? - (searchQuery.error ? (searchQuery.error as Error).message : null); + result?.error ?? + (mutation.error ? (mutation.error as Error).message : null); function handleSearch() { const q = query.trim(); if (!q) return; - setActiveQuery(q); setEditingQuery(false); - if (activeQuery === q) searchQuery.refetch(); + setSearched(true); + mutation.mutate( + { mode: "search", query: q }, + { onSuccess: (data) => setResult(data as SlackSearchResult) }, + ); } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter") handleSearch(); - } - - // Not yet triggered - if (!activeQuery) { - return ( -
-
-
- - Search Slack for mentions of this error -
- -
- {/* Query preview */} -

- "{query}" -

-
- ); + if (e.key === "Escape") setEditingQuery(false); } return ( -
- {/* Header row */} +
+ {/* Header */}
-
- - Slack mentions - {!searchQuery.isLoading && !dataError && ( - +
+ + Slack mentions + {searched && !mutation.isPending && !dataError && ( + ({messages.length} result{messages.length !== 1 ? "s" : ""}) )}
- + {searched && ( + + )}
- {/* Query editor */} + {/* Search bar */} {editingQuery ? ( -
+
setQuery(e.target.value)} onKeyDown={handleKeyDown} - className="h-7 text-xs font-mono" + className="h-8 text-xs font-mono" autoFocus /> - +
) : ( )} {/* Results */} - {searchQuery.isLoading ? ( -
+ {mutation.isPending ? ( +
{Array.from({ length: 3 }).map((_, i) => (
+
))}
) : dataError ? ( -
+
{dataError}
- ) : messages.length === 0 ? ( -
+ ) : searched && messages.length === 0 ? ( +
- No Slack messages found for this query + No Slack messages found
) : ( -
+
{messages.map((msg) => { const name = resolveUsername(msg, users); const text = stripSlackFormatting(msg.text); @@ -242,7 +250,7 @@ export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) { {name[0] ?? "?"}
-
+
{name} {tsToDate(msg.ts)} From ab1f6f5539519fa56ab7eca9693cb03e52d9dc83 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 20:15:41 +0000 Subject: [PATCH 06/23] analytics: add Slack user token support and AI issue classification --- templates/analytics/app/lib/data-sources.ts | 11 +- .../app/pages/adhoc/sentry-errors/index.tsx | 97 ++++++++++- .../adhoc/sentry-errors/issueClassifier.ts | 159 ++++++++++++++++++ templates/analytics/server/lib/slack.ts | 55 ++++-- 4 files changed, 303 insertions(+), 19 deletions(-) create mode 100644 templates/analytics/app/pages/adhoc/sentry-errors/issueClassifier.ts diff --git a/templates/analytics/app/lib/data-sources.ts b/templates/analytics/app/lib/data-sources.ts index 5217b97ca6..8d7a0ba508 100644 --- a/templates/analytics/app/lib/data-sources.ts +++ b/templates/analytics/app/lib/data-sources.ts @@ -651,7 +651,7 @@ export const dataSources: DataSource[] = [ description: "Channel messages and workspace search", category: "communication", icon: IconMessage, - envKeys: ["SLACK_BOT_TOKEN"], + envKeys: ["SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"], docsUrl: "https://api.slack.com/methods", walkthroughSteps: [ { @@ -674,6 +674,15 @@ export const dataSources: DataSource[] = [ inputPlaceholder: "xoxb-...", inputType: "password", }, + { + title: "Add a User Token for search (optional)", + description: + 'Slack\'s search API requires a user token. Under "OAuth & Permissions", copy the User OAuth Token (starts with "xoxp-"). Required for the Slack search feature in Sentry Error Intelligence.', + inputKey: "SLACK_USER_TOKEN", + inputLabel: "User Token (for search)", + inputPlaceholder: "xoxp-...", + inputType: "password", + }, ], }, { diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx index 1376e00517..65301228ac 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx @@ -1,6 +1,11 @@ import { useState, useMemo } from "react"; -import { useActionQuery } from "@agent-native/core/client"; +import { useActionQuery, sendToAgentChat } from "@agent-native/core/client"; import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -20,10 +25,18 @@ import { IconCopy, IconCircleCheck, IconLink, + IconSparkles, + IconFilter, } from "@tabler/icons-react"; import { IssueSparkline } from "./IssueSparkline"; import { ErrorGroupsPanel } from "./ErrorGroupsPanel"; import { SlackMentionsPanel } from "./SlackMentionsPanel"; +import { + classifyIssue, + classificationLabel, + classificationColor, + type IssueClass, +} from "./issueClassifier"; // ---- Types ------------------------------------------------------------------ @@ -152,13 +165,14 @@ function IssueRow({ onSelect: () => void; }) { const count = parseInt(issue.count, 10); + const { classification, reason } = classifyIssue(issue); return ( +
+ )} + {/* Main Content */} {error ? ( diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/issueClassifier.ts b/templates/analytics/app/pages/adhoc/sentry-errors/issueClassifier.ts new file mode 100644 index 0000000000..3440d68912 --- /dev/null +++ b/templates/analytics/app/pages/adhoc/sentry-errors/issueClassifier.ts @@ -0,0 +1,159 @@ +import type { SentryIssue } from "./index"; + +export type IssueClass = "actionable" | "noise" | "user-error" | "unknown"; + +export interface ClassificationResult { + classification: IssueClass; + reason: string; +} + +// Patterns strongly indicating non-actionable / noise +const NOISE_TYPE_PATTERNS = [ + /^ChunkLoadError/i, + /^ResizeObserver loop/i, + /^Script error/i, + /^Non-Error (promise rejection|exception)/i, + /^UnhandledRejection.*undefined/i, + /cancelled/i, + /aborted/i, +]; + +const NOISE_VALUE_PATTERNS = [ + /load failed/i, + /failed to fetch/i, + /networkerror/i, + /network request failed/i, + /the internet connection appears to be offline/i, + /cancelled/i, + /aborted/i, + /timeout/i, +]; + +// Patterns indicating user-caused errors (auth, validation, not-found) +const USER_ERROR_TYPE_PATTERNS = [ + /ValidationError/i, + /ZodError/i, + /AuthError/i, + /UnauthorizedError/i, + /ForbiddenError/i, + /NotFoundError/i, +]; + +const USER_ERROR_VALUE_PATTERNS = [ + /401/, + /403/, + /404/, + /unauthorized/i, + /forbidden/i, + /not found/i, + /invalid (token|credentials|password|email)/i, + /user.*not.*found/i, + /permission denied/i, + /not_allowed_token_type/i, + /invalid_auth/i, +]; + +// Third-party culprit patterns — lower signal, only marks as noise when combined +const THIRD_PARTY_CULPRIT_PATTERNS = [ + /node_modules/, + /cdn\./, + /googleapis\.com/, + /cloudflare/, + /amazonaws\.com/, +]; + +export function classifyIssue(issue: SentryIssue): ClassificationResult { + const type = (issue.metadata.type ?? issue.type ?? "").toLowerCase(); + const value = (issue.metadata.value ?? issue.title ?? "").toLowerCase(); + const culprit = (issue.culprit ?? "").toLowerCase(); + const title = (issue.title ?? "").toLowerCase(); + + // 1. Noise: known transient / browser / network patterns + for (const pat of NOISE_TYPE_PATTERNS) { + if (pat.test(issue.metadata.type ?? issue.type ?? issue.title)) { + return { + classification: "noise", + reason: `Error type "${issue.metadata.type ?? issue.type}" is typically non-actionable browser/network noise`, + }; + } + } + for (const pat of NOISE_VALUE_PATTERNS) { + if (pat.test(value) || pat.test(title)) { + return { + classification: "noise", + reason: + "Error message matches known transient network/browser noise patterns", + }; + } + } + + // 2. User error: auth, validation, expected HTTP errors + for (const pat of USER_ERROR_TYPE_PATTERNS) { + if (pat.test(issue.metadata.type ?? issue.type ?? "")) { + return { + classification: "user-error", + reason: `Error type "${issue.metadata.type ?? issue.type}" is typically caused by invalid user input or auth`, + }; + } + } + for (const pat of USER_ERROR_VALUE_PATTERNS) { + if (pat.test(value) || pat.test(title)) { + return { + classification: "user-error", + reason: + "Error message matches expected user-caused patterns (auth, validation, not-found)", + }; + } + } + + // 3. Third-party culprit with no affected users → likely noise + const isThirdParty = THIRD_PARTY_CULPRIT_PATTERNS.some((p) => + p.test(culprit), + ); + if (isThirdParty && issue.userCount === 0) { + return { + classification: "noise", + reason: + "Error originates in a third-party dependency and affects no users", + }; + } + + // 4. High user count + first-party → likely actionable + if (issue.userCount > 5 && !isThirdParty) { + return { + classification: "actionable", + reason: `Affects ${issue.userCount} users and originates in first-party code`, + }; + } + + return { + classification: "unknown", + reason: "Could not determine actionability from available metadata", + }; +} + +export function classificationLabel(c: IssueClass): string { + switch (c) { + case "actionable": + return "Actionable"; + case "noise": + return "Noise"; + case "user-error": + return "User error"; + case "unknown": + return "Unclassified"; + } +} + +export function classificationColor(c: IssueClass): string { + switch (c) { + case "actionable": + return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800"; + case "noise": + return "bg-slate-100 text-slate-500 dark:bg-slate-800/50 dark:text-slate-400 border-slate-200 dark:border-slate-700"; + case "user-error": + return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800"; + case "unknown": + return "bg-muted text-muted-foreground border-border"; + } +} diff --git a/templates/analytics/server/lib/slack.ts b/templates/analytics/server/lib/slack.ts index 08d88f87ca..6655be2c00 100644 --- a/templates/analytics/server/lib/slack.ts +++ b/templates/analytics/server/lib/slack.ts @@ -276,24 +276,47 @@ export async function searchMessages( query: string, count = 50, ): Promise<{ messages: SlackMessage[]; total: number }> { - // search.messages requires a user token, but we'll try with bot token - // If it fails, we'll fall back to channel history filtering - const data = await slackApi<{ - messages: { matches: SlackMessage[]; total: number }; - }>( - workspace, - "search.messages", - { - query, - count: String(Math.min(count, 100)), - sort: "timestamp", - sort_dir: "desc", - }, - false, + // search.messages requires a user token (xoxp-), not a bot token. + // Try SLACK_USER_TOKEN first, fall back to the configured bot token. + const ctx = requireRequestCredentialContext( + workspace === "secondary" ? "SLACK_BOT_TOKEN_2" : "SLACK_BOT_TOKEN", ); + const userToken = await resolveCredential("SLACK_USER_TOKEN", ctx); + const botToken = userToken + ? null + : await resolveCredential( + workspace === "secondary" ? "SLACK_BOT_TOKEN_2" : "SLACK_BOT_TOKEN", + ctx, + ); + const token = userToken ?? botToken; + if (!token) throw new Error("No Slack token configured"); + + const params = new URLSearchParams({ + query, + count: String(Math.min(count, 100)), + sort: "timestamp", + sort_dir: "desc", + }); + const res = await fetch(`https://slack.com/api/search.messages?${params}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`Slack API error ${res.status}`); + const json = (await res.json()) as { + ok: boolean; + error?: string; + messages?: { matches: SlackMessage[]; total: number }; + }; + if (!json.ok) { + if (json.error === "not_allowed_token_type") { + throw new Error( + "Slack search requires a user token (xoxp-). Add SLACK_USER_TOKEN in Data Sources → Slack.", + ); + } + throw new Error(`Slack API error: ${json.error}`); + } return { - messages: data.messages?.matches || [], - total: data.messages?.total || 0, + messages: json.messages?.matches || [], + total: json.messages?.total || 0, }; } From 96e2fbaa2ed4fde236338ea9dbcc7b0283d16a9b Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 20:18:24 +0000 Subject: [PATCH 07/23] analytics: add multi-project filtering to Sentry errors dashboard --- .../app/pages/adhoc/sentry-errors/index.tsx | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx index 65301228ac..7c36f89b2f 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx @@ -367,6 +367,9 @@ export default function SentryErrorsDashboard() { const [activeTab, setActiveTab] = useState<"top10" | "escalating" | "groups">( "top10", ); + const [selectedProjects, setSelectedProjects] = useState>( + new Set(), + ); const issuesQuery = useActionQuery("sentry", { mode: "issues", @@ -397,11 +400,39 @@ export default function SentryErrorsDashboard() { return null; }, [issuesQuery.error, rawData]); - const issues: SentryIssue[] = useMemo( + const allIssues: SentryIssue[] = useMemo( () => (rawData && "issues" in rawData ? (rawData.issues ?? []) : []), [rawData], ); + const allProjects = useMemo(() => { + const seen = new Map(); + for (const issue of allIssues) { + if (!seen.has(issue.project.slug)) { + seen.set(issue.project.slug, issue.project.name); + } + } + return Array.from(seen.entries()).map(([slug, name]) => ({ slug, name })); + }, [allIssues]); + + const issues: SentryIssue[] = useMemo( + () => + selectedProjects.size === 0 + ? allIssues + : allIssues.filter((i) => selectedProjects.has(i.project.slug)), + [allIssues, selectedProjects], + ); + + function toggleProject(slug: string) { + setSelectedProjects((prev) => { + const next = new Set(prev); + if (next.has(slug)) next.delete(slug); + else next.add(slug); + return next; + }); + setSelectedIssueId(null); + } + const top10 = useMemo( () => [...issues] @@ -547,6 +578,44 @@ export default function SentryErrorsDashboard() { />
+ {/* Project Filter */} + {!isLoading && allProjects.length > 1 && ( +
+ + Projects: + + {allProjects.map(({ slug, name }) => { + const active = selectedProjects.has(slug); + return ( + + ); + })} + {selectedProjects.size > 0 && ( + + )} +
+ )} + {/* Classification Summary */} {!isLoading && !error && issues.length > 0 && (
From d569c81f6cbbabf7169ed5d4a813b9eb3b667a60 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 20:25:49 +0000 Subject: [PATCH 08/23] slack: add fallback message search using bot token --- templates/analytics/server/lib/slack.ts | 116 ++++++++++++++++-------- 1 file changed, 77 insertions(+), 39 deletions(-) diff --git a/templates/analytics/server/lib/slack.ts b/templates/analytics/server/lib/slack.ts index 6655be2c00..8d31a5cab2 100644 --- a/templates/analytics/server/lib/slack.ts +++ b/templates/analytics/server/lib/slack.ts @@ -102,6 +102,7 @@ export interface SlackMessage { reactions?: { name: string; count: number }[]; files?: { name: string; mimetype: string; url_private: string }[]; icons?: { image_48?: string; image_72?: string }; + channel?: string; } export interface SlackBotInfo { @@ -276,48 +277,85 @@ export async function searchMessages( query: string, count = 50, ): Promise<{ messages: SlackMessage[]; total: number }> { - // search.messages requires a user token (xoxp-), not a bot token. - // Try SLACK_USER_TOKEN first, fall back to the configured bot token. - const ctx = requireRequestCredentialContext( - workspace === "secondary" ? "SLACK_BOT_TOKEN_2" : "SLACK_BOT_TOKEN", - ); + const botEnvKey = + workspace === "secondary" ? "SLACK_BOT_TOKEN_2" : "SLACK_BOT_TOKEN"; + const ctx = requireRequestCredentialContext(botEnvKey); + + // Prefer a user token (xoxp-) for search.messages if one was configured. const userToken = await resolveCredential("SLACK_USER_TOKEN", ctx); - const botToken = userToken - ? null - : await resolveCredential( - workspace === "secondary" ? "SLACK_BOT_TOKEN_2" : "SLACK_BOT_TOKEN", - ctx, - ); - const token = userToken ?? botToken; - if (!token) throw new Error("No Slack token configured"); - - const params = new URLSearchParams({ - query, - count: String(Math.min(count, 100)), - sort: "timestamp", - sort_dir: "desc", - }); - const res = await fetch(`https://slack.com/api/search.messages?${params}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) throw new Error(`Slack API error ${res.status}`); - const json = (await res.json()) as { - ok: boolean; - error?: string; - messages?: { matches: SlackMessage[]; total: number }; - }; - if (!json.ok) { - if (json.error === "not_allowed_token_type") { - throw new Error( - "Slack search requires a user token (xoxp-). Add SLACK_USER_TOKEN in Data Sources → Slack.", - ); + if (userToken) { + const params = new URLSearchParams({ + query, + count: String(Math.min(count, 100)), + sort: "timestamp", + sort_dir: "desc", + }); + const res = await fetch(`https://slack.com/api/search.messages?${params}`, { + headers: { Authorization: `Bearer ${userToken}` }, + }); + if (!res.ok) throw new Error(`Slack API error ${res.status}`); + const json = (await res.json()) as { + ok: boolean; + error?: string; + messages?: { matches: SlackMessage[]; total: number }; + }; + if (json.ok) { + return { + messages: json.messages?.matches || [], + total: json.messages?.total || 0, + }; } - throw new Error(`Slack API error: ${json.error}`); + // fall through to bot-token scan on token errors } - return { - messages: json.messages?.matches || [], - total: json.messages?.total || 0, - }; + + // Fallback: scan recent messages from accessible channels using the bot token. + // search.messages requires a user token; this covers the common case where only + // a bot token is configured. + const terms = query + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length > 2); + + const channelsData = await slackApi<{ + channels: { id: string; name: string }[]; + }>(workspace, "conversations.list", { + types: "public_channel,private_channel", + exclude_archived: "true", + limit: "200", + }); + + const channels = (channelsData.channels ?? []).slice(0, 20); + const oldest = String( + Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000), + ); + const matches: SlackMessage[] = []; + + await Promise.all( + channels.map(async (ch) => { + try { + const histData = await slackApi<{ messages: SlackMessage[] }>( + workspace, + "conversations.history", + { channel: ch.id, limit: "200", oldest }, + false, + ); + for (const msg of histData.messages ?? []) { + const text = msg.text?.toLowerCase() ?? ""; + if (terms.length === 0 || terms.some((t) => text.includes(t))) { + matches.push({ ...msg, channel: ch.id } as SlackMessage & { + channel: string; + }); + } + } + } catch { + // bot may not have access to this channel — skip silently + } + }), + ); + + matches.sort((a, b) => parseFloat(b.ts) - parseFloat(a.ts)); + const limited = matches.slice(0, count); + return { messages: limited, total: matches.length }; } export async function getUserInfo( From 25812a3c68898e26f6d6af2c6ae48e43b301b51d Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 20:27:41 +0000 Subject: [PATCH 09/23] fix(analytics): remove private_channel type from conversations.list query --- templates/analytics/server/lib/slack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/analytics/server/lib/slack.ts b/templates/analytics/server/lib/slack.ts index 8d31a5cab2..ce756be35a 100644 --- a/templates/analytics/server/lib/slack.ts +++ b/templates/analytics/server/lib/slack.ts @@ -319,7 +319,7 @@ export async function searchMessages( const channelsData = await slackApi<{ channels: { id: string; name: string }[]; }>(workspace, "conversations.list", { - types: "public_channel,private_channel", + types: "public_channel", exclude_archived: "true", limit: "200", }); From a1b1d90f581a70dad6784242b7000575f965db53 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 20:30:40 +0000 Subject: [PATCH 10/23] analytics: search Sentry issue URL in Slack mentions panel --- .../app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx index bfe7486c52..1ec3abe88b 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx @@ -86,6 +86,8 @@ function buildSearchQuery(issue: SentryIssue): string { const parts: string[] = []; // Issue short ID (e.g. "AIR-LAYOUT-1176") is the most specific search term if (issue.shortId) parts.push(issue.shortId); + // Permalink — matches messages where someone pasted the Sentry issue URL + if (issue.permalink) parts.push(issue.permalink.replace(/\/$/, "")); // Error type is meaningful (e.g. "McpError", "TypeError") if (issue.metadata.type) parts.push(issue.metadata.type); // Truncated error value gives context From fe1f398aa13ca17cc6403d817c690f31290f8f3c Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 20:33:05 +0000 Subject: [PATCH 11/23] fix: include trailing slash in Slack search query for URL matching --- .../adhoc/sentry-errors/SlackMentionsPanel.tsx | 2 +- templates/analytics/server/lib/slack.ts | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx index 1ec3abe88b..9a071608ac 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx @@ -87,7 +87,7 @@ function buildSearchQuery(issue: SentryIssue): string { // Issue short ID (e.g. "AIR-LAYOUT-1176") is the most specific search term if (issue.shortId) parts.push(issue.shortId); // Permalink — matches messages where someone pasted the Sentry issue URL - if (issue.permalink) parts.push(issue.permalink.replace(/\/$/, "")); + if (issue.permalink) parts.push(issue.permalink); // Error type is meaningful (e.g. "McpError", "TypeError") if (issue.metadata.type) parts.push(issue.metadata.type); // Truncated error value gives context diff --git a/templates/analytics/server/lib/slack.ts b/templates/analytics/server/lib/slack.ts index ce756be35a..207271e4c7 100644 --- a/templates/analytics/server/lib/slack.ts +++ b/templates/analytics/server/lib/slack.ts @@ -316,6 +316,21 @@ export async function searchMessages( .split(/\s+/) .filter((t) => t.length > 2); + function fuzzyMatch(text: string, term: string): boolean { + if (text.includes(term)) return true; + // URLs must match exactly (trailing slash variations are already normalised) + if (term.startsWith("http")) return false; + // For Sentry short IDs like BRIDGE-TM-1234, also try just the numeric suffix + const numericSuffix = term.match(/\d{4,}$/)?.[0]; + if (numericSuffix && text.includes(numericSuffix)) return true; + // Prefix fuzzy: if the term is 6+ chars, match on the first 70% of it + if (term.length >= 6) { + const prefix = term.slice(0, Math.floor(term.length * 0.7)); + if (text.includes(prefix)) return true; + } + return false; + } + const channelsData = await slackApi<{ channels: { id: string; name: string }[]; }>(workspace, "conversations.list", { @@ -341,7 +356,7 @@ export async function searchMessages( ); for (const msg of histData.messages ?? []) { const text = msg.text?.toLowerCase() ?? ""; - if (terms.length === 0 || terms.some((t) => text.includes(t))) { + if (terms.length === 0 || terms.some((t) => fuzzyMatch(text, t))) { matches.push({ ...msg, channel: ch.id } as SlackMessage & { channel: string; }); From 7f4e44bc275b147b75d821a87b4bd6bdd9750a0d Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 20:39:11 +0000 Subject: [PATCH 12/23] debug: add logging to slack-messages search endpoint --- templates/analytics/server/lib/slack.ts | 33 +++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/templates/analytics/server/lib/slack.ts b/templates/analytics/server/lib/slack.ts index 207271e4c7..e49862f2c9 100644 --- a/templates/analytics/server/lib/slack.ts +++ b/templates/analytics/server/lib/slack.ts @@ -316,6 +316,9 @@ export async function searchMessages( .split(/\s+/) .filter((t) => t.length > 2); + console.log("[slack-search] query:", query); + console.log("[slack-search] terms:", terms); + function fuzzyMatch(text: string, term: string): boolean { if (text.includes(term)) return true; // URLs must match exactly (trailing slash variations are already normalised) @@ -339,7 +342,14 @@ export async function searchMessages( limit: "200", }); - const channels = (channelsData.channels ?? []).slice(0, 20); + const allChannels = channelsData.channels ?? []; + console.log( + "[slack-search] total channels visible to bot:", + allChannels.length, + allChannels.map((c) => c.name), + ); + + const channels = allChannels.slice(0, 20); const oldest = String( Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000), ); @@ -354,20 +364,33 @@ export async function searchMessages( { channel: ch.id, limit: "200", oldest }, false, ); - for (const msg of histData.messages ?? []) { + const msgs = histData.messages ?? []; + console.log( + `[slack-search] #${ch.name} (${ch.id}): ${msgs.length} messages fetched`, + ); + for (const msg of msgs) { const text = msg.text?.toLowerCase() ?? ""; - if (terms.length === 0 || terms.some((t) => fuzzyMatch(text, t))) { + const matched = + terms.length === 0 || terms.some((t) => fuzzyMatch(text, t)); + if (matched) { + console.log( + `[slack-search] MATCH in #${ch.name}: "${msg.text?.slice(0, 80)}"`, + ); matches.push({ ...msg, channel: ch.id } as SlackMessage & { channel: string; }); } } - } catch { - // bot may not have access to this channel — skip silently + } catch (err) { + console.log( + `[slack-search] #${ch.name} error:`, + (err as Error).message, + ); } }), ); + console.log("[slack-search] total matches:", matches.length); matches.sort((a, b) => parseFloat(b.ts) - parseFloat(a.ts)); const limited = matches.slice(0, count); return { messages: limited, total: matches.length }; From 6f19b83fcf7bb22fe9ba38b26eb527603cd519e5 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 20:42:31 +0000 Subject: [PATCH 13/23] fix: clarify Slack user token as optional with add button --- templates/analytics/app/lib/data-sources.ts | 3 ++- templates/analytics/app/pages/DataSources.tsx | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/templates/analytics/app/lib/data-sources.ts b/templates/analytics/app/lib/data-sources.ts index 8d7a0ba508..228b19a221 100644 --- a/templates/analytics/app/lib/data-sources.ts +++ b/templates/analytics/app/lib/data-sources.ts @@ -677,11 +677,12 @@ export const dataSources: DataSource[] = [ { title: "Add a User Token for search (optional)", description: - 'Slack\'s search API requires a user token. Under "OAuth & Permissions", copy the User OAuth Token (starts with "xoxp-"). Required for the Slack search feature in Sentry Error Intelligence.', + "Optional. Enables the Slack search.messages API for richer search results. Without it, the bot scans channels it's been invited to.", inputKey: "SLACK_USER_TOKEN", inputLabel: "User Token (for search)", inputPlaceholder: "xoxp-...", inputType: "password", + optional: true, }, ], }, diff --git a/templates/analytics/app/pages/DataSources.tsx b/templates/analytics/app/pages/DataSources.tsx index a2e5ef89ae..623f8accd2 100644 --- a/templates/analytics/app/pages/DataSources.tsx +++ b/templates/analytics/app/pages/DataSources.tsx @@ -535,10 +535,14 @@ function ConnectedView({ Configured ) : optional ? ( - - - Optional - + ) : ( From 6a0cd94c1c7709643dd74f2f2239cf021e81eb75 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 20:48:50 +0000 Subject: [PATCH 14/23] analytics: improve Slack channel access messaging with setup guide --- .../sentry-errors/SlackMentionsPanel.tsx | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx index 9a071608ac..7c9cb0e911 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx @@ -237,9 +237,36 @@ export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) { {dataError}
) : searched && messages.length === 0 ? ( -
- - No Slack messages found +
+
+ + No messages found in accessible channels +
+
+

+ Searching private channels? +

+

+ The bot can only scan public channels it's been added to. To + search private channels, add a Slack{" "} + User OAuth Token (xoxp-) in{" "} + + Data Sources → Slack + + . +

+

+ To get one: Slack app → OAuth & Permissions →{" "} + User Token Scopes → add{" "} + + search:read + {" "} + → Reinstall app → copy the User OAuth Token. +

+
) : (
From 5c0dd93b8723d438fb4f4d3fb69bb22723d11f68 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 20:52:11 +0000 Subject: [PATCH 15/23] Add Slack User Token credential support to analytics template --- templates/analytics/server/lib/credential-keys.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/templates/analytics/server/lib/credential-keys.ts b/templates/analytics/server/lib/credential-keys.ts index 8524e7f48b..938b878b2c 100644 --- a/templates/analytics/server/lib/credential-keys.ts +++ b/templates/analytics/server/lib/credential-keys.ts @@ -87,6 +87,11 @@ export const credentialKeys: CredentialKeyConfig[] = [ label: "Slack Bot Token (secondary)", required: false, }, + { + key: "SLACK_USER_TOKEN", + label: "Slack User Token", + required: false, + }, // Notion { key: "NOTION_API_KEY", label: "Notion", required: false }, // Twitter/X @@ -240,7 +245,7 @@ export const credentialProviderConfigs: CredentialProviderConfig[] = [ provider: "slack", label: "Slack", requiredKeys: ["SLACK_BOT_TOKEN"], - optionalKeys: ["SLACK_BOT_TOKEN_2"], + optionalKeys: ["SLACK_BOT_TOKEN_2", "SLACK_USER_TOKEN"], }, { provider: "notion", @@ -294,7 +299,7 @@ const credentialAliases: Record = { posthog: ["POSTHOG_API_KEY", "POSTHOG_PROJECT_ID"], pylon: ["PYLON_API_KEY"], sentry: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG_SLUG", "SENTRY_SERVER_TOKEN"], - slack: ["SLACK_BOT_TOKEN", "SLACK_BOT_TOKEN_2"], + slack: ["SLACK_BOT_TOKEN", "SLACK_BOT_TOKEN_2", "SLACK_USER_TOKEN"], stripe: ["STRIPE_SECRET_KEY"], twitter: ["TWITTER_BEARER_TOKEN"], x: ["TWITTER_BEARER_TOKEN"], From b6348d541cb9680fe9574779e4a24b6501a35085 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 20:56:15 +0000 Subject: [PATCH 16/23] analytics: resolve usernames and make Slack messages clickable --- .../sentry-errors/SlackMentionsPanel.tsx | 35 +++++++++++-------- templates/analytics/server/lib/slack.ts | 29 ++++++++++++--- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx index 7c9cb0e911..1346a34420 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx @@ -24,7 +24,7 @@ interface SlackMessage { ts: string; thread_ts?: string; reply_count?: number; - channel?: { id: string; name: string }; + channel?: { id: string; name: string } | string; permalink?: string; } @@ -269,12 +269,26 @@ export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) {
) : ( -
+
{messages.map((msg) => { const name = resolveUsername(msg, users); const text = stripSlackFormatting(msg.text); + const channelName = + typeof msg.channel === "string" ? msg.channel : msg.channel?.name; + const Row = msg.permalink ? "a" : "div"; + const rowProps = msg.permalink + ? { + href: msg.permalink, + target: "_blank" as const, + rel: "noopener noreferrer", + } + : {}; return ( -
+
{name[0] ?? "?"}
@@ -284,9 +298,9 @@ export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) { {tsToDate(msg.ts)} - {msg.channel && ( + {channelName && ( - #{msg.channel.name} + #{channelName} )} {msg.reply_count ? ( @@ -301,16 +315,9 @@ export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) {

{msg.permalink && ( - - - + )} -
+ ); })}
diff --git a/templates/analytics/server/lib/slack.ts b/templates/analytics/server/lib/slack.ts index e49862f2c9..fdbd47552d 100644 --- a/templates/analytics/server/lib/slack.ts +++ b/templates/analytics/server/lib/slack.ts @@ -102,7 +102,8 @@ export interface SlackMessage { reactions?: { name: string; count: number }[]; files?: { name: string; mimetype: string; url_private: string }[]; icons?: { image_48?: string; image_72?: string }; - channel?: string; + channel?: { id: string; name: string } | string; + permalink?: string; } export interface SlackBotInfo { @@ -353,6 +354,19 @@ export async function searchMessages( const oldest = String( Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000), ); + + // Fetch team domain so we can build message permalinks + let teamDomain = ""; + try { + const teamData = await slackApi<{ team: { domain: string } }>( + workspace, + "team.info", + ); + teamDomain = teamData.team?.domain ?? ""; + } catch { + // non-fatal + } + const matches: SlackMessage[] = []; await Promise.all( @@ -376,9 +390,16 @@ export async function searchMessages( console.log( `[slack-search] MATCH in #${ch.name}: "${msg.text?.slice(0, 80)}"`, ); - matches.push({ ...msg, channel: ch.id } as SlackMessage & { - channel: string; - }); + // Build a Slack deep-link permalink for bot-scanned messages + const tsSlug = msg.ts.replace(".", ""); + const permalink = teamDomain + ? `https://${teamDomain}.slack.com/archives/${ch.id}/p${tsSlug}` + : undefined; + matches.push({ + ...msg, + channel: { id: ch.id, name: ch.name }, + permalink, + } as SlackMessage); } } } catch (err) { From 50e78bf0e4a178228cb95a9fbc1d3216ec634834 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 20:58:17 +0000 Subject: [PATCH 17/23] fix: resolve display name instead of falling back to user ID --- .../app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx | 6 ++++-- templates/analytics/server/lib/slack.ts | 8 ++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx index 1346a34420..c25b75d150 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx @@ -66,9 +66,11 @@ function resolveUsername( ): string { if (msg.user && users[msg.user]) { const u = users[msg.user]; - return u.profile.display_name || u.real_name || u.name; + const resolved = u.profile.display_name || u.real_name || u.name; + // Reject raw user IDs (e.g. "U08ABC123") — fall through to username + if (resolved && !/^[UW][A-Z0-9]{6,}$/.test(resolved)) return resolved; } - return msg.username ?? "Unknown"; + return msg.username || "Unknown"; } function stripSlackFormatting(text: string): string { diff --git a/templates/analytics/server/lib/slack.ts b/templates/analytics/server/lib/slack.ts index fdbd47552d..5d9770e05a 100644 --- a/templates/analytics/server/lib/slack.ts +++ b/templates/analytics/server/lib/slack.ts @@ -476,12 +476,8 @@ export async function resolveUsers( try { results[id] = await getUserInfo(workspace, id); } catch { - results[id] = { - id, - name: id, - real_name: id, - profile: { display_name: id, image_48: "", image_72: "" }, - }; + // Don't create a fake entry — leave it absent so resolveUsername + // falls back to msg.username (populated by search.messages API). } }), ); From 256f561ef699a7b0a367e18955e2f917a5104f42 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 21:02:46 +0000 Subject: [PATCH 18/23] analytics: limit Slack message search results to 10 --- templates/analytics/actions/slack-messages.ts | 2 +- .../app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/analytics/actions/slack-messages.ts b/templates/analytics/actions/slack-messages.ts index ad4d82ea35..d3a6580687 100644 --- a/templates/analytics/actions/slack-messages.ts +++ b/templates/analytics/actions/slack-messages.ts @@ -153,7 +153,7 @@ export default defineAction({ if (args.mode === "search") { if (!args.query) return { error: "query is required" }; - const result = await searchMessages(workspace, args.query); + const result = await searchMessages(workspace, args.query, args.limit); const userIds = result.messages .map((message) => message.user) .filter((id): id is string => !!id); diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx index c25b75d150..d014679bbe 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx @@ -118,7 +118,7 @@ export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) { const mutation = useActionMutation("slack-messages"); - const messages = result?.messages ?? []; + const messages = (result?.messages ?? []).slice(0, 10); const users: Record = result?.users ?? {}; const dataError = result?.error ?? @@ -130,7 +130,7 @@ export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) { setEditingQuery(false); setSearched(true); mutation.mutate( - { mode: "search", query: q }, + { mode: "search", query: q, limit: 10 }, { onSuccess: (data) => setResult(data as SlackSearchResult) }, ); } From 0feaca6e77d99e44b7b7246802e8b5172051fa82 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 21:10:09 +0000 Subject: [PATCH 19/23] analytics: add GitHub blame action and Sentry integration panel --- templates/analytics/actions/github-blame.ts | 41 +++ templates/analytics/actions/sentry.ts | 15 +- .../adhoc/sentry-errors/GitHubBlamePanel.tsx | 348 ++++++++++++++++++ .../app/pages/adhoc/sentry-errors/index.tsx | 2 + templates/analytics/server/lib/github.ts | 116 ++++++ templates/analytics/server/lib/sentry.ts | 34 ++ 6 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 templates/analytics/actions/github-blame.ts create mode 100644 templates/analytics/app/pages/adhoc/sentry-errors/GitHubBlamePanel.tsx diff --git a/templates/analytics/actions/github-blame.ts b/templates/analytics/actions/github-blame.ts new file mode 100644 index 0000000000..46ff06167c --- /dev/null +++ b/templates/analytics/actions/github-blame.ts @@ -0,0 +1,41 @@ +import { defineAction } from "@agent-native/core"; +import { z } from "zod"; +import { getFileBlame } from "../server/lib/github"; +import { + providerError, + requireActionCredentials, +} from "./_provider-action-utils"; + +export default defineAction({ + description: + "Get Git blame for a file path in a GitHub repository. Returns the most recent commit per blame range, who authored it, and any associated PR.", + schema: z.object({ + owner: z.string().describe("GitHub repository owner (org or user)"), + repo: z.string().describe("GitHub repository name"), + path: z.string().describe("File path within the repository"), + ref: z + .string() + .default("HEAD") + .describe("Branch, tag, or commit SHA (default: HEAD)"), + }), + readOnly: true, + run: async (args) => { + const credentials = await requireActionCredentials( + ["GITHUB_TOKEN"], + "GitHub", + ); + if (credentials.ok === false) return credentials.response; + + try { + const result = await getFileBlame( + args.owner, + args.repo, + args.path, + args.ref, + ); + return result; + } catch (err) { + return providerError(err); + } + }, +}); diff --git a/templates/analytics/actions/sentry.ts b/templates/analytics/actions/sentry.ts index 7e1e366c63..3f26b7545e 100644 --- a/templates/analytics/actions/sentry.ts +++ b/templates/analytics/actions/sentry.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { getIssueEvents, getOrganizationStats, + getCodeMappings, listOrganizations, listIssues, listProjects, @@ -17,7 +18,14 @@ export default defineAction({ "Query Sentry projects, frequent issues, issue events, and organization error stats. Use this for Sentry error questions; pass statsPeriod like 7d, 30d, or 1y.", schema: z.object({ mode: z - .enum(["organizations", "projects", "issues", "issue-events", "stats"]) + .enum([ + "organizations", + "projects", + "issues", + "issue-events", + "stats", + "code-mappings", + ]) .default("issues") .describe("What to query from Sentry"), orgSlug: z @@ -53,6 +61,11 @@ export default defineAction({ if (credentials.ok === false) return credentials.response; try { + if (args.mode === "code-mappings") { + const mappings = await getCodeMappings(args.orgSlug); + return { mappings }; + } + if (args.mode === "organizations") { const organizations = await listOrganizations(); return { organizations, total: organizations.length }; diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/GitHubBlamePanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/GitHubBlamePanel.tsx new file mode 100644 index 0000000000..6cf340ded5 --- /dev/null +++ b/templates/analytics/app/pages/adhoc/sentry-errors/GitHubBlamePanel.tsx @@ -0,0 +1,348 @@ +import { useState, useEffect } from "react"; +import { useActionMutation, useActionQuery } from "@agent-native/core/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + IconBrandGithub, + IconGitCommit, + IconExternalLink, + IconAlertTriangle, + IconRefresh, + IconSettings, +} from "@tabler/icons-react"; +import type { SentryIssue } from "./index"; + +// ---- Types ------------------------------------------------------------------ + +interface BlameCommit { + oid: string; + abbreviatedOid: string; + message: string; + committedDate: string; + url: string; + author: { name: string; email: string; avatarUrl?: string }; + associatedPullRequests?: { + nodes: { number: number; title: string; url: string }[]; + }; +} + +interface BlameRange { + startingLine: number; + endingLine: number; + age: number; + commit: BlameCommit; +} + +interface BlameResult { + owner: string; + repo: string; + path: string; + ranges: BlameRange[]; + latestCommit: BlameCommit | null; + hotRange: BlameRange | null; + error?: string; +} + +interface CodeMapping { + projectSlug: string; + repoName: string; + stackRoot: string; + sourceRoot: string; +} + +// ---- Helpers ---------------------------------------------------------------- + +function parseCulpritPath(issue: SentryIssue): string { + // Prefer the explicit filename from metadata + if (issue.metadata.filename) return issue.metadata.filename; + // Culprit is usually "path/to/file.ts in functionName" + const culprit = issue.culprit ?? ""; + const inIdx = culprit.indexOf(" in "); + return inIdx !== -1 ? culprit.slice(0, inIdx).trim() : culprit.trim(); +} + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const days = Math.floor(diff / 86_400_000); + if (days === 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + return `${Math.floor(months / 12)}y ago`; +} + +function getRepoFromStorage(): string { + return localStorage.getItem("sentry_github_repo") ?? ""; +} +function saveRepoToStorage(repo: string) { + localStorage.setItem("sentry_github_repo", repo); +} + +// ---- Main Component --------------------------------------------------------- + +interface GitHubBlamePanelProps { + issue: SentryIssue; +} + +export function GitHubBlamePanel({ issue }: GitHubBlamePanelProps) { + const filePath = parseCulpritPath(issue); + const [repo, setRepo] = useState(getRepoFromStorage); + const [repoInput, setRepoInput] = useState(getRepoFromStorage); + const [editingRepo, setEditingRepo] = useState(!getRepoFromStorage()); + const [result, setResult] = useState(null); + const [fetched, setFetched] = useState(false); + + const mutation = useActionMutation("github-blame"); + + // Auto-detect repo from Sentry code mappings + const mappingsQuery = useActionQuery("sentry", { mode: "code-mappings" }); + const mappingsData = mappingsQuery.data as { + mappings?: CodeMapping[]; + } | null; + + useEffect(() => { + if (repo || !mappingsData?.mappings) return; + const projectSlug = issue.project.slug; + // Try to find a mapping for this project, or fall back to the first mapping + const match = + mappingsData.mappings.find((m) => m.projectSlug === projectSlug) ?? + mappingsData.mappings[0]; + if (match?.repoName) { + setRepo(match.repoName); + setRepoInput(match.repoName); + saveRepoToStorage(match.repoName); + setEditingRepo(false); + } + }, [mappingsData, issue.project.slug, repo]); + + const dataError = + result?.error ?? + (mutation.error ? (mutation.error as Error).message : null); + + function runBlame(repoSlug: string) { + if (!repoSlug || !filePath) return; + const [owner, repoName] = repoSlug.split("/"); + if (!owner || !repoName) return; + setFetched(true); + mutation.mutate( + { owner, repo: repoName, path: filePath }, + { onSuccess: (data) => setResult(data as BlameResult) }, + ); + } + + function handleSaveRepo() { + const slug = repoInput.trim(); + setRepo(slug); + saveRepoToStorage(slug); + setEditingRepo(false); + runBlame(slug); + } + + const latestCommit = result?.latestCommit; + const pr = latestCommit?.associatedPullRequests?.nodes?.[0]; + + // Top-N unique authors weighted by recency + const authors = result?.ranges + ? Object.values( + result.ranges.reduce< + Record< + string, + { name: string; email: string; age: number; lines: number } + > + >((acc, r) => { + const key = r.commit.author.email; + if (!acc[key]) { + acc[key] = { + name: r.commit.author.name, + email: key, + age: r.age, + lines: 0, + }; + } + acc[key].lines += r.endingLine - r.startingLine + 1; + if (r.age < acc[key].age) acc[key].age = r.age; + return acc; + }, {}), + ).sort((a, b) => b.lines - a.lines) + : []; + + return ( +
+ {/* Header */} +
+
+ + GitHub blame + {filePath && ( + + · {filePath.split("/").pop()} + + )} +
+
+ {fetched && !editingRepo && ( + + )} + +
+
+ + {/* Repo config */} + {editingRepo && ( +
+ setRepoInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveRepo(); + if (e.key === "Escape") setEditingRepo(false); + }} + placeholder="owner/repo (e.g. acme/backend)" + className="h-8 text-xs font-mono" + autoFocus + /> + +
+ )} + + {/* No file path */} + {!filePath && !editingRepo && ( +

+ No file path found in this issue's culprit. +

+ )} + + {/* Prompt to configure repo */} + {!editingRepo && !repo && filePath && ( + + )} + + {/* Loading */} + {mutation.isPending && ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ )} + + {/* Error */} + {!mutation.isPending && dataError && ( +
+ + {dataError} +
+ )} + + {/* Results */} + {!mutation.isPending && !dataError && latestCommit && ( +
+ {/* Latest commit card */} + +
+
+ + {pr ? ( + + PR #{pr.number}{" "} + + · {pr.title} + + + ) : ( + + {latestCommit.abbreviatedOid} + + )} +
+ +
+

+ {latestCommit.message.split("\n")[0]} +

+
+ + {latestCommit.author.name} + + · + {timeAgo(latestCommit.committedDate)} +
+
+ + {/* Author breakdown */} + {authors.length > 1 && ( +
+

+ File ownership +

+ {authors.slice(0, 4).map((a) => ( +
+
+ {a.name[0]} +
+ + {a.name} + + + {a.lines} lines · last {a.age}d ago + +
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx index 7c36f89b2f..962c9a9658 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx @@ -31,6 +31,7 @@ import { import { IssueSparkline } from "./IssueSparkline"; import { ErrorGroupsPanel } from "./ErrorGroupsPanel"; import { SlackMentionsPanel } from "./SlackMentionsPanel"; +import { GitHubBlamePanel } from "./GitHubBlamePanel"; import { classifyIssue, classificationLabel, @@ -308,6 +309,7 @@ function IssueDetail({ issue }: { issue: SentryIssue }) {
+
); diff --git a/templates/analytics/server/lib/github.ts b/templates/analytics/server/lib/github.ts index bd6b401a20..da5f8da205 100644 --- a/templates/analytics/server/lib/github.ts +++ b/templates/analytics/server/lib/github.ts @@ -420,3 +420,119 @@ export async function searchOrgPRs(opts: { return searchPRs({ query: q, type: "pr", limit }); } + +// ─── File blame (last commit per line range) ────────────────────────────────── + +export interface BlameRange { + startingLine: number; + endingLine: number; + age: number; // days since last commit + commit: { + oid: string; + abbreviatedOid: string; + message: string; + committedDate: string; + url: string; + author: { + name: string; + email: string; + avatarUrl?: string; + }; + associatedPullRequests?: { + nodes: { number: number; title: string; url: string }[]; + }; + }; +} + +export interface FileBlameResult { + owner: string; + repo: string; + path: string; + ref: string; + ranges: BlameRange[]; + /** The single most recent commit touching this file */ + latestCommit: BlameRange["commit"] | null; + /** The most recent blame range (covers the most recently touched lines) */ + hotRange: BlameRange | null; +} + +export async function getFileBlame( + owner: string, + repo: string, + path: string, + ref = "HEAD", +): Promise { + const query = ` + query FileBlame($owner: String!, $repo: String!, $path: String!, $ref: String!) { + repository(owner: $owner, name: $repo) { + object(expression: $ref) { + ... on Commit { + blame(path: $path) { + ranges { + startingLine + endingLine + commit { + oid + abbreviatedOid + message + committedDate + url + author { + name + email + avatarUrl + } + associatedPullRequests(first: 1) { + nodes { + number + title + url + } + } + } + } + } + } + } + } + } + `; + + const data = await graphql<{ + repository: { + object: { + blame: { + ranges: { + startingLine: number; + endingLine: number; + commit: BlameRange["commit"]; + }[]; + }; + } | null; + } | null; + }>(query, { owner, repo, path, ref }); + + const rawRanges = data?.repository?.object?.blame?.ranges ?? []; + + const ranges: BlameRange[] = rawRanges.map((r) => ({ + ...r, + age: Math.floor( + (Date.now() - new Date(r.commit.committedDate).getTime()) / + (1000 * 60 * 60 * 24), + ), + })); + + // Most recently committed range + const hotRange = + ranges.length > 0 + ? ranges.reduce((a, b) => + new Date(a.commit.committedDate) > new Date(b.commit.committedDate) + ? a + : b, + ) + : null; + + const latestCommit = hotRange?.commit ?? null; + + return { owner, repo, path, ref, ranges, latestCommit, hotRange }; +} diff --git a/templates/analytics/server/lib/sentry.ts b/templates/analytics/server/lib/sentry.ts index 46b82bf743..86e0eacc89 100644 --- a/templates/analytics/server/lib/sentry.ts +++ b/templates/analytics/server/lib/sentry.ts @@ -199,3 +199,37 @@ export async function getOrganizationStats( `/organizations/${org}/stats_v2/?${params.toString()}`, ); } + +export interface SentryCodeMapping { + id: string; + projectSlug: string; + repoName: string; // "owner/repo" + sourceRoot: string; + stackRoot: string; + defaultBranch: string; +} + +export async function getCodeMappings( + orgSlug?: string, +): Promise { + const org = await getOrgSlug(orgSlug); + const raw = await apiGet< + { + id: string; + project: { slug: string }; + repoName?: string; + repository?: { name: string }; + sourceRoot: string; + stackRoot: string; + defaultBranch: string; + }[] + >(`/organizations/${org}/code-mappings/`); + return raw.map((m) => ({ + id: m.id, + projectSlug: m.project?.slug ?? "", + repoName: m.repoName ?? m.repository?.name ?? "", + sourceRoot: m.sourceRoot ?? "", + stackRoot: m.stackRoot ?? "", + defaultBranch: m.defaultBranch ?? "main", + })); +} From 7cb07347698ec040a99eac0f629a2de0ca458bac Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 21:12:41 +0000 Subject: [PATCH 20/23] remove GitHub blame panel from Sentry errors page --- templates/analytics/app/pages/adhoc/sentry-errors/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx index 962c9a9658..7c36f89b2f 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx @@ -31,7 +31,6 @@ import { import { IssueSparkline } from "./IssueSparkline"; import { ErrorGroupsPanel } from "./ErrorGroupsPanel"; import { SlackMentionsPanel } from "./SlackMentionsPanel"; -import { GitHubBlamePanel } from "./GitHubBlamePanel"; import { classifyIssue, classificationLabel, @@ -309,7 +308,6 @@ function IssueDetail({ issue }: { issue: SentryIssue }) {
-
); From db032aa37bb5ffe46662e15bc61467290c7ae343 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 21:15:51 +0000 Subject: [PATCH 21/23] slack: include thread replies in search results for context --- .../sentry-errors/SlackMentionsPanel.tsx | 173 ++++++++++++++---- templates/analytics/server/lib/slack.ts | 52 +++++- 2 files changed, 181 insertions(+), 44 deletions(-) diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx index d014679bbe..732fb8d604 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useState as useLocalState } from "react"; import { useActionMutation } from "@agent-native/core/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -11,6 +12,8 @@ import { IconExternalLink, IconRefresh, IconX, + IconChevronDown, + IconChevronUp, } from "@tabler/icons-react"; import type { SentryIssue } from "./index"; @@ -26,6 +29,7 @@ interface SlackMessage { reply_count?: number; channel?: { id: string; name: string } | string; permalink?: string; + replies?: SlackMessage[]; } interface SlackUser { @@ -103,6 +107,49 @@ function buildSearchQuery(issue: SentryIssue): string { return parts.join(" ").replace(/['"]/g, "").trim(); } +// ---- Sub-components --------------------------------------------------------- + +function MessageContent({ + name, + text, + ts, + channelName, + showLink, +}: { + name: string; + text: string; + ts: string; + channelName?: string; + showLink?: boolean; +}) { + return ( + <> +
+ {name[0] ?? "?"} +
+
+
+ {name} + + {tsToDate(ts)} + + {channelName && ( + + #{channelName} + + )} +
+

+ {text} +

+
+ {showLink && ( + + )} + + ); +} + // ---- Main Component --------------------------------------------------------- interface SlackMentionsPanelProps { @@ -115,6 +162,9 @@ export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) { const [editingQuery, setEditingQuery] = useState(false); const [result, setResult] = useState(null); const [searched, setSearched] = useState(false); + const [expandedThreads, setExpandedThreads] = useLocalState>( + new Set(), + ); const mutation = useActionMutation("slack-messages"); @@ -271,55 +321,100 @@ export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) {
) : ( -
+
{messages.map((msg) => { const name = resolveUsername(msg, users); const text = stripSlackFormatting(msg.text); const channelName = typeof msg.channel === "string" ? msg.channel : msg.channel?.name; - const Row = msg.permalink ? "a" : "div"; - const rowProps = msg.permalink - ? { - href: msg.permalink, - target: "_blank" as const, - rel: "noopener noreferrer", - } - : {}; + const hasThread = msg.replies && msg.replies.length > 0; + const isExpanded = expandedThreads.has(msg.ts); + return ( - -
- {name[0] ?? "?"} +
+ {/* Parent message */} +
+ {msg.permalink ? ( + + + + ) : ( +
+ +
+ )}
-
-
- {name} - - {tsToDate(msg.ts)} - - {channelName && ( - - #{channelName} - + + {/* Thread toggle */} + {hasThread && ( +
+ + + {isExpanded && ( +
+ {msg.replies!.map((reply) => { + const rName = resolveUsername(reply, users); + const rText = stripSlackFormatting(reply.text); + return ( +
+
+ {rName[0] ?? "?"} +
+
+ + {rName} + + + {tsToDate(reply.ts)} + +

+ {rText} +

+
+
+ ); + })} +
)} - {msg.reply_count ? ( - - {msg.reply_count} repl - {msg.reply_count !== 1 ? "ies" : "y"} - - ) : null}
-

- {text} -

-
- {msg.permalink && ( - )} - +
); })}
diff --git a/templates/analytics/server/lib/slack.ts b/templates/analytics/server/lib/slack.ts index 5d9770e05a..bdbd122565 100644 --- a/templates/analytics/server/lib/slack.ts +++ b/templates/analytics/server/lib/slack.ts @@ -104,6 +104,7 @@ export interface SlackMessage { icons?: { image_48?: string; image_72?: string }; channel?: { id: string; name: string } | string; permalink?: string; + replies?: SlackMessage[]; } export interface SlackBotInfo { @@ -273,6 +274,47 @@ export async function getChannelHistory( } } +async function fetchThreadReplies( + workspace: Workspace, + channelId: string, + threadTs: string, + limit = 10, +): Promise { + try { + const data = await slackApi<{ messages: SlackMessage[] }>( + workspace, + "conversations.replies", + { channel: channelId, ts: threadTs, limit: String(limit) }, + false, + ); + // First message is the parent — skip it, return only replies + return (data.messages ?? []).slice(1); + } catch { + return []; + } +} + +async function enrichWithThreads( + workspace: Workspace, + messages: SlackMessage[], +): Promise { + return Promise.all( + messages.map(async (msg) => { + if (!msg.reply_count || msg.reply_count === 0) return msg; + const channelId = + typeof msg.channel === "string" ? msg.channel : msg.channel?.id; + if (!channelId) return msg; + const replies = await fetchThreadReplies( + workspace, + channelId, + msg.thread_ts ?? msg.ts, + 8, + ); + return { ...msg, replies }; + }), + ); +} + export async function searchMessages( workspace: Workspace, query: string, @@ -301,10 +343,9 @@ export async function searchMessages( messages?: { matches: SlackMessage[]; total: number }; }; if (json.ok) { - return { - messages: json.messages?.matches || [], - total: json.messages?.total || 0, - }; + const matches = json.messages?.matches || []; + const enriched = await enrichWithThreads(workspace, matches); + return { messages: enriched, total: json.messages?.total || 0 }; } // fall through to bot-token scan on token errors } @@ -414,7 +455,8 @@ export async function searchMessages( console.log("[slack-search] total matches:", matches.length); matches.sort((a, b) => parseFloat(b.ts) - parseFloat(a.ts)); const limited = matches.slice(0, count); - return { messages: limited, total: matches.length }; + const enriched = await enrichWithThreads(workspace, limited); + return { messages: enriched, total: matches.length }; } export async function getUserInfo( From bbfd0b5ecc25fea8d7cd71127543f23057f46eb1 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 21:18:04 +0000 Subject: [PATCH 22/23] fix: remove duplicate useState import in SlackMentionsPanel --- .../app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx index 732fb8d604..144d45097c 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx @@ -1,5 +1,4 @@ import { useState } from "react"; -import { useState as useLocalState } from "react"; import { useActionMutation } from "@agent-native/core/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -162,7 +161,7 @@ export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) { const [editingQuery, setEditingQuery] = useState(false); const [result, setResult] = useState(null); const [searched, setSearched] = useState(false); - const [expandedThreads, setExpandedThreads] = useLocalState>( + const [expandedThreads, setExpandedThreads] = useState>( new Set(), ); From 6ab24c281483ed2b410de2663e2768b0475d0287 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 12 May 2026 21:23:16 +0000 Subject: [PATCH 23/23] analytics: thread summarization + unified thread fetching --- .../sentry-errors/SlackMentionsPanel.tsx | 78 +++++++++++++------ templates/analytics/server/lib/slack.ts | 27 ++++--- 2 files changed, 69 insertions(+), 36 deletions(-) diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx index 144d45097c..bba9ee05a0 100644 --- a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx +++ b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useActionMutation } from "@agent-native/core/client"; +import { useActionMutation, sendToAgentChat } from "@agent-native/core/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; @@ -13,6 +13,7 @@ import { IconX, IconChevronDown, IconChevronUp, + IconSparkles, } from "@tabler/icons-react"; import type { SentryIssue } from "./index"; @@ -360,33 +361,60 @@ export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) { )}
- {/* Thread toggle */} + {/* Thread section */} {hasThread && ( -
- +
+ {/* Toggle */} +
+ + +
+ {/* Replies */} {isExpanded && ( -
+
{msg.replies!.map((reply) => { const rName = resolveUsername(reply, users); const rText = stripSlackFormatting(reply.text); diff --git a/templates/analytics/server/lib/slack.ts b/templates/analytics/server/lib/slack.ts index bdbd122565..c667dd51d2 100644 --- a/templates/analytics/server/lib/slack.ts +++ b/templates/analytics/server/lib/slack.ts @@ -274,11 +274,11 @@ export async function getChannelHistory( } } -async function fetchThreadReplies( +async function fetchFullThread( workspace: Workspace, channelId: string, threadTs: string, - limit = 10, + limit = 20, ): Promise { try { const data = await slackApi<{ messages: SlackMessage[] }>( @@ -287,8 +287,7 @@ async function fetchThreadReplies( { channel: channelId, ts: threadTs, limit: String(limit) }, false, ); - // First message is the parent — skip it, return only replies - return (data.messages ?? []).slice(1); + return data.messages ?? []; } catch { return []; } @@ -300,16 +299,22 @@ async function enrichWithThreads( ): Promise { return Promise.all( messages.map(async (msg) => { - if (!msg.reply_count || msg.reply_count === 0) return msg; const channelId = typeof msg.channel === "string" ? msg.channel : msg.channel?.id; if (!channelId) return msg; - const replies = await fetchThreadReplies( - workspace, - channelId, - msg.thread_ts ?? msg.ts, - 8, - ); + + // Determine the thread root timestamp: + // - If msg is a reply (thread_ts differs from ts), the root is thread_ts + // - If msg is a parent with replies (reply_count > 0), the root is ts + const isReply = msg.thread_ts && msg.thread_ts !== msg.ts; + const hasReplies = (msg.reply_count ?? 0) > 0; + + if (!isReply && !hasReplies) return msg; + + const threadRootTs = isReply ? msg.thread_ts! : msg.ts; + const thread = await fetchFullThread(workspace, channelId, threadRootTs); + // thread[0] is the parent message; the rest are replies + const replies = thread.slice(1); return { ...msg, replies }; }), );