sourcemap view
This commit is contained in:
1 parent
412e430cac
commit
97743ea21c
10 files changed
+452
-147
No files matched your search
@@ -293,6 +293,7 @@ func main() {
|
||||
// Source-map uploads are intentionally public so the release build script
|
||||
// can upload maps without an OIDC session or bearer token.
|
||||
uiMux.HandleFunc("POST /api/sourcemaps", coll.HandleSourceMap)
|
||||
uiMux.Handle("POST /api/sourcemaps/resolve", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleResolveStacks)))
|
||||
uiMux.HandleFunc("GET /api/needle/status", coll.HandleNeedleStatus)
|
||||
uiMux.HandleFunc("POST /api/needle/classify", coll.HandleNeedleClassify)
|
||||
|
||||
|
||||
@@ -500,6 +500,31 @@ func (c *Collector) HandleSourceMap(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// HandleResolveStacks maps raw browser stacktraces back to source using the
|
||||
// uploaded source maps. Body: {"items":[{"release":"...","stacktrace":"..."}]};
|
||||
// the response has one resolved stacktrace per item, in order.
|
||||
func (c *Collector) HandleResolveStacks(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Items []struct {
|
||||
Release string `json:"release"`
|
||||
Stacktrace string `json:"stacktrace"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
out := make([]string, len(req.Items))
|
||||
for i, it := range req.Items {
|
||||
out[i] = it.Stacktrace
|
||||
if c.sourceMaps != nil {
|
||||
out[i] = c.sourceMaps.ResolveStack(it.Release, it.Stacktrace)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"resolved": out})
|
||||
}
|
||||
|
||||
// HandleIssueByID returns a single issue by id, including its samples.
|
||||
func (c *Collector) HandleIssueByID(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
+3
-1
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { AlertCircle, CloudCog, Lock } from "lucide-react";
|
||||
import { useObservabilityData } from "./hooks/useObservabilityData";
|
||||
import { IconRail } from "./components/layout/IconRail";
|
||||
import { AnomalyRadarBar } from "./components/AnomalyRadarBar";
|
||||
import { TopBar } from "./components/layout/TopBar";
|
||||
import { CommandAndQueryBar } from "./components/CommandAndQueryBar";
|
||||
import { WaveformHistogram } from "./components/WaveformHistogram";
|
||||
@@ -91,10 +92,11 @@ function App() {
|
||||
|
||||
{/* Main Telemetry Console View */}
|
||||
<main className="relative w-full bg-ds-bg pb-12 pt-[58px]">
|
||||
{!isTriageOverview && <AnomalyRadarBar />}
|
||||
<div className="flex w-full flex-col px-6">
|
||||
{!isTriageOverview && (
|
||||
<>
|
||||
{/* Real Anomaly Radar & Filter Bar */}
|
||||
{/* Filter Bar */}
|
||||
<CommandAndQueryBar />
|
||||
|
||||
{/* Ingest & Incident Waveform Histogram */}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { AlertTriangle, ArrowRight } from "lucide-react"
|
||||
import React from "react"
|
||||
import { useObservabilityData } from "../hooks/useObservabilityData"
|
||||
import { useIssues } from "../hooks/useIssues"
|
||||
import { getServiceName, isSpanError } from "../lib/otel-utils"
|
||||
|
||||
/** Full-width alert strip docked under the top bar; renders nothing when there is nothing anomalous. */
|
||||
export const AnomalyRadarBar: React.FC = () => {
|
||||
const { logs, traces, showErrorsOnly, setShowErrorsOnly, setActiveTab } = useObservabilityData()
|
||||
const { issues } = useIssues()
|
||||
|
||||
const errorStats = React.useMemo(() => {
|
||||
let errorCount = 0
|
||||
const services = new Set<string>()
|
||||
const note = (name: string) => { if (name !== "unknown-service") services.add(name) }
|
||||
|
||||
logs.forEach((l) =>
|
||||
(l.scopeLogs ?? []).forEach((sl) =>
|
||||
(sl.logRecords ?? []).forEach((r) => {
|
||||
if ((r.severityNumber ?? 0) >= 17) {
|
||||
errorCount++
|
||||
note(getServiceName(l.resource?.attributes ?? [], r.attributes ?? []))
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
traces.forEach((t) =>
|
||||
(t.resourceSpans ?? []).forEach((rs) =>
|
||||
(rs.scopeSpans ?? []).forEach((ss) =>
|
||||
(ss.spans ?? []).forEach((s) => {
|
||||
if (isSpanError(s)) {
|
||||
errorCount++
|
||||
note(getServiceName(rs.resource?.attributes ?? [], s.attributes ?? []))
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return { errorCount, services: Array.from(services) }
|
||||
}, [logs, traces])
|
||||
|
||||
const topOpenIssue = React.useMemo(
|
||||
() => issues.filter((i) => !i.status || i.status === "open").sort((a, b) => b.count - a.count)[0] ?? null,
|
||||
[issues],
|
||||
)
|
||||
|
||||
if (errorStats.errorCount === 0 && !topOpenIssue) return null
|
||||
|
||||
const services = errorStats.services.slice(0, 3).join(", ") + (errorStats.services.length > 3 ? ` +${errorStats.services.length - 3}` : "")
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="sticky top-[58px] z-30 flex flex-wrap items-center gap-x-4 gap-y-1.5 border-b-2 border-ds-critical bg-ds-critical-bg px-6 py-2 font-[family-name:var(--font-ui)]"
|
||||
>
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.1em] text-ds-critical">
|
||||
<AlertTriangle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Anomaly radar
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ds-text">
|
||||
{errorStats.errorCount > 0 && (
|
||||
<><strong className="font-semibold">{errorStats.errorCount} errors</strong> in the active window{services && <span className="text-ds-text-2"> · {services}</span>}</>
|
||||
)}
|
||||
{topOpenIssue && (
|
||||
<span className="text-ds-text-2">
|
||||
{errorStats.errorCount > 0 ? " · top issue " : ""}
|
||||
<span className="font-medium text-ds-text">{topOpenIssue.title}</span> ×{topOpenIssue.count} ({topOpenIssue.service})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!showErrorsOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowErrorsOnly(true)}
|
||||
className="inline-flex h-7 items-center rounded-[8px] border border-ds-critical/40 px-2.5 text-[12px] font-medium text-ds-critical hover:bg-ds-critical/10"
|
||||
>
|
||||
Isolate errors
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("tab0")}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-[8px] bg-ds-critical px-2.5 text-[12px] font-semibold text-white hover:opacity-90"
|
||||
>
|
||||
Triage issues <ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnomalyRadarBar
|
||||
@@ -1,8 +1,6 @@
|
||||
import { AlertTriangle, Download } from "lucide-react"
|
||||
import { Download } from "lucide-react"
|
||||
import React from "react";
|
||||
import { useObservabilityData } from "../hooks/useObservabilityData";
|
||||
import { useIssues } from "../hooks/useIssues";
|
||||
import { getServiceName, isSpanError } from "../lib/otel-utils";
|
||||
|
||||
export const CommandAndQueryBar: React.FC = () => {
|
||||
const {
|
||||
@@ -21,58 +19,8 @@ export const CommandAndQueryBar: React.FC = () => {
|
||||
setMinDurationMs,
|
||||
timeWindow,
|
||||
setTimeWindow,
|
||||
setActiveTab,
|
||||
} = useObservabilityData();
|
||||
|
||||
const { issues } = useIssues();
|
||||
|
||||
// Compute real error metrics from filtered signals
|
||||
const errorStats = React.useMemo(() => {
|
||||
let errorCount = 0;
|
||||
const servicesWithErrors = new Set<string>();
|
||||
|
||||
logs.forEach((l) =>
|
||||
(l.scopeLogs ?? []).forEach((sl) =>
|
||||
(sl.logRecords ?? []).forEach((r) => {
|
||||
if ((r.severityNumber ?? 0) >= 17) {
|
||||
errorCount++;
|
||||
const sName = getServiceName(l.resource?.attributes ?? [], r.attributes ?? []);
|
||||
if (sName !== "unknown-service") {
|
||||
servicesWithErrors.add(sName);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
traces.forEach((t) =>
|
||||
(t.resourceSpans ?? []).forEach((rs) =>
|
||||
(rs.scopeSpans ?? []).forEach((ss) =>
|
||||
(ss.spans ?? []).forEach((s) => {
|
||||
if (isSpanError(s)) {
|
||||
errorCount++;
|
||||
const sName = getServiceName(rs.resource?.attributes ?? [], s.attributes ?? []);
|
||||
if (sName !== "unknown-service") {
|
||||
servicesWithErrors.add(sName);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
errorCount,
|
||||
services: Array.from(servicesWithErrors),
|
||||
};
|
||||
}, [logs, traces]);
|
||||
|
||||
// Top open issue if available
|
||||
const topOpenIssue = React.useMemo(() => {
|
||||
const open = issues.filter((i) => !i.status || i.status === "open");
|
||||
return open.sort((a, b) => b.count - a.count)[0] || null;
|
||||
}, [issues]);
|
||||
|
||||
const totalFilteredEvents = logs.length + traces.length + metrics.length;
|
||||
|
||||
const exportNDJSON = () => {
|
||||
@@ -100,53 +48,6 @@ export const CommandAndQueryBar: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5 mb-4 font-body">
|
||||
{/* Real Anomaly Radar Banner */}
|
||||
{(errorStats.errorCount > 0 || topOpenIssue) && (
|
||||
<div className="rounded-xl border border-ds-critical/30 bg-ds-surface-2 p-3 flex flex-col sm:flex-row sm:items-center justify-between gap-3 animate-in fade-in duration-300">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-ds-critical-bg text-ds-critical border border-ds-critical/40">
|
||||
<AlertTriangle className="h-[18px] w-[18px] shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs font-bold text-ds-critical uppercase tracking-wider">
|
||||
Anomaly Radar:
|
||||
</span>
|
||||
<span className="font-mono text-xs text-ds-text truncate">
|
||||
{errorStats.errorCount > 0
|
||||
? `${errorStats.errorCount} errors in active window (${errorStats.services.join(", ") || "various"})`
|
||||
: `${topOpenIssue?.count} occurrences of "${topOpenIssue?.title}"`}
|
||||
</span>
|
||||
</div>
|
||||
{topOpenIssue && (
|
||||
<span className="text-[11px] text-ds-text-2 truncate">
|
||||
Top Issue: <span className="text-ds-text font-semibold">{topOpenIssue.title}</span> (Service: {topOpenIssue.service}, ×{topOpenIssue.count})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("tab0")}
|
||||
className="px-3 py-1 rounded-lg bg-ds-critical hover:bg-ds-critical text-ds-text font-mono text-xs font-bold transition-colors"
|
||||
>
|
||||
Triage Issues
|
||||
</button>
|
||||
{!showErrorsOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowErrorsOnly(true)}
|
||||
className="px-2.5 py-1 rounded-lg bg-ds-fill hover:bg-ds-fill text-ds-text font-mono text-xs transition-colors"
|
||||
>
|
||||
Isolate Errors
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter and Telemetry Status Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2.5 p-3 rounded-xl bg-ds-bg border border-ds-line">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
|
||||
@@ -37,7 +37,7 @@ import { NeedleTestModal } from "./NeedleTestModal"
|
||||
import { FilterBar, type FilterChip } from "./FilterBar"
|
||||
import type { Issue, IssueSample } from "../lib/types"
|
||||
|
||||
const kindMeta: Record<string, { label: string; Icon: typeof Activity }> = {
|
||||
export const kindMeta: Record<string, { label: string; Icon: typeof Activity }> = {
|
||||
span_error: { label: "Trace error", Icon: Activity },
|
||||
log_error: { label: "Log error", Icon: ScrollText },
|
||||
frontend_error: { label: "Frontend error", Icon: Globe },
|
||||
@@ -98,7 +98,7 @@ const toneDot: Record<SeverityTone, string> = {
|
||||
muted: "bg-ds-text-3",
|
||||
}
|
||||
|
||||
const tonePill: Record<SeverityTone, string> = {
|
||||
export const tonePill: Record<SeverityTone, string> = {
|
||||
critical: "bg-ds-critical-bg text-ds-critical",
|
||||
warn: "bg-ds-warn-bg text-ds-warn",
|
||||
ok: "bg-ds-ok/10 text-ds-ok",
|
||||
@@ -112,7 +112,7 @@ const toneLabel: Record<SeverityTone, string> = {
|
||||
muted: "Ignored",
|
||||
}
|
||||
|
||||
const toneLine: Record<SeverityTone, string> = {
|
||||
export const toneLine: Record<SeverityTone, string> = {
|
||||
critical: "border-l-ds-critical",
|
||||
warn: "border-l-ds-warn",
|
||||
ok: "border-l-ds-ok",
|
||||
@@ -120,7 +120,7 @@ const toneLine: Record<SeverityTone, string> = {
|
||||
}
|
||||
|
||||
/** The sparkline + expected-band + breach segment described by the anomaly presentation spec. */
|
||||
const IssueSparkline = ({ issue }: { issue: Issue }) => {
|
||||
export const IssueSparkline = ({ issue }: { issue: Issue }) => {
|
||||
const { points, max, reference, breachIndex } = sparklineSeries(issue, 24)
|
||||
const width = 100
|
||||
const height = 100
|
||||
@@ -180,7 +180,7 @@ const IssueSparkline = ({ issue }: { issue: Issue }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const StatCell = ({ label, value, tone }: { label: string; value: ReactNode; tone?: SeverityTone }) => (
|
||||
export const StatCell = ({ label, value, tone }: { label: string; value: ReactNode; tone?: SeverityTone }) => (
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.09em] text-ds-text-3">{label}</span>
|
||||
<span
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { GitBranch, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react"
|
||||
import { Bug, ChevronDown, ChevronUp, GitBranch, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react"
|
||||
import { usePatterns } from "../hooks/usePatterns"
|
||||
import { formatRelativeTime } from "../lib/timeUtils"
|
||||
import type { Issue, SimilarIssue } from "../lib/types"
|
||||
import { issueSeverityTone, issueStatusOf, type SeverityTone } from "../lib/issue-utils"
|
||||
import { IssueSparkline, StatCell, kindMeta, toneLine, tonePill } from "./IssuesVisualizer"
|
||||
import type { Issue, IssuePattern, SimilarIssue } from "../lib/types"
|
||||
|
||||
const PatternIssues = ({ pattern, issues, similar, review }: {
|
||||
pattern: { issueIds: string[] }
|
||||
issues: Issue[]
|
||||
const toneRank: Record<SeverityTone, number> = { critical: 3, warn: 2, muted: 1, ok: 0 }
|
||||
|
||||
/** Folds member issues into one synthetic issue so the shared sparkline can chart the whole pattern. */
|
||||
const mergeIssues = (members: Issue[]): Issue => {
|
||||
const trend: Record<string, number> = {}
|
||||
members.forEach((m) => Object.entries(m.trend ?? {}).forEach(([hour, n]) => { trend[hour] = (trend[hour] ?? 0) + n }))
|
||||
return { ...members[0], trend }
|
||||
}
|
||||
|
||||
const PatternCard = ({ pattern, members, similar, review }: {
|
||||
pattern: IssuePattern
|
||||
members: Issue[]
|
||||
similar: (id: string) => Promise<SimilarIssue[]>
|
||||
review: (id: string, otherId: string, decision: "same" | "different") => Promise<void>
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [related, setRelated] = useState<Record<string, SimilarIssue[]>>({})
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const members = pattern.issueIds.map((id) => issues.find((issue) => issue.id === id)).filter(Boolean) as Issue[]
|
||||
|
||||
const toggle = async (issue: Issue) => {
|
||||
if (expanded) { setExpanded(false); return }
|
||||
setExpanded(true)
|
||||
const merged = useMemo(() => (members.length ? mergeIssues(members) : null), [members])
|
||||
const tone = members.reduce<SeverityTone>((worst, m) => (toneRank[issueSeverityTone(m)] > toneRank[worst] ? issueSeverityTone(m) : worst), "ok")
|
||||
const openCount = members.filter((m) => issueStatusOf(m) === "open").length
|
||||
const meta = kindMeta[pattern.kind] ?? { label: pattern.kind, Icon: Bug }
|
||||
const title = [...members].sort((a, b) => b.count - a.count)[0]?.title ?? pattern.signature.split("\u0000")[0]
|
||||
|
||||
const select = async (issue: Issue) => {
|
||||
if (selected === issue.id) { setSelected(null); return }
|
||||
setSelected(issue.id)
|
||||
if (!related[issue.id]) {
|
||||
const suggestions = await similar(issue.id)
|
||||
setRelated((prev) => ({ ...prev, [issue.id]: suggestions }))
|
||||
@@ -29,48 +46,148 @@ const PatternIssues = ({ pattern, issues, similar, review }: {
|
||||
setBusy(key)
|
||||
try {
|
||||
await review(issue.id, item.issue.id, decision)
|
||||
setRelated((prev) => ({ ...prev, [issue.id]: (prev[issue.id] ?? []).map((entry) => entry.issue.id === item.issue.id ? { ...entry, decision } : entry) }))
|
||||
setRelated((prev) => ({ ...prev, [issue.id]: (prev[issue.id] ?? []).map((e) => e.issue.id === item.issue.id ? { ...e, decision } : e) }))
|
||||
} finally { setBusy(null) }
|
||||
}
|
||||
|
||||
const selectedIssue = members.find((m) => m.id === selected)
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2 border-t border-ds-line pt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{members.map((issue) => <button key={issue.id} onClick={() => void toggle(issue)} className="rounded-lg border border-ds-line bg-ds-surface-2 px-2 py-1 text-left text-[10px] text-ds-text-2 hover:border-ds-accent/40">{issue.title} <span className="text-ds-text-3">×{issue.count}</span></button>)}
|
||||
</div>
|
||||
{expanded && members[0] && <div className="space-y-2 rounded-lg bg-ds-surface-2 p-3">
|
||||
<div className="text-[10px] uppercase tracking-widest text-ds-text-3">Deterministic suggestions for {members[0].title}</div>
|
||||
{(related[members[0].id] ?? []).map((item) => {
|
||||
const key = `${members[0].id}:${item.issue.id}`
|
||||
return <div key={item.issue.id} className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span className="text-ds-text-2">{item.issue.title}</span><span className="text-ds-accent">{Math.round(item.score * 100)}%</span><span className="text-ds-text-3">{item.reasons.join(" · ")}</span>
|
||||
<button disabled={busy === key} onClick={() => void submitReview(members[0], item, "same")} className="ml-auto inline-flex items-center gap-1 rounded border border-ds-ok/20 px-2 py-1 text-ds-ok hover:bg-ds-ok/15"><ThumbsUp className="h-3 w-3" /> same</button>
|
||||
<button disabled={busy === key} onClick={() => void submitReview(members[0], item, "different")} className="inline-flex items-center gap-1 rounded border border-ds-critical/20 px-2 py-1 text-ds-critical hover:bg-ds-critical-bg"><ThumbsDown className="h-3 w-3" /> separate</button>
|
||||
{item.decision && <span className="text-ds-text-3">reviewed: {item.decision}</span>}
|
||||
<article className={`flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] ${toneLine[tone]} bg-ds-surface`}>
|
||||
<div className="flex w-full flex-wrap items-start gap-4 px-5 pt-[18px] pb-1">
|
||||
<button type="button" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded} className="flex min-w-0 flex-1 flex-col gap-1.5 text-left">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<span className={`inline-flex h-[23px] items-center rounded-[6px] px-2 text-[10.5px] font-bold uppercase tracking-wider ${tonePill[tone]}`}>
|
||||
{openCount > 0 ? `${openCount} open` : "All closed"}
|
||||
</span>
|
||||
<span className="inline-flex h-[23px] items-center gap-1.5 rounded-[6px] border border-ds-line-strong px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-text-2">
|
||||
<meta.Icon className="h-3 w-3" aria-hidden="true" />{meta.label}
|
||||
</span>
|
||||
<span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(pattern.lastSeen / 1e6)}</span>
|
||||
</div>
|
||||
})}
|
||||
</div>}
|
||||
</div>
|
||||
<h3 className="break-all font-[family-name:var(--font-display)] text-[17px] font-bold leading-snug text-ds-text">{title}</h3>
|
||||
<p className="break-all font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">service={pattern.service} · {pattern.signature.split("\u0000").join(" · ")}</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} pattern ${title}`}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[9px] text-ds-text-3 hover:bg-ds-surface-2 hover:text-ds-text-2"
|
||||
>
|
||||
{expanded ? <ChevronUp className="h-4 w-4" aria-hidden="true" /> : <ChevronDown className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{merged && <div className="px-5"><IssueSparkline issue={merged} /></div>}
|
||||
|
||||
<div className="flex flex-wrap gap-5 border-t border-ds-fill px-5 py-3">
|
||||
<StatCell label="Exact issues" value={pattern.issueCount} />
|
||||
<StatCell label="Occurrences" value={`×${pattern.occurrenceCount}`} />
|
||||
<StatCell label="Open" value={openCount} tone={openCount > 0 ? tone : undefined} />
|
||||
<StatCell label="Last seen" value={formatRelativeTime(pattern.lastSeen / 1e6)} />
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-3 border-t border-ds-line bg-ds-bg px-5 py-4 text-[12.5px]">
|
||||
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">Member issues — select one to review similar issues</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{members.map((issue) => (
|
||||
<button
|
||||
key={issue.id}
|
||||
type="button"
|
||||
onClick={() => void select(issue)}
|
||||
aria-pressed={selected === issue.id}
|
||||
className={`flex items-center gap-3 rounded-[9px] border bg-ds-surface px-2.5 py-1.5 text-left hover:border-ds-accent/40 ${selected === issue.id ? "border-ds-accent/50" : "border-ds-line"}`}
|
||||
>
|
||||
<span className={`inline-flex h-[20px] shrink-0 items-center rounded-[6px] px-1.5 text-[10px] font-bold uppercase ${tonePill[issueSeverityTone(issue)]}`}>{issueStatusOf(issue)}</span>
|
||||
<span className="min-w-0 flex-1 break-all text-ds-text-2">{issue.title}</span>
|
||||
<span className="shrink-0 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">×{issue.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedIssue && (
|
||||
<div className="space-y-2 rounded-[12px] border border-ds-line bg-ds-surface-2 p-3">
|
||||
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">Deterministic suggestions for {selectedIssue.title}</p>
|
||||
{related[selectedIssue.id] === undefined && <p className="text-ds-text-3">Loading…</p>}
|
||||
{related[selectedIssue.id]?.length === 0 && <p className="text-ds-text-3">No similar issues found.</p>}
|
||||
{(related[selectedIssue.id] ?? []).map((item) => {
|
||||
const key = `${selectedIssue.id}:${item.issue.id}`
|
||||
return (
|
||||
<div key={item.issue.id} className="flex flex-wrap items-center gap-2 text-[12px]">
|
||||
<span className="text-ds-text-2">{item.issue.title}</span>
|
||||
<span className="font-[family-name:var(--font-data)] text-ds-accent">{Math.round(item.score * 100)}%</span>
|
||||
<span className="text-ds-text-3">{item.reasons.join(" · ")}</span>
|
||||
{item.decision && <span className="text-ds-text-3">reviewed: {item.decision}</span>}
|
||||
<button disabled={busy === key} onClick={() => void submitReview(selectedIssue, item, "same")} className="ml-auto inline-flex items-center gap-1 rounded-[9px] border border-ds-ok/30 px-2.5 py-1 font-medium text-ds-ok hover:bg-ds-ok/10 disabled:opacity-50"><ThumbsUp className="h-3 w-3" aria-hidden="true" /> Same</button>
|
||||
<button disabled={busy === key} onClick={() => void submitReview(selectedIssue, item, "different")} className="inline-flex items-center gap-1 rounded-[9px] border border-ds-critical/30 px-2.5 py-1 font-medium text-ds-critical hover:bg-ds-critical-bg disabled:opacity-50"><ThumbsDown className="h-3 w-3" aria-hidden="true" /> Separate</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function PatternsVisualizer() {
|
||||
const { patterns, issues, loading, error, refresh, similar, review } = usePatterns()
|
||||
const [query, setQuery] = useState("")
|
||||
const visible = useMemo(() => patterns.filter((pattern) => `${pattern.service} ${pattern.kind} ${pattern.signature}`.toLowerCase().includes(query.toLowerCase())), [patterns, query])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.toLowerCase()
|
||||
return patterns
|
||||
.filter((p) => `${p.service} ${p.kind} ${p.signature}`.toLowerCase().includes(q))
|
||||
.sort((a, b) => b.occurrenceCount - a.occurrenceCount)
|
||||
}, [patterns, query])
|
||||
|
||||
const byId = useMemo(() => new Map(issues.map((i) => [i.id, i])), [issues])
|
||||
|
||||
if (loading && patterns.length === 0) return <div className="py-16 text-center text-sm text-ds-text-3">Loading deterministic patterns…</div>
|
||||
return <div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-ds-line pb-3">
|
||||
<div><div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.2em] text-ds-text-3"><GitBranch className="h-3 w-3 text-ds-accent" /> Similarity Patterns</div><div className="mt-1 text-xs text-ds-text-3">Exact issues stay separate; broader deterministic patterns are reviewable.</div></div>
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter patterns…" className="ml-auto rounded-lg border border-ds-line bg-ds-surface-2 px-3 py-2 text-xs text-ds-text outline-none placeholder:text-ds-text-3 focus:border-ds-accent/40" />
|
||||
<button onClick={() => void refresh()} className="inline-flex items-center gap-2 rounded-lg border border-ds-line px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-ds-text-2 hover:text-ds-text"><RefreshCw className="h-3 w-3" /> Refresh</button>
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="flex items-center gap-2 font-[family-name:var(--font-display)] text-[22px] font-bold tracking-[-0.015em] text-ds-text">
|
||||
<GitBranch className="h-5 w-5 text-ds-accent" aria-hidden="true" /> Patterns
|
||||
</h1>
|
||||
<span className="inline-flex h-[22px] items-center rounded-[6px] bg-ds-accent-bg px-2 text-[11.5px] font-semibold text-ds-accent">{visible.length}</span>
|
||||
<span className="text-[12.5px] text-ds-text-3">Exact issues stay separate; broader deterministic patterns are reviewable.</span>
|
||||
<span className="flex-grow" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filter patterns…"
|
||||
className="h-9 rounded-[9px] border border-ds-line-strong bg-ds-surface px-3 text-[12px] text-ds-text outline-none placeholder:text-ds-text-3 focus:border-ds-accent/40"
|
||||
/>
|
||||
<button type="button" onClick={() => void refresh()} className="inline-flex h-9 items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 text-[12px] font-medium text-ds-text-2 hover:text-ds-text">
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} aria-hidden="true" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[12px] text-ds-critical">Failed to load patterns: {String(error)}</p>}
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-16 text-center">
|
||||
<GitBranch className="h-8 w-8 text-ds-text-3/60" aria-hidden="true" />
|
||||
<p className="text-[14px] font-medium text-ds-text">{patterns.length === 0 ? "No deterministic patterns observed yet" : "Nothing matches your filter"}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{visible.map((pattern) => (
|
||||
<PatternCard
|
||||
key={pattern.id}
|
||||
pattern={pattern}
|
||||
members={pattern.issueIds.map((id) => byId.get(id)).filter((i): i is Issue => Boolean(i))}
|
||||
similar={similar}
|
||||
review={review}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="rounded-lg border border-ds-critical/20 bg-ds-critical-bg p-3 text-xs text-ds-critical">Failed to load patterns: {String(error)}</div>}
|
||||
{visible.length === 0 ? <div className="py-16 text-center text-sm text-ds-text-3">No deterministic patterns observed yet.</div> : <div className="space-y-3">{visible.map((pattern) => <div key={pattern.id} className="rounded-xl border border-ds-accent/15 bg-ds-surface-2 p-4">
|
||||
<div className="flex flex-wrap items-center gap-3"><span className="rounded bg-ds-accent-bg px-2 py-1 text-[10px] font-bold uppercase text-ds-accent">{pattern.service}</span><span className="text-xs text-ds-text-2">{pattern.kind}</span><span className="text-xs text-ds-text-2">{pattern.issueCount} exact issues · {pattern.occurrenceCount} occurrences</span><span className="ml-auto text-[10px] text-ds-text-3">last seen {formatRelativeTime(pattern.lastSeen / 1e6)}</span></div>
|
||||
<div className="mt-3 break-all font-mono text-[10px] text-ds-text-3">{pattern.signature.split("\u0000").join(" · ")}</div>
|
||||
<PatternIssues pattern={pattern} issues={issues} similar={similar} review={review} />
|
||||
</div>)}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Bug, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { authFetch } from "../lib/auth"
|
||||
import { formatRelativeTime } from "../lib/timeUtils"
|
||||
import type { RumErrorEvent, RumSession } from "../lib/rum-utils"
|
||||
|
||||
interface JsErrorGroup {
|
||||
key: string
|
||||
message: string
|
||||
exceptionType?: string
|
||||
count: number
|
||||
sessionIds: Set<string>
|
||||
lastSeen: number
|
||||
/** Newest occurrence that carries a stacktrace. */
|
||||
sample?: RumErrorEvent
|
||||
}
|
||||
|
||||
const groupJsErrors = (sessions: RumSession[]): JsErrorGroup[] => {
|
||||
const groups = new Map<string, JsErrorGroup>()
|
||||
for (const session of sessions) {
|
||||
for (const err of session.errors) {
|
||||
if (err.kind !== "js") continue
|
||||
const key = `${err.exceptionType ?? ""}|${err.message}`
|
||||
let g = groups.get(key)
|
||||
if (!g) {
|
||||
g = { key, message: err.message, exceptionType: err.exceptionType, count: 0, sessionIds: new Set(), lastSeen: 0 }
|
||||
groups.set(key, g)
|
||||
}
|
||||
g.count += 1
|
||||
g.sessionIds.add(session.id)
|
||||
g.lastSeen = Math.max(g.lastSeen, err.timestamp)
|
||||
if (err.stacktrace && (!g.sample || err.timestamp > g.sample.timestamp)) g.sample = err
|
||||
}
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => b.count - a.count || b.lastSeen - a.lastSeen)
|
||||
}
|
||||
|
||||
/** Maps each group's sample stacktrace through the server's source maps; falls back to the raw stack. */
|
||||
const useResolvedStacks = (groups: JsErrorGroup[]) => {
|
||||
const [resolved, setResolved] = useState<Record<string, string>>({})
|
||||
const items = useMemo(
|
||||
() => groups.filter((g) => g.sample?.stacktrace).map((g) => ({ key: g.key, release: g.sample!.release ?? "", stacktrace: g.sample!.stacktrace! })),
|
||||
[groups],
|
||||
)
|
||||
const signature = items.map((i) => `${i.key}\u0000${i.release}\u0000${i.stacktrace.length}`).join("\u0001")
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length === 0) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await authFetch("/api/sourcemaps/resolve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: items.map(({ release, stacktrace }) => ({ release, stacktrace })) }),
|
||||
})
|
||||
if (!response.ok) return
|
||||
const body = (await response.json()) as { resolved: string[] }
|
||||
if (cancelled) return
|
||||
setResolved(Object.fromEntries(items.map((item, i) => [item.key, body.resolved[i] ?? item.stacktrace])))
|
||||
} catch {
|
||||
/* raw stacks still render */
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [signature])
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
const JsErrorCard = ({ group, resolvedStack }: { group: JsErrorGroup; resolvedStack?: string }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [showRaw, setShowRaw] = useState(false)
|
||||
const raw = group.sample?.stacktrace
|
||||
const mapped = Boolean(raw && resolvedStack && resolvedStack !== raw)
|
||||
const stack = showRaw || !mapped ? raw : resolvedStack
|
||||
|
||||
return (
|
||||
<article className="flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] border-l-ds-critical bg-ds-surface">
|
||||
<button type="button" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded} className="flex items-start gap-4 px-5 pt-4 text-left">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{group.exceptionType && (
|
||||
<span className="inline-flex h-[23px] items-center rounded-[6px] bg-ds-critical-bg px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-critical">{group.exceptionType}</span>
|
||||
)}
|
||||
{mapped && (
|
||||
<span className="inline-flex h-[23px] items-center rounded-[6px] border border-ds-ok/30 px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-ok">Source-mapped</span>
|
||||
)}
|
||||
<span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(group.lastSeen)}</span>
|
||||
</div>
|
||||
<h3 className="break-all font-[family-name:var(--font-display)] text-[16px] font-bold leading-snug text-ds-text">{group.message}</h3>
|
||||
{group.sample?.release && <p className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">release={group.sample.release}</p>}
|
||||
</div>
|
||||
<span className="mt-1 shrink-0 text-ds-text-3">{expanded ? <ChevronUp className="h-4 w-4" aria-hidden="true" /> : <ChevronDown className="h-4 w-4" aria-hidden="true" />}</span>
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-5 border-t border-ds-fill px-5 py-3">
|
||||
<div className="flex flex-col gap-1"><span className="text-[10.5px] font-semibold uppercase tracking-[0.09em] text-ds-text-3">Occurrences</span><span className="font-[family-name:var(--font-display)] text-[18px] font-bold text-ds-critical">×{group.count}</span></div>
|
||||
<div className="flex flex-col gap-1"><span className="text-[10.5px] font-semibold uppercase tracking-[0.09em] text-ds-text-3">Sessions</span><span className="font-[family-name:var(--font-display)] text-[18px] font-bold text-ds-text">{group.sessionIds.size}</span></div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-2 border-t border-ds-line bg-ds-bg px-5 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">{mapped && !showRaw ? "Source-mapped stacktrace" : "Stacktrace"}</p>
|
||||
{mapped && (
|
||||
<button type="button" onClick={() => setShowRaw((v) => !v)} className="text-[11.5px] text-ds-text-3 hover:text-ds-text-2">{showRaw ? "Show mapped" : "Show raw"}</button>
|
||||
)}
|
||||
</div>
|
||||
{stack ? (
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-[10px] border border-ds-line bg-ds-bg p-3 font-[family-name:var(--font-data)] text-[11px] leading-relaxed text-ds-text-2">{stack}</pre>
|
||||
) : (
|
||||
<p className="text-[12px] text-ds-text-3">No stacktrace was reported for this error.</p>
|
||||
)}
|
||||
{!mapped && raw && !group.sample?.release && (
|
||||
<p className="text-[11.5px] text-ds-text-3">No release on this event, so source maps can't be applied.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export const RumJsErrors = ({ sessions }: { sessions: RumSession[] }) => {
|
||||
const groups = useMemo(() => groupJsErrors(sessions), [sessions])
|
||||
const resolved = useResolvedStacks(groups)
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-16 text-center">
|
||||
<Bug className="h-8 w-8 text-ds-ok/60" aria-hidden="true" />
|
||||
<p className="text-[14px] font-medium text-ds-text">No JavaScript errors</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className="flex flex-col gap-3">{groups.map((g) => <JsErrorCard key={g.key} group={g} resolvedStack={resolved[g.key]} />)}</div>
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type RumTimelineEntry,
|
||||
type VitalRating,
|
||||
} from "../lib/rum-utils"
|
||||
import { RumJsErrors } from "./RumJsErrors"
|
||||
import { FilterBar, type FilterChip } from "./FilterBar"
|
||||
|
||||
type DeviceFilter = "all" | "mobile" | "desktop"
|
||||
@@ -57,13 +58,26 @@ const StatTile = ({
|
||||
value,
|
||||
detail,
|
||||
tone,
|
||||
onClick,
|
||||
active,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
detail: string
|
||||
tone?: "critical" | "warn" | "ok"
|
||||
onClick?: () => void
|
||||
active?: boolean
|
||||
}) => (
|
||||
<div className="flex flex-col gap-1 rounded-[12px] border border-ds-line bg-ds-surface px-4 py-3">
|
||||
<div
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
aria-pressed={onClick ? active : undefined}
|
||||
onClick={onClick}
|
||||
onKeyDown={onClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick() } } : undefined}
|
||||
className={`flex flex-col gap-1 rounded-[12px] border bg-ds-surface px-4 py-3 ${
|
||||
active ? "border-ds-critical/60" : "border-ds-line"
|
||||
} ${onClick ? "cursor-pointer hover:border-ds-line-strong" : ""}`}
|
||||
>
|
||||
<span className="text-[10.5px] font-bold uppercase tracking-[0.09em] text-ds-text-3">{label}</span>
|
||||
<span
|
||||
className={`font-[family-name:var(--font-display)] text-[22px] font-bold ${
|
||||
@@ -382,6 +396,7 @@ export const RumSessionsVisualizer = () => {
|
||||
const [errorsOnly, setErrorsOnly] = useState(false)
|
||||
const [sortMode, setSortMode] = useState<SortMode>("worst")
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||
const [jsFocus, setJsFocus] = useState(false)
|
||||
|
||||
const sessions = useMemo(() => buildRumSessions(logs), [logs])
|
||||
const backend = useMemo(() => backendRequestsFromTraces(allTraces), [allTraces])
|
||||
@@ -489,6 +504,15 @@ export const RumSessionsVisualizer = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<FilterBar
|
||||
scope={{
|
||||
label: "View",
|
||||
options: [
|
||||
{ value: "sessions", label: "Sessions" },
|
||||
{ value: "js-errors", label: "JS errors" },
|
||||
],
|
||||
value: jsFocus ? "js-errors" : "sessions",
|
||||
onChange: (v) => setJsFocus(v === "js-errors"),
|
||||
}}
|
||||
chips={chips}
|
||||
addFilterOptions={addFilterOptions}
|
||||
onAddFilter={onAddFilter}
|
||||
@@ -508,6 +532,8 @@ export const RumSessionsVisualizer = () => {
|
||||
value={String(aggregates.totalJsErrors)}
|
||||
detail={`${aggregates.distinctJsErrorMessages} distinct messages`}
|
||||
tone={aggregates.totalJsErrors > 0 ? "critical" : "ok"}
|
||||
onClick={() => setJsFocus((v) => !v)}
|
||||
active={jsFocus}
|
||||
/>
|
||||
<StatTile
|
||||
label="HTTP failures"
|
||||
@@ -523,7 +549,9 @@ export const RumSessionsVisualizer = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
{jsFocus ? (
|
||||
<RumJsErrors sessions={filtered} />
|
||||
) : sorted.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 rounded-[14px] border border-ds-line bg-ds-surface py-16 text-center">
|
||||
<AlertOctagon className="h-6 w-6 text-ds-text-3" aria-hidden="true" />
|
||||
<p className="text-[13px] text-ds-text-3">No sessions match your filters.</p>
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface RumErrorEvent {
|
||||
message: string
|
||||
exceptionType?: string
|
||||
stacktrace?: string
|
||||
release?: string
|
||||
httpMethod?: string
|
||||
httpUrl?: string
|
||||
httpStatus?: number
|
||||
@@ -237,6 +238,7 @@ export function buildRumSessions(logs: LogEntry[]): RumSession[] {
|
||||
message: r.exceptionMessage || r.body || r.exceptionType || "Unhandled error",
|
||||
exceptionType: r.exceptionType,
|
||||
stacktrace: r.stacktrace,
|
||||
release: r.release,
|
||||
traceId: r.traceId,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user