Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .craft.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,21 @@ workspaces:
- name: npm
id: "@sentry/mcp-server"
includeNames: '/^sentry-mcp-server-\d.*\.tgz$/'

packages/spotlight:
changelogPolicy: none
preReleaseCommand: bash -c 'cd packages/spotlight && npm version "$CRAFT_NEW_VERSION" --no-git-tag-version'
releaseBranchPrefix: release/spotlight
artifactProvider:
name: github
config:
artifacts:
Post-merge tasks:
- "/^sentry-spotlight-.*$/"
targets:
- name: github
tagPrefix: "spotlight@"
includeNames: '/^sentry-spotlight-\d.*\.tgz$/'
- name: npm
id: "@sentry/spotlight"
includeNames: '/^sentry-spotlight-\d.*\.tgz$/'
1 change: 1 addition & 0 deletions .github/workflows/local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "apps/local/**"
- "packages/spotlight/**"
- "packages/cli/src/commands/local/**"
- "packages/cli/test/commands/local/**"
- "packages/cli/test/e2e/local.test.ts"
Expand Down
17 changes: 16 additions & 1 deletion .github/workflows/merge-jobs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Post-merge tasks

on:
push:
branches: ["main", "release/*", "release/mcp/**"]
branches: ["main", "release/*", "release/mcp/**", "release/spotlight/**"]
workflow_dispatch:

jobs:
Expand Down Expand Up @@ -38,13 +38,28 @@ jobs:
working-directory: packages/mcp-server
run: pnpm pack --pack-destination dist

- name: Package Spotlight SDK
working-directory: packages/spotlight
run: pnpm pack --pack-destination dist

- name: Check packed Spotlight SDK consumer
working-directory: packages/spotlight
run: node scripts/check-package.mjs dist/sentry-spotlight-*.tgz

