more testing
This commit is contained in:
1 parent
1aa264675e
commit
2da2649a2f
11 files changed
+293
-51
No files matched your search
@@ -301,9 +301,10 @@ RELEASE="$RELEASE" OTEL_URL="https://otel.example.com" \
|
|||||||
```
|
```
|
||||||
|
|
||||||
The receiver stores uploaded maps under `OTEL_SOURCEMAP_DIR` (default
|
The receiver stores uploaded maps under `OTEL_SOURCEMAP_DIR` (default
|
||||||
`./sourcemaps`, `/sourcemaps` in the Kubernetes deployment). Upload maps for
|
`./sourcemaps`, `/sourcemaps` in the Kubernetes deployment). Maps are reloaded on start, so keep the directory on persistent storage. See
|
||||||
each release before investigating its errors. The API never returns map
|
`instrumentation.md` (*Source maps → Requirements checklist*) for everything a
|
||||||
contents or original source files.
|
project needs for stacks to resolve. `GET /api/sourcemaps` lists loaded maps.
|
||||||
|
The API never returns map files themselves.
|
||||||
|
|
||||||
`build.sh` passes the release into the Docker UI build. If `OTEL_URL` is set,
|
`build.sh` passes the release into the Docker UI build. If `OTEL_URL` is set,
|
||||||
it also builds the matching UI locally and uploads all generated JavaScript
|
it also builds the matching UI locally and uploads all generated JavaScript
|
||||||
@@ -362,6 +363,18 @@ already points at it. The UI's Trends tab (and live issue updates) appear
|
|||||||
automatically once the stats endpoints answer; on file-store deployments the
|
automatically once the stats endpoints answer; on file-store deployments the
|
||||||
tab hides itself. SPA routes (/issues, /traces, /logs, /metrics, /trends,
|
tab hides itself. SPA routes (/issues, /traces, /logs, /metrics, /trends,
|
||||||
/setup) are served client-side by wouter and survive the OIDC login redirect.
|
/setup) are served client-side by wouter and survive the OIDC login redirect.
|
||||||
|
Selection lives in the URL too, so every step can be reloaded, stepped with
|
||||||
|
back/forward, and shared (each detail view has a link chip that copies its
|
||||||
|
permalink):
|
||||||
|
|
||||||
|
| Path | Shows |
|
||||||
|
| --- | --- |
|
||||||
|
| `/issues/<issueId>` | one issue with its traces |
|
||||||
|
| `/issues/<issueId>/trace/<traceId>` | the same, with a trace selected |
|
||||||
|
| `/patterns/<patternId>[/<issueId>]` | a pattern card open, optionally reviewing one member |
|
||||||
|
| `/traces/<traceId>[/<spanId>]` | a trace, optionally with a span selected |
|
||||||
|
| `/rum/session/<sessionId>` | a RUM session |
|
||||||
|
| `/rum/errors[/<errorId>]` | JS errors, optionally one error expanded (`errorId` is a hash of type + message) |
|
||||||
|
|
||||||
Currently, the history buffer size is hardcoded to 100 entries. This can be made configurable in future versions.
|
Currently, the history buffer size is hardcoded to 100 entries. This can be made configurable in future versions.
|
||||||
|
|
||||||
|
|||||||
+55
-4
@@ -150,9 +150,59 @@ For example, if the deployed JavaScript is available at
|
|||||||
`source=/assets/main-abc123.js`. The source-map filename on the build machine
|
`source=/assets/main-abc123.js`. The source-map filename on the build machine
|
||||||
does not need to match the URL, but the `source` field must.
|
does not need to match the URL, but the `source` field must.
|
||||||
|
|
||||||
Maps are parsed and retained server-side. Issue responses include a resolved
|
Maps are parsed, written to `OTEL_SOURCEMAP_DIR`, and reloaded on receiver
|
||||||
stack when `service.release` and the generated bundle path match. Original
|
start. Issue responses include a resolved stack when `service.release` and the
|
||||||
source files and map contents are never returned by the API.
|
generated bundle path match. Uploaded maps are never returned, but when a map
|
||||||
|
embeds `sourcesContent` the RUM "JS errors" view shows a few lines of original
|
||||||
|
source around each mapped frame to signed-in users.
|
||||||
|
|
||||||
|
### Requirements checklist
|
||||||
|
|
||||||
|
A stack only resolves when every one of these holds. When it does not, check
|
||||||
|
them in this order:
|
||||||
|
|
||||||
|
1. **The build emits maps, with sources embedded.** Enable source maps in the
|
||||||
|
bundler (Vite `build.sourcemap: true`, webpack `devtool: "source-map"`).
|
||||||
|
Keep `sourcesContent` (the default) or the code view has nothing to show.
|
||||||
|
2. **The browser reports a release.** Pass the release to `OtelRUM.init({ release })`
|
||||||
|
(or `data-release` on the script tag), injected at build time. An event
|
||||||
|
with no release, or `dev`, can never match an upload.
|
||||||
|
3. **The uploaded release is byte-for-byte the same string.** Use one value for
|
||||||
|
both the bundle build and the upload (a git short SHA works).
|
||||||
|
4. **`source` matches the path in the browser stack.** Everything before
|
||||||
|
`/assets/` and any `?query` is ignored, so `source=/assets/main-abc123.js`
|
||||||
|
matches `https://app.example.com/assets/main-abc123.js`. Content-hashed
|
||||||
|
names must match exactly, and chunks each need their own upload.
|
||||||
|
5. **The map belongs to the deployed bundle.** Upload the maps from the very
|
||||||
|
build that produced the deployed files. A second local rebuild can differ
|
||||||
|
(dependencies, Node version) and silently mismatch. In Docker, copy the maps
|
||||||
|
out of the build stage (`docker build --target <stage>`, then `docker cp`)
|
||||||
|
instead of rebuilding. `build.sh` does this.
|
||||||
|
6. **Every chunk is uploaded.** Loop over all `*.js.map` files and fail the
|
||||||
|
deploy when none are found, since an empty loop otherwise succeeds silently.
|
||||||
|
7. **The receiver keeps its maps.** `OTEL_SOURCEMAP_DIR` must be on persistent
|
||||||
|
storage (a PVC in Kubernetes, not `emptyDir`). Maps written to an ephemeral
|
||||||
|
directory vanish on restart.
|
||||||
|
8. **Maps are not published with the site.** Delete `*.map` from the served
|
||||||
|
image unless public maps are intended (see below).
|
||||||
|
|
||||||
|
### Verifying a deploy
|
||||||
|
|
||||||
|
List what the receiver has loaded (requires a signed-in session or token):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" https://otel.example.com/api/sourcemaps
|
||||||
|
# [{"release":"2026.09.18-a1b2c3","source":"/assets/main-abc123.js"}, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
Then trigger a real uncaught error from application code, for example a
|
||||||
|
button that runs `setTimeout(() => { throw new Error("source-map check") })`
|
||||||
|
(this repository's RUM "JS errors" view has a *Generate test error* button),
|
||||||
|
and confirm the card shows **Source-mapped** with the original file and line.
|
||||||
|
|
||||||
|
Frame status in the stack explorer explains a miss: `no-release` (event has no
|
||||||
|
release), `no-map` (nothing uploaded for that release and file), or `unmapped`
|
||||||
|
(a map exists but has no mapping for that position).
|
||||||
|
|
||||||
The Docker build removes `.map` files from production images by default. For
|
The Docker build removes `.map` files from production images by default. For
|
||||||
development or staging, public maps can be enabled for Chrome DevTools:
|
development or staging, public maps can be enabled for Chrome DevTools:
|
||||||
@@ -170,6 +220,7 @@ production unless that is intentional.
|
|||||||
2. Trigger a controlled backend error span or ERROR log.
|
2. Trigger a controlled backend error span or ERROR log.
|
||||||
3. Confirm both appear under Issues as separate frontend/backend issues.
|
3. Confirm both appear under Issues as separate frontend/backend issues.
|
||||||
4. Confirm the issue sample contains the expected release.
|
4. Confirm the issue sample contains the expected release.
|
||||||
5. Confirm a mapped frontend stack shows original file and line information.
|
5. Confirm a mapped frontend stack shows original file and line information
|
||||||
|
(see *Requirements checklist* under Source maps if it does not).
|
||||||
6. Confirm trace IDs connect frontend requests to backend traces where headers
|
6. Confirm trace IDs connect frontend requests to backend traces where headers
|
||||||
were propagated.
|
were propagated.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
|
import { CopyRef } from "./common/CopyRef"
|
||||||
import { MasterDetail } from "./MasterDetail"
|
import { MasterDetail } from "./MasterDetail"
|
||||||
import { BackButton } from "./common/BackButton"
|
import { BackButton } from "./common/BackButton"
|
||||||
import { SectionLabel } from "./common/DetailField"
|
import { SectionLabel } from "./common/DetailField"
|
||||||
@@ -26,15 +27,18 @@ interface Row {
|
|||||||
/**
|
/**
|
||||||
* Focused view of one issue: an overview of every trace it occurred in
|
* Focused view of one issue: an overview of every trace it occurred in
|
||||||
* (server-side summaries, paged), with the selected trace's waterfall and its
|
* (server-side summaries, paged), with the selected trace's waterfall and its
|
||||||
* occurrences below. `initialTraceId` preselects the trace the user came from.
|
* occurrences below. The selected trace comes from the URL (`traceId`).
|
||||||
*/
|
*/
|
||||||
export const IssueOccurrences = ({
|
export const IssueOccurrences = ({
|
||||||
issue,
|
issue,
|
||||||
initialTraceId,
|
traceId: routeTraceId,
|
||||||
|
onTraceChange,
|
||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
issue: Issue
|
issue: Issue
|
||||||
initialTraceId?: string
|
/** Trace named by the URL; the URL is the source of truth for the selection. */
|
||||||
|
traceId?: string
|
||||||
|
onTraceChange?: (traceId: string | null) => void
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const { traces, untraced, total, complete, hasMore, loading, error, loadMore } = useIssueTraces(issue.id)
|
const { traces, untraced, total, complete, hasMore, loading, error, loadMore } = useIssueTraces(issue.id)
|
||||||
@@ -59,7 +63,12 @@ export const IssueOccurrences = ({
|
|||||||
)
|
)
|
||||||
}, [traces, untraced, sort])
|
}, [traces, untraced, sort])
|
||||||
|
|
||||||
const [picked, setPicked] = useState<string | null>(initialTraceId ?? null)
|
const [pickedLocal, setPickedLocal] = useState<string | null>(null)
|
||||||
|
const picked = routeTraceId ?? pickedLocal
|
||||||
|
const setPicked = (key: string) => {
|
||||||
|
setPickedLocal(key)
|
||||||
|
onTraceChange?.(rows.find((r) => r.key === key)?.traceId ?? null)
|
||||||
|
}
|
||||||
const selected = rows.find((r) => r.key === picked) ?? rows[0]
|
const selected = rows.find((r) => r.key === picked) ?? rows[0]
|
||||||
const selectedTraceId = selected?.traceId
|
const selectedTraceId = selected?.traceId
|
||||||
|
|
||||||
@@ -81,7 +90,19 @@ export const IssueOccurrences = ({
|
|||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<StatHeader
|
<StatHeader
|
||||||
back={<BackButton label="All issues" onClick={onBack} />}
|
back={<BackButton label="All issues" onClick={onBack} />}
|
||||||
eyebrow={`service=${issue.service}`}
|
eyebrow={
|
||||||
|
<>
|
||||||
|
<span>service={issue.service}</span>
|
||||||
|
<CopyRef id={issue.id} path={`/issues/${encodeURIComponent(issue.id)}`} label="Copy issue link" />
|
||||||
|
{selectedTraceId && (
|
||||||
|
<CopyRef
|
||||||
|
id={selectedTraceId}
|
||||||
|
path={`/issues/${encodeURIComponent(issue.id)}/trace/${encodeURIComponent(selectedTraceId)}`}
|
||||||
|
label="Copy trace link"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
title={issue.title}
|
title={issue.title}
|
||||||
stats={
|
stats={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
strongestCorrelate,
|
strongestCorrelate,
|
||||||
type SeverityTone,
|
type SeverityTone,
|
||||||
} from "../lib/issue-utils"
|
} from "../lib/issue-utils"
|
||||||
|
import { useSubRoute } from "../lib/routes"
|
||||||
|
import { CopyRef } from "./common/CopyRef"
|
||||||
import { IssueOccurrences } from "./IssueOccurrences"
|
import { IssueOccurrences } from "./IssueOccurrences"
|
||||||
import { NeedleTestModal } from "./NeedleTestModal"
|
import { NeedleTestModal } from "./NeedleTestModal"
|
||||||
import { FilterBar, type FilterChip } from "./FilterBar"
|
import { FilterBar, type FilterChip } from "./FilterBar"
|
||||||
@@ -343,6 +345,7 @@ const IssueCard = ({
|
|||||||
<meta.Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
<meta.Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||||
{meta.label}
|
{meta.label}
|
||||||
</span>
|
</span>
|
||||||
|
<CopyRef id={issue.id} path={`/issues/${encodeURIComponent(issue.id)}`} />
|
||||||
<span className="text-[12.5px] text-ds-text-3">
|
<span className="text-[12.5px] text-ds-text-3">
|
||||||
Detected {formatRelativeTime(issue.firstSeen / 1e6)} · last seen{" "}
|
Detected {formatRelativeTime(issue.firstSeen / 1e6)} · last seen{" "}
|
||||||
{formatRelativeTime(issue.lastSeen / 1e6)}
|
{formatRelativeTime(issue.lastSeen / 1e6)}
|
||||||
@@ -653,8 +656,10 @@ export const IssuesVisualizer = ({
|
|||||||
}: IssuesVisualizerProps) => {
|
}: IssuesVisualizerProps) => {
|
||||||
const { selectedServices, toggleService, resetFilters, searchQuery, setSearchQuery, uniqueServices } =
|
const { selectedServices, toggleService, resetFilters, searchQuery, setSearchQuery, uniqueServices } =
|
||||||
useObservabilityData()
|
useObservabilityData()
|
||||||
const [focus, setFocus] = useState<{ issueId: string; traceId?: string } | null>(null)
|
// /issues/<issueId>[/trace/<traceId>] - the focused issue and trace live in the URL.
|
||||||
const focusIssue = (issue: Issue, traceId?: string) => setFocus({ issueId: issue.id, traceId })
|
const { segments, go } = useSubRoute()
|
||||||
|
const focus = segments[0] ? { issueId: segments[0], traceId: segments[1] === "trace" ? segments[2] : undefined } : null
|
||||||
|
const focusIssue = (issue: Issue, traceId?: string) => go(traceId ? [issue.id, "trace", traceId] : [issue.id])
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||||
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
||||||
const [needleModalOpen, setNeedleModalOpen] = useState(false)
|
const [needleModalOpen, setNeedleModalOpen] = useState(false)
|
||||||
@@ -819,11 +824,22 @@ export const IssuesVisualizer = ({
|
|||||||
<IssueOccurrences
|
<IssueOccurrences
|
||||||
key={focus.issueId}
|
key={focus.issueId}
|
||||||
issue={focusedIssue}
|
issue={focusedIssue}
|
||||||
initialTraceId={focus.traceId}
|
traceId={focus.traceId}
|
||||||
onBack={() => setFocus(null)}
|
onTraceChange={(traceId) => go(traceId ? [focus.issueId, "trace", traceId] : [focus.issueId], { replace: true })}
|
||||||
|
onBack={() => go([])}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (focus) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-20 text-center">
|
||||||
|
<p className="text-[14px] font-medium text-ds-text">Issue not found</p>
|
||||||
|
<p className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{focus.issueId}</p>
|
||||||
|
<p className="max-w-sm text-[12.5px] text-ds-text-3">It may have been evicted, or the link is from another environment.</p>
|
||||||
|
<button type="button" onClick={() => go([])} className="inline-flex h-9 items-center rounded-[9px] border border-ds-line-strong px-3 text-[12.5px] font-medium text-ds-text-2 hover:text-ds-text">All issues</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Bug, ChevronDown, ChevronUp, GitBranch, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react"
|
import { Bug, ChevronDown, ChevronUp, GitBranch, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react"
|
||||||
|
import { useSubRoute } from "../lib/routes"
|
||||||
|
import { CopyRef } from "./common/CopyRef"
|
||||||
import { usePatterns } from "../hooks/usePatterns"
|
import { usePatterns } from "../hooks/usePatterns"
|
||||||
import { formatRelativeTime } from "../lib/timeUtils"
|
import { formatRelativeTime } from "../lib/timeUtils"
|
||||||
import { issueSeverityTone, issueStatusOf, type SeverityTone } from "../lib/issue-utils"
|
import { issueSeverityTone, issueStatusOf, type SeverityTone } from "../lib/issue-utils"
|
||||||
@@ -15,14 +17,18 @@ const mergeIssues = (members: Issue[]): Issue => {
|
|||||||
return { ...members[0], trend }
|
return { ...members[0], trend }
|
||||||
}
|
}
|
||||||
|
|
||||||
const PatternCard = ({ pattern, members, similar, review }: {
|
const PatternCard = ({ pattern, members, similar, review, expanded, selected, onExpand, onSelect }: {
|
||||||
pattern: IssuePattern
|
pattern: IssuePattern
|
||||||
members: Issue[]
|
members: Issue[]
|
||||||
|
expanded: boolean
|
||||||
|
/** Member issue whose similar-issue suggestions are open (from the URL). */
|
||||||
|
selected: string | null
|
||||||
|
onExpand: (open: boolean) => void
|
||||||
|
onSelect: (issueId: string | null) => void
|
||||||
similar: (id: string) => Promise<SimilarIssue[]>
|
similar: (id: string) => Promise<SimilarIssue[]>
|
||||||
review: (id: string, otherId: string, decision: "same" | "different") => Promise<void>
|
review: (id: string, otherId: string, decision: "same" | "different") => Promise<void>
|
||||||
}) => {
|
}) => {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const card = useRef<HTMLElement>(null)
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
|
||||||
const [related, setRelated] = useState<Record<string, SimilarIssue[]>>({})
|
const [related, setRelated] = useState<Record<string, SimilarIssue[]>>({})
|
||||||
const [busy, setBusy] = useState<string | null>(null)
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -32,14 +38,20 @@ const PatternCard = ({ pattern, members, similar, review }: {
|
|||||||
const meta = kindMeta[pattern.kind] ?? { label: pattern.kind, Icon: Bug }
|
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 title = [...members].sort((a, b) => b.count - a.count)[0]?.title ?? pattern.signature.split("\u0000")[0]
|
||||||
|
|
||||||
const select = async (issue: Issue) => {
|
// A deep link to /patterns/<id> scrolls its card into view once.
|
||||||
if (selected === issue.id) { setSelected(null); return }
|
useEffect(() => {
|
||||||
setSelected(issue.id)
|
if (expanded) card.current?.scrollIntoView({ block: "start" })
|
||||||
if (!related[issue.id]) {
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on first render of a linked card
|
||||||
const suggestions = await similar(issue.id)
|
}, [])
|
||||||
setRelated((prev) => ({ ...prev, [issue.id]: suggestions }))
|
|
||||||
}
|
// Suggestions load whenever the URL selects a member issue.
|
||||||
}
|
useEffect(() => {
|
||||||
|
if (!selected || related[selected]) return
|
||||||
|
void similar(selected)
|
||||||
|
.then((suggestions) => setRelated((prev) => ({ ...prev, [selected]: suggestions })))
|
||||||
|
.catch(() => setRelated((prev) => ({ ...prev, [selected]: [] })))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selected])
|
||||||
|
|
||||||
const submitReview = async (issue: Issue, item: SimilarIssue, decision: "same" | "different") => {
|
const submitReview = async (issue: Issue, item: SimilarIssue, decision: "same" | "different") => {
|
||||||
const key = `${issue.id}:${item.issue.id}`
|
const key = `${issue.id}:${item.issue.id}`
|
||||||
@@ -53,9 +65,9 @@ const PatternCard = ({ pattern, members, similar, review }: {
|
|||||||
const selectedIssue = members.find((m) => m.id === selected)
|
const selectedIssue = members.find((m) => m.id === selected)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={`flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] ${toneLine[tone]} bg-ds-surface`}>
|
<article ref={card} 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">
|
<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">
|
<button type="button" onClick={() => onExpand(!expanded)} 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">
|
<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]}`}>
|
<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"}
|
{openCount > 0 ? `${openCount} open` : "All closed"}
|
||||||
@@ -63,6 +75,7 @@ const PatternCard = ({ pattern, members, similar, review }: {
|
|||||||
<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">
|
<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}
|
<meta.Icon className="h-3 w-3" aria-hidden="true" />{meta.label}
|
||||||
</span>
|
</span>
|
||||||
|
<CopyRef id={pattern.id} path={`/patterns/${encodeURIComponent(pattern.id)}`} label="Copy pattern link" />
|
||||||
<span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(pattern.lastSeen / 1e6)}</span>
|
<span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(pattern.lastSeen / 1e6)}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="break-all font-[family-name:var(--font-display)] text-[17px] font-bold leading-snug text-ds-text">{title}</h3>
|
<h3 className="break-all font-[family-name:var(--font-display)] text-[17px] font-bold leading-snug text-ds-text">{title}</h3>
|
||||||
@@ -70,7 +83,7 @@ const PatternCard = ({ pattern, members, similar, review }: {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => onExpand(!expanded)}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
aria-label={`${expanded ? "Collapse" : "Expand"} pattern ${title}`}
|
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"
|
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"
|
||||||
@@ -96,7 +109,7 @@ const PatternCard = ({ pattern, members, similar, review }: {
|
|||||||
<button
|
<button
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void select(issue)}
|
onClick={() => onSelect(selected === issue.id ? null : issue.id)}
|
||||||
aria-pressed={selected === issue.id}
|
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"}`}
|
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"}`}
|
||||||
>
|
>
|
||||||
@@ -135,6 +148,8 @@ const PatternCard = ({ pattern, members, similar, review }: {
|
|||||||
|
|
||||||
export function PatternsVisualizer() {
|
export function PatternsVisualizer() {
|
||||||
const { patterns, issues, loading, error, refresh, similar, review } = usePatterns()
|
const { patterns, issues, loading, error, refresh, similar, review } = usePatterns()
|
||||||
|
// /patterns/<patternId>[/<issueId>]: which card is open and which member is reviewed.
|
||||||
|
const { segments, go } = useSubRoute()
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
@@ -170,6 +185,12 @@ export function PatternsVisualizer() {
|
|||||||
|
|
||||||
{error && <p className="text-[12px] text-ds-critical">Failed to load patterns: {String(error)}</p>}
|
{error && <p className="text-[12px] text-ds-critical">Failed to load patterns: {String(error)}</p>}
|
||||||
|
|
||||||
|
{segments[0] && !patterns.some((p) => p.id === segments[0]) && patterns.length > 0 && (
|
||||||
|
<p className="text-[12px] text-ds-warn">
|
||||||
|
Pattern <span className="font-[family-name:var(--font-data)]">{segments[0]}</span> not found (it may have been merged or evicted).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{visible.length === 0 ? (
|
{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">
|
<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" />
|
<GitBranch className="h-8 w-8 text-ds-text-3/60" aria-hidden="true" />
|
||||||
@@ -184,6 +205,10 @@ export function PatternsVisualizer() {
|
|||||||
members={pattern.issueIds.map((id) => byId.get(id)).filter((i): i is Issue => Boolean(i))}
|
members={pattern.issueIds.map((id) => byId.get(id)).filter((i): i is Issue => Boolean(i))}
|
||||||
similar={similar}
|
similar={similar}
|
||||||
review={review}
|
review={review}
|
||||||
|
expanded={segments[0] === pattern.id}
|
||||||
|
selected={segments[0] === pattern.id ? (segments[1] ?? null) : null}
|
||||||
|
onExpand={(open) => go(open ? [pattern.id] : [])}
|
||||||
|
onSelect={(issueId) => go(issueId ? [pattern.id, issueId] : [pattern.id])}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Bug, ChevronDown, ChevronUp } from "lucide-react"
|
import { Bug, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
import { CopyRef } from "./common/CopyRef"
|
||||||
import { triggerTestError } from "../lib/test-error"
|
import { triggerTestError } from "../lib/test-error"
|
||||||
import { authFetch } from "../lib/auth"
|
import { authFetch } from "../lib/auth"
|
||||||
import { formatRelativeTime } from "../lib/timeUtils"
|
import { formatRelativeTime } from "../lib/timeUtils"
|
||||||
@@ -8,6 +9,8 @@ import type { RumErrorEvent, RumSession } from "../lib/rum-utils"
|
|||||||
|
|
||||||
interface JsErrorGroup {
|
interface JsErrorGroup {
|
||||||
key: string
|
key: string
|
||||||
|
/** Stable short id derived from the error's type and message, used in URLs. */
|
||||||
|
id: string
|
||||||
message: string
|
message: string
|
||||||
exceptionType?: string
|
exceptionType?: string
|
||||||
count: number
|
count: number
|
||||||
@@ -17,6 +20,12 @@ interface JsErrorGroup {
|
|||||||
sample?: RumErrorEvent
|
sample?: RumErrorEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hashId = (text: string): string => {
|
||||||
|
let h = 0x811c9dc5
|
||||||
|
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
|
||||||
|
return (h >>> 0).toString(16).padStart(8, "0")
|
||||||
|
}
|
||||||
|
|
||||||
const groupJsErrors = (sessions: RumSession[]): JsErrorGroup[] => {
|
const groupJsErrors = (sessions: RumSession[]): JsErrorGroup[] => {
|
||||||
const groups = new Map<string, JsErrorGroup>()
|
const groups = new Map<string, JsErrorGroup>()
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
@@ -25,7 +34,7 @@ const groupJsErrors = (sessions: RumSession[]): JsErrorGroup[] => {
|
|||||||
const key = `${err.exceptionType ?? ""}|${err.message}`
|
const key = `${err.exceptionType ?? ""}|${err.message}`
|
||||||
let g = groups.get(key)
|
let g = groups.get(key)
|
||||||
if (!g) {
|
if (!g) {
|
||||||
g = { key, message: err.message, exceptionType: err.exceptionType, count: 0, sessionIds: new Set(), lastSeen: 0 }
|
g = { key, id: hashId(key), message: err.message, exceptionType: err.exceptionType, count: 0, sessionIds: new Set(), lastSeen: 0 }
|
||||||
groups.set(key, g)
|
groups.set(key, g)
|
||||||
}
|
}
|
||||||
g.count += 1
|
g.count += 1
|
||||||
@@ -73,8 +82,12 @@ const useResolvedStacks = (groups: JsErrorGroup[]) => {
|
|||||||
return { resolved, frames }
|
return { resolved, frames }
|
||||||
}
|
}
|
||||||
|
|
||||||
const JsErrorCard = ({ group, resolvedStack, frames }: { group: JsErrorGroup; resolvedStack?: string; frames: StackFrame[] }) => {
|
const JsErrorCard = ({ group, resolvedStack, frames, expanded, onToggle }: { group: JsErrorGroup; resolvedStack?: string; frames: StackFrame[]; expanded: boolean; onToggle: () => void }) => {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const card = useRef<HTMLElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (expanded) card.current?.scrollIntoView({ block: "start" })
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only when a link opens this card on first render
|
||||||
|
}, [])
|
||||||
const [showRaw, setShowRaw] = useState(false)
|
const [showRaw, setShowRaw] = useState(false)
|
||||||
const raw = group.sample?.stacktrace
|
const raw = group.sample?.stacktrace
|
||||||
const mapped = Boolean(raw && resolvedStack && resolvedStack !== raw)
|
const mapped = Boolean(raw && resolvedStack && resolvedStack !== raw)
|
||||||
@@ -82,8 +95,8 @@ const JsErrorCard = ({ group, resolvedStack, frames }: { group: JsErrorGroup; re
|
|||||||
const explorable = frames.length > 0 && frames.some((f) => f.status === "mapped")
|
const explorable = frames.length > 0 && frames.some((f) => f.status === "mapped")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] border-l-ds-critical bg-ds-surface">
|
<article ref={card} 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">
|
<button type="button" onClick={onToggle} 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 min-w-0 flex-1 flex-col gap-1.5">
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
{group.exceptionType && (
|
{group.exceptionType && (
|
||||||
@@ -92,6 +105,7 @@ const JsErrorCard = ({ group, resolvedStack, frames }: { group: JsErrorGroup; re
|
|||||||
{mapped && (
|
{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="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>
|
||||||
)}
|
)}
|
||||||
|
<CopyRef id={group.id} path={`/rum/errors/${group.id}`} label="Copy error link" />
|
||||||
<span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(group.lastSeen)}</span>
|
<span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(group.lastSeen)}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="break-all font-[family-name:var(--font-display)] text-[16px] font-bold leading-snug text-ds-text">{group.message}</h3>
|
<h3 className="break-all font-[family-name:var(--font-display)] text-[16px] font-bold leading-snug text-ds-text">{group.message}</h3>
|
||||||
@@ -142,7 +156,7 @@ const TestErrorButton = () => (
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const RumJsErrors = ({ sessions }: { sessions: RumSession[] }) => {
|
export const RumJsErrors = ({ sessions, openId, onOpen }: { sessions: RumSession[]; openId: string | null; onOpen: (id: string | null) => void }) => {
|
||||||
const groups = useMemo(() => groupJsErrors(sessions), [sessions])
|
const groups = useMemo(() => groupJsErrors(sessions), [sessions])
|
||||||
const { resolved, frames } = useResolvedStacks(groups)
|
const { resolved, frames } = useResolvedStacks(groups)
|
||||||
|
|
||||||
@@ -155,5 +169,5 @@ export const RumJsErrors = ({ sessions }: { sessions: RumSession[] }) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <div className="flex flex-col gap-3"><TestErrorButton />{groups.map((g) => <JsErrorCard key={g.key} group={g} resolvedStack={resolved[g.key]} frames={frames[g.key] ?? []} />)}</div>
|
return <div className="flex flex-col gap-3"><TestErrorButton />{groups.map((g) => <JsErrorCard key={g.key} group={g} resolvedStack={resolved[g.key]} frames={frames[g.key] ?? []} expanded={openId === g.id} onToggle={() => onOpen(openId === g.id ? null : g.id)} />)}</div>
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
type RumTimelineEntry,
|
type RumTimelineEntry,
|
||||||
type VitalRating,
|
type VitalRating,
|
||||||
} from "../lib/rum-utils"
|
} from "../lib/rum-utils"
|
||||||
|
import { useSubRoute } from "../lib/routes"
|
||||||
|
import { CopyRef } from "./common/CopyRef"
|
||||||
import { RumJsErrors } from "./RumJsErrors"
|
import { RumJsErrors } from "./RumJsErrors"
|
||||||
import { FilterBar, type FilterChip } from "./FilterBar"
|
import { FilterBar, type FilterChip } from "./FilterBar"
|
||||||
|
|
||||||
@@ -266,6 +268,7 @@ const SessionDetail = ({
|
|||||||
{session.environment ? ` · ${session.environment}` : ""}
|
{session.environment ? ` · ${session.environment}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<CopyRef id={session.id} path={`/rum/session/${encodeURIComponent(session.id)}`} label="Copy session link" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copySessionId}
|
onClick={copySessionId}
|
||||||
@@ -395,8 +398,12 @@ export const RumSessionsVisualizer = () => {
|
|||||||
const [deviceFilter, setDeviceFilter] = useState<DeviceFilter>("all")
|
const [deviceFilter, setDeviceFilter] = useState<DeviceFilter>("all")
|
||||||
const [errorsOnly, setErrorsOnly] = useState(false)
|
const [errorsOnly, setErrorsOnly] = useState(false)
|
||||||
const [sortMode, setSortMode] = useState<SortMode>("worst")
|
const [sortMode, setSortMode] = useState<SortMode>("worst")
|
||||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
// /rum, /rum/session/<sessionId>, /rum/errors[/<errorId>]
|
||||||
const [jsFocus, setJsFocus] = useState(false)
|
const { segments, go } = useSubRoute()
|
||||||
|
const jsFocus = segments[0] === "errors"
|
||||||
|
const selectedSessionId = segments[0] === "session" ? (segments[1] ?? null) : null
|
||||||
|
const setJsFocus = (on: boolean) => go(on ? ["errors"] : [])
|
||||||
|
const setSelectedSessionId = (id: string) => go(["session", id])
|
||||||
|
|
||||||
const sessions = useMemo(() => buildRumSessions(logs), [logs])
|
const sessions = useMemo(() => buildRumSessions(logs), [logs])
|
||||||
const backend = useMemo(() => backendRequestsFromTraces(allTraces), [allTraces])
|
const backend = useMemo(() => backendRequestsFromTraces(allTraces), [allTraces])
|
||||||
@@ -532,7 +539,7 @@ export const RumSessionsVisualizer = () => {
|
|||||||
value={String(aggregates.totalJsErrors)}
|
value={String(aggregates.totalJsErrors)}
|
||||||
detail={`${aggregates.distinctJsErrorMessages} distinct messages`}
|
detail={`${aggregates.distinctJsErrorMessages} distinct messages`}
|
||||||
tone={aggregates.totalJsErrors > 0 ? "critical" : "ok"}
|
tone={aggregates.totalJsErrors > 0 ? "critical" : "ok"}
|
||||||
onClick={() => setJsFocus((v) => !v)}
|
onClick={() => setJsFocus(!jsFocus)}
|
||||||
active={jsFocus}
|
active={jsFocus}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
@@ -550,7 +557,7 @@ export const RumSessionsVisualizer = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{jsFocus ? (
|
{jsFocus ? (
|
||||||
<RumJsErrors sessions={filtered} />
|
<RumJsErrors sessions={filtered} openId={segments[1] ?? null} onOpen={(id) => go(id ? ["errors", id] : ["errors"])} />
|
||||||
) : sorted.length === 0 ? (
|
) : 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">
|
<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" />
|
<AlertOctagon className="h-6 w-6 text-ds-text-3" aria-hidden="true" />
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { Check, Link2 } from "lucide-react"
|
||||||
|
import { shortId } from "../../lib/routes"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a short id and copies the full permalink (origin + `path`) on click,
|
||||||
|
* so any issue, pattern, session or error can be referenced in a message.
|
||||||
|
* The full id is in the tooltip and in the copied URL.
|
||||||
|
*/
|
||||||
|
export const CopyRef = ({ id, path, label = "Copy link" }: { id: string; path: string; label?: string }) => {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(window.location.origin + path)
|
||||||
|
.then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1200)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}}
|
||||||
|
title={`${label}: ${id}`}
|
||||||
|
aria-label={`${label} for ${id}`}
|
||||||
|
className="inline-flex h-[23px] shrink-0 items-center gap-1 rounded-[6px] border border-ds-line px-1.5 font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-3 hover:border-ds-line-strong hover:text-ds-text-2"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-3 w-3 text-ds-ok" aria-hidden="true" /> : <Link2 className="h-3 w-3" aria-hidden="true" />}
|
||||||
|
{copied ? "copied" : shortId(id)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export const StatHeader = ({
|
|||||||
back,
|
back,
|
||||||
stats,
|
stats,
|
||||||
}: {
|
}: {
|
||||||
eyebrow?: string
|
eyebrow?: React.ReactNode
|
||||||
title: string
|
title: string
|
||||||
back?: React.ReactNode
|
back?: React.ReactNode
|
||||||
stats: React.ReactNode
|
stats: React.ReactNode
|
||||||
@@ -24,7 +24,7 @@ export const StatHeader = ({
|
|||||||
<div className="flex flex-wrap items-end gap-x-7 gap-y-3">
|
<div className="flex flex-wrap items-end gap-x-7 gap-y-3">
|
||||||
{back}
|
{back}
|
||||||
<div className="min-w-0 flex-1 basis-[280px]">
|
<div className="min-w-0 flex-1 basis-[280px]">
|
||||||
{eyebrow && <p className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{eyebrow}</p>}
|
{eyebrow && <div className="flex flex-wrap items-center gap-2 font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{eyebrow}</div>}
|
||||||
<h2 className="break-all font-[family-name:var(--font-display)] text-[22px] font-bold leading-tight text-ds-text">
|
<h2 className="break-all font-[family-name:var(--font-display)] text-[22px] font-bold leading-tight text-ds-text">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from "react"
|
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from "react"
|
||||||
import { useLocation } from "wouter"
|
import { useLocation } from "wouter"
|
||||||
import { tabForPath, pathForTab } from "../lib/routes"
|
import { tabForPath, pathForTab, splitPath, joinPath } from "../lib/routes"
|
||||||
import type {
|
import type {
|
||||||
MetricData,
|
MetricData,
|
||||||
OtelHistoryMessage,
|
OtelHistoryMessage,
|
||||||
@@ -150,11 +150,31 @@ export function ObservabilityProvider({
|
|||||||
const setActiveTabWithRoute = useCallback(
|
const setActiveTabWithRoute = useCallback(
|
||||||
(tab: string) => {
|
(tab: string) => {
|
||||||
setActiveTab(tab)
|
setActiveTab(tab)
|
||||||
|
// Already inside this tab (possibly on a deep link): keep the sub-route.
|
||||||
|
if (tabForPath(window.location.pathname) === tab) return
|
||||||
const path = pathForTab(tab)
|
const path = pathForTab(tab)
|
||||||
if (path && path !== window.location.pathname) navigate(path)
|
if (path) navigate(path)
|
||||||
},
|
},
|
||||||
[navigate],
|
[navigate],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Trace selection <-> /traces/<traceId>[/<spanId>]. The URL is adopted when
|
||||||
|
// it names a trace; a bare /traces never clears the selection, because other
|
||||||
|
// views set the trace first and switch tabs second.
|
||||||
|
useEffect(() => {
|
||||||
|
const { base, rest } = splitPath(location)
|
||||||
|
if (base !== "/traces" || !rest[0]) return
|
||||||
|
setActiveTraceId((prev) => (prev === rest[0] ? prev : rest[0]))
|
||||||
|
setActiveSpanId((prev) => (prev === (rest[1] ?? null) ? prev : (rest[1] ?? null)))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only follow URL changes
|
||||||
|
}, [location])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== "tab1" || !activeTraceId) return
|
||||||
|
const target = joinPath("/traces", activeSpanId ? [activeTraceId, activeSpanId] : [activeTraceId])
|
||||||
|
if (target !== window.location.pathname) navigate(target)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- location is read imperatively
|
||||||
|
}, [activeTab, activeTraceId, activeSpanId, navigate])
|
||||||
const [filtersExpanded, setFiltersExpanded] = useState<boolean>(false)
|
const [filtersExpanded, setFiltersExpanded] = useState<boolean>(false)
|
||||||
// Trace ids we already have spans for, so selecting a trace that fell
|
// Trace ids we already have spans for, so selecting a trace that fell
|
||||||
// outside the initial history window (or arrived before this session
|
// outside the initial history window (or arrived before this session
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { useCallback, useMemo } from "react"
|
||||||
|
import { useLocation } from "wouter"
|
||||||
|
|
||||||
// Route ↔ tab mapping. Tabs live in the URL (wouter) so deep links survive
|
// Route ↔ tab mapping. Tabs live in the URL (wouter) so deep links survive
|
||||||
// the OIDC login redirect at the ingress: users.k6n.net sends you back to
|
// the OIDC login redirect at the ingress: users.k6n.net sends you back to
|
||||||
// exactly the view you requested, and URLs are shareable/bookmarkable.
|
// exactly the view you requested, and URLs are shareable/bookmarkable.
|
||||||
@@ -12,14 +15,28 @@ export const TAB_PATHS: Record<string, string> = {
|
|||||||
"/setup": "tab4",
|
"/setup": "tab4",
|
||||||
"/trends": "tab5",
|
"/trends": "tab5",
|
||||||
"/search": "tab6",
|
"/search": "tab6",
|
||||||
"/frontend": "tab7",
|
|
||||||
"/rum": "tab7",
|
"/rum": "tab7",
|
||||||
|
"/frontend": "tab7",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Tab value for a location pathname, or null when the path isn't a tab. */
|
/** Splits "/issues/abc/trace/def" into base "/issues" and rest ["abc","trace","def"]. */
|
||||||
|
export function splitPath(path: string): { base: string; rest: string[] } {
|
||||||
|
const parts = path.split("/").filter(Boolean)
|
||||||
|
return { base: "/" + (parts[0] ?? ""), rest: parts.slice(1).map((p) => decodeURIComponent(p)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a path below a tab base, e.g. joinPath("/issues", ["abc", "trace", "def"]). */
|
||||||
|
export function joinPath(base: string, rest: string[]): string {
|
||||||
|
return [base, ...rest.map((p) => encodeURIComponent(p))].join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab value for a location pathname, or null when the path isn't a tab.
|
||||||
|
* Deeper paths ("/issues/<id>/trace/<id>") belong to their first segment's tab.
|
||||||
|
*/
|
||||||
export function tabForPath(path: string): string | null {
|
export function tabForPath(path: string): string | null {
|
||||||
return TAB_PATHS[path] ?? null
|
return TAB_PATHS[splitPath(path).base] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Canonical path for a tab value, or null when the tab has no route. */
|
/** Canonical path for a tab value, or null when the tab has no route. */
|
||||||
@@ -29,3 +46,28 @@ export function pathForTab(tab: string): string | null {
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-route below the current tab: `segments` are the path parts after the
|
||||||
|
* tab base and `go` rewrites them, so selection state (which issue, which
|
||||||
|
* trace, ...) lives in the URL and survives reloads, back/forward and sharing.
|
||||||
|
*/
|
||||||
|
export function useSubRoute(): {
|
||||||
|
segments: string[]
|
||||||
|
go: (segments: string[], options?: { replace?: boolean }) => void
|
||||||
|
} {
|
||||||
|
const [location, navigate] = useLocation()
|
||||||
|
const { base, rest } = splitPath(location)
|
||||||
|
const key = rest.join("/")
|
||||||
|
// Stable identity per URL so effects can depend on `segments` directly.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const segments = useMemo(() => rest, [key])
|
||||||
|
const go = useCallback(
|
||||||
|
(next: string[], options?: { replace?: boolean }) => navigate(joinPath(base, next), options),
|
||||||
|
[base, navigate],
|
||||||
|
)
|
||||||
|
return { segments, go }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short reference for showing an id to humans (full id stays in the URL). */
|
||||||
|
export const shortId = (id: string) => (id.length > 10 ? id.slice(0, 8) : id)
|
||||||
Reference in new issue
Block a user