+ {todos.length === 0
+ ? 'Nothing here yet. Add your first todo above.'
+ : `No ${filter} todos.`}
+
+ ) : (
+ visible.map((todo) => (
+
+
+
+
+ ))
+ )}
+
+
+
+
Send data to Uptrace
+
+
+
+
+
+
+ Each button sends a different signal. Click a few times, then open your project in
+ Uptrace to see the spans, messages, and errors.
+
+
+
+ )
+}
+
+// breadcrumb records an action so it appears in the breadcrumb trail of any
+// event sent to Uptrace.
+function breadcrumb(message: string) {
+ Sentry.addBreadcrumb({ category: 'todo', message, level: 'info' })
+}
+
+// A severity level Sentry understands. Kept local so we don't import a type.
+type Level = 'info' | 'warning' | 'error'
+
+// MESSAGES are log-style events sent with Sentry.captureMessage. Picking one at
+// random gives Uptrace a mix of levels to display.
+const MESSAGES: ReadonlyArray = [
+ ['Todos synced with the backend', 'info'],
+ ['Todo sync retried after a timeout', 'warning'],
+ ['Could not reach the sync backend', 'error'],
+]
+
+// ERRORS are the demo failures. Varied types and messages so Uptrace groups
+// them as distinct issues instead of one repeated error. Each is built fresh on
+// throw so its stack trace points at the app, not this list.
+const ERRORS: ReadonlyArray<() => Error> = [
+ () => new Error('Example error from the React Todo app'),
+ () => new TypeError("Cannot read properties of undefined (reading 'text')"),
+ () => new RangeError('Todo limit of 100 exceeded'),
+ () => Object.assign(new Error('Failed to sync todos: network request timed out'), {
+ name: 'TodoSyncError',
+ }),
+]
+
+// sendTestMessage captures a random log message at its level. This is a handled
+// (non-error) event, the "send" counterpart to throwing.
+function sendTestMessage() {
+ const [message, level] = pickRandom(MESSAGES)
+ breadcrumb(`Sent a ${level} message`)
+ Sentry.captureMessage(message, level)
+}
+
+// throwTestError simulates a real app operation that fails a few calls deep, so
+// the error reported to Uptrace has app frames in its stack trace (syncTodos ->
+// buildSyncPayload) instead of only the React internals that call the handler.
+// The app does not catch it, so Sentry's global handler captures and reports it.
+function throwTestError() {
+ breadcrumb('About to throw a test error')
+ syncTodos()
+}
+
+// syncTodos pretends to push the saved todos to a backend.
+function syncTodos() {
+ const payload = buildSyncPayload(loadTodos())
+ console.debug('would send payload', payload)
+}
+
+// buildSyncPayload pretends to serialize the todos, but always fails here on
+// purpose with a randomly chosen error. (The length check is just to keep the
+// return reachable for the type checker.)
+function buildSyncPayload(todos: Todo[]): string {
+ if (todos.length >= 0) {
+ throw pickRandom(ERRORS)()
+ }
+ return JSON.stringify(todos)
+}
+
+// pickRandom returns a random element of a non-empty array.
+function pickRandom(items: ReadonlyArray): T {
+ return items[Math.floor(Math.random() * items.length)]
+}
+
+// runTracedTask runs fake async work inside a span. The span and its children
+// appear as a trace in Uptrace.
+async function runTracedTask() {
+ await Sentry.startSpan({ name: 'run_traced_task', op: 'task' }, async () => {
+ await Sentry.startSpan({ name: 'step_one', op: 'task.step' }, () => wait(150))
+ await Sentry.startSpan({ name: 'step_two', op: 'task.step' }, () => wait(250))
+ })
+}
+
+// loadTodos reads the persisted list, tolerating absent or corrupt storage.
+function loadTodos(): Todo[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ return raw ? (JSON.parse(raw) as Todo[]) : []
+ } catch {
+ return []
+ }
+}
+
+// wait resolves after `ms` milliseconds, standing in for real async work.
+function wait(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/react/src/index.css b/react/src/index.css
new file mode 100644
index 0000000..ef972fb
--- /dev/null
+++ b/react/src/index.css
@@ -0,0 +1,44 @@
+:root {
+ /* Warm-tinted neutrals (no pure black/white) plus one cool violet accent.
+ OKLCH keeps the tints consistent in lightness. */
+ --paper: oklch(0.985 0.006 85);
+ --raised: oklch(0.998 0.004 85);
+ --ink: oklch(0.27 0.018 60);
+ --muted: oklch(0.55 0.016 70);
+ --faint: oklch(0.7 0.014 75);
+ --line: oklch(0.92 0.008 80);
+ --line-strong: oklch(0.84 0.01 80);
+
+ --accent: oklch(0.55 0.19 300);
+ --accent-strong: oklch(0.48 0.2 300);
+ --on-accent: oklch(0.99 0.01 300);
+ --accent-ghost: oklch(0.55 0.19 300 / 0.12);
+
+ --danger: oklch(0.55 0.18 25);
+ --danger-tint: oklch(0.96 0.03 25);
+
+ --radius: 9px;
+ --ease: cubic-bezier(0.22, 1, 0.36, 1); /* ease-out-quint */
+
+ font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+
+ color: var(--ink);
+ background-color: var(--paper);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+}
+
+:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
diff --git a/react/src/instrument.ts b/react/src/instrument.ts
new file mode 100644
index 0000000..12776e3
--- /dev/null
+++ b/react/src/instrument.ts
@@ -0,0 +1,40 @@
+// Sentry initialization for the browser.
+//
+// This file is imported FIRST in main.tsx (before React) so that Sentry can
+// install its instrumentation before the app starts rendering.
+//
+// The DSN is read from the `VITE_SENTRY_DSN` environment variable. Copy
+// `.env.example` to `.env` and paste the Sentry DSN from your Uptrace project
+// (Project -> DSN -> "Sentry" tab). See README.md for details.
+import * as Sentry from '@sentry/react'
+
+const dsn = import.meta.env.VITE_SENTRY_DSN
+
+if (!dsn) {
+ // Make the misconfiguration loud instead of silently dropping every event.
+ console.warn(
+ 'VITE_SENTRY_DSN is not set. Copy .env.example to .env and paste your ' +
+ 'Uptrace Sentry DSN, then restart `npm run dev`.',
+ )
+}
+
+Sentry.init({
+ dsn,
+
+ // browserTracingIntegration emits performance spans for page loads,
+ // navigations and fetch/XHR requests. Together with the manual spans in
+ // App.tsx these show up as traces in Uptrace.
+ integrations: [Sentry.browserTracingIntegration()],
+
+ // Sample 100% of traces. Lower this in production; for a demo we want to
+ // see every interaction in Uptrace.
+ tracesSampleRate: 1.0,
+
+ // Attach a default user/IP so events are easier to find in the UI. Turn
+ // this off if you do not want to send personally identifiable information.
+ sendDefaultPii: true,
+
+ // Surfaces as an attribute on every event so you can filter this example's
+ // data in Uptrace.
+ environment: 'development',
+})
diff --git a/react/src/main.tsx b/react/src/main.tsx
new file mode 100644
index 0000000..cda57d8
--- /dev/null
+++ b/react/src/main.tsx
@@ -0,0 +1,20 @@
+// Import Sentry instrumentation BEFORE anything else so it is initialized
+// before React renders.
+import './instrument.ts'
+
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import * as Sentry from '@sentry/react'
+
+import App from './App.tsx'
+import './index.css'
+
+createRoot(document.getElementById('root')!).render(
+
+ {/* Sentry.ErrorBoundary reports any uncaught render error to Uptrace and
+ shows a fallback instead of a blank screen. */}
+ Something went wrong — check Uptrace.
}>
+
+
+ ,
+)
diff --git a/react/tsconfig.json b/react/tsconfig.json
new file mode 100644
index 0000000..ab81502
--- /dev/null
+++ b/react/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+
+ /* Vite injects this type for `import.meta.env` */
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
diff --git a/react/vite.config.ts b/react/vite.config.ts
new file mode 100644
index 0000000..567aecb
--- /dev/null
+++ b/react/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// Minimal Vite config for the React + Sentry example.
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
From 9e7e5b39c5e142b1879c2244d3c3069d8f78c10c Mon Sep 17 00:00:00 2001
From: Catalin4513
Date: Tue, 30 Jun 2026 14:10:47 +0300
Subject: [PATCH 02/73] feat(react): add trace links and Uptrace activity feed,
polish UI
---
react/.env.example | 6 ++
react/AGENTS.md | 39 +++++++++
react/README.md | 12 ++-
react/src/App.css | 101 +++++++++++++++++++++-
react/src/App.tsx | 204 +++++++++++++++++++++++++++++++-------------
react/src/index.css | 1 +
6 files changed, 299 insertions(+), 64 deletions(-)
create mode 100644 react/AGENTS.md
diff --git a/react/.env.example b/react/.env.example
index 9fbc639..5a46db9 100644
--- a/react/.env.example
+++ b/react/.env.example
@@ -8,3 +8,9 @@
# For a local self-hosted Uptrace the ingest host is usually localhost:14318,
# e.g. http://project2_secret_token@localhost:14318/2
VITE_SENTRY_DSN=
+
+# Optional: base URL of your Uptrace UI (its site URL), used to turn each demo
+# action into a clickable link to its trace. Self-hosted default is
+# http://localhost:5000; Uptrace Cloud is https://app.uptrace.dev. Without it the
+# app still logs the trace id to the console.
+VITE_UPTRACE_URL=
diff --git a/react/AGENTS.md b/react/AGENTS.md
new file mode 100644
index 0000000..47b07f1
--- /dev/null
+++ b/react/AGENTS.md
@@ -0,0 +1,39 @@
+# React + Sentry example
+
+Rules here apply to `examples/react`, one of the `uptrace/examples` projects:
+small, runnable apps that show how to send data to Uptrace. This one shows how
+to integrate the Sentry SDK (`@sentry/react`) with a React frontend reporting to
+Uptrace, and that is the one thing it teaches. Keep it minimal: an example is
+read more than it is run, so every extra feature, file, or dependency is noise
+that hides the integration. Add something only when it makes the integration
+clearer, not the app richer. The `README.md` is the primary deliverable; when
+behavior changes, update it in the same change.
+
+## Core Rules
+
+- The only Uptrace-specific code is `Sentry.init()` in `src/instrument.ts`,
+ imported first in `src/main.tsx` (before React) so instrumentation is in place
+ before the app renders.
+- The DSN comes from `VITE_SENTRY_DSN`; never hardcode a DSN. `.env` is
+ gitignored — keep `.env.example` as the template and document it in the README.
+- Uptrace ingests the standard Sentry protocol, so there is no Uptrace exporter
+ or adapter. If you reach for one, you are doing it wrong.
+- The app is a small todo list: Vite + React + TypeScript, state in `useState`
+ held in memory only. No router, no backend, no UI framework.
+- Every user action (add / toggle / delete / filter) leaves a Sentry breadcrumb
+ so a reported event carries the trail that led to it; keep that going for new
+ actions. The "Send data to Uptrace" buttons exist only to emit telemetry (a
+ span, a log message, an error). Document any new button in the README.
+- TypeScript with `strict` on. JS/TS comments use `//` line comments, including
+ comments for exported types and functions.
+- Plain CSS only. Design tokens (OKLCH colors, radius, easing) live in `:root` in
+ `src/index.css`; component styles in `src/App.css`. No Tailwind, no component
+ library, no CSS-in-JS. Keep the dependency list short.
+
+## Commands
+
+- `npm install`
+- `npm run dev` — local dev server.
+- `npm run build` — type-check (`tsc -b`) and production build. This is the
+ verification step; there are no unit tests (it is an example). Also run the app
+ and click through it when changing behavior.
diff --git a/react/README.md b/react/README.md
index 6518fe9..363917a 100644
--- a/react/README.md
+++ b/react/README.md
@@ -9,10 +9,13 @@ Uptrace project.
You'll be able to:
- click around a todo list and generate **breadcrumbs**,
-- press a button to throw an **error** (of a few different types) that shows up in Uptrace,
+- press a button to report an **error** (of a few different types) that shows up in Uptrace,
- press a button to send a **log message** at a chosen level,
- press a button to run a traced task and see the resulting **spans / trace**.
+Each demo button prints its trace id to the browser console and, if you set
+`VITE_UPTRACE_URL`, shows a link straight to that trace in Uptrace.
+
## How it works
The Sentry SDK builds its request URLs from the DSN you give it
@@ -51,6 +54,8 @@ http://project2_secret_token@localhost:14318/2
# from this directory: examples/react
cp .env.example .env
# then edit .env and paste your DSN into VITE_SENTRY_DSN
+# optionally set VITE_UPTRACE_URL to your Uptrace UI (e.g. http://localhost:5000)
+# to get clickable trace links
npm install
npm run dev
@@ -67,8 +72,11 @@ In the app:
- Click **Run traced task** — runs a span with two child spans.
- Click **Send log message** — sends a log message at a random level
(info / warning / error).
-- Click **Throw test error** — throws an uncaught error that Sentry captures.
+- Click **Throw test error** — raises an error (caught and reported with
+ `captureException`) so its trace is linkable.
+Each click runs in its own trace, prints `[uptrace] … sent on trace ` to the
+console, and shows a **view in Uptrace** link (when `VITE_UPTRACE_URL` is set).
The last two buttons pick from a small pool each click, so repeated clicks
produce a variety of messages and error types in Uptrace. The SDK sends events
over the network as you interact. (Open your browser's devtools Network tab and
diff --git a/react/src/App.css b/react/src/App.css
index c5b5250..93f1bbd 100644
--- a/react/src/App.css
+++ b/react/src/App.css
@@ -62,16 +62,21 @@
transform 0.08s var(--ease);
}
-.btn:active {
+.btn:active:not(:disabled) {
transform: translateY(1px);
}
+.btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
.btn-primary {
color: var(--on-accent);
background: var(--accent);
}
-.btn-primary:hover {
+.btn-primary:hover:not(:disabled) {
background: var(--accent-strong);
}
@@ -171,6 +176,21 @@
padding: 0;
}
+/* Past ~10 rows the list becomes a scrollable panel so the content below it
+ (the activity feed) stays in place instead of being pushed down. */
+.todos--scroll {
+ max-height: 29rem;
+ overflow-y: auto;
+ scrollbar-gutter: stable;
+ padding: 0 0.7rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+}
+
+.todos--scroll .todo:last-child {
+ border-bottom: none;
+}
+
.todo {
display: flex;
align-items: center;
@@ -256,6 +276,83 @@
color: var(--faint);
}
+.feed {
+ list-style: none;
+ margin: 1rem 0 0;
+ padding: 0 0.85rem;
+ background: var(--raised);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+}
+
+.feed__row {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ padding: 0.55rem 0;
+ border-top: 1px solid var(--line);
+ animation: enter 0.24s var(--ease);
+ /* The dot color follows the signal kind. */
+ --signal: var(--accent);
+}
+
+.feed__row:first-child {
+ border-top: none;
+}
+
+.feed__row[data-kind='log'] {
+ --signal: var(--info);
+}
+
+.feed__row[data-kind='error'] {
+ --signal: var(--danger);
+}
+
+.feed__dot {
+ flex: none;
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 999px;
+ background: var(--signal);
+ box-shadow: 0 0 0 3px color-mix(in oklch, var(--signal) 18%, transparent);
+}
+
+.feed__label {
+ flex: none;
+ font-size: 0.82rem;
+ font-weight: 560;
+ color: var(--ink);
+}
+
+.feed__id {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 0.74rem;
+ color: var(--muted);
+}
+
+.feed__cta {
+ flex: none;
+ font-size: 0.8rem;
+ font-weight: 560;
+ white-space: nowrap;
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.feed__cta:hover {
+ text-decoration: underline;
+}
+
+.feed__hint {
+ flex: none;
+ font-size: 0.76rem;
+ color: var(--faint);
+}
+
@keyframes enter {
from {
opacity: 0;
diff --git a/react/src/App.tsx b/react/src/App.tsx
index fb35bb8..f3e7634 100644
--- a/react/src/App.tsx
+++ b/react/src/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useMemo, useState } from 'react'
import * as Sentry from '@sentry/react'
import './App.css'
@@ -13,23 +13,37 @@ interface Todo {
// Which todos the list is showing.
type Filter = 'all' | 'active' | 'completed'
-const STORAGE_KEY = 'uptrace-sentry-todos'
+// Base URL of your Uptrace UI (its site URL, e.g. http://localhost:5000), used
+// to build a link to each trace. Optional: without it we still log the trace id.
+const UPTRACE_URL = import.meta.env.VITE_UPTRACE_URL
-// App is a small but real todo list: add, complete, filter, delete, and clear
-// completed, persisted to localStorage. Every interaction also leaves a Sentry
-// breadcrumb, so when an event reaches Uptrace you can see the trail of actions
-// that led to it. Two extra buttons exist purely to generate telemetry: one
-// throws an error, one runs a traced task (a span).
+// Project id, taken from the last path segment of the Sentry DSN. The trace link
+// is project-scoped (/explore//traces/), so we need it.
+const PROJECT_ID = projectIdFromDsn(import.meta.env.VITE_SENTRY_DSN)
+
+// The kind of signal a demo action produced, used to color the result.
+type Signal = 'trace' | 'log' | 'error'
+
+// TraceLink describes one signal sent to Uptrace, for the console and the feed.
+interface TraceLink {
+ kind: Signal
+ label: string
+ traceId: string
+ url: string | null
+}
+
+// App is a small todo list: add, complete, filter, delete, and clear completed.
+// State is in memory only. Every interaction also leaves a Sentry breadcrumb, so
+// when an event reaches Uptrace you can see the trail of actions that led to it.
+// Two extra buttons exist purely to generate telemetry: one throws an error, one
+// runs a traced task (a span).
export default function App() {
- const [todos, setTodos] = useState(loadTodos)
+ const [todos, setTodos] = useState([])
const [filter, setFilter] = useState('all')
const [text, setText] = useState('')
-
- // Persist on every change so a reload (or a developer coming back to the tab)
- // keeps the list.
- useEffect(() => {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
- }, [todos])
+ // A running feed of signals sent to Uptrace, newest first, capped.
+ const [sent, setSent] = useState([])
+ const recordTrace = (link: TraceLink) => setSent((prev) => [link, ...prev].slice(0, 8))
const remaining = todos.filter((t) => !t.done).length
const completed = todos.length - remaining
@@ -49,11 +63,19 @@ export default function App() {
return
}
- // Wrap the work in a span so adding a todo shows up as a trace in Uptrace.
- Sentry.startSpan({ name: 'add_todo', op: 'ui.action' }, () => {
- setTodos((prev) => [...prev, { id: crypto.randomUUID(), text: trimmed, done: false }])
- setText('')
- breadcrumb(`Added todo "${trimmed}"`)
+ const id = crypto.randomUUID()
+ // Adding a todo is instrumented like the demo actions: its own trace, with a
+ // link in the result panel. Real app interactions are traced too, not just
+ // the demo buttons.
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'add_todo', op: 'ui.action' }, (span) => {
+ setTodos((prev) => [{ id, text: trimmed, done: false }, ...prev])
+ setText('')
+ breadcrumb(`Added todo "${trimmed}"`)
+ recordTrace(
+ announce('trace', 'Added todo', span.spanContext().traceId, span.spanContext().spanId),
+ )
+ })
})
}
@@ -92,7 +114,7 @@ export default function App() {
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTodo()}
/>
-
-
+ Every action here, and adding a todo, is traced. Each one prints its trace to the
+ console and appears below with a link to Uptrace.
+
+ )}
)
@@ -174,6 +215,41 @@ function breadcrumb(message: string) {
Sentry.addBreadcrumb({ category: 'todo', message, level: 'info' })
}
+// announce logs the trace to the console and returns a link for the UI. Every
+// action shares the page's trace, so we deep-link to this action's own span to
+// land on the right place (the error, the log message, the task).
+function announce(kind: Signal, label: string, traceId: string, spanId: string): TraceLink {
+ const url = traceUrl(traceId, spanId)
+ console.log(
+ `[uptrace] ${label} sent on trace ${traceId}`,
+ url ?? '(set VITE_UPTRACE_URL to get a link)',
+ )
+ return { kind, label, traceId, url }
+}
+
+// traceUrl builds a link to a span within a trace in the Uptrace UI. We use the
+// project-scoped /explore route; the bare /traces/ shortcut can 404.
+function traceUrl(traceId: string, spanId: string): string | null {
+ if (!UPTRACE_URL || !PROJECT_ID) {
+ return null
+ }
+ return `${UPTRACE_URL.replace(/\/+$/, '')}/explore/${PROJECT_ID}/traces/${traceId}/${spanId}`
+}
+
+// projectIdFromDsn returns the project id, the last path segment of the DSN
+// (e.g. "2" in http://token@host/2), or null if the DSN is absent or malformed.
+function projectIdFromDsn(dsn: string | undefined): string | null {
+ if (!dsn) {
+ return null
+ }
+ try {
+ const segments = new URL(dsn).pathname.split('/').filter(Boolean)
+ return segments.at(-1) ?? null
+ } catch {
+ return null
+ }
+}
+
// A severity level Sentry understands. Kept local so we don't import a type.
type Level = 'info' | 'warning' | 'error'
@@ -198,36 +274,50 @@ const ERRORS: ReadonlyArray<() => Error> = [
]
// sendTestMessage captures a random log message at its level. This is a handled
-// (non-error) event, the "send" counterpart to throwing.
-function sendTestMessage() {
+// (non-error) event, the "send" counterpart to throwing. It runs in its own
+// trace so we can link to it.
+function sendTestMessage(onTrace: (link: TraceLink) => void) {
const [message, level] = pickRandom(MESSAGES)
breadcrumb(`Sent a ${level} message`)
- Sentry.captureMessage(message, level)
+ // startNewTrace gives each click its own trace, so repeated clicks are
+ // distinct in Uptrace rather than sharing the page's trace.
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'send_log_message', op: 'task' }, (span) => {
+ Sentry.captureMessage(message, level)
+ onTrace(announce('log', `Message (${level})`, span.spanContext().traceId, span.spanContext().spanId))
+ })
+ })
}
// throwTestError simulates a real app operation that fails a few calls deep, so
// the error reported to Uptrace has app frames in its stack trace (syncTodos ->
-// buildSyncPayload) instead of only the React internals that call the handler.
-// The app does not catch it, so Sentry's global handler captures and reports it.
-function throwTestError() {
+// buildSyncPayload) instead of only the React internals. We catch and report it
+// with captureException (rather than letting it propagate) so the event lands on
+// this trace and we can link to it; a real unhandled error is auto-captured.
+function throwTestError(onTrace: (link: TraceLink) => void) {
breadcrumb('About to throw a test error')
- syncTodos()
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'throw_test_error', op: 'task' }, (span) => {
+ try {
+ syncTodos()
+ } catch (err) {
+ Sentry.captureException(err)
+ }
+ onTrace(announce('error', 'Error', span.spanContext().traceId, span.spanContext().spanId))
+ })
+ })
}
-// syncTodos pretends to push the saved todos to a backend.
+// syncTodos pretends to push the todos to a backend.
function syncTodos() {
- const payload = buildSyncPayload(loadTodos())
+ const payload = buildSyncPayload()
console.debug('would send payload', payload)
}
// buildSyncPayload pretends to serialize the todos, but always fails here on
-// purpose with a randomly chosen error. (The length check is just to keep the
-// return reachable for the type checker.)
-function buildSyncPayload(todos: Todo[]): string {
- if (todos.length >= 0) {
- throw pickRandom(ERRORS)()
- }
- return JSON.stringify(todos)
+// purpose with a randomly chosen error.
+function buildSyncPayload(): string {
+ throw pickRandom(ERRORS)()
}
// pickRandom returns a random element of a non-empty array.
@@ -236,22 +326,16 @@ function pickRandom(items: ReadonlyArray): T {
}
// runTracedTask runs fake async work inside a span. The span and its children
-// appear as a trace in Uptrace.
-async function runTracedTask() {
- await Sentry.startSpan({ name: 'run_traced_task', op: 'task' }, async () => {
- await Sentry.startSpan({ name: 'step_one', op: 'task.step' }, () => wait(150))
- await Sentry.startSpan({ name: 'step_two', op: 'task.step' }, () => wait(250))
- })
-}
-
-// loadTodos reads the persisted list, tolerating absent or corrupt storage.
-function loadTodos(): Todo[] {
- try {
- const raw = localStorage.getItem(STORAGE_KEY)
- return raw ? (JSON.parse(raw) as Todo[]) : []
- } catch {
- return []
- }
+// appear as a trace in Uptrace. The trace id is reported as soon as the parent
+// span opens, while the child spans keep running.
+async function runTracedTask(onTrace: (link: TraceLink) => void) {
+ await Sentry.startNewTrace(() =>
+ Sentry.startSpan({ name: 'run_traced_task', op: 'task' }, async (span) => {
+ onTrace(announce('trace', 'Traced task', span.spanContext().traceId, span.spanContext().spanId))
+ await Sentry.startSpan({ name: 'step_one', op: 'task.step' }, () => wait(150))
+ await Sentry.startSpan({ name: 'step_two', op: 'task.step' }, () => wait(250))
+ }),
+ )
}
// wait resolves after `ms` milliseconds, standing in for real async work.
diff --git a/react/src/index.css b/react/src/index.css
index ef972fb..c87f27e 100644
--- a/react/src/index.css
+++ b/react/src/index.css
@@ -16,6 +16,7 @@
--danger: oklch(0.55 0.18 25);
--danger-tint: oklch(0.96 0.03 25);
+ --info: oklch(0.6 0.12 230);
--radius: 9px;
--ease: cubic-bezier(0.22, 1, 0.36, 1); /* ease-out-quint */
From bb76ae1a4697707bba7faa244b5cff7be65c30a5 Mon Sep 17 00:00:00 2001
From: Catalin4513
Date: Wed, 1 Jul 2026 08:40:40 +0300
Subject: [PATCH 03/73] docs(react): drop concrete dev/cloud URLs from
.env.example
---
react/.env.example | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/react/.env.example b/react/.env.example
index 5a46db9..28c67dc 100644
--- a/react/.env.example
+++ b/react/.env.example
@@ -4,13 +4,9 @@
# "Sentry" tab, and copy the DSN. It looks like:
#
# http://@/
-#
-# For a local self-hosted Uptrace the ingest host is usually localhost:14318,
-# e.g. http://project2_secret_token@localhost:14318/2
VITE_SENTRY_DSN=
# Optional: base URL of your Uptrace UI (its site URL), used to turn each demo
-# action into a clickable link to its trace. Self-hosted default is
-# http://localhost:5000; Uptrace Cloud is https://app.uptrace.dev. Without it the
-# app still logs the trace id to the console.
+# action into a clickable link to its trace. Without it the app still logs the
+# trace id to the console.
VITE_UPTRACE_URL=
From 0e6f94710528959e4d0fb469f8b468129bc4c8d4 Mon Sep 17 00:00:00 2001
From: Catalin4513
Date: Wed, 1 Jul 2026 08:45:47 +0300
Subject: [PATCH 04/73] refactor(examples): rename react example to
sentry-react for naming consistency
---
README.md | 2 +-
{react => sentry-react}/.env.example | 0
{react => sentry-react}/.gitignore | 0
{react => sentry-react}/AGENTS.md | 2 +-
{react => sentry-react}/README.md | 2 +-
{react => sentry-react}/index.html | 0
{react => sentry-react}/package-lock.json | 0
{react => sentry-react}/package.json | 0
{react => sentry-react}/src/App.css | 0
{react => sentry-react}/src/App.tsx | 0
{react => sentry-react}/src/index.css | 0
{react => sentry-react}/src/instrument.ts | 0
{react => sentry-react}/src/main.tsx | 0
{react => sentry-react}/tsconfig.json | 0
{react => sentry-react}/vite.config.ts | 0
15 files changed, 3 insertions(+), 3 deletions(-)
rename {react => sentry-react}/.env.example (100%)
rename {react => sentry-react}/.gitignore (100%)
rename {react => sentry-react}/AGENTS.md (96%)
rename {react => sentry-react}/README.md (99%)
rename {react => sentry-react}/index.html (100%)
rename {react => sentry-react}/package-lock.json (100%)
rename {react => sentry-react}/package.json (100%)
rename {react => sentry-react}/src/App.css (100%)
rename {react => sentry-react}/src/App.tsx (100%)
rename {react => sentry-react}/src/index.css (100%)
rename {react => sentry-react}/src/instrument.ts (100%)
rename {react => sentry-react}/src/main.tsx (100%)
rename {react => sentry-react}/tsconfig.json (100%)
rename {react => sentry-react}/vite.config.ts (100%)
diff --git a/README.md b/README.md
index 0f2069a..1b1dc94 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,7 @@ project.
- [sentry-go](sentry-go) — errors and tracing with sentry-go.
- [sentry-python](sentry-python) — nested exception chains with sentry-python.
- [sentry-browser](sentry-browser) — events with @sentry/browser.
-- [react](react) — errors, breadcrumbs, and tracing with @sentry/react in a todo app.
+- [sentry-react](sentry-react) — errors, breadcrumbs, and tracing with @sentry/react in a todo app.
### Logs, Collector, and demos
diff --git a/react/.env.example b/sentry-react/.env.example
similarity index 100%
rename from react/.env.example
rename to sentry-react/.env.example
diff --git a/react/.gitignore b/sentry-react/.gitignore
similarity index 100%
rename from react/.gitignore
rename to sentry-react/.gitignore
diff --git a/react/AGENTS.md b/sentry-react/AGENTS.md
similarity index 96%
rename from react/AGENTS.md
rename to sentry-react/AGENTS.md
index 47b07f1..5708efe 100644
--- a/react/AGENTS.md
+++ b/sentry-react/AGENTS.md
@@ -1,6 +1,6 @@
# React + Sentry example
-Rules here apply to `examples/react`, one of the `uptrace/examples` projects:
+Rules here apply to `examples/sentry-react`, one of the `uptrace/examples` projects:
small, runnable apps that show how to send data to Uptrace. This one shows how
to integrate the Sentry SDK (`@sentry/react`) with a React frontend reporting to
Uptrace, and that is the one thing it teaches. Keep it minimal: an example is
diff --git a/react/README.md b/sentry-react/README.md
similarity index 99%
rename from react/README.md
rename to sentry-react/README.md
index 363917a..c72508d 100644
--- a/react/README.md
+++ b/sentry-react/README.md
@@ -51,7 +51,7 @@ http://project2_secret_token@localhost:14318/2
## 2. Configure and run
```bash
-# from this directory: examples/react
+# from this directory: examples/sentry-react
cp .env.example .env
# then edit .env and paste your DSN into VITE_SENTRY_DSN
# optionally set VITE_UPTRACE_URL to your Uptrace UI (e.g. http://localhost:5000)
diff --git a/react/index.html b/sentry-react/index.html
similarity index 100%
rename from react/index.html
rename to sentry-react/index.html
diff --git a/react/package-lock.json b/sentry-react/package-lock.json
similarity index 100%
rename from react/package-lock.json
rename to sentry-react/package-lock.json
diff --git a/react/package.json b/sentry-react/package.json
similarity index 100%
rename from react/package.json
rename to sentry-react/package.json
diff --git a/react/src/App.css b/sentry-react/src/App.css
similarity index 100%
rename from react/src/App.css
rename to sentry-react/src/App.css
diff --git a/react/src/App.tsx b/sentry-react/src/App.tsx
similarity index 100%
rename from react/src/App.tsx
rename to sentry-react/src/App.tsx
diff --git a/react/src/index.css b/sentry-react/src/index.css
similarity index 100%
rename from react/src/index.css
rename to sentry-react/src/index.css
diff --git a/react/src/instrument.ts b/sentry-react/src/instrument.ts
similarity index 100%
rename from react/src/instrument.ts
rename to sentry-react/src/instrument.ts
diff --git a/react/src/main.tsx b/sentry-react/src/main.tsx
similarity index 100%
rename from react/src/main.tsx
rename to sentry-react/src/main.tsx
diff --git a/react/tsconfig.json b/sentry-react/tsconfig.json
similarity index 100%
rename from react/tsconfig.json
rename to sentry-react/tsconfig.json
diff --git a/react/vite.config.ts b/sentry-react/vite.config.ts
similarity index 100%
rename from react/vite.config.ts
rename to sentry-react/vite.config.ts
From 9ec90892c746a0297c77773b3a596677a0916a12 Mon Sep 17 00:00:00 2001
From: Catalin4513
Date: Mon, 6 Jul 2026 10:31:34 +0300
Subject: [PATCH 05/73] feat(sentry-react): emit logs for add/delete todos and
escalate the error demo to a multi-record sync
---
sentry-react/README.md | 26 ++++++++-----
sentry-react/src/App.tsx | 81 ++++++++++++++++++++++++++--------------
2 files changed, 70 insertions(+), 37 deletions(-)
diff --git a/sentry-react/README.md b/sentry-react/README.md
index c72508d..c445935 100644
--- a/sentry-react/README.md
+++ b/sentry-react/README.md
@@ -69,11 +69,15 @@ In the app:
- **Add / toggle / delete / filter** a few todos — each action records a
breadcrumb, so the error you trigger next has a trail leading up to it.
+ **Adding** and **deleting** also send a structured info **log** carrying the
+ todo's text and id as attributes (`tags_todo_text`, `tags_todo_id`).
- Click **Run traced task** — runs a span with two child spans.
- Click **Send log message** — sends a log message at a random level
(info / warning / error).
-- Click **Throw test error** — raises an error (caught and reported with
- `captureException`) so its trace is linkable.
+- Click **Throw test error** — simulates a failing backend sync: a single trace
+ that emits an **info**, then a **warning**, then the **error** (a random type,
+ caught and reported with `captureException`), so one trace carries several
+ Logs & Errors records with the breadcrumb trail linking back to them.
Each click runs in its own trace, prints `[uptrace] … sent on trace ` to the
console, and shows a **view in Uptrace** link (when `VITE_UPTRACE_URL` is set).
@@ -86,15 +90,17 @@ the browser.)
## 4. See it in Uptrace
- **Errors** — open your project and look under **Errors** / **Logs**. Each
- click of **Throw test error** sends one of several types (`Error`,
- `TypeError`, `RangeError`, `TodoSyncError`). Open one to see the stack trace
- (which runs through the app's `syncTodos` / `buildSyncPayload` frames) and, in
- the event detail, the **breadcrumbs** (the trail of todo actions that preceded
- it).
-- **Messages** — the **Send log message** events also appear under
- **Logs** / **Errors**, tagged with their level.
+ click of **Throw test error** runs a `sync_todos` trace with three records — an
+ info, a warning, and the error (one of `Error`, `TypeError`, `RangeError`,
+ `TodoSyncError`). Open the error to see the stack trace (which runs through the
+ app's `syncTodos` / `buildSyncPayload` frames) and, in the event detail, the
+ **breadcrumbs** (the trail of todo actions that preceded it).
+- **Messages / logs** — **Send log message**, plus the info logs from **adding**
+ and **deleting** todos, appear under **Logs** / **Errors**, tagged with their
+ level. The add / delete logs carry the todo's text and id as `tags_todo_*`
+ attributes.
- **Traces / spans** — open **Traces & Spans** and look for `run_traced_task`
- (with `step_one` / `step_two` children) and `add_todo`. The browser tracing
+ (with `step_one` / `step_two` children) and `sync_todos`. The browser tracing
integration also produces page-load and navigation spans.
If nothing shows up, double-check that `VITE_SENTRY_DSN` is set (the app logs a
diff --git a/sentry-react/src/App.tsx b/sentry-react/src/App.tsx
index f3e7634..c145d58 100644
--- a/sentry-react/src/App.tsx
+++ b/sentry-react/src/App.tsx
@@ -64,18 +64,20 @@ export default function App() {
}
const id = crypto.randomUUID()
- // Adding a todo is instrumented like the demo actions: its own trace, with a
- // link in the result panel. Real app interactions are traced too, not just
- // the demo buttons.
+ setTodos((prev) => [{ id, text: trimmed, done: false }, ...prev])
+ setText('')
+ breadcrumb(`Added todo "${trimmed}"`)
+ // A real interaction, reported as a structured info log rather than a span:
+ // the entered text and id ride along as queryable attributes (tags_todo_*),
+ // and the breadcrumb trail links back to this log on the trace's Events tab.
+ // startNewTrace gives each add its own trace so repeated adds stay distinct.
Sentry.startNewTrace(() => {
- Sentry.startSpan({ name: 'add_todo', op: 'ui.action' }, (span) => {
- setTodos((prev) => [{ id, text: trimmed, done: false }, ...prev])
- setText('')
- breadcrumb(`Added todo "${trimmed}"`)
- recordTrace(
- announce('trace', 'Added todo', span.spanContext().traceId, span.spanContext().spanId),
- )
+ Sentry.captureMessage(`Added todo "${trimmed}"`, {
+ level: 'info',
+ tags: { todo_text: trimmed, todo_id: id },
})
+ const traceId = Sentry.getCurrentScope().getPropagationContext().traceId
+ recordTrace(announce('log', 'Added todo', traceId))
})
}
@@ -85,8 +87,20 @@ export default function App() {
}
function deleteTodo(id: string) {
+ const removed = todos.find((t) => t.id === id)
setTodos((prev) => prev.filter((t) => t.id !== id))
- breadcrumb(`Deleted todo ${id}`)
+ breadcrumb(`Deleted todo "${removed?.text ?? id}"`)
+ // Mirror addTodo: report the removal as a structured info log with the todo's
+ // text and id as queryable attributes, on its own trace so the breadcrumb
+ // trail links back to it in Uptrace.
+ Sentry.startNewTrace(() => {
+ Sentry.captureMessage(`Deleted todo "${removed?.text ?? id}"`, {
+ level: 'info',
+ tags: { todo_text: removed?.text ?? '', todo_id: id },
+ })
+ const traceId = Sentry.getCurrentScope().getPropagationContext().traceId
+ recordTrace(announce('log', 'Deleted todo', traceId))
+ })
}
function clearCompleted() {
@@ -215,10 +229,11 @@ function breadcrumb(message: string) {
Sentry.addBreadcrumb({ category: 'todo', message, level: 'info' })
}
-// announce logs the trace to the console and returns a link for the UI. Every
-// action shares the page's trace, so we deep-link to this action's own span to
-// land on the right place (the error, the log message, the task).
-function announce(kind: Signal, label: string, traceId: string, spanId: string): TraceLink {
+// announce logs the trace to the console and returns a link for the UI. Span
+// actions deep-link to their own span to land on the right place (the error,
+// the task); a log has no span of its own, so it links to the trace and omits
+// the span id.
+function announce(kind: Signal, label: string, traceId: string, spanId?: string): TraceLink {
const url = traceUrl(traceId, spanId)
console.log(
`[uptrace] ${label} sent on trace ${traceId}`,
@@ -227,13 +242,15 @@ function announce(kind: Signal, label: string, traceId: string, spanId: string):
return { kind, label, traceId, url }
}
-// traceUrl builds a link to a span within a trace in the Uptrace UI. We use the
-// project-scoped /explore route; the bare /traces/ shortcut can 404.
-function traceUrl(traceId: string, spanId: string): string | null {
+// traceUrl builds a link into a trace in the Uptrace UI, optionally deep-linking
+// to a span within it. We use the project-scoped /explore route; the bare
+// /traces/ shortcut can 404.
+function traceUrl(traceId: string, spanId?: string): string | null {
if (!UPTRACE_URL || !PROJECT_ID) {
return null
}
- return `${UPTRACE_URL.replace(/\/+$/, '')}/explore/${PROJECT_ID}/traces/${traceId}/${spanId}`
+ const base = `${UPTRACE_URL.replace(/\/+$/, '')}/explore/${PROJECT_ID}/traces/${traceId}`
+ return spanId ? `${base}/${spanId}` : base
}
// projectIdFromDsn returns the project id, the last path segment of the DSN
@@ -289,21 +306,31 @@ function sendTestMessage(onTrace: (link: TraceLink) => void) {
})
}
-// throwTestError simulates a real app operation that fails a few calls deep, so
-// the error reported to Uptrace has app frames in its stack trace (syncTodos ->
-// buildSyncPayload) instead of only the React internals. We catch and report it
-// with captureException (rather than letting it propagate) so the event lands on
-// this trace and we can link to it; a real unhandled error is auto-captured.
+// throwTestError simulates a real backend sync that logs its progress and then
+// fails, so a single trace carries several Logs & Errors records (an info, then
+// a warning, then the error) instead of just one. Each record inherits the
+// breadcrumb trail of the actions before it, so in Uptrace the Events tab links
+// back to every row on the trace. The failure runs a few calls deep (syncTodos
+// -> buildSyncPayload) so the error's stack trace has app frames, not just React
+// internals; we catch and report it with captureException so it lands on this
+// trace and we can link to it.
function throwTestError(onTrace: (link: TraceLink) => void) {
- breadcrumb('About to throw a test error')
Sentry.startNewTrace(() => {
- Sentry.startSpan({ name: 'throw_test_error', op: 'task' }, (span) => {
+ Sentry.startSpan({ name: 'sync_todos', op: 'task' }, (span) => {
+ breadcrumb('Syncing todos with the backend')
+ Sentry.captureMessage('Syncing todos with the backend', 'info')
+
+ breadcrumb('Sync timed out, retrying')
+ Sentry.captureMessage('Todo sync retried after a timeout', 'warning')
+
+ breadcrumb('Retry failed, giving up')
try {
syncTodos()
} catch (err) {
Sentry.captureException(err)
}
- onTrace(announce('error', 'Error', span.spanContext().traceId, span.spanContext().spanId))
+
+ onTrace(announce('error', 'Sync failed', span.spanContext().traceId, span.spanContext().spanId))
})
})
}
From 0ba13e883d5b8590d19dc258bff28361a9a3e4ef Mon Sep 17 00:00:00 2001
From: Catalin4513
Date: Mon, 6 Jul 2026 15:00:57 +0300
Subject: [PATCH 06/73] feat(sentry-react): wrap every telemetry action in a
span via reportInSpan
---
sentry-react/README.md | 8 +--
sentry-react/src/App.tsx | 107 +++++++++++++++++++++------------------
2 files changed, 64 insertions(+), 51 deletions(-)
diff --git a/sentry-react/README.md b/sentry-react/README.md
index c445935..c768348 100644
--- a/sentry-react/README.md
+++ b/sentry-react/README.md
@@ -99,9 +99,11 @@ the browser.)
and **deleting** todos, appear under **Logs** / **Errors**, tagged with their
level. The add / delete logs carry the todo's text and id as `tags_todo_*`
attributes.
-- **Traces / spans** — open **Traces & Spans** and look for `run_traced_task`
- (with `step_one` / `step_two` children) and `sync_todos`. The browser tracing
- integration also produces page-load and navigation spans.
+- **Traces / spans** — open **Traces & Spans**. Every action runs in its own
+ span: `run_traced_task` (with `step_one` / `step_two` children), `sync_todos`,
+ `send_log_message`, and `add_todo` / `delete_todo` (each with its info log
+ attached). The browser tracing integration also produces page-load and
+ navigation spans.
If nothing shows up, double-check that `VITE_SENTRY_DSN` is set (the app logs a
warning in the browser console if it isn't) and that the DSN host matches your
diff --git a/sentry-react/src/App.tsx b/sentry-react/src/App.tsx
index c145d58..605b0e9 100644
--- a/sentry-react/src/App.tsx
+++ b/sentry-react/src/App.tsx
@@ -67,18 +67,17 @@ export default function App() {
setTodos((prev) => [{ id, text: trimmed, done: false }, ...prev])
setText('')
breadcrumb(`Added todo "${trimmed}"`)
- // A real interaction, reported as a structured info log rather than a span:
- // the entered text and id ride along as queryable attributes (tags_todo_*),
- // and the breadcrumb trail links back to this log on the trace's Events tab.
- // startNewTrace gives each add its own trace so repeated adds stay distinct.
- Sentry.startNewTrace(() => {
- Sentry.captureMessage(`Added todo "${trimmed}"`, {
- level: 'info',
- tags: { todo_text: trimmed, todo_id: id },
- })
- const traceId = Sentry.getCurrentScope().getPropagationContext().traceId
- recordTrace(announce('log', 'Added todo', traceId))
- })
+ // A real interaction, reported as an info log inside its own span: the entered
+ // text and id ride along as queryable attributes (tags_todo_*), and the
+ // breadcrumb trail links back on the trace's Events tab.
+ recordTrace(
+ reportInSpan('add_todo', 'log', 'Added todo', () => {
+ Sentry.captureMessage(`Added todo "${trimmed}"`, {
+ level: 'info',
+ tags: { todo_text: trimmed, todo_id: id },
+ })
+ }),
+ )
}
function toggleTodo(id: string) {
@@ -90,17 +89,15 @@ export default function App() {
const removed = todos.find((t) => t.id === id)
setTodos((prev) => prev.filter((t) => t.id !== id))
breadcrumb(`Deleted todo "${removed?.text ?? id}"`)
- // Mirror addTodo: report the removal as a structured info log with the todo's
- // text and id as queryable attributes, on its own trace so the breadcrumb
- // trail links back to it in Uptrace.
- Sentry.startNewTrace(() => {
- Sentry.captureMessage(`Deleted todo "${removed?.text ?? id}"`, {
- level: 'info',
- tags: { todo_text: removed?.text ?? '', todo_id: id },
- })
- const traceId = Sentry.getCurrentScope().getPropagationContext().traceId
- recordTrace(announce('log', 'Deleted todo', traceId))
- })
+ // Mirror addTodo: report the removal as an info log inside its own span.
+ recordTrace(
+ reportInSpan('delete_todo', 'log', 'Deleted todo', () => {
+ Sentry.captureMessage(`Deleted todo "${removed?.text ?? id}"`, {
+ level: 'info',
+ tags: { todo_text: removed?.text ?? '', todo_id: id },
+ })
+ }),
+ )
}
function clearCompleted() {
@@ -214,8 +211,8 @@ export default function App() {
) : (
- Every action here, and adding a todo, is traced. Each one prints its trace to the
- console and appears below with a link to Uptrace.
+ Every action here, plus adding or deleting a todo, is traced. Each one prints its
+ trace to the console and appears below with a link to Uptrace.
)}
@@ -229,11 +226,27 @@ function breadcrumb(message: string) {
Sentry.addBreadcrumb({ category: 'todo', message, level: 'info' })
}
-// announce logs the trace to the console and returns a link for the UI. Span
-// actions deep-link to their own span to land on the right place (the error,
-// the task); a log has no span of its own, so it links to the trace and omits
-// the span id.
-function announce(kind: Signal, label: string, traceId: string, spanId?: string): TraceLink {
+// reportInSpan runs capture() in a fresh trace and a named span, then returns the
+// trace link. Captured logs/errors attach to the span, passed in for status.
+function reportInSpan(
+ name: string,
+ kind: Signal,
+ label: string,
+ capture: (span: Sentry.Span) => void,
+): TraceLink {
+ return Sentry.startNewTrace(() =>
+ Sentry.startSpan({ name, op: 'task' }, (span) => {
+ capture(span)
+ const { traceId, spanId } = span.spanContext()
+ return announce(kind, label, traceId, spanId)
+ }),
+ )
+}
+
+// announce logs the trace to the console and returns a link for the UI. Every
+// action runs in its own span, so the link deep-links to that span to land on
+// the right place (the error, the task, the log).
+function announce(kind: Signal, label: string, traceId: string, spanId: string): TraceLink {
const url = traceUrl(traceId, spanId)
console.log(
`[uptrace] ${label} sent on trace ${traceId}`,
@@ -242,15 +255,15 @@ function announce(kind: Signal, label: string, traceId: string, spanId?: string)
return { kind, label, traceId, url }
}
-// traceUrl builds a link into a trace in the Uptrace UI, optionally deep-linking
-// to a span within it. We use the project-scoped /explore route; the bare
-// /traces/ shortcut can 404.
-function traceUrl(traceId: string, spanId?: string): string | null {
+// traceUrl builds a link into a trace in the Uptrace UI, deep-linking to a span
+// within it. We use the project-scoped /explore route; the bare /traces/
+// shortcut can 404.
+function traceUrl(traceId: string, spanId: string): string | null {
if (!UPTRACE_URL || !PROJECT_ID) {
return null
}
const base = `${UPTRACE_URL.replace(/\/+$/, '')}/explore/${PROJECT_ID}/traces/${traceId}`
- return spanId ? `${base}/${spanId}` : base
+ return `${base}/${spanId}`
}
// projectIdFromDsn returns the project id, the last path segment of the DSN
@@ -296,14 +309,11 @@ const ERRORS: ReadonlyArray<() => Error> = [
function sendTestMessage(onTrace: (link: TraceLink) => void) {
const [message, level] = pickRandom(MESSAGES)
breadcrumb(`Sent a ${level} message`)
- // startNewTrace gives each click its own trace, so repeated clicks are
- // distinct in Uptrace rather than sharing the page's trace.
- Sentry.startNewTrace(() => {
- Sentry.startSpan({ name: 'send_log_message', op: 'task' }, (span) => {
+ onTrace(
+ reportInSpan('send_log_message', 'log', `Message (${level})`, () => {
Sentry.captureMessage(message, level)
- onTrace(announce('log', `Message (${level})`, span.spanContext().traceId, span.spanContext().spanId))
- })
- })
+ }),
+ )
}
// throwTestError simulates a real backend sync that logs its progress and then
@@ -315,8 +325,8 @@ function sendTestMessage(onTrace: (link: TraceLink) => void) {
// internals; we catch and report it with captureException so it lands on this
// trace and we can link to it.
function throwTestError(onTrace: (link: TraceLink) => void) {
- Sentry.startNewTrace(() => {
- Sentry.startSpan({ name: 'sync_todos', op: 'task' }, (span) => {
+ onTrace(
+ reportInSpan('sync_todos', 'error', 'Sync failed', (span) => {
breadcrumb('Syncing todos with the backend')
Sentry.captureMessage('Syncing todos with the backend', 'info')
@@ -327,12 +337,13 @@ function throwTestError(onTrace: (link: TraceLink) => void) {
try {
syncTodos()
} catch (err) {
+ // We catch the error, so startSpan sees the callback return normally and
+ // would leave the span OK — mark it failed so the trace reads as errored.
+ span.setStatus({ code: 2, message: 'internal_error' }) // SPAN_STATUS_ERROR
Sentry.captureException(err)
}
-
- onTrace(announce('error', 'Sync failed', span.spanContext().traceId, span.spanContext().spanId))
- })
- })
+ }),
+ )
}
// syncTodos pretends to push the todos to a backend.
From ce200fbaaf42f8dea748d019a2bda6f9ff7959e2 Mon Sep 17 00:00:00 2001
From: Catalin4513
Date: Thu, 9 Jul 2026 10:47:55 +0300
Subject: [PATCH 07/73] docs(sentry-react): design spec for faithful Sentry
signal-model refactor
Co-Authored-By: Claude Opus 4.8 (1M context)
---
...-07-09-sentry-react-signal-model-design.md | 136 ++++++++++++++++++
1 file changed, 136 insertions(+)
create mode 100644 docs/superpowers/specs/2026-07-09-sentry-react-signal-model-design.md
diff --git a/docs/superpowers/specs/2026-07-09-sentry-react-signal-model-design.md b/docs/superpowers/specs/2026-07-09-sentry-react-signal-model-design.md
new file mode 100644
index 0000000..356c8a7
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-09-sentry-react-signal-model-design.md
@@ -0,0 +1,136 @@
+# sentry-react — faithful Sentry signal model refactor
+
+Date: 2026-07-09
+Scope: `examples/sentry-react`
+Status: approved design, pending spec review
+
+## Problem
+
+The example was built to demonstrate Sentry functionality against Uptrace, but
+it models Sentry's behavior incorrectly. The single biggest defect: every user
+action wraps itself in `Sentry.startNewTrace()` (via `reportInSpan`), so every
+click, add, and delete mints its own artificial trace. The real browser SDK
+never does this. The Sentry reference apps
+(`react-send-to-sentry`, `nextjs-app-dir`, `sentry-javascript-examples`) all
+state the same principle:
+
+> New trace ID only on page reload (pageload trace) or navigation (route
+> change). `captureException` / spans / logs do NOT start a trace — they attach
+> to the current route's trace, so multiple signals on one page share one
+> `trace_id`.
+
+Secondary problems:
+
+- **No router.** Bare `browserTracingIntegration()`, so navigation traces and
+ parameterized routes are never demonstrated.
+- **Logs use the wrong API.** Add/delete go out via `Sentry.captureMessage`,
+ which produces message *events*. The real Sentry Logs product is
+ `enableLogs: true` + `Sentry.logger.*`.
+- **Artificial machinery.** `reportInSpan`, a fabricated `syncTodos` sequence
+ that emits info→warning→error on one synthetic trace, a per-action trace-link
+ feed, `runTracedTask`. All of it buries the integration it exists to teach.
+
+## Goal
+
+Rewrite the app so that each todo action maps to the *correct* Sentry signal,
+using the real APIs, with trace boundaries that match the browser SDK — while
+keeping the one genuinely Uptrace-specific feature (trace links) as a live
+demonstration of the trace model.
+
+## Design
+
+### 1. Trace model (the core fix)
+
+- Remove `reportInSpan`, `startNewTrace`, `traceUrl`-per-action, `projectIdFromDsn`
+ as an action helper, `runTracedTask`, `syncTodos`/`buildSyncPayload`, and all
+ `captureMessage` calls.
+- No app code ever starts a trace. New traces come only from the browser-tracing
+ integration on pageload and navigation.
+- Errors, spans, and logs attach to the current route's trace. Firing the error
+ button twice on the same page yields the same `trace_id`.
+
+### 2. Routing
+
+Add `react-router-dom` v6 with the Sentry integration, exactly as the reference:
+
+- `reactRouterV6BrowserTracingIntegration({ useEffect, useLocation, useNavigationType, createRoutesFromChildren, matchRoutes })`
+- `withSentryReactRouterV6Routing(Routes)`
+
+Routes:
+
+- `/` — todo list: composer, list, demo buttons, current-trace badge.
+- `/todo/:id` — todo detail page. Clicking a todo navigates here. Complete /
+ delete available from the detail page too. Navigating here produces a
+ navigation trace named by the parameterized route `/todo/:id` (not the literal
+ id).
+
+### 3. Signal mapping
+
+| Action | Signal | API |
+| --- | --- | --- |
+| Add todo | open a span (held in state) + a log | `startInactiveSpan({ name: 'todo.open', attributes: { todo_id, todo_text } })`; `Sentry.logger.info('Added todo', { todo_id, todo_text })` |
+| Complete todo | end the span (sends it) | `span.end()` — duration = how long the todo stayed open |
+| Delete open todo | end the span, tagged cancelled | `span.setAttribute('cancelled', true); span.end()`; `Sentry.logger.info('Deleted todo', {...})` |
+| Toggle back to active / change filter | breadcrumb only | `Sentry.addBreadcrumb({ category: 'todo', ... })` |
+| Throw test error | error on current trace | `Sentry.captureException(pickRandom(ERRORS)())` |
+| Sync todos | nested wrapping spans + error path | `Sentry.startSpan({ name: 'sync_todos' }, async () => { startSpan('serialize', ...); startSpan('upload', ...) })`, may throw → `captureException` |
+
+Notes:
+
+- The **lifecycle span** (add → complete/delete) is the inactive start/stop
+ idiom, integrated into real business logic — one meaningful span per todo, no
+ hover noise.
+- The **sync** button is the wrapping/nested idiom (parent + child spans with
+ real awaited durations), and is where the error demo lives naturally.
+- Keep the varied `ERRORS` pool (`Error`, `TypeError`, `RangeError`,
+ `TodoSyncError`) — richer than the references and worth keeping so Uptrace
+ groups distinct issues.
+- Span attributes carry the todo id/text so they are queryable in Uptrace.
+
+Open spans are stored in a `Map` in component state so the
+lifecycle can end them by id. On unmount / clear, any still-open spans are ended
+with a `cancelled` attribute so nothing leaks.
+
+### 4. Config (`src/instrument.ts`)
+
+- Swap `browserTracingIntegration()` → `reactRouterV6BrowserTracingIntegration({...})`.
+- Add `enableLogs: true`.
+- Keep `dsn` from `VITE_SENTRY_DSN`, `tracesSampleRate: 1.0`,
+ `environment: 'development'`. Keep the missing-DSN warning.
+- `sendDefaultPii` — keep, documented.
+
+### 5. Current-trace badge (replaces the per-action feed)
+
+The old feed showed one row per action with a link, which only made sense under
+the (wrong) one-trace-per-action model. Replace it with a single **current-trace
+badge**: shows the active `trace_id` and a "view in Uptrace" link, read from the
+active span (`Sentry.getActiveSpan()` / root span). It updates only on pageload
+and navigation — visibly demonstrating that the trace id is stable across
+button clicks and changes only when the route changes. `projectIdFromDsn` +
+`traceUrl` are retained solely to build this one link.
+
+### 6. Docs
+
+- `README.md`: rewrite sections 3–4 to describe routes, the lifecycle span, the
+ log API, the error button, the sync button, and the current-trace badge.
+ Update the "How it works" and project-layout sections.
+- `AGENTS.md`: update the "no router" rule and the breadcrumb/telemetry
+ description to match the new model.
+
+## Out of scope
+
+- No Playwright / e2e tests (this stays a user-facing example, not an SDK test
+ app).
+- No Replay integration.
+- No backend; state stays in-memory `useState`.
+
+## Verification
+
+- `npm run build` (`tsc -b` + `vite build`) passes clean.
+- `npm run dev`, then in the browser confirm via devtools Network
+ (`/api//envelope/`) and the trace badge:
+ - Trace id changes on reload and on navigating to `/todo/:id`, and only then.
+ - Two error-button clicks on one page share a trace id.
+ - Completing a todo sends a `todo.open` span with a non-zero duration.
+ - Sync sends `sync_todos` with `serialize` / `upload` children.
+ - Add/delete appear as logs (not message events) in Uptrace.
From 1a7edb6be760827fa7337895f0abb572c453fcf1 Mon Sep 17 00:00:00 2001
From: Catalin4513
Date: Thu, 9 Jul 2026 10:55:30 +0300
Subject: [PATCH 08/73] docs(sentry-react): implementation plan for
signal-model refactor
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../2026-07-09-sentry-react-signal-model.md | 923 ++++++++++++++++++
1 file changed, 923 insertions(+)
create mode 100644 docs/superpowers/plans/2026-07-09-sentry-react-signal-model.md
diff --git a/docs/superpowers/plans/2026-07-09-sentry-react-signal-model.md b/docs/superpowers/plans/2026-07-09-sentry-react-signal-model.md
new file mode 100644
index 0000000..d64d753
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-09-sentry-react-signal-model.md
@@ -0,0 +1,923 @@
+# sentry-react Signal-Model Refactor — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Rewrite the `sentry-react` example so each todo action maps to the correct Sentry signal (span / log / error) using real APIs, with trace boundaries that match the browser SDK (new trace only on pageload/navigation).
+
+**Architecture:** All Sentry-usage logic lives in one pure module (`src/telemetry.ts`). Todo state + action→telemetry wiring lives in a React context (`src/todos-context.tsx`). Two routed pages (`/`, `/todo/:id`) render the UI. `src/instrument.ts` swaps to the react-router tracing integration and enables logs. No app code ever starts a trace.
+
+**Tech Stack:** React 19, TypeScript (strict), Vite 6, `@sentry/react` v10, `react-router-dom` v7 (declarative routing with Sentry's v6 integration).
+
+## Global Constraints
+
+- Working directory: `examples/sentry-react` (git root is `examples/`).
+- **No unit-test harness** and adding one is out of scope. The verification cycle for every task is: `npm run build` (runs `tsc -b` + `vite build`; the typecheck is the guardrail) must pass clean, plus any manual browser check the task names. Do NOT add a test runner.
+- TypeScript `strict`, plus `noUnusedLocals` / `noUnusedParameters` are on — no unused imports/vars or the build fails.
+- Comments use `//` line comments, including on exported types/functions.
+- Plain CSS only. Tokens live in `src/index.css` `:root`; component styles in `src/App.css`. No Tailwind/CSS-in-JS/component libs.
+- Never hardcode a DSN; it comes from `VITE_SENTRY_DSN`.
+- No app code may call `Sentry.startNewTrace()` — that is the exact anti-pattern being removed.
+- Commit after each task with the shown message.
+
+---
+
+### Task 1: Telemetry module + router dependency
+
+**Files:**
+- Modify: `package.json` (declare `react-router-dom`)
+- Create: `src/telemetry.ts`
+
+**Interfaces:**
+- Produces:
+ - `breadcrumb(message: string): void`
+ - `openTodoSpan(id: string, text: string): Sentry.Span`
+ - `endTodoSpan(span: Sentry.Span, opts?: { cancelled?: boolean }): void`
+ - `logAdded(id: string, text: string): void`
+ - `logDeleted(id: string, text: string): void`
+ - `captureTestError(): void`
+ - `syncTodos(count: number): Promise`
+ - `interface TraceLink { traceId: string; url: string | null }`
+ - `currentTraceLink(): TraceLink | null`
+
+- [ ] **Step 1: Declare react-router-dom in package.json**
+
+`react-router-dom` v7 is already present in `node_modules` but undeclared. Add it to `dependencies` in `package.json` (keep alphabetical grouping with the other runtime deps):
+
+```json
+ "dependencies": {
+ "@sentry/react": "^10.57.0",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "react-router-dom": "^7.18.0"
+ },
+```
+
+- [ ] **Step 2: Run install to sync the lockfile**
+
+Run: `npm install`
+Expected: completes without errors; `react-router-dom` now in `package.json` + lockfile.
+
+- [ ] **Step 3: Create `src/telemetry.ts` with the full module**
+
+```ts
+// telemetry.ts — every Sentry SDK call the app makes lives here, isolated from
+// the UI. Nothing in this file starts a new trace: spans, logs and errors all
+// attach to the trace the browser-tracing integration opened for the current
+// page load or navigation. That is the whole point of the example — signals
+// share the current route's trace instead of each minting its own.
+import * as Sentry from '@sentry/react'
+
+// Base URL of the Uptrace UI (e.g. http://localhost:5000), used to build a link
+// to the current trace. Optional: without it the badge still shows the trace id.
+const UPTRACE_URL = import.meta.env.VITE_UPTRACE_URL
+
+// Project id, taken from the last path segment of the DSN. The trace link is
+// project-scoped (/explore//traces/), so we need it.
+const PROJECT_ID = projectIdFromDsn(import.meta.env.VITE_SENTRY_DSN)
+
+// breadcrumb records an action so it appears in the breadcrumb trail of any
+// event later sent on this trace.
+export function breadcrumb(message: string): void {
+ Sentry.addBreadcrumb({ category: 'todo', message, level: 'info' })
+}
+
+// openTodoSpan starts an inactive span standing for an open todo. The caller
+// holds the span and ends it when the todo is completed or deleted, so the
+// span's duration measures how long the todo stayed open. It is an inactive
+// span (startInactiveSpan, not startSpan) precisely because its lifetime is a
+// user's, not a function call's. No startNewTrace: it joins the current trace.
+export function openTodoSpan(id: string, text: string): Sentry.Span {
+ return Sentry.startInactiveSpan({
+ name: 'todo.open',
+ op: 'todo',
+ attributes: { todo_id: id, todo_text: text },
+ })
+}
+
+// endTodoSpan closes a todo's span. When the todo was removed before being
+// completed we tag it cancelled so the two outcomes are distinguishable.
+export function endTodoSpan(span: Sentry.Span, opts?: { cancelled?: boolean }): void {
+ if (opts?.cancelled) {
+ span.setAttribute('cancelled', true)
+ }
+ span.end()
+}
+
+// logAdded / logDeleted emit real structured logs via the Sentry Logs API
+// (enabled with enableLogs in instrument.ts). This is NOT captureMessage, which
+// produces message events; logger.* is how Sentry models logs. The todo id and
+// text ride along as queryable attributes.
+export function logAdded(id: string, text: string): void {
+ Sentry.logger.info('Added todo', { todo_id: id, todo_text: text })
+}
+
+export function logDeleted(id: string, text: string): void {
+ Sentry.logger.info('Deleted todo', { todo_id: id, todo_text: text })
+}
+
+// ERRORS are the demo failures. Varied types and messages so Uptrace groups
+// them as distinct issues instead of one repeated error. Each is built fresh on
+// use so its stack trace points at the app.
+const ERRORS: ReadonlyArray<() => Error> = [
+ () => new Error('Example error from the React Todo app'),
+ () => new TypeError("Cannot read properties of undefined (reading 'text')"),
+ () => new RangeError('Todo limit of 100 exceeded'),
+ () =>
+ Object.assign(new Error('Failed to sync todos: network request timed out'), {
+ name: 'TodoSyncError',
+ }),
+]
+
+// captureTestError reports a random error on the CURRENT trace. captureException
+// does not start a trace, so firing this twice on one page yields two errors
+// that share the page's trace id.
+export function captureTestError(): void {
+ breadcrumb('Reporting a test error')
+ Sentry.captureException(pickRandom(ERRORS)())
+}
+
+// syncTodos demonstrates nested wrapping spans. startSpan measures the callback
+// and auto-ends the span; the parent 'sync_todos' has 'serialize' and 'upload'
+// children with real awaited durations. The upload fails part of the time,
+// producing an error attached to the same trace, nested under its span.
+export async function syncTodos(count: number): Promise {
+ breadcrumb(`Syncing ${count} todos`)
+ await Sentry.startSpan(
+ { name: 'sync_todos', op: 'task', attributes: { count } },
+ async () => {
+ await Sentry.startSpan({ name: 'serialize', op: 'task.step' }, () => wait(150))
+ await Sentry.startSpan({ name: 'upload', op: 'task.step' }, async () => {
+ await wait(250)
+ if (Math.random() < 0.5) {
+ throw pickRandom(ERRORS)()
+ }
+ })
+ },
+ ).catch((err) => {
+ // startSpan already marked the spans errored and rethrew; report the error
+ // so it lands on this trace, then swallow it (the UI stays responsive).
+ Sentry.captureException(err)
+ })
+}
+
+// TraceLink is the current trace id plus an optional deep link to it in Uptrace.
+export interface TraceLink {
+ traceId: string
+ url: string | null
+}
+
+// currentTraceLink reads the trace id of the active root span — the pageload or
+// navigation trace the integration opened — and builds a link to it. Read it
+// right after a navigation, while that idle span is still active.
+export function currentTraceLink(): TraceLink | null {
+ const active = Sentry.getActiveSpan()
+ const root = active ? Sentry.getRootSpan(active) : undefined
+ if (!root) {
+ return null
+ }
+ const { traceId } = root.spanContext()
+ return { traceId, url: traceUrl(traceId) }
+}
+
+// traceUrl builds a project-scoped link into a trace in the Uptrace UI, or null
+// when the UI URL or project id is unavailable.
+function traceUrl(traceId: string): string | null {
+ if (!UPTRACE_URL || !PROJECT_ID) {
+ return null
+ }
+ const base = UPTRACE_URL.replace(/\/+$/, '')
+ return `${base}/explore/${PROJECT_ID}/traces/${traceId}`
+}
+
+// projectIdFromDsn returns the project id, the last path segment of the DSN
+// (e.g. "2" in http://token@host/2), or null if the DSN is absent or malformed.
+function projectIdFromDsn(dsn: string | undefined): string | null {
+ if (!dsn) {
+ return null
+ }
+ try {
+ const segments = new URL(dsn).pathname.split('/').filter(Boolean)
+ return segments.at(-1) ?? null
+ } catch {
+ return null
+ }
+}
+
+// pickRandom returns a random element of a non-empty array.
+function pickRandom(items: ReadonlyArray): T {
+ return items[Math.floor(Math.random() * items.length)]
+}
+
+// wait resolves after `ms` milliseconds, standing in for real async work.
+function wait(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+```
+
+- [ ] **Step 4: Run the build**
+
+Run: `npm run build`
+Expected: PASS. `telemetry.ts` is not imported yet, but it must type-check. If `Sentry.logger` errors, confirm `@sentry/react` is v10 (`node -e "console.log(require('@sentry/react/package.json').version)"`).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add package.json package-lock.json src/telemetry.ts
+git commit -m "feat(sentry-react): add telemetry module with faithful signal APIs"
+```
+
+---
+
+### Task 2: Todo state + action→telemetry context
+
+**Files:**
+- Create: `src/todos-context.tsx`
+
+**Interfaces:**
+- Consumes (from Task 1): `breadcrumb`, `openTodoSpan`, `endTodoSpan`, `logAdded`, `logDeleted` from `./telemetry`.
+- Produces:
+ - `interface Todo { id: string; text: string; done: boolean }`
+ - `type Filter = 'all' | 'active' | 'completed'`
+ - `function TodosProvider({ children }: { children: ReactNode }): JSX.Element`
+ - `function useTodos(): TodosValue` where `TodosValue = { todos: Todo[]; filter: Filter; addTodo(text: string): void; toggleTodo(id: string): void; deleteTodo(id: string): void; clearCompleted(): void; setFilter(f: Filter): void }`
+
+- [ ] **Step 1: Create `src/todos-context.tsx`**
+
+Note: side effects (telemetry, span map mutation) are kept OUT of the `setTodos` updater callbacks, because React StrictMode invokes updaters twice — running telemetry inside them would double-fire. Updaters stay pure; telemetry runs once per handler call.
+
+```tsx
+// todos-context.tsx — the in-memory todo list plus the wiring from each action
+// to its Sentry signal. State is useState only; there is no backend. Open todos
+// hold a span in `spans` until they are completed or deleted.
+import { createContext, useCallback, useContext, useRef, useState } from 'react'
+import type { ReactNode } from 'react'
+import type { Span } from '@sentry/react'
+import {
+ breadcrumb,
+ endTodoSpan,
+ logAdded,
+ logDeleted,
+ openTodoSpan,
+} from './telemetry'
+
+// Todo is a single item in the list.
+export interface Todo {
+ id: string
+ text: string
+ done: boolean
+}
+
+// Filter is which todos the list is showing.
+export type Filter = 'all' | 'active' | 'completed'
+
+// TodosValue is the state and actions exposed to the pages.
+interface TodosValue {
+ todos: Todo[]
+ filter: Filter
+ addTodo: (text: string) => void
+ toggleTodo: (id: string) => void
+ deleteTodo: (id: string) => void
+ clearCompleted: () => void
+ setFilter: (f: Filter) => void
+}
+
+const TodosContext = createContext(null)
+
+// TodosProvider holds the list so both routes (`/` and `/todo/:id`) share it,
+// and turns each action into the matching Sentry signal.
+export function TodosProvider({ children }: { children: ReactNode }) {
+ const [todos, setTodos] = useState([])
+ const [filter, setFilterState] = useState('all')
+ // Open todos' spans, keyed by todo id. Ended on completion or deletion.
+ const spans = useRef(new Map()).current
+
+ const addTodo = useCallback(
+ (raw: string) => {
+ const text = raw.trim()
+ if (!text) {
+ return
+ }
+ const id = crypto.randomUUID()
+ setTodos((prev) => [{ id, text, done: false }, ...prev])
+ breadcrumb(`Added todo "${text}"`)
+ logAdded(id, text)
+ spans.set(id, openTodoSpan(id, text))
+ },
+ [spans],
+ )
+
+ const toggleTodo = useCallback(
+ (id: string) => {
+ const todo = todos.find((t) => t.id === id)
+ if (!todo) {
+ return
+ }
+ const done = !todo.done
+ setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done } : t)))
+ const span = spans.get(id)
+ if (done) {
+ // Completing ends the open span, sending its measured duration.
+ if (span) {
+ endTodoSpan(span)
+ spans.delete(id)
+ }
+ } else if (!span) {
+ // Reopening starts a fresh span.
+ spans.set(id, openTodoSpan(id, todo.text))
+ }
+ breadcrumb(`${done ? 'Completed' : 'Reopened'} todo "${todo.text}"`)
+ },
+ [todos, spans],
+ )
+
+ const deleteTodo = useCallback(
+ (id: string) => {
+ const todo = todos.find((t) => t.id === id)
+ setTodos((prev) => prev.filter((t) => t.id !== id))
+ const span = spans.get(id)
+ if (span) {
+ // Deleting an open (not-yet-done) todo cancels its span.
+ endTodoSpan(span, { cancelled: todo ? !todo.done : true })
+ spans.delete(id)
+ }
+ if (todo) {
+ breadcrumb(`Deleted todo "${todo.text}"`)
+ logDeleted(id, todo.text)
+ }
+ },
+ [todos, spans],
+ )
+
+ const clearCompleted = useCallback(() => {
+ // Completed todos already ended their spans when toggled done.
+ setTodos((prev) => prev.filter((t) => !t.done))
+ breadcrumb('Cleared completed todos')
+ }, [])
+
+ const setFilter = useCallback((f: Filter) => {
+ setFilterState(f)
+ breadcrumb(`Filtered by "${f}"`)
+ }, [])
+
+ const value: TodosValue = {
+ todos,
+ filter,
+ addTodo,
+ toggleTodo,
+ deleteTodo,
+ clearCompleted,
+ setFilter,
+ }
+
+ return {children}
+}
+
+// useTodos reads the shared todo state. Throws if used outside the provider.
+export function useTodos(): TodosValue {
+ const value = useContext(TodosContext)
+ if (!value) {
+ throw new Error('useTodos must be used within a TodosProvider')
+ }
+ return value
+}
+```
+
+- [ ] **Step 2: Run the build**
+
+Run: `npm run build`
+Expected: PASS. The provider is unused so far but must type-check.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/todos-context.tsx
+git commit -m "feat(sentry-react): add todos context wiring actions to telemetry"
+```
+
+---
+
+### Task 3: Trace badge + both pages + styles
+
+**Files:**
+- Create: `src/components/TraceBadge.tsx`
+- Create: `src/pages/TodoList.tsx`
+- Create: `src/pages/TodoDetail.tsx`
+- Modify: `src/App.css` (add badge/detail styles, remove `.feed*` styles)
+
+**Interfaces:**
+- Consumes (Task 1): `currentTraceLink`, `TraceLink`, `captureTestError`, `syncTodos` from `../telemetry`.
+- Consumes (Task 2): `useTodos`, `Todo`, `Filter` from `../todos-context`.
+- Produces: `function TraceBadge(): JSX.Element`, `function TodoList(): JSX.Element`, `function TodoDetail(): JSX.Element`.
+
+- [ ] **Step 1: Create `src/components/TraceBadge.tsx`**
+
+```tsx
+// TraceBadge shows the trace id of the current page and a link to it in Uptrace.
+// It reads the active trace on each navigation (keyed on location) and keeps
+// showing that id until the next navigation — so you can watch the id change
+// ONLY on reload and route change, never on a button click. requestAnimationFrame
+// defers the read until after the router integration has opened the new trace.
+import { useEffect, useState } from 'react'
+import { useLocation } from 'react-router-dom'
+import { currentTraceLink } from '../telemetry'
+import type { TraceLink } from '../telemetry'
+
+export function TraceBadge() {
+ const location = useLocation()
+ const [link, setLink] = useState(null)
+
+ useEffect(() => {
+ const raf = requestAnimationFrame(() => setLink(currentTraceLink()))
+ return () => cancelAnimationFrame(raf)
+ }, [location.key])
+
+ return (
+
+ current trace
+ {link?.traceId ?? '—'}
+ {link?.url ? (
+
+ View in Uptrace ↗
+
+ ) : (
+ set VITE_UPTRACE_URL for a link
+ )}
+
+ )
+}
+```
+
+- [ ] **Step 2: Create `src/pages/TodoList.tsx`**
+
+```tsx
+// TodoList is the main route (`/`): compose, list, filter todos, and the two
+// demo buttons. Each todo's text links to its detail route, so clicking it
+// triggers a navigation — and a new navigation trace named `/todo/:id`.
+import { useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useTodos } from '../todos-context'
+import type { Filter } from '../todos-context'
+import { captureTestError, syncTodos } from '../telemetry'
+import { TraceBadge } from '../components/TraceBadge'
+
+export function TodoList() {
+ const { todos, filter, addTodo, toggleTodo, deleteTodo, clearCompleted, setFilter } = useTodos()
+ const [text, setText] = useState('')
+
+ const remaining = todos.filter((t) => !t.done).length
+ const completed = todos.length - remaining
+ const visible = useMemo(
+ () =>
+ todos.filter((t) => {
+ if (filter === 'active') return !t.done
+ if (filter === 'completed') return t.done
+ return true
+ }),
+ [todos, filter],
+ )
+
+ function submit() {
+ if (!text.trim()) return
+ addTodo(text)
+ setText('')
+ }
+
+ return (
+
+
+
Todo
+
React, instrumented with Sentry, reporting to Uptrace.
+ syncTodos(todos.length)}>
+ Sync todos
+
+
+ Throw test error
+
+
+
+ Adding, completing and deleting todos emit spans and logs on this page's trace. Sync
+ runs nested spans; the error button reports an exception. All of them share the current
+ trace — it changes only when you reload or open a todo.
+
+
+
+
+ )
+}
+```
+
+- [ ] **Step 3: Create `src/pages/TodoDetail.tsx`**
+
+```tsx
+// TodoDetail is the `/todo/:id` route. Reaching it is a navigation, so the SDK
+// opens a new navigation trace named by the parameterized path `/todo/:id`.
+import { Link, useParams } from 'react-router-dom'
+import { useTodos } from '../todos-context'
+import { TraceBadge } from '../components/TraceBadge'
+
+export function TodoDetail() {
+ const { id } = useParams()
+ const { todos, toggleTodo, deleteTodo } = useTodos()
+ const todo = todos.find((t) => t.id === id)
+
+ return (
+
+