- name: Archive artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ github.sha }}
path: |
${{ github.workspace }}/packages/mcp-server/dist/*.tgz

- name: Archive Spotlight SDK artifact
uses: actions/upload-artifact@v4
with:
name: sentry-spotlight-${{ github.sha }}
path: ${{ github.workspace }}/packages/spotlight/dist/*.tgz
if-no-files-found: error

- name: Publish Test Report
uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857
if: ${{ !cancelled() }}
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on:
options:
- cli
- mcp
- spotlight
bump:
description: Version bump type
required: true
Expand Down Expand Up @@ -68,6 +69,10 @@ jobs:
workspace=packages/mcp-server
package_path=packages/mcp-server
;;
spotlight)
workspace=packages/spotlight
package_path=packages/spotlight
;;
*)
echo "::error::Unsupported product: $PRODUCT"
exit 1
Expand Down
9 changes: 9 additions & 0 deletions apps/local/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ The receiver can also be a named Local session or an explicitly selected remote
receiver. Capture lifecycle and retention are managed through the existing
Local controls.

The browser uses `@sentry/spotlight` for trace assembly, database and resource
summaries, AI field interpretation, profile call trees and Web Vitals analysis.
Raw envelopes, item headers and attachment payloads remain available for inspection.
Each receiver connection owns a bounded, memory-only processing store; clearing
or switching captures also invalidates pending processing. SDK timestamps use
milliseconds; the Local waterfall adapter converts its display coordinates to
wire seconds. Transactions without trace context remain visible as standalone
navigation groups.

## Replay example captures

With a receiver running, load all bundled examples:
Expand Down
10 changes: 5 additions & 5 deletions apps/local/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,20 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"dev": "pnpm --filter @sentry/spotlight run build && vite",
"build": "pnpm --filter @sentry/spotlight run build && tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview",
"test": "pnpm --filter sentry exec node script/ensure-generated.mjs schema && vitest run",
"test": "pnpm --filter @sentry/spotlight run build && pnpm --filter sentry exec node script/ensure-generated.mjs schema && vitest run",
"test:integration": "pnpm run test src/App.integration.test.tsx",
"typecheck": "tsc -b",
"typecheck": "pnpm --filter @sentry/spotlight run build && tsc -b",
"sample": "tsx scripts/replay.ts"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"dependencies": {
"@sentry/spotlight": "workspace:*",
"@hookform/resolvers": "^5.9.1",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-popover": "^1.3.1",
Expand Down Expand Up @@ -45,7 +46,6 @@
"valibot": "catalog:"
},
"devDependencies": {
"@spotlightjs/spotlight": "^4.11.7",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
Expand Down
4 changes: 2 additions & 2 deletions apps/local/src/App.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { replayEnvelopes, defaultFixtures } from "../scripts/replay.ts";
import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk";
import { createLocalTelemetryBuffer } from "../../../packages/cli/src/commands/local/telemetry-buffer.ts";
import { EventSource as NodeEventSource } from "eventsource";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
Expand Down Expand Up @@ -75,7 +75,7 @@ function envelope(transaction: string, options: EnvelopeOptions = {}): string {

async function startReceiver() {
return await tryListen(
buildApp(createSpotlightBuffer(10), { uiActions: true }),
buildApp(createLocalTelemetryBuffer(10), { uiActions: true }),
0,
"127.0.0.1",
);
Expand Down
5 changes: 2 additions & 3 deletions apps/local/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ import { Menu, Settings, Search } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
Expand Down Expand Up @@ -95,7 +94,7 @@ import {
type LocalFeedItem,
} from "@/lib/spotlight.ts";
import { createLocalTelemetryStore } from "@/lib/telemetry-store.ts";
import { buildTraceGroups, traceIdForItem } from "@/lib/trace-model.ts";
import { traceIdForItem } from "@/lib/trace-model.ts";
import { workspaceNavigation } from "@/lib/workspace-navigation.ts";
import {
isErrorEvent,
Expand Down Expand Up @@ -403,7 +402,7 @@ export default function App() {
() => saveConnectionHistory(recentConnections),
[recentConnections],
);
const traces = useMemo(() => buildTraceGroups(items), [items]);
const traces = telemetry.traceGroups;
const searchQuery = workspaceQuery.query;
const filterKey = `${workspaceView}:${streamUrl}`;
const [eventFilterState, setEventFilterState] = useState<{
Expand Down
10 changes: 5 additions & 5 deletions apps/local/src/components/event-detail.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { decodeEnvelope } from "@/lib/spotlight.ts";
import { buildTraceGroups } from "@/lib/trace-model.ts";
import { buildTraceGroups } from "@/test-fixtures/trace-groups.ts";
import { EventDetail } from "./event-detail.tsx";

afterEach(() => {
Expand Down Expand Up @@ -176,15 +176,15 @@ test("span summaries use captured typed URL attributes without inventing request
expect(summary.textContent).not.toContain("Status");
});

test("repeated parent updates keep one request summary and its waterfall", () => {
test("repeated parent updates keep one request summary and its waterfall", async () => {
const transaction = item("transaction", {
transaction: "GET /orders",
start_timestamp: 1,
timestamp: 2,
request: { status_code: 200 },
contexts: { trace: { trace_id: "trace", span_id: "root" } },
});
const [trace] = buildTraceGroups([transaction]);
const [trace] = await buildTraceGroups([transaction]);
const { rerender } = render(<EventDetail item={transaction} trace={trace} />);
for (let index = 0; index < 10; index += 1) {
rerender(
Expand Down Expand Up @@ -217,14 +217,14 @@ test("captured context suppresses the empty-details message", () => {
expect(screen.queryByText("No additional event details.")).toBeNull();
});

test("a related trace suppresses the empty-details message", () => {
test("a related trace suppresses the empty-details message", async () => {
const transaction = item("transaction", {
transaction: "GET /trace",
contexts: { trace: { trace_id: "trace", span_id: "root" } },
start_timestamp: 1,
timestamp: 2,
});
const [trace] = buildTraceGroups([transaction]);
const [trace] = await buildTraceGroups([transaction]);
render(<EventDetail item={item("event", {})} trace={trace} />);
expect(screen.getByRole("region", { name: "Trace waterfall" })).toBeDefined();
expect(screen.queryByText("No additional event details.")).toBeNull();
Expand Down
6 changes: 3 additions & 3 deletions apps/local/src/components/investigation-controls.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test } from "vitest";
import { decodeEnvelope } from "@/lib/spotlight.ts";
import { buildTraceGroups } from "@/lib/trace-model.ts";
import { buildTraceGroups } from "@/test-fixtures/trace-groups.ts";
import { TraceWaterfall } from "./trace-waterfall.tsx";
import { filterEvents, defaultEventFilters } from "@/lib/event-filters.ts";
afterEach(cleanup);
Expand Down Expand Up @@ -47,8 +47,8 @@ test("log filters combine severity and logger and sort timestamps without changi
).toBe(20);
expect(events[0]?.timestamp).toBe(10);
});
test("trace search reveals matching descendants and branch collapse is reversible", () => {
const [trace] = buildTraceGroups(
test("trace search reveals matching descendants and branch collapse is reversible", async () => {
const [trace] = await buildTraceGroups(
decodeEnvelope(
JSON.stringify([
{},
Expand Down
6 changes: 3 additions & 3 deletions apps/local/src/components/performance-workspace.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { decodeEnvelope } from "@/lib/spotlight.ts";
import { buildTraceGroups } from "@/lib/trace-model.ts";
import { buildTraceGroups } from "@/test-fixtures/trace-groups.ts";
import { SpanAnalysis, WebVitalsWorkspace } from "./performance-workspace.tsx";
import { ProfileWorkspace } from "./profile-workspace.tsx";
afterEach(cleanup);
const item = (type: string, payload: unknown) =>
decodeEnvelope(JSON.stringify([{}, [[{ type }, payload]]]), "sample")[0]!;
test("resource groups sort and open the selected execution in its trace", () => {
const traces = buildTraceGroups([
test("resource groups sort and open the selected execution in its trace", async () => {
const traces = await buildTraceGroups([
item("transaction", {
transaction: "Page",
contexts: { trace: { trace_id: "t", span_id: "root" } },
Expand Down
6 changes: 3 additions & 3 deletions apps/local/src/components/span-detail.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { decodeEnvelope } from "@/lib/spotlight.ts";
import { buildTraceGroups } from "@/lib/trace-model.ts";
import { buildTraceGroups } from "@/test-fixtures/trace-groups.ts";
import { SpanDetail } from "./span-detail.tsx";

afterEach(cleanup);

test("span inspection shows its own timing, attributes and JSON plus navigable trace telemetry", () => {
const [trace] = buildTraceGroups(
test("span inspection shows its own timing, attributes and JSON plus navigable trace telemetry", async () => {
const [trace] = await buildTraceGroups(
decodeEnvelope(
JSON.stringify([
{},
Expand Down
14 changes: 7 additions & 7 deletions apps/local/src/components/trace-waterfall.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { afterEach, expect, test } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { cleanup, render, screen } from "@testing-library/react";
import { decodeEnvelope } from "@/lib/spotlight.ts";
import { buildTraceGroups } from "@/lib/trace-model.ts";
import { buildTraceGroups } from "@/test-fixtures/trace-groups.ts";
import { TraceWaterfall } from "./trace-waterfall.tsx";

afterEach(cleanup);

test.each(["internal_error", "error"])(
"styles status-only %s failures without affecting healthy children",
(status) => {
async (status) => {
const items = decodeEnvelope(
JSON.stringify([
{},
Expand Down Expand Up @@ -47,7 +47,7 @@ test.each(["internal_error", "error"])(
]),
"envelope",
);
const trace = buildTraceGroups(items)[0];
const trace = (await buildTraceGroups(items))[0];
expect(trace).toBeDefined();
if (!trace) throw new Error("Expected trace");
render(<TraceWaterfall trace={trace} />);
Expand All @@ -63,7 +63,7 @@ test.each(["internal_error", "error"])(
},
);

test("renders a deeply nested valid span chain without overflowing the stack", () => {
test("renders a deeply nested valid span chain without overflowing the stack", async () => {
const count = 4000;
const items = decodeEnvelope(
JSON.stringify([
Expand All @@ -89,7 +89,7 @@ test("renders a deeply nested valid span chain without overflowing the stack", (
]),
"deep-envelope",
);
const trace = buildTraceGroups(items)[0];
const trace = (await buildTraceGroups(items))[0];
if (!trace) throw new Error("Expected deep trace");
const markup = renderToStaticMarkup(<TraceWaterfall trace={trace} />);
expect(markup).toContain(`waterfall-bar-span-${count - 1}`);
Expand All @@ -98,8 +98,8 @@ test("renders a deeply nested valid span chain without overflowing the stack", (
);
});

test("span descriptions are keyboard-selectable and expose selected state", () => {
const [trace] = buildTraceGroups(
test("span descriptions are keyboard-selectable and expose selected state", async () => {
const [trace] = await buildTraceGroups(
decodeEnvelope(
JSON.stringify([
{},
Expand Down
14 changes: 7 additions & 7 deletions apps/local/src/components/trace-workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { afterEach, expect, test, vi } from "vitest";
import { decodeEnvelope } from "@/lib/spotlight.ts";
import { buildTraceGroups } from "@/lib/trace-model.ts";
import { buildTraceGroups } from "@/test-fixtures/trace-groups.ts";
import { TraceWorkspace } from "./trace-workspace.tsx";

afterEach(cleanup);

test("selects spans within the trace inspector and returns to the waterfall", () => {
const traces = buildTraceGroups(
test("selects spans within the trace inspector and returns to the waterfall", async () => {
const traces = await buildTraceGroups(
decodeEnvelope(
JSON.stringify([
{},
Expand Down Expand Up @@ -56,8 +56,8 @@ test("selects spans within the trace inspector and returns to the waterfall", ()
expect(clearSpan).toHaveBeenCalledOnce();
});

test("a stale trace selection cannot drill into a duplicate span ID on a fallback trace", () => {
const traces = buildTraceGroups(
test("a stale trace selection cannot drill into a duplicate span ID on a fallback trace", async () => {
const traces = await buildTraceGroups(
decodeEnvelope(
JSON.stringify([
{},
Expand Down Expand Up @@ -94,8 +94,8 @@ test("a stale trace selection cannot drill into a duplicate span ID on a fallbac
).toBe("false");
});

test("drilling into a span and returning restores keyboard focus", () => {
const traces = buildTraceGroups(
test("drilling into a span and returning restores keyboard focus", async () => {
const traces = await buildTraceGroups(
decodeEnvelope(
JSON.stringify([
{},
Expand Down
Loading
Loading