diff --git a/.craft.yml b/.craft.yml
index 6b927495d..0f71edc4f 100644
--- a/.craft.yml
+++ b/.craft.yml
@@ -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$/'
diff --git a/.github/workflows/local.yml b/.github/workflows/local.yml
index c287154ad..d11772226 100644
--- a/.github/workflows/local.yml
+++ b/.github/workflows/local.yml
@@ -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"
diff --git a/.github/workflows/merge-jobs.yml b/.github/workflows/merge-jobs.yml
index c021908dc..238e73f61 100644
--- a/.github/workflows/merge-jobs.yml
+++ b/.github/workflows/merge-jobs.yml
@@ -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:
@@ -38,6 +38,14 @@ 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:
@@ -45,6 +53,13 @@ jobs:
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() }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8eeea45b0..293df4d37 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,6 +11,7 @@ on:
options:
- cli
- mcp
+ - spotlight
bump:
description: Version bump type
required: true
@@ -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
diff --git a/apps/local/README.md b/apps/local/README.md
index 2a362420f..f8be6f4c7 100644
--- a/apps/local/README.md
+++ b/apps/local/README.md
@@ -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:
diff --git a/apps/local/package.json b/apps/local/package.json
index 45e9fc771..f4e316f04 100644
--- a/apps/local/package.json
+++ b/apps/local/package.json
@@ -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",
@@ -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",
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index 1bc432db5..3857b07da 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -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 {
@@ -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",
);
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 196e30e9d..39726f745 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -45,7 +45,6 @@ import { Menu, Settings, Search } from "lucide-react";
import {
useCallback,
useEffect,
- useMemo,
useRef,
useState,
useSyncExternalStore,
@@ -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,
@@ -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<{
diff --git a/apps/local/src/components/event-detail.test.tsx b/apps/local/src/components/event-detail.test.tsx
index 2cb389db4..bcaaecd6e 100644
--- a/apps/local/src/components/event-detail.test.tsx
+++ b/apps/local/src/components/event-detail.test.tsx
@@ -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(() => {
@@ -176,7 +176,7 @@ 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,
@@ -184,7 +184,7 @@ test("repeated parent updates keep one request summary and its waterfall", () =>
request: { status_code: 200 },
contexts: { trace: { trace_id: "trace", span_id: "root" } },
});
- const [trace] = buildTraceGroups([transaction]);
+ const [trace] = await buildTraceGroups([transaction]);
const { rerender } = render();
for (let index = 0; index < 10; index += 1) {
rerender(
@@ -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();
expect(screen.getByRole("region", { name: "Trace waterfall" })).toBeDefined();
expect(screen.queryByText("No additional event details.")).toBeNull();
diff --git a/apps/local/src/components/investigation-controls.test.tsx b/apps/local/src/components/investigation-controls.test.tsx
index 1c27a917b..c05a63f99 100644
--- a/apps/local/src/components/investigation-controls.test.tsx
+++ b/apps/local/src/components/investigation-controls.test.tsx
@@ -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);
@@ -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([
{},
diff --git a/apps/local/src/components/performance-workspace.test.tsx b/apps/local/src/components/performance-workspace.test.tsx
index 150ad788d..268f796bd 100644
--- a/apps/local/src/components/performance-workspace.test.tsx
+++ b/apps/local/src/components/performance-workspace.test.tsx
@@ -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" } },
diff --git a/apps/local/src/components/span-detail.test.tsx b/apps/local/src/components/span-detail.test.tsx
index 00ec3cade..05ebb8039 100644
--- a/apps/local/src/components/span-detail.test.tsx
+++ b/apps/local/src/components/span-detail.test.tsx
@@ -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([
{},
diff --git a/apps/local/src/components/trace-waterfall.test.tsx b/apps/local/src/components/trace-waterfall.test.tsx
index 8aea70b97..ac2a301dd 100644
--- a/apps/local/src/components/trace-waterfall.test.tsx
+++ b/apps/local/src/components/trace-waterfall.test.tsx
@@ -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([
{},
@@ -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();
@@ -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([
@@ -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();
expect(markup).toContain(`waterfall-bar-span-${count - 1}`);
@@ -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([
{},
diff --git a/apps/local/src/components/trace-workspace.test.tsx b/apps/local/src/components/trace-workspace.test.tsx
index 83d42ff99..55aa14a92 100644
--- a/apps/local/src/components/trace-workspace.test.tsx
+++ b/apps/local/src/components/trace-workspace.test.tsx
@@ -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([
{},
@@ -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([
{},
@@ -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([
{},
diff --git a/apps/local/src/lib/inspection.ts b/apps/local/src/lib/inspection.ts
index 27431074c..cf7eaecd2 100644
--- a/apps/local/src/lib/inspection.ts
+++ b/apps/local/src/lib/inspection.ts
@@ -1,3 +1,8 @@
+import { inspectAI, isAIInteraction as isAiPayload } from "@sentry/spotlight";
+export {
+ isAIInteraction as isAiPayload,
+ readSpanData as spanData,
+} from "@sentry/spotlight";
import { getEventMetadata, type LocalFeedItem } from "./spotlight.ts";
import { getEventExceptions, formatExceptionTitle } from "./exceptions.ts";
@@ -27,39 +32,6 @@ export function parsed(value: unknown): unknown {
return value;
}
}
-/** Merge legacy span data and typed span-v2 attributes without losing unknown fields. */
-export function spanData(payload: unknown): Record {
- const p = record(payload);
- const attributes = Object.fromEntries(
- Object.entries(record(p.attributes)).map(([key, value]) => [
- key,
- "value" in record(value) ? record(value).value : value,
- ]),
- );
- return {
- ...record(record(record(p.contexts).trace).data),
- ...record(p.data),
- ...attributes,
- };
-}
-export function isAiPayload(payload: unknown): boolean {
- const p = record(payload),
- data = spanData(p);
- return (
- [
- p.type,
- p.op,
- record(record(p.contexts).trace).op,
- data["sentry.op"],
- data["sentry.origin"],
- ].some(
- (value) =>
- typeof value === "string" &&
- /(?:^|[.\s:_-])(?:ai|gen_ai|llm)(?:$|[.\s:_-])/i.test(value),
- ) ||
- Object.keys(data).some((key) => /^(?:gen_ai|vercel\.ai|ai|llm)\./.test(key))
- );
-}
/** Include AI spans embedded in transactions, deduplicated against standalone copies. */
export function aiItems(items: readonly LocalFeedItem[]): LocalFeedItem[] {
const found = new Map();
@@ -102,97 +74,24 @@ export type AiInspection = {
tools: unknown[];
};
export function inspectAi(payload: unknown): AiInspection | undefined {
- if (!isAiPayload(payload)) return;
- const data = spanData(payload);
- const pick = (...keys: string[]) =>
- keys
- .map((key) => data[key])
- .find((value) => value !== undefined && value !== null);
- const definitions: [string, string[]][] = [
- [
- "Model",
- ["gen_ai.request.model", "gen_ai.response.model", "vercel.ai.model.id"],
- ],
- [
- "Provider",
- ["gen_ai.system", "gen_ai.provider.name", "vercel.ai.model.provider"],
- ],
- [
- "Input tokens",
- [
- "gen_ai.usage.input_tokens",
- "vercel.ai.usage.promptTokens",
- "ai.usage.promptTokens",
- ],
- ],
- [
- "Output tokens",
- [
- "gen_ai.usage.output_tokens",
- "vercel.ai.usage.completionTokens",
- "ai.usage.completionTokens",
- ],
- ],
- [
- "Finish reason",
- ["gen_ai.response.finish_reasons", "vercel.ai.response.finishReason"],
- ],
- ["Function", ["gen_ai.function_id", "vercel.ai.telemetry.functionId"]],
- ["Max retries", ["vercel.ai.settings.maxRetries"]],
- ["Max steps", ["vercel.ai.settings.maxSteps"]],
- ];
- const calls = parsed(
- pick(
- "gen_ai.response.tool_calls",
- "vercel.ai.response.toolCalls",
- "ai.response.toolCalls",
- ),
- );
- const tools = Array.isArray(calls)
- ? [...calls]
- : calls === undefined
- ? []
- : [calls];
- const toolName = pick("gen_ai.tool.name", "ai.toolCall.name");
- if (toolName !== undefined)
- tools.push({
- name: toolName,
- id: pick("gen_ai.tool.call.id", "ai.toolCall.id"),
- arguments: parsed(pick("gen_ai.tool.input", "ai.toolCall.args")),
- result: parsed(pick("gen_ai.tool.output", "ai.toolCall.result")),
- });
+ const ai = inspectAI(payload);
+ if (!ai) return;
+ const labels: Record = {
+ model: "Model",
+ provider: "Provider",
+ inputTokens: "Input tokens",
+ outputTokens: "Output tokens",
+ finishReason: "Finish reason",
+ function: "Function",
+ maxRetries: "Max retries",
+ maxSteps: "Max steps",
+ };
return {
- fields: definitions.flatMap(([label, keys]) => {
- const value = pick(...keys);
- return value === undefined ? [] : [{ label, value: display(value) }];
- }),
- system: parsed(
- pick(
- "gen_ai.system_instructions",
- "gen_ai.prompt.system",
- "ai.prompt.system",
- ),
- ),
- messages: parsed(
- pick(
- "gen_ai.request.messages",
- "gen_ai.input.messages",
- "vercel.ai.prompt.messages",
- "vercel.ai.prompt",
- "gen_ai.prompt",
- "ai.prompt.messages",
- "ai.prompt",
- ),
- ),
- response: parsed(
- pick(
- "gen_ai.response.text",
- "gen_ai.output.messages",
- "vercel.ai.response.text",
- "ai.response.text",
- ),
- ),
- tools,
+ ...ai,
+ fields: Object.entries(ai.fields).map(([key, value]) => ({
+ label: labels[key] ?? key,
+ value: display(value),
+ })),
};
}
export function feedbackData(payload: unknown): Record {
diff --git a/apps/local/src/lib/performance.test.ts b/apps/local/src/lib/performance.test.ts
index 2e7628903..08908c9b3 100644
--- a/apps/local/src/lib/performance.test.ts
+++ b/apps/local/src/lib/performance.test.ts
@@ -1,11 +1,11 @@
import { expect, test } from "vitest";
import { decodeEnvelope } from "./spotlight.ts";
-import { buildTraceGroups } from "./trace-model.ts";
+import { buildTraceGroups } from "../test-fixtures/trace-groups.ts";
import { spanInsights, webVitalPages } from "./performance.ts";
import { parseProfile, profileMatchesTrace } from "./profiles.ts";
const decode = (type: string, payload: unknown) =>
decodeEnvelope(JSON.stringify([{}, [[{ type }, payload]]]), "fixture")[0]!;
-test("query analysis groups embedded and standalone executions without counting a copied span twice", () => {
+test("query analysis groups embedded and standalone executions without counting a copied span twice", async () => {
const transaction = decode("transaction", {
transaction: "GET /orders",
start_timestamp: 1,
@@ -30,7 +30,7 @@ test("query analysis groups embedded and standalone executions without counting
timestamp: 1.1,
});
const [group] = spanInsights(
- buildTraceGroups([transaction, copy]),
+ await buildTraceGroups([transaction, copy]),
"queries",
);
expect(group).toMatchObject({ count: 1, averageMs: 100, totalMs: 100 });
@@ -98,7 +98,7 @@ test("continuous chunks and missing samples remain honest", () => {
expect(parseProfile(decode("profile", {})).sampleCount).toBe(0);
});
-test("profiles require captured IDs before associating standalone spans or logs", () => {
+test("profiles require captured IDs before associating standalone spans or logs", async () => {
const chunk = decode("profile_chunk", {
profiler_id: "unrelated",
profile: { samples: [{ timestamp: 1000 }] },
@@ -112,13 +112,13 @@ test("profiles require captured IDs before associating standalone spans or logs"
}),
decode("log", { trace_id: "other", timestamp: 1, body: "Unrelated log" }),
]) {
- expect(profileMatchesTrace(chunk, buildTraceGroups([item])[0]!)).toBe(
- false,
- );
+ expect(
+ profileMatchesTrace(chunk, (await buildTraceGroups([item]))[0]!),
+ ).toBe(false);
}
});
-test("legacy and continuous profiles retain real transaction associations", () => {
+test("legacy and continuous profiles retain real transaction associations", async () => {
const transaction = decode("transaction", {
event_id: "event",
start_timestamp: 1,
@@ -128,7 +128,7 @@ test("legacy and continuous profiles retain real transaction associations", () =
profile: { profiler_id: "profiler", profile_id: "legacy" },
},
});
- const trace = buildTraceGroups([transaction])[0]!;
+ const trace = (await buildTraceGroups([transaction]))[0]!;
for (const payload of [
{ transaction: { id: "event" } },
{ transactions: [{ trace_id: "t" }] },
@@ -147,7 +147,7 @@ test("legacy and continuous profiles retain real transaction associations", () =
).toBe(false);
});
-test("large continuous profiles can be matched without exceeding argument limits", () => {
+test("large continuous profiles can be matched without exceeding argument limits", async () => {
const transaction = decode("transaction", {
event_id: "event",
start_timestamp: 1000,
@@ -157,7 +157,7 @@ test("large continuous profiles can be matched without exceeding argument limits
profile: { profiler_id: "profiler" },
},
});
- const trace = buildTraceGroups([transaction])[0]!;
+ const trace = (await buildTraceGroups([transaction]))[0]!;
const profile = {
samples: Array.from({ length: 150_000 }, (_, index) => ({
timestamp: 1000 + index / 100,
diff --git a/apps/local/src/lib/performance.ts b/apps/local/src/lib/performance.ts
index efc6256bb..d13a3bf11 100644
--- a/apps/local/src/lib/performance.ts
+++ b/apps/local/src/lib/performance.ts
@@ -1,6 +1,12 @@
+import {
+ createTelemetryReader,
+ groupWebVitalPages,
+ type VitalVisit as SharedVitalVisit,
+ type TelemetrySnapshot,
+} from "@sentry/spotlight";
import type { LocalFeedItem } from "./spotlight.ts";
import type { TraceGroup, TraceSpan } from "./trace-model.ts";
-import { finite, record, spanData, text } from "./inspection.ts";
+import { record, text } from "./inspection.ts";
export type SpanInsight = {
id: string;
name: string;
@@ -15,101 +21,89 @@ export function spanInsights(
traces: readonly TraceGroup[],
kind: "queries" | "resources",
): SpanInsight[] {
- const groups = new Map();
- for (const trace of traces)
- for (const span of trace.spans) {
- if (
- !(kind === "queries" ? /^db(?:\.|$)/ : /^resource(?:\.|$)/).test(
- span.operation ?? "",
- )
- )
- continue;
- const id = `${span.operation}:${span.description}`;
- const group = groups.get(id) ?? {
- id,
- name: span.description,
- operation: span.operation ?? "",
- count: 0,
- executions: [],
- };
- group.count++;
- group.executions.push({
- traceId: trace.id,
- traceTitle: trace.title,
- span,
- });
- groups.set(id, group);
- }
- for (const group of groups.values()) {
- const durations = group.executions.flatMap((entry) =>
- entry.span.durationMs === undefined ? [] : [entry.span.durationMs],
- );
- if (durations.length) {
- group.totalMs = durations.reduce((a, b) => a + b, 0);
- group.averageMs = group.totalMs / durations.length;
- }
- const sizes = group.executions.flatMap((entry) => {
- const data = spanData(entry.span.payload);
- const size =
- finite(data["http.response_content_length"]) ??
- finite(data["http.response.body.size"]) ??
- finite(data["http.decoded_response_content_length"]);
- return size === undefined || size < 0 ? [] : [size];
+ const reader = createTelemetryReader(
+ () =>
+ ({
+ revision: 0,
+ envelopes: [],
+ events: [],
+ logs: [],
+ sdks: [],
+ profiles: new Map(),
+ traces: traces.flatMap(({ processed }) =>
+ processed
+ ? [
+ {
+ ...processed,
+ spans: new Map(
+ [...processed.spans].map(([id, span]) => [
+ id,
+ { ...span, trace_id: processed.trace_id },
+ ]),
+ ),
+ },
+ ]
+ : [],
+ ),
+ }) satisfies TelemetrySnapshot,
+ );
+ const summaries =
+ kind === "queries"
+ ? reader.getDatabaseQuerySummaries({ includeSpans: true })
+ : reader.getResourceSummaries({ includeSpans: true });
+ return summaries.map((summary) => {
+ const executions = summary.spans.flatMap((processed) => {
+ // Embedded legacy spans need their containing trace to resolve their ID.
+ const trace = traces.find((trace) => trace.id === processed.trace_id);
+ const span = trace?.spans.find((span) => span.id === processed.span_id);
+ return trace && span
+ ? [{ traceId: trace.id, traceTitle: trace.title, span }]
+ : [];
});
- if (sizes.length)
- group.averageBytes = sizes.reduce((a, b) => a + b, 0) / sizes.length;
- }
- return [...groups.values()];
+ return {
+ id: summary.description,
+ name: summary.description,
+ operation: summary.spans[0]?.op ?? "",
+ count: summary.count,
+ totalMs: summary.totalDurationMs,
+ averageMs: summary.averageDurationMs,
+ averageBytes:
+ "averageSizeBytes" in summary &&
+ typeof summary.averageSizeBytes === "number"
+ ? summary.averageSizeBytes
+ : undefined,
+ executions,
+ };
+ });
}
-export const VITALS = {
- lcp: { label: "LCP", unit: "ms", good: 2500, poor: 4000 },
- inp: { label: "INP", unit: "ms", good: 200, poor: 500 },
- cls: { label: "CLS", unit: "", good: 0.1, poor: 0.25 },
- fcp: { label: "FCP", unit: "ms", good: 1800, poor: 3000 },
- ttfb: { label: "TTFB", unit: "ms", good: 800, poor: 1800 },
- fid: { label: "FID (legacy)", unit: "ms", good: 100, poor: 300 },
-} as const;
-export type VitalName = keyof typeof VITALS;
-export type VitalMeasurement = {
- value: number;
- rating: "Good" | "Needs improvement" | "Poor";
-};
+
+export { WEB_VITALS as VITALS } from "@sentry/spotlight";
+export type { VitalName, VitalMeasurement } from "@sentry/spotlight";
export type VitalVisit = {
item: LocalFeedItem;
- metrics: Partial>;
+ metrics: SharedVitalVisit["metrics"];
};
export type VitalPage = { name: string; visits: VitalVisit[] };
export function webVitalPages(items: readonly LocalFeedItem[]): VitalPage[] {
- const pages = new Map();
- for (const item of items) {
- const p = record(item.payload),
- measurements = record(p.measurements);
- if (item.type !== "transaction" || !Object.keys(measurements).length)
- continue;
- const metrics: VitalVisit["metrics"] = {};
- for (const name of Object.keys(VITALS) as VitalName[]) {
- const metric = record(measurements[name]),
- value = finite(metric.value);
- if (value === undefined || value < 0) continue;
- const config = VITALS[name];
- // SDK measurements normally use milliseconds; honor seconds when explicitly supplied.
- const normalized =
- config.unit === "ms" && metric.unit === "second" ? value * 1000 : value;
- metrics[name] = {
- value: normalized,
- rating:
- normalized <= config.good
- ? "Good"
- : normalized <= config.poor
- ? "Needs improvement"
- : "Poor",
- };
- }
- if (!Object.keys(metrics).length) continue;
- const name = text(p.transaction) ?? item.metadata?.route ?? "Unknown page";
- const page = pages.get(name) ?? { name, visits: [] };
- page.visits.push({ item, metrics });
- pages.set(name, page);
- }
- return [...pages.values()].sort((a, b) => a.name.localeCompare(b.name));
+ const byId = new Map(items.map((item, index) => [String(index), item]));
+ const pages = groupWebVitalPages(
+ items.flatMap((item, index) => {
+ if (item.type !== "transaction") return [];
+ const payload = record(item.payload);
+ return [
+ {
+ id: String(index),
+ transaction: text(payload.transaction) ?? item.metadata?.route,
+ measurements: payload.measurements,
+ },
+ ];
+ }),
+ );
+ return pages.map((page) => ({
+ ...page,
+ visits: page.visits.map((visit) => ({
+ item: byId.get(visit.eventId)!,
+ metrics: visit.metrics,
+ })),
+ }));
}
diff --git a/apps/local/src/lib/profiles.ts b/apps/local/src/lib/profiles.ts
index b73415b79..74298cac4 100644
--- a/apps/local/src/lib/profiles.ts
+++ b/apps/local/src/lib/profiles.ts
@@ -1,168 +1,20 @@
+import { buildProfileModel, type ParsedProfile } from "@sentry/spotlight";
import type { LocalFeedItem } from "./spotlight.ts";
import type { TraceGroup } from "./trace-model.ts";
import { record, finite, text } from "./inspection.ts";
-export type ProfileNode = {
- id: string;
- name: string;
- location: string;
- inApp: boolean;
- samples: number;
- selfSamples: number;
- totalMs: number;
- selfMs: number;
- children: ProfileNode[];
-};
-export type ProfileFunction = Omit;
-export type ParsedProfile = {
- root: ProfileNode;
- functions: ProfileFunction[];
- sampleCount: number;
- durationMs?: number;
- threads: { id: string; name: string }[];
- threadId?: string;
-};
-function node(
- id: string,
- name: string,
- location = "",
- inApp = false,
-): ProfileNode {
- return {
- id,
- name,
- location,
- inApp,
- samples: 0,
- selfSamples: 0,
- totalMs: 0,
- selfMs: 0,
- children: [],
- };
-}
-function sampleTime(sample: Record): number | undefined {
- const ns = sample.elapsed_since_start_ns;
- if (typeof ns === "string" && ns.trim() && Number.isFinite(Number(ns)))
- return Number(ns) / 1e6;
- if (finite(ns) !== undefined) return Number(ns) / 1e6;
- const seconds = finite(sample.timestamp);
- return seconds === undefined ? undefined : seconds * 1000;
-}
-function sampleThread(value: unknown): string {
- const number = finite(value);
- return text(value) ?? (number === undefined ? "main" : String(number));
-}
-/** SDK stacks are leaf-first; sample intervals are calculated independently per thread. */
+export type {
+ ProfileNode,
+ ProfileFunction,
+ ParsedProfile,
+} from "@sentry/spotlight";
+
export function parseProfile(
item: LocalFeedItem,
selectedThread?: string,
): ParsedProfile {
- const payload = record(item.payload),
- profile = Object.keys(record(payload.profile)).length
- ? record(payload.profile)
- : payload;
- const samples = (Array.isArray(profile.samples) ? profile.samples : []).map(
- record,
- );
- const frames = Array.isArray(profile.frames) ? profile.frames : [];
- const stacks = Array.isArray(profile.stacks) ? profile.stacks : [];
- const metadata = record(profile.thread_metadata);
- const threads = [
- ...new Set(samples.map((sample) => sampleThread(sample.thread_id))),
- ].map((id) => ({ id, name: text(record(metadata[id]).name) ?? id }));
- const threadId = threads.some((thread) => thread.id === selectedThread)
- ? selectedThread
- : threads[0]?.id;
- const selected = samples
- .filter((sample) => sampleThread(sample.thread_id) === threadId)
- .sort((a, b) => (sampleTime(a) ?? Infinity) - (sampleTime(b) ?? Infinity));
- const root = node("root", "All samples"),
- functions = new Map();
- const children = new Map>();
- let hasTiming = false;
- for (let index = 0; index < selected.length; index++) {
- const sample = selected[index]!,
- stackId = finite(sample.stack_id),
- rawStack: unknown = stackId === undefined ? undefined : stacks[stackId];
- const stack = Array.isArray(rawStack)
- ? rawStack
- .filter(
- (id): id is number =>
- typeof id === "number" && Number.isInteger(id) && id >= 0,
- )
- .toReversed()
- : [];
- const time = sampleTime(sample),
- next = selected[index + 1] ? sampleTime(selected[index + 1]!) : undefined;
- const weight =
- time !== undefined && next !== undefined ? Math.max(0, next - time) : 0;
- if (time !== undefined && next !== undefined) hasTiming = true;
- root.samples++;
- root.totalMs += weight;
- let parent = root;
- const seenFunctions = new Set();
- // Bound pathological stack depth to protect the UI; raw data remains inspectable.
- for (const frameId of stack.slice(0, 256)) {
- if (!frames[frameId] || typeof frames[frameId] !== "object") continue;
- const frame = record(frames[frameId]);
- const name =
- text(frame.function) ??
- text(frame.module) ??
- text(frame.instruction_addr) ??
- "Anonymous";
- const location = [
- text(frame.abs_path) ?? text(frame.filename),
- finite(frame.lineno),
- ]
- .filter((value) => value !== undefined)
- .join(":");
- const childMap = children.get(parent) ?? new Map();
- children.set(parent, childMap);
- let child = childMap.get(frameId);
- if (!child) {
- child = node(
- `${parent.id}/${frameId}`,
- name,
- location,
- frame.in_app === true,
- );
- childMap.set(frameId, child);
- parent.children.push(child);
- }
- child.samples++;
- child.totalMs += weight;
- parent = child;
- const key = `${name}:${location}`;
- const fn = functions.get(key) ?? {
- ...node(key, name, location, frame.in_app === true),
- };
- if (!seenFunctions.has(key)) {
- fn.samples++;
- fn.totalMs += weight;
- seenFunctions.add(key);
- }
- functions.set(key, fn);
- }
- parent.selfSamples++;
- parent.selfMs += weight;
- if (parent !== root) {
- const fn = functions.get(`${parent.name}:${parent.location}`);
- if (fn) {
- fn.selfSamples++;
- fn.selfMs += weight;
- }
- }
- }
- return {
- root,
- functions: [...functions.values()].sort(
- (a, b) => b.totalMs - a.totalMs || b.samples - a.samples,
- ),
- sampleCount: selected.length,
- durationMs: hasTiming ? root.totalMs : undefined,
- threads,
- threadId,
- };
+ return buildProfileModel(item.payload, selectedThread);
}
+
type ProfileBounds = { start: number; end: number };
// Captured items are immutable. Reuse bounds across trace rows and inspectors,
// and let retention release the cache together with its captured item.
diff --git a/apps/local/src/lib/telemetry-regressions.test.ts b/apps/local/src/lib/telemetry-regressions.test.ts
new file mode 100644
index 000000000..9dde2d6ec
--- /dev/null
+++ b/apps/local/src/lib/telemetry-regressions.test.ts
@@ -0,0 +1,369 @@
+import { expect, test } from "vitest";
+import type { ProcessedSpan } from "@sentry/spotlight";
+import { adaptTraceGroups } from "./trace-model.ts";
+import { decodeEnvelope } from "./spotlight.ts";
+import { createLocalTelemetryStore } from "./telemetry-store.ts";
+import { spanInsights } from "./performance.ts";
+
+async function capture(items: unknown[]) {
+ const store = createLocalTelemetryStore();
+ const text = JSON.stringify([{}, items]);
+ store.recordEnvelope(
+ { id: "wire", type: "envelope", text, payload: text },
+ decodeEnvelope(text, "wire"),
+ );
+ await store.drain();
+ return store;
+}
+
+test("production log-batch wire inputs remain discoverable in the trace workspace", async () => {
+ const store = await capture([
+ [
+ { type: "log" },
+ {
+ items: [
+ {
+ trace_id: "log-trace",
+ span_id: "log-span",
+ timestamp: 1700000000,
+ body: "log-only trace",
+ level: "info",
+ },
+ ],
+ },
+ ],
+ ]);
+ expect(store.getSnapshot().traceGroups).toMatchObject([
+ {
+ id: "log-trace",
+ logCount: 1,
+ title: "log-only trace",
+ startTimestamp: 1700000000,
+ endTimestamp: 1700000000,
+ spans: [],
+ },
+ ]);
+ store.clear();
+ expect(store.getSnapshot().traceGroups).toEqual([]);
+});
+
+test("production span-v2 resource sizes use known wire aliases and omit unknown sizes", async () => {
+ const attributes = [
+ { "http.response.body.size": { type: "integer", value: 200 } },
+ { "http.decoded_response_content_length": { type: "integer", value: 400 } },
+ {},
+ ];
+ const store = await capture([
+ [
+ { type: "span" },
+ {
+ version: 2,
+ items: attributes.map((data, index) => ({
+ trace_id: "resource-trace",
+ span_id: String(index),
+ name: "asset.js",
+ start_timestamp: 1,
+ end_timestamp: 2,
+ attributes: {
+ "sentry.op": { type: "string", value: "resource.script" },
+ ...data,
+ },
+ })),
+ },
+ ],
+ ]);
+ expect(
+ spanInsights(store.getSnapshot().traceGroups, "resources"),
+ ).toMatchObject([{ count: 3, averageBytes: 300, averageMs: 1000 }]);
+});
+
+test.each(["queries", "resources"] as const)(
+ "production unnamed and blank %s spans remain discoverable with operation labels",
+ async (kind) => {
+ const operation = kind === "queries" ? "db.query" : "resource.script";
+ const store = await capture([
+ [
+ { type: "transaction" },
+ {
+ transaction: "GET /unnamed",
+ start_timestamp: 1,
+ timestamp: 2,
+ contexts: { trace: { trace_id: "unnamed", span_id: "root" } },
+ spans: [
+ {
+ span_id: "missing",
+ op: operation,
+ start_timestamp: 1,
+ timestamp: 1.1,
+ },
+ {
+ span_id: "blank",
+ op: operation,
+ description: " ",
+ start_timestamp: 1.1,
+ timestamp: 1.2,
+ },
+ {
+ span_id: "named",
+ op: operation,
+ description: " SELECT 1 ",
+ start_timestamp: 1.2,
+ timestamp: 1.3,
+ },
+ ],
+ },
+ ],
+ ]);
+ const summaries = spanInsights(store.getSnapshot().traceGroups, kind);
+ expect(summaries).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: operation,
+ count: 2,
+ totalMs: 200,
+ averageMs: 100,
+ executions: [
+ expect.objectContaining({
+ span: expect.objectContaining({
+ id: "missing",
+ description: operation,
+ }),
+ }),
+ expect.objectContaining({
+ span: expect.objectContaining({
+ id: "blank",
+ description: " ",
+ }),
+ }),
+ ],
+ }),
+ expect.objectContaining({
+ name: "SELECT 1",
+ count: 1,
+ executions: [
+ expect.objectContaining({
+ span: expect.objectContaining({
+ id: "named",
+ description: " SELECT 1 ",
+ }),
+ }),
+ ],
+ }),
+ ]),
+ );
+ expect(
+ store.getSnapshot().traceGroups[0]?.processed?.spans.get("missing")
+ ?.description,
+ ).toBeUndefined();
+ },
+);
+
+test("production missing parents under a real root retain descendants in the waterfall tree", async () => {
+ const store = await capture([
+ [
+ { type: "transaction" },
+ {
+ transaction: "GET /partial",
+ start_timestamp: 1,
+ timestamp: 2,
+ contexts: { trace: { trace_id: "partial", span_id: "root" } },
+ spans: [
+ {
+ span_id: "child",
+ parent_span_id: "missing",
+ description: "child",
+ start_timestamp: 1.1,
+ timestamp: 1.2,
+ },
+ {
+ span_id: "grandchild",
+ parent_span_id: "child",
+ description: "grandchild",
+ start_timestamp: 1.11,
+ timestamp: 1.15,
+ },
+ ],
+ },
+ ],
+ ]);
+ expect(store.getSnapshot().traceGroups[0]?.roots).toMatchObject([
+ {
+ id: "root",
+ children: [
+ { id: "child", parentId: "missing", children: [{ id: "grandchild" }] },
+ ],
+ },
+ ]);
+ expect(store.getSnapshot().traceGroups[0]?.spans).toHaveLength(3);
+});
+
+test("retention removes evicted spans from synchronous snapshots and every subscriber publication", async () => {
+ const store = createLocalTelemetryStore({ limit: 2 });
+ function recordSpan(id: string, payload: unknown) {
+ const text = JSON.stringify([
+ {},
+ [[{ type: "span" }, { version: 2, items: [payload] }]],
+ ]);
+ store.recordEnvelope(
+ { id, type: "envelope", text, payload: text },
+ decodeEnvelope(text, id),
+ );
+ }
+ recordSpan("child-envelope", {
+ trace_id: "retained",
+ span_id: "evicted-child",
+ parent_span_id: "root",
+ start_timestamp: 1.1,
+ end_timestamp: 1.2,
+ });
+ recordSpan("parent-envelope", {
+ trace_id: "retained",
+ span_id: "root",
+ name: "retained root",
+ is_segment: true,
+ start_timestamp: 1,
+ end_timestamp: 2,
+ });
+ await store.drain();
+ expect(
+ store.getSnapshot().traceGroups[0]?.spans.map((span) => span.id),
+ ).toContain("evicted-child");
+ const publishedSpanIds: string[][] = [];
+ store.subscribe(() =>
+ publishedSpanIds.push(
+ store
+ .getSnapshot()
+ .traceGroups.flatMap((trace) => trace.spans.map((span) => span.id)),
+ ),
+ );
+ recordSpan("next-envelope", {
+ trace_id: "new",
+ span_id: "next",
+ start_timestamp: 2,
+ end_timestamp: 3,
+ });
+ expect(
+ store
+ .getSnapshot()
+ .traceGroups.flatMap((trace) => trace.spans.map((span) => span.id)),
+ ).not.toContain("evicted-child");
+ await store.drain();
+ expect(publishedSpanIds.length).toBeGreaterThanOrEqual(2);
+ for (const ids of publishedSpanIds)
+ expect(ids).not.toContain("evicted-child");
+ expect(
+ store
+ .getSnapshot()
+ .traceGroups.flatMap((trace) => trace.spans.map((span) => span.id))
+ .sort(),
+ ).toEqual(["next", "root"]);
+});
+
+test("retention changes during shared ingestion cannot republish the previous window", async () => {
+ const store = createLocalTelemetryStore({ limit: 2 });
+ function captureSpan(id: string) {
+ const text = JSON.stringify([
+ {},
+ [
+ [
+ { type: "span" },
+ {
+ version: 2,
+ items: [
+ {
+ trace_id: "same-trace",
+ span_id: id,
+ start_timestamp: 1,
+ end_timestamp: 2,
+ },
+ ],
+ },
+ ],
+ ],
+ ]);
+ store.recordEnvelope(
+ { id, type: "envelope", text, payload: text },
+ decodeEnvelope(text, id),
+ );
+ }
+ captureSpan("evicted");
+ await store.drain();
+ captureSpan("second");
+ // Let the shared ingestion start, then change retention before its await resumes.
+ await Promise.resolve();
+ const published: string[][] = [];
+ store.subscribe(() =>
+ published.push(
+ store
+ .getSnapshot()
+ .traceGroups.flatMap((trace) => trace.spans.map((span) => span.id)),
+ ),
+ );
+ captureSpan("third");
+ await store.drain();
+ for (const ids of published) expect(ids).not.toContain("evicted");
+ expect(
+ store
+ .getSnapshot()
+ .traceGroups[0]?.spans.map((span) => span.id)
+ .sort(),
+ ).toEqual(["second", "third"]);
+});
+
+test("nested orphan wrappers promote each real child once even when wrappers cycle", async () => {
+ const store = await capture([
+ [
+ { type: "transaction" },
+ {
+ transaction: "partial tree",
+ start_timestamp: 1,
+ timestamp: 2,
+ contexts: { trace: { trace_id: "partial", span_id: "root" } },
+ spans: [
+ {
+ span_id: "child",
+ parent_span_id: "missing",
+ start_timestamp: 1.1,
+ timestamp: 1.2,
+ },
+ ],
+ },
+ ],
+ ]);
+ const snapshot = store.getSnapshot();
+ const processed = snapshot.traceGroups[0]!.processed!;
+ const child = processed.spans.get("child")!;
+ const wrapper = {
+ ...child,
+ span_id: "missing",
+ op: "orphan",
+ children: [] as ProcessedSpan[],
+ };
+ const nested: ProcessedSpan = {
+ ...wrapper,
+ span_id: "nested",
+ children: [wrapper, child],
+ };
+ wrapper.children = [nested];
+ const root: ProcessedSpan = {
+ ...processed.spans.get("root")!,
+ children: [wrapper],
+ };
+ const [trace] = adaptTraceGroups(snapshot.items, [
+ {
+ ...processed,
+ spans: new Map([
+ ["root", root],
+ ["missing", wrapper],
+ ["nested", nested],
+ ["child", child],
+ ]),
+ spanTree: [root],
+ },
+ ]);
+ expect(trace?.roots).toMatchObject([
+ { id: "root", children: [{ id: "child" }] },
+ ]);
+ expect(trace?.roots[0]?.children).toHaveLength(1);
+ expect(trace?.spans.map((span) => span.id)).toEqual(["root", "child"]);
+});
diff --git a/apps/local/src/lib/telemetry-store.test.ts b/apps/local/src/lib/telemetry-store.test.ts
index fcb36d73c..79834ea6f 100644
--- a/apps/local/src/lib/telemetry-store.test.ts
+++ b/apps/local/src/lib/telemetry-store.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
-import type { LocalFeedItem } from "./spotlight.ts";
+import { decodeEnvelope, type LocalFeedItem } from "./spotlight.ts";
import { createLocalTelemetryStore } from "./telemetry-store.ts";
function item(overrides: Partial): LocalFeedItem {
@@ -106,3 +106,111 @@ describe("LocalTelemetryStore", () => {
expect(store.getSnapshot().envelopes).toEqual([envelope]);
});
});
+
+test("shared processing assembles streamed traces and keeps the original capture", async () => {
+ const store = createLocalTelemetryStore({ limit: 2 });
+ const capture = (id: string, payload: unknown) => {
+ const text = JSON.stringify([
+ {},
+ [[{ type: "span" }, { version: 2, items: [payload] }]],
+ ]);
+ const envelope = item({ id, type: "envelope", text, payload: text });
+ store.recordEnvelope(envelope, decodeEnvelope(text, id));
+ return envelope;
+ };
+ const child = capture("child", {
+ trace_id: "trace",
+ span_id: "query",
+ parent_span_id: "root",
+ name: "SELECT 1",
+ start_timestamp: 1.1,
+ end_timestamp: 1.2,
+ attributes: { "sentry.op": { type: "string", value: "db" } },
+ });
+ capture("parent", {
+ trace_id: "trace",
+ span_id: "root",
+ name: "GET /orders",
+ is_segment: true,
+ start_timestamp: 1,
+ end_timestamp: 2,
+ });
+ await store.drain();
+ expect(store.getSnapshot().traceGroups[0]).toMatchObject({
+ title: "GET /orders",
+ durationMs: 1000,
+ roots: [{ id: "root", children: [{ id: "query", durationMs: 100 }] }],
+ });
+ expect(store.getSnapshot().envelopes[0]).toBe(child);
+ expect(
+ store
+ .getSnapshot()
+ .traceGroups[0]?.spans.find((span) => span.id === "query")?.payload,
+ ).toEqual(JSON.parse(child.text)[1][0][1].items[0]);
+
+ // A retained parent must not keep spans from an evicted child envelope.
+ const raw = item({
+ id: "opaque",
+ type: "envelope",
+ text: '[{},[[{"type":"attachment","filename":"raw.txt"},"unmodified"]]]',
+ });
+ store.recordEnvelope(raw, decodeEnvelope(raw.text, raw.id));
+ await store.drain();
+ expect(
+ store.getSnapshot().traceGroups[0]?.spans.map((span) => span.id),
+ ).toEqual(["root"]);
+ expect(store.getSnapshot().attachments[0]?.payload).toBe("unmodified");
+});
+
+test("clearing invalidates queued processing from the previous capture", async () => {
+ const store = createLocalTelemetryStore();
+ const text = JSON.stringify([
+ {},
+ [
+ [
+ { type: "transaction" },
+ {
+ event_id: "old",
+ type: "transaction",
+ transaction: "old capture",
+ start_timestamp: 1,
+ timestamp: 2,
+ contexts: { trace: { trace_id: "old", span_id: "root" } },
+ },
+ ],
+ ],
+ ]);
+ store.recordEnvelope(
+ item({ id: "old", type: "envelope", text }),
+ decodeEnvelope(text, "old"),
+ );
+ store.clear();
+ await store.drain();
+ await Promise.resolve();
+ expect(store.getSnapshot().traceGroups).toEqual([]);
+ expect(store.getSnapshot().items).toEqual([]);
+});
+
+test("uncorrelated transactions stay discoverable without invented trace context", async () => {
+ const store = createLocalTelemetryStore();
+ const text = JSON.stringify([
+ {},
+ [
+ [
+ { type: "transaction" },
+ { transaction: "GET /standalone", timestamp: 2 },
+ ],
+ ],
+ ]);
+ const raw = item({ id: "standalone", type: "envelope", text });
+ store.recordEnvelope(raw, decodeEnvelope(text, raw.id));
+ await store.drain();
+ expect(store.getSnapshot().traceGroups).toMatchObject([
+ { id: "standalone:0", title: "GET /standalone", spans: [], itemCount: 1 },
+ ]);
+ expect(store.getSnapshot().envelopes[0]?.text).toBe(text);
+ expect(store.getSnapshot().items[0]?.payload).toEqual({
+ transaction: "GET /standalone",
+ timestamp: 2,
+ });
+});
diff --git a/apps/local/src/lib/telemetry-store.ts b/apps/local/src/lib/telemetry-store.ts
index 0038f12ef..f6cf8f69a 100644
--- a/apps/local/src/lib/telemetry-store.ts
+++ b/apps/local/src/lib/telemetry-store.ts
@@ -1,3 +1,5 @@
+import { createTelemetryStore } from "@sentry/spotlight";
+import { adaptTraceGroups, type TraceGroup } from "./trace-model.ts";
import { aiItems } from "./inspection.ts";
import {
DEFAULT_MAX_ENVELOPES,
@@ -8,6 +10,7 @@ import { isErrorEvent } from "./workspace.ts";
export type LocalTelemetrySnapshot = {
items: LocalFeedItem[];
+ traceGroups: TraceGroup[];
errors: LocalFeedItem[];
traces: LocalFeedItem[];
logs: LocalFeedItem[];
@@ -26,12 +29,14 @@ export type LocalTelemetryStore = {
) => void;
setLimit: (limit: number) => void;
clear: () => void;
+ drain: () => Promise;
getSnapshot: () => LocalTelemetrySnapshot;
subscribe: (listener: () => void) => () => void;
};
const EMPTY_SNAPSHOT: LocalTelemetrySnapshot = {
items: [],
+ traceGroups: [],
errors: [],
traces: [],
logs: [],
@@ -46,9 +51,11 @@ const EMPTY_SNAPSHOT: LocalTelemetrySnapshot = {
function buildSnapshot(
items: LocalFeedItem[],
envelopes: LocalFeedItem[],
+ traceGroups: TraceGroup[] = [],
): LocalTelemetrySnapshot {
return {
items,
+ traceGroups,
errors: items.filter(isErrorEvent),
traces: items.filter(
(item) => item.type === "transaction" || item.type === "span",
@@ -81,9 +88,91 @@ export function createLocalTelemetryStore({
let snapshot = EMPTY_SNAPSHOT;
const envelopeItems = new Map();
const listeners = new Set<() => void>();
+ // The raw capture is synchronous. Shared processing is serialized so bursts
+ // cannot overflow the SDK queue, and reset invalidates work from older captures.
+ let processor = createProcessor();
+ let processing = Promise.resolve();
+ let generation = 0;
+ let processorLimit = limit;
+ let needsRebuild = false;
+ let scheduled = false;
+ let dirty = false;
+ const processedRawIds = new Set();
+ function createProcessor() {
+ return createTelemetryStore({
+ retention: { maxEnvelopes: limit, maxBytes: Number.MAX_SAFE_INTEGER },
+ maxEnvelopeBytes: Number.MAX_SAFE_INTEGER,
+ });
+ }
+ function parsedEnvelope(envelope: LocalFeedItem): unknown {
+ try {
+ return JSON.parse(envelope.text);
+ } catch {
+ return undefined;
+ }
+ }
+ function updateProcessed() {
+ dirty = true;
+ if (scheduled) return;
+ scheduled = true;
+ const epoch = generation;
+ processing = Promise.resolve().then(async () => {
+ try {
+ // Coalesce a burst into the latest retained capture instead of retaining
+ // an unbounded promise chain or an envelope-list copy per delivery.
+ while (dirty && epoch === generation) {
+ dirty = false;
+ const retained = snapshot.envelopes;
+ const retainedIds = new Set(retained.map((raw) => raw.id));
+ if (
+ needsRebuild ||
+ [...processedRawIds].some((id) => !retainedIds.has(id))
+ ) {
+ processor.reset();
+ processedRawIds.clear();
+ needsRebuild = false;
+ }
+ if (processorLimit !== limit) {
+ const nextLimit = limit;
+ await processor.setRetention({ maxEnvelopes: nextLimit });
+ if (epoch !== generation) return;
+ processorLimit = nextLimit;
+ }
+ for (const raw of retained) {
+ if (epoch !== generation) return;
+ if (processedRawIds.has(raw.id)) continue;
+ await processor.ingestEnvelope(parsedEnvelope(raw), {
+ deliveryId: raw.id,
+ });
+ if (epoch !== generation) return;
+ processedRawIds.add(raw.id);
+ }
+ if (epoch !== generation) return;
+ // A capture can change while ingestion yields. Rebuild against the
+ // latest retention window before publishing any of that work.
+ if (dirty) continue;
+ publish(
+ snapshot.items,
+ snapshot.envelopes,
+ adaptTraceGroups(snapshot.items, processor.getTraces()),
+ );
+ }
+ } catch {
+ // A malformed capture must not poison later deliveries. Keep its raw data
+ // inspectable and rebuild derived state on the next successful operation.
+ if (epoch === generation) needsRebuild = true;
+ } finally {
+ if (epoch === generation) scheduled = false;
+ }
+ });
+ }
- function publish(items: LocalFeedItem[], envelopes = snapshot.envelopes) {
- snapshot = buildSnapshot(items, envelopes);
+ function publish(
+ items: LocalFeedItem[],
+ envelopes = snapshot.envelopes,
+ traceGroups = snapshot.traceGroups,
+ ) {
+ snapshot = buildSnapshot(items, envelopes, traceGroups);
for (const listener of listeners) {
listener();
}
@@ -102,17 +191,36 @@ export function createLocalTelemetryStore({
envelopeItems.set(envelope.id, decoded);
const envelopes = [...snapshot.envelopes, envelope].slice(-limit);
const retainedIds = new Set(envelopes.map((item) => item.id));
- for (const id of envelopeItems.keys())
- if (!retainedIds.has(id)) envelopeItems.delete(id);
+ for (const id of envelopeItems.keys()) {
+ if (!retainedIds.has(id)) {
+ envelopeItems.delete(id);
+ needsRebuild = true;
+ }
+ }
+ const items = envelopes.flatMap((item) => [
+ ...(envelopeItems.get(item.id) ?? []),
+ ]);
publish(
- envelopes.flatMap((item) => [...(envelopeItems.get(item.id) ?? [])]),
+ items,
envelopes,
+ // The shared processor still contains the previous retention window
+ // until its asynchronous rebuild. Never expose those evicted spans.
+ adaptTraceGroups(items, needsRebuild ? [] : processor.getTraces()),
);
+ updateProcessed();
},
+ drain: () => processing,
clear() {
+ generation++;
+ processor.reset();
+ processing = Promise.resolve();
+ dirty = false;
+ scheduled = false;
+ needsRebuild = false;
+ processedRawIds.clear();
envelopeItems.clear();
if (snapshot.items.length > 0 || snapshot.envelopes.length > 0) {
- publish([], []);
+ publish([], [], []);
}
},
getSnapshot: () => snapshot,
diff --git a/apps/local/src/lib/trace-model.test.ts b/apps/local/src/lib/trace-model.test.ts
index 32c6c10bd..848b7a71b 100644
--- a/apps/local/src/lib/trace-model.test.ts
+++ b/apps/local/src/lib/trace-model.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { decodeEnvelope, type LocalFeedItem } from "./spotlight.js";
-import { buildTraceGroups, traceIdForItem } from "./trace-model.js";
+import { traceIdForItem } from "./trace-model.js";
+import { buildTraceGroups } from "../test-fixtures/trace-groups.ts";
function item(overrides: Partial): LocalFeedItem {
return {
@@ -16,7 +17,7 @@ function item(overrides: Partial): LocalFeedItem {
describe("buildTraceGroups", () => {
test.each([undefined, "remote-parent"])(
"promotes a late streamed segment title and reparents earlier children (parent: %s)",
- (parentId) => {
+ async (parentId) => {
const child = {
trace_id: "trace-stream",
span_id: "query",
@@ -32,7 +33,7 @@ describe("buildTraceGroups", () => {
id,
);
const earlyItems = decode([child], "child");
- expect(buildTraceGroups(earlyItems)[0]?.roots).toMatchObject([
+ expect((await buildTraceGroups(earlyItems))[0]?.roots).toMatchObject([
{ id: "query" },
]);
const items = [
@@ -56,7 +57,7 @@ describe("buildTraceGroups", () => {
),
...decode([child], "redelivery"),
];
- const [trace] = buildTraceGroups(items);
+ const [trace] = await buildTraceGroups(items);
expect(trace).toMatchObject({
title: "GET /orders",
startTimestamp: 1700000000,
@@ -73,7 +74,7 @@ describe("buildTraceGroups", () => {
test.each([true, false])(
"combines legacy transactions and streamed spans without duplicate spans (transaction first: %s)",
- (transactionFirst) => {
+ async (transactionFirst) => {
const transaction = decodeEnvelope(
JSON.stringify([
{},
@@ -141,7 +142,7 @@ describe("buildTraceGroups", () => {
]),
"streamed",
);
- const traces = buildTraceGroups(
+ const traces = await buildTraceGroups(
transactionFirst
? [...transaction, ...streamed]
: [...streamed, ...transaction],
@@ -158,7 +159,7 @@ describe("buildTraceGroups", () => {
},
);
- test("reads trace timing in linear work for a long single trace", () => {
+ test("reads trace timing in linear work for a long single trace", async () => {
let timestampReads = 0;
const entries: LocalFeedItem[] = Array.from(
{ length: 1000 },
@@ -174,7 +175,7 @@ describe("buildTraceGroups", () => {
},
}),
);
- const [trace] = buildTraceGroups(entries);
+ const [trace] = await buildTraceGroups(entries);
expect(trace).toMatchObject({
itemCount: 1000,
logCount: 1000,
@@ -185,14 +186,14 @@ describe("buildTraceGroups", () => {
expect(timestampReads).toBeLessThanOrEqual(entries.length * 2);
});
- test("handles timing for a valid transaction with many child spans without argument overflow", () => {
+ test("handles timing for a valid transaction with many child spans without argument overflow", async () => {
const spans = Array.from({ length: 70000 }, (_, index) => ({
span_id: String(index),
parent_span_id: "root",
start_timestamp: index,
timestamp: index + 1,
}));
- const [trace] = buildTraceGroups([
+ const [trace] = await buildTraceGroups([
item({
metadata: { title: "large transaction", traceId: "large-trace" },
payload: {
@@ -225,8 +226,8 @@ describe("buildTraceGroups", () => {
).toBe("transaction-without-trace");
});
- test("keeps a standalone transaction discoverable when it has no trace context", () => {
- const traces = buildTraceGroups([
+ test("keeps a standalone transaction discoverable when it has no trace context", async () => {
+ const traces = await buildTraceGroups([
{
id: "transaction-without-trace",
type: "transaction",
@@ -242,8 +243,8 @@ describe("buildTraceGroups", () => {
expect(traces[0]?.title).toBe("GET /standalone");
});
- test("builds a timestamped span tree and correlates trace errors and logs", () => {
- const traces = buildTraceGroups([
+ test("builds a timestamped span tree and correlates trace errors and logs", async () => {
+ const traces = await buildTraceGroups([
item({
id: "transaction",
timestamp: 1_700_000_000.012,
@@ -328,8 +329,8 @@ describe("buildTraceGroups", () => {
]);
});
- test("keeps a span visible when its declared parent is absent", () => {
- const traces = buildTraceGroups([
+ test("keeps a span visible when its declared parent is absent", async () => {
+ const traces = await buildTraceGroups([
item({
metadata: {
title: "orphaned work",
@@ -356,8 +357,66 @@ describe("buildTraceGroups", () => {
]);
});
- test("nests a standalone span using its top-level parent span id", () => {
- const traces = buildTraceGroups([
+ test.each([true, false])(
+ "orders promoted missing-parent spans with their visible siblings (real root: %s)",
+ async (hasRoot) => {
+ const [trace] = await buildTraceGroups(
+ decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ { type: "span" },
+ {
+ version: 2,
+ items: [
+ ...(hasRoot
+ ? [{ span_id: "root", start_timestamp: 0 }]
+ : []),
+ {
+ span_id: "a",
+ parent_span_id: "missing-parent",
+ start_timestamp: 1,
+ },
+ {
+ span_id: "b",
+ parent_span_id: hasRoot ? "root" : "other-missing-parent",
+ start_timestamp: 2,
+ },
+ {
+ span_id: "c",
+ parent_span_id: "missing-parent",
+ start_timestamp: 3,
+ },
+ ].map((span) => ({
+ ...span,
+ trace_id: "trace-promoted-order",
+ end_timestamp: 4,
+ attributes: {
+ "sentry.op": { type: "string", value: "task" },
+ },
+ })),
+ },
+ ],
+ ],
+ ]),
+ "promoted-order",
+ ),
+ );
+
+ if (hasRoot) expect(trace?.roots).toHaveLength(1);
+ const siblings = hasRoot ? trace?.roots[0]?.children : trace?.roots;
+ expect(siblings?.map((span) => span.id)).toEqual(["a", "b", "c"]);
+ expect(siblings?.map((span) => span.parentId)).toEqual([
+ "missing-parent",
+ hasRoot ? "root" : "other-missing-parent",
+ "missing-parent",
+ ]);
+ },
+ );
+
+ test("nests a standalone span using its top-level parent span id", async () => {
+ const traces = await buildTraceGroups([
item({
id: "root",
metadata: {
@@ -409,7 +468,7 @@ describe("buildTraceGroups", () => {
});
});
-test("child spans retain their own raw payload independently of the transaction", () => {
+test("child spans retain their own raw payload independently of the transaction", async () => {
const payload = {
span_id: "query",
parent_span_id: "root",
@@ -418,7 +477,7 @@ test("child spans retain their own raw payload independently of the transaction"
start_timestamp: 1.1,
timestamp: 1.2,
};
- const [trace] = buildTraceGroups(
+ const [trace] = await buildTraceGroups(
decodeEnvelope(
JSON.stringify([
{},
diff --git a/apps/local/src/lib/trace-model.ts b/apps/local/src/lib/trace-model.ts
index f38be8afe..caf9666a7 100644
--- a/apps/local/src/lib/trace-model.ts
+++ b/apps/local/src/lib/trace-model.ts
@@ -1,9 +1,11 @@
+import { type ProcessedTrace, type ProcessedSpan } from "@sentry/spotlight";
import {
getEventMetadata,
type EventMetadata,
type LocalFeedItem,
-} from "./spotlight.js";
-import { isErrorEvent } from "./workspace.js";
+} from "./spotlight.ts";
+import { record, text } from "./inspection.ts";
+import { isErrorEvent } from "./workspace.ts";
export type TraceSpan = {
id: string;
@@ -21,6 +23,7 @@ export type TraceSpan = {
export type TraceGroup = {
id: string;
+ processed?: ProcessedTrace;
title: string;
items: LocalFeedItem[];
roots: TraceSpan[];
@@ -33,126 +36,6 @@ export type TraceGroup = {
durationMs?: number;
};
-type MutableTraceGroup = Omit & {
- spanMap: Map;
- titlePriority: number;
-};
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function getString(value: unknown): string | undefined {
- return typeof value === "string" && value ? value : undefined;
-}
-
-function getTimestamp(value: unknown): number | undefined {
- if (typeof value === "number" && Number.isFinite(value)) {
- return value;
- }
- if (typeof value === "string" && value.trim()) {
- const parsed = Number(value);
- return Number.isFinite(parsed) ? parsed : undefined;
- }
- return undefined;
-}
-
-function getTraceContext(payload: unknown): Record {
- if (!isRecord(payload)) {
- return {};
- }
- const contexts = isRecord(payload.contexts) ? payload.contexts : {};
- return isRecord(contexts.trace) ? contexts.trace : {};
-}
-
-function getPayloadSpan(
- payload: unknown,
- item: LocalFeedItem,
-): TraceSpan | undefined {
- const event = isRecord(payload) ? payload : {};
- const trace = getTraceContext(payload);
- const id = getString(trace.span_id) ?? item.metadata?.spanId;
- if (!id) {
- return undefined;
- }
- const startTimestamp = getTimestamp(event.start_timestamp);
- const endTimestamp =
- getTimestamp(event.timestamp) ??
- getTimestamp(event.end_timestamp) ??
- getTimestamp(item.timestamp);
- const operation = getString(trace.op) ?? item.metadata?.operation;
- const description =
- getString(event.transaction) ??
- item.metadata?.route ??
- item.metadata?.title ??
- operation ??
- item.type;
-
- return {
- id,
- parentId:
- getString(trace.parent_span_id) ?? getString(event.parent_span_id),
- operation,
- description,
- startTimestamp,
- endTimestamp,
- durationMs: getDurationMs(startTimestamp, endTimestamp),
- item,
- payload,
- metadata: item.metadata ?? getEventMetadata(payload, item.type),
- children: [],
- };
-}
-
-function getChildSpans(payload: unknown, item: LocalFeedItem): TraceSpan[] {
- const event = isRecord(payload) ? payload : {};
- if (!Array.isArray(event.spans)) {
- return [];
- }
-
- return event.spans.flatMap((value) => {
- if (!isRecord(value)) {
- return [];
- }
- const id = getString(value.span_id);
- if (!id) {
- return [];
- }
- const startTimestamp = getTimestamp(value.start_timestamp);
- const endTimestamp = getTimestamp(value.timestamp);
- const operation = getString(value.op);
- return [
- {
- id,
- parentId: getString(value.parent_span_id),
- operation,
- description: getString(value.description) ?? operation ?? id,
- startTimestamp,
- endTimestamp,
- durationMs: getDurationMs(startTimestamp, endTimestamp),
- item,
- payload: value,
- metadata: getEventMetadata(value, "span"),
- children: [],
- },
- ];
- });
-}
-
-function getDurationMs(
- startTimestamp: number | undefined,
- endTimestamp: number | undefined,
-): number | undefined {
- if (
- startTimestamp === undefined ||
- endTimestamp === undefined ||
- endTimestamp < startTimestamp
- ) {
- return undefined;
- }
- return Math.round((endTimestamp - startTimestamp) * 100_000) / 100;
-}
-
export function traceIdForItem(item: LocalFeedItem): string | undefined {
return (
item.metadata?.traceId ??
@@ -160,126 +43,161 @@ export function traceIdForItem(item: LocalFeedItem): string | undefined {
);
}
-function sortByTimestamp(
- items: T[],
-): T[] {
- return items.sort(
- (left, right) =>
- (left.startTimestamp ?? Infinity) - (right.startTimestamp ?? Infinity),
- );
-}
-
-function updateTraceTiming(trace: MutableTraceGroup): void {
- let start = Infinity;
- let end = -Infinity;
- function includeTimestamp(timestamp: number | undefined): void {
- if (timestamp === undefined) return;
- start = Math.min(start, timestamp);
- end = Math.max(end, timestamp);
- }
- for (const item of trace.items)
- includeTimestamp(getTimestamp(item.timestamp));
- for (const span of trace.spanMap.values()) {
- includeTimestamp(span.startTimestamp);
- includeTimestamp(span.endTimestamp);
- }
- if (start === Infinity) return;
- trace.startTimestamp = start;
- trace.endTimestamp = end;
- trace.durationMs = getDurationMs(start, end);
-}
-
-/** Build a small, read-only trace model once for every view in Local. */
-export function buildTraceGroups(
+/** Adapt shared trace assembly to Local's raw-item inspection and display model.
+ * Local's presentation timestamps are wire seconds; the SDK uses milliseconds.
+ */
+export function adaptTraceGroups(
items: readonly LocalFeedItem[],
+ processed: readonly ProcessedTrace[],
): TraceGroup[] {
- const traces = new Map();
-
+ const itemsByTrace = new Map();
+ const rawSpans = new Map();
for (const item of items) {
- const traceId = traceIdForItem(item);
- if (!traceId) {
- continue;
- }
-
- const trace: MutableTraceGroup = traces.get(traceId) ?? {
- id: traceId,
- title: item.metadata?.title ?? item.type,
- titlePriority: 0,
- items: [],
- logCount: 0,
- errorCount: 0,
- spanMap: new Map(),
- };
- if (!traces.has(traceId)) {
- traces.set(traceId, trace);
- }
-
- trace.items.push(item);
- if (item.type === "log") {
- trace.logCount += 1;
- }
- if (isErrorEvent(item)) {
- trace.errorCount += 1;
- }
-
- if (item.type === "transaction" || item.type === "span") {
- const rootSpan = getPayloadSpan(item.payload, item);
- if (rootSpan) {
- trace.spanMap.set(rootSpan.id, rootSpan);
- const isSegment =
- isRecord(item.payload) && item.payload.is_segment === true;
- if (item.type === "transaction" || isSegment) {
- // A local segment can have a remote parent absent from this receiver.
- const priority = rootSpan.parentId ? 1 : 2;
- if (priority > trace.titlePriority) {
- trace.title = item.metadata?.title ?? rootSpan.description;
- trace.titlePriority = priority;
- }
- }
- }
- }
- for (const childSpan of getChildSpans(item.payload, item)) {
- trace.spanMap.set(childSpan.id, childSpan);
+ const id = traceIdForItem(item);
+ if (!id) continue;
+ const entries = itemsByTrace.get(id) ?? [];
+ entries.push(item);
+ itemsByTrace.set(id, entries);
+ if (
+ (item.type === "transaction" || item.type === "span") &&
+ item.metadata?.spanId
+ )
+ rawSpans.set(`${id}:${item.metadata.spanId}`, {
+ item,
+ payload: item.payload,
+ });
+ const payload = record(item.payload);
+ for (const span of Array.isArray(payload.spans) ? payload.spans : []) {
+ const spanId = text(record(span).span_id);
+ if (spanId) rawSpans.set(`${id}:${spanId}`, { item, payload: span });
}
}
-
- return [...traces.values()]
- .map((trace) => {
- updateTraceTiming(trace);
- const spans = sortByTimestamp([...trace.spanMap.values()]);
- const roots: TraceSpan[] = [];
- for (const span of spans) {
- span.children = [];
+ const assembled = processed
+ .flatMap((trace) => {
+ const entries = itemsByTrace.get(trace.trace_id);
+ if (!entries?.length) return [];
+ const mapped = new Map();
+ function adapt(span: ProcessedSpan): TraceSpan {
+ const previous = mapped.get(span.span_id);
+ if (previous) return previous;
+ const raw = rawSpans.get(`${trace.trace_id}:${span.span_id}`);
+ const item = raw?.item ?? entries![0]!;
+ const payload = raw?.payload ?? span;
+ const duration = span.timestamp - span.start_timestamp;
+ const result: TraceSpan = {
+ id: span.span_id,
+ parentId: span.parent_span_id ?? undefined,
+ operation: span.op ?? undefined,
+ description: span.description ?? span.op ?? span.span_id,
+ startTimestamp: span.start_timestamp / 1000,
+ endTimestamp: span.timestamp / 1000,
+ durationMs:
+ duration >= 0 && Number.isFinite(duration)
+ ? Math.round(duration * 100) / 100
+ : undefined,
+ item,
+ payload,
+ metadata: getEventMetadata(payload, "span"),
+ children: [],
+ };
+ mapped.set(span.span_id, result);
+ return result;
}
- for (const span of spans) {
- const parent = span.parentId
- ? trace.spanMap.get(span.parentId)
- : undefined;
- if (parent && parent !== span) {
- parent.children.push(span);
- } else {
- roots.push(span);
+ // Convert the SDK's existing tree iteratively; very deep captures must not
+ // use the JavaScript call stack merely to create presentation objects.
+ const sharedSpans = [...trace.spans.values()];
+ for (const span of sharedSpans) if (span.op !== "orphan") adapt(span);
+ function visibleChildren(spans: readonly ProcessedSpan[]): TraceSpan[] {
+ const visible: TraceSpan[] = [];
+ const pending = [...spans].reverse();
+ const visited = new Set();
+ while (pending.length) {
+ const span = pending.pop()!;
+ if (visited.has(span.span_id)) continue;
+ visited.add(span.span_id);
+ if (span.op === "orphan") {
+ // Missing-parent wrappers can occur below real spans as well as at
+ // the root. Promote their captured children at every depth.
+ for (
+ let index = (span.children?.length ?? 0) - 1;
+ index >= 0;
+ index--
+ )
+ pending.push(span.children![index]!);
+ } else {
+ visible.push(adapt(span));
+ }
}
+ // Promoted children can interleave with siblings outside their wrapper.
+ return visible.sort(
+ (a, b) =>
+ (a.startTimestamp ?? Infinity) - (b.startTimestamp ?? Infinity),
+ );
}
- for (const span of spans) {
- sortByTimestamp(span.children);
+ for (const span of sharedSpans) {
+ const local = mapped.get(span.span_id);
+ if (local) local.children = visibleChildren(span.children ?? []);
}
-
+ const roots = visibleChildren(trace.spanTree);
+ return [
+ {
+ id: trace.trace_id,
+ processed: trace,
+ title:
+ trace.rootTransaction?.transaction ??
+ trace.transactions[0]?.transaction ??
+ entries[0]!.metadata?.title ??
+ trace.rootTransactionName,
+ items: entries,
+ roots,
+ spans: [...mapped.values()].sort(
+ (a, b) =>
+ (a.startTimestamp ?? Infinity) - (b.startTimestamp ?? Infinity),
+ ),
+ logCount: entries.filter((item) => item.type === "log").length,
+ errorCount: entries.filter(isErrorEvent).length,
+ itemCount: entries.length,
+ startTimestamp: trace.start_timestamp / 1000,
+ endTimestamp: trace.timestamp / 1000,
+ durationMs:
+ Math.round((trace.timestamp - trace.start_timestamp) * 100) / 100,
+ },
+ ];
+ })
+ .sort((a, b) => (b.endTimestamp ?? 0) - (a.endTimestamp ?? 0));
+ // Local also presents standalone transactions that carry no trace context.
+ // These are navigation groups for captured records, without invented SDK spans.
+ const assembledIds = new Set(assembled.map((trace) => trace.id));
+ const uncorrelated: TraceGroup[] = items
+ .filter(
+ (item) =>
+ item.type === "transaction" &&
+ !item.metadata?.traceId &&
+ !assembledIds.has(item.id),
+ )
+ .map((item) => {
+ const payload = record(item.payload);
+ const start =
+ typeof payload.start_timestamp === "number"
+ ? payload.start_timestamp
+ : undefined;
+ const end =
+ typeof payload.timestamp === "number" ? payload.timestamp : undefined;
return {
- id: trace.id,
- title: trace.title,
- items: trace.items,
- roots: sortByTimestamp(roots),
- spans,
- logCount: trace.logCount,
- errorCount: trace.errorCount,
- itemCount: trace.items.length,
- startTimestamp: trace.startTimestamp,
- endTimestamp: trace.endTimestamp,
- durationMs: trace.durationMs,
+ id: item.id,
+ title: item.metadata?.title ?? "transaction",
+ items: [item],
+ roots: [],
+ spans: [],
+ logCount: 0,
+ errorCount: 0,
+ itemCount: 1,
+ startTimestamp: start,
+ endTimestamp: end,
+ durationMs: item.metadata?.durationMs,
};
- })
- .sort(
- (left, right) => (right.endTimestamp ?? 0) - (left.endTimestamp ?? 0),
- );
+ });
+ return [...assembled, ...uncorrelated].sort(
+ (a, b) => (b.endTimestamp ?? 0) - (a.endTimestamp ?? 0),
+ );
}
diff --git a/apps/local/src/test-fixtures/trace-groups.ts b/apps/local/src/test-fixtures/trace-groups.ts
new file mode 100644
index 000000000..2f532ef74
--- /dev/null
+++ b/apps/local/src/test-fixtures/trace-groups.ts
@@ -0,0 +1,112 @@
+import { createTelemetryStore } from "@sentry/spotlight";
+import {
+ adaptTraceGroups,
+ traceIdForItem,
+ type TraceGroup,
+} from "../lib/trace-model.ts";
+import type { LocalFeedItem } from "../lib/spotlight.ts";
+import { record } from "../lib/inspection.ts";
+
+/** Reconstruct a parsed wire envelope for callers that already decoded its items. */
+export function telemetryEnvelope(items: readonly LocalFeedItem[]): unknown {
+ const fallbackTimestamp =
+ items
+ .map((item) => record(item.payload).timestamp ?? item.timestamp)
+ .find((value) => value !== undefined) ?? 0;
+ return [
+ items[0]?.envelopeHeader ?? {},
+ items.map((item) => {
+ const payload = record(item.payload);
+ const metadata = item.metadata;
+ const contexts = record(payload.contexts);
+ const trace = record(contexts.trace);
+ const traceId = traceIdForItem(item);
+ const timestamp =
+ payload.timestamp ??
+ payload.end_timestamp ??
+ item.timestamp ??
+ fallbackTimestamp;
+ const enriched = {
+ ...payload,
+ event_id: payload.event_id ?? item.id,
+ timestamp,
+ ...(traceId
+ ? {
+ contexts: {
+ ...contexts,
+ trace: {
+ trace_id: traceId,
+ span_id: metadata?.spanId ?? item.id,
+ op: metadata?.operation,
+ ...trace,
+ },
+ },
+ }
+ : {}),
+ };
+ if (item.type === "transaction") {
+ Object.assign(enriched, {
+ type: "transaction",
+ transaction: payload.transaction ?? metadata?.title,
+ start_timestamp: payload.start_timestamp ?? timestamp,
+ });
+ }
+ if (item.type === "span") {
+ // Decoded span-v2 items have typed attributes. Pass a v2 container to
+ // the shared processor rather than reimplementing attribute normalization.
+ const span = {
+ ...payload,
+ trace_id: payload.trace_id ?? traceId,
+ span_id: payload.span_id ?? metadata?.spanId ?? item.id,
+ op: payload.op ?? metadata?.operation,
+ description: payload.description ?? metadata?.title,
+ timestamp,
+ };
+ return [
+ item.itemHeader ?? { type: item.type },
+ "attributes" in payload
+ ? {
+ version: 2,
+ items: [
+ {
+ ...span,
+ end_timestamp: payload.end_timestamp ?? timestamp,
+ },
+ ],
+ }
+ : span,
+ ];
+ }
+ if (item.type === "log") {
+ const log = {
+ ...payload,
+ timestamp,
+ trace_id: payload.trace_id ?? traceId,
+ level: payload.level ?? metadata?.level ?? "info",
+ body: payload.body ?? metadata?.title ?? "",
+ };
+ return [
+ item.itemHeader ?? { type: item.type },
+ { ...enriched, items: [log] },
+ ];
+ }
+ return [item.itemHeader ?? { type: item.type }, enriched];
+ }),
+ ];
+}
+
+/** Convenience for isolated captures; the live workspace uses its session store. */
+export async function buildTraceGroups(
+ items: readonly LocalFeedItem[],
+): Promise {
+ const store = createTelemetryStore({
+ maxEnvelopeBytes: 128 * 1024 * 1024,
+ retention: { maxBytes: 128 * 1024 * 1024 },
+ });
+ try {
+ await store.ingestEnvelope(telemetryEnvelope(items));
+ return adaptTraceGroups(items, store.getTraces());
+ } finally {
+ store.dispose();
+ }
+}
diff --git a/docs/README.md b/docs/README.md
index c74d1a52c..56aa7f9ef 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -16,6 +16,8 @@ and package it from that workspace. The contributor index here links to its
source and to . MCP documentation lives in the topic
directories below; the MCP index explains which guides apply to that product.
+Shared telemetry processing for the receiver and browser UI lives in [`@sentry/spotlight`](../packages/spotlight/README.md).
+
## Shared Contributor Guides
- [Coding guidelines](contributing/coding-guidelines.md) — repository-wide style
diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md
index 7eab6eb32..d9a19094a 100644
--- a/docs/cli/local-control-plane.md
+++ b/docs/cli/local-control-plane.md
@@ -77,6 +77,13 @@ Increasing the limit preserves existing envelopes, their IDs, and active streams
previously evicted envelopes cannot be recovered. Lowering below the retained count
is rejected without changing any data. Clear envelopes first to reduce that limit.
Changing capacity does not reset the session's three-hour lifecycle retention.
+The receiver decodes and processes envelopes with `@sentry/spotlight/node`, shared
+with the browser viewer's telemetry engine. Envelope retention also has a 64 MiB
+parsed-data budget. Raw downloads have a separate 64 MiB decoded-body budget;
+a raw download returns 404 once its decoded bytes have been evicted. Both budgets
+are in memory and clear with the session. Malformed envelopes and invalid compressed
+bodies return HTTP 400 without entering retained history. The existing ingest URLs,
+SSE event names, resume IDs, and opaque attachment representation are preserved.
An explicit limit also configures an existing plain receiver; ordinary attachment
preserves its current setting. Older receivers that cannot apply the flag report an
error. Retrying session creation with the same client ID preserves its original settings.
@@ -240,8 +247,8 @@ the loopback receiver. Other browser origins cannot change retention. Remote HTT
Standalone spans and version 2 span containers populate trace waterfalls as they
arrive. Child spans remain visible before their parent or segment arrives; the
segment supplies the trace name once available. Typed span attributes supply
-operation and HTTP metadata. Unsupported containers and malformed spans are
-skipped by the trace view while their raw envelopes remain inspectable. Legacy
+operation and HTTP metadata. Unsupported containers are retained without trace processing. Envelopes containing
+only malformed spans are rejected; valid items in mixed envelopes are retained. Legacy
standalone spans and transaction envelopes remain supported.
Exception event titles use the current SDK exception type and message, including
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 9e5f41a5c..3dc3b9bcd 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -61,14 +61,14 @@
"build": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single",
"build:all": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts",
"bundle": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts",
- "typecheck": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:docs && pnpm run generate:sdk && tsc --noEmit",
+ "typecheck": "pnpm --filter @sentry/spotlight run build && pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:docs && pnpm run generate:sdk && tsc --noEmit",
"lint": "pnpm -w exec oxlint --report-unused-disable-directives packages/cli && pnpm -w exec oxfmt --check packages/cli",
"lint:fix": "pnpm -w exec oxlint --fix packages/cli && pnpm -w exec oxfmt --write packages/cli",
"test": "pnpm run test:unit",
- "test:unit": "pnpm run generate:docs && pnpm run generate:sdk && vitest run test/lib test/commands test/types test/script --coverage",
- "test:changed": "pnpm run generate:docs && pnpm run generate:sdk && vitest run --changed",
+ "test:unit": "pnpm --filter @sentry/spotlight run build && pnpm run generate:docs && pnpm run generate:sdk && vitest run test/lib test/commands test/types test/script --coverage",
+ "test:changed": "pnpm --filter @sentry/spotlight run build && pnpm run generate:docs && pnpm run generate:sdk && vitest run --changed",
"test:e2e": "pnpm tsx script/prepare-e2e-bundle.ts && vitest run test/e2e",
- "test:init-eval": "vitest run test/init-eval --testTimeout 600000",
+ "test:init-eval": "pnpm --filter @sentry/spotlight run build && vitest run test/init-eval --testTimeout 600000",
"generate:sdk": "pnpm tsx script/generate-sdk.ts",
"generate:skill": "pnpm tsx script/generate-skill.ts",
"generate:banner": "pnpm tsx script/generate-banner-sixel.ts",
@@ -76,7 +76,7 @@
"generate:grammar": "node ../../scripts/generate-parsers.mjs .",
"generate:completions": "pnpm tsx script/generate-completion-targets.ts",
"generate:docs-sections": "pnpm tsx script/generate-docs-sections.ts",
- "generate:schema": "pnpm tsx script/generate-api-schema.ts",
+ "generate:schema": "pnpm --filter @sentry/spotlight run build && pnpm tsx script/generate-api-schema.ts",
"generate:command-docs": "pnpm tsx script/generate-command-docs.ts",
"eval:skill": "pnpm tsx script/eval-skill.ts",
"bench": "pnpm tsx script/bench.ts",
@@ -108,7 +108,7 @@
"@sentry/sqlish": "^1.0.1",
"@sentry/symbolic": "13.7.0",
"@sentry/toolkit-core": "workspace:*",
- "@spotlightjs/spotlight": "^4.11.7",
+ "@sentry/spotlight": "workspace:*",
"@stricli/auto-complete": "^1.2.8",
"@stricli/core": "1.2.8",
"@types/http-cache-semantics": "^4.2.0",
diff --git a/packages/cli/src/commands/local/control-plane.ts b/packages/cli/src/commands/local/control-plane.ts
index b4fcd2f73..334c6ad49 100644
--- a/packages/cli/src/commands/local/control-plane.ts
+++ b/packages/cli/src/commands/local/control-plane.ts
@@ -6,8 +6,11 @@
*/
import { randomBytes } from "node:crypto";
+import {
+ localEnvelopeContentType,
+ localIngestStatus,
+} from "./telemetry-buffer.js";
import { Buffer } from "node:buffer";
-import { pushToSpotlightBuffer } from "@spotlightjs/spotlight/sdk";
import { type Context, Hono } from "hono";
import { cors } from "hono/cors";
import { HTTPException } from "hono/http-exception";
@@ -407,13 +410,7 @@ export function createLocalControlPlane({
return c.body(null, 413);
}
const body = Buffer.from(arrayBuffer);
- const rawEncoding = c.req.header("content-encoding");
- const encoding =
- rawEncoding === "gzip" ||
- rawEncoding === "deflate" ||
- rawEncoding === "br"
- ? rawEncoding
- : undefined;
+ const encoding = c.req.header("content-encoding");
const browserBeacon =
c.req.query("sentry_client")?.startsWith("sentry.javascript.browser") &&
c.req.header("origin");
@@ -423,15 +420,17 @@ export function createLocalControlPlane({
{ error: "Session is closed; create a new session to capture events." },
410,
);
- pushToSpotlightBuffer({
- spotlightBuffer: sessionStore.getBuffer(reference),
+ const receipt = await sessionStore.getBuffer(reference).ingestPayload({
body,
contentType: browserBeacon
? SENTRY_CONTENT_TYPE
- : (c.req.header("content-type") ?? ""),
+ : localEnvelopeContentType(c.req.header("content-type")),
encoding,
userAgent: c.req.header("user-agent"),
});
+ const status = localIngestStatus(receipt);
+ if (status === 503) c.header("Retry-After", "1");
+ if (status !== 204) return c.body(null, status);
sessionStore.recordActivity(reference);
return c.body(null, 204);
};
@@ -491,9 +490,17 @@ export function createLocalControlPlane({
});
const unsubscribeSession = sessionStore.subscribe(reference, (event) => {
if (event.state === "retained" && event.action !== "reset") {
- buffer.stream(readerId);
- buffer.unsubscribe(readerId);
- void sendSession(event).finally(finish);
+ void buffer
+ .drain()
+ .then(async () => {
+ buffer.stream(readerId);
+ buffer.unsubscribe(readerId);
+ await sendSession(event);
+ })
+ .catch((error: unknown) =>
+ logger.debug("Local session close failed", error),
+ )
+ .finally(finish);
} else {
void sendSession(event);
}
@@ -502,6 +509,7 @@ export function createLocalControlPlane({
try {
const { state, expiresAt } = sessionStore.resolve(reference);
if (state === "retained") {
+ await buffer.drain();
buffer.stream(readerId);
await sendSession({ state, expiresAt });
} else {
@@ -525,7 +533,7 @@ export function createLocalControlPlane({
: undefined;
try {
validateMaxEnvelopes(value);
- sessionStore.setMaxEnvelopes(c.req.param("id") ?? "", value);
+ await sessionStore.setMaxEnvelopes(c.req.param("id") ?? "", value);
return c.json(
sessionStore.getRetention(c.req.param("id") ?? "").getSettings(),
);
@@ -654,15 +662,15 @@ export function createLocalControlPlane({
if (!sessionExists(c.req.param("id"))) {
return c.body(null, 404);
}
- const container = sessionStore
+ const payload = sessionStore
.getBuffer(c.req.param("id"))
- .read({ envelopeId: c.req.param("envelopeId") })[0];
- if (!container) {
+ .pipeline.getDecodedPayload(c.req.param("envelopeId"));
+ if (!payload) {
return c.body(null, 404);
}
return c.json({
- contentType: container.getContentType(),
- data: Buffer.from(container.getData()).toString("base64"),
+ contentType: payload.contentType,
+ data: payload.body.toString("base64"),
id: c.req.param("envelopeId"),
});
});
diff --git a/packages/cli/src/commands/local/retention-buffer.ts b/packages/cli/src/commands/local/retention-buffer.ts
index 9f4f13fe9..2fc2596fa 100644
--- a/packages/cli/src/commands/local/retention-buffer.ts
+++ b/packages/cli/src/commands/local/retention-buffer.ts
@@ -1,5 +1,8 @@
-/** Resize through the Spotlight SDK's public API while keeping a stable buffer facade. */
-import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk";
+/** Receiver retention controls backed by the shared telemetry pipeline. */
+import {
+ createLocalTelemetryBuffer,
+ type LocalTelemetryBuffer,
+} from "./telemetry-buffer.js";
import { ValidationError } from "../../lib/errors.js";
import {
DEFAULT_MAX_ENVELOPES,
@@ -7,19 +10,14 @@ import {
MAX_ENVELOPES,
} from "./retention-limits.js";
-type SpotlightBuffer = ReturnType;
-type Subscriber = {
- callback: Parameters[0];
- id: string;
-};
export type RetentionSettings = {
maxEnvelopes: number;
retainedEnvelopes: number;
};
export type LocalRetentionBuffer = {
- buffer: SpotlightBuffer;
+ buffer: LocalTelemetryBuffer;
getSettings: () => RetentionSettings;
- resize: (maxEnvelopes: number) => void;
+ resize: (maxEnvelopes: number) => Promise;
subscribeSettings: (
listener: (settings: RetentionSettings) => void,
) => () => void;
@@ -65,78 +63,29 @@ export function createLocalRetentionBuffer(
maxEnvelopes = DEFAULT_MAX_ENVELOPES,
): LocalRetentionBuffer {
validateMaxEnvelopes(maxEnvelopes);
- let delegate = createSpotlightBuffer(maxEnvelopes);
- const buffer = createSpotlightBuffer(1);
- const subscribers = new Map();
- const arrivalTimes = new WeakMap