Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{"sdk":{"name":"sentry.javascript.react","version":"10.34.0"}}
{"type":"trace_metric","item_count":12,"content_type":"application/vnd.sentry.items.trace-metric+json"}
{"items":[{"timestamp":1769353796.9620998,"trace_id":"5becd2182ebd4b4bb5afe9fbc6b95866","name":"frontend.button.clicked","type":"counter","value":1,"attributes":{"component":{"value":"SentryMetrics","type":"string"},"sentry.environment":{"value":"development","type":"string"},"sentry.sdk.name":{"value":"sentry.javascript.react","type":"string"},"sentry.sdk.version":{"value":"10.34.0","type":"string"},"sentry.replay_id":{"value":"e88e195d7a5249a2a0df5b3f2123816d","type":"string"}}},{"timestamp":1769353796.9625998,"trace_id":"5becd2182ebd4b4bb5afe9fbc6b95866","name":"frontend.button2.clicked","type":"counter","value":1,"attributes":{"component":{"value":"SentryMetrics","type":"string"},"sentry.environment":{"value":"development","type":"string"},"sentry.sdk.name":{"value":"sentry.javascript.react","type":"string"},"sentry.sdk.version":{"value":"10.34.0","type":"string"},"sentry.replay_id":{"value":"e88e195d7a5249a2a0df5b3f2123816d","type":"string"}}},{"timestamp":1769353797.2412999,"trace_id":"5becd2182ebd4b4bb5afe9fbc6b95866","name":"frontend.button.clicked","type":"counter","value":1,"attributes":{"component":{"value":"SentryMetrics","type":"string"},"sentry.environment":{"value":"development","type":"string"},"sentry.sdk.name":{"value":"sentry.javascript.react","type":"string"},"sentry.sdk.version":{"value":"10.34.0","type":"string"},"sentry.replay_id":{"value":"e88e195d7a5249a2a0df5b3f2123816d","type":"string"}}},{"timestamp":1769353797.2414,"trace_id":"5becd2182ebd4b4bb5afe9fbc6b95866","name":"frontend.button2.clicked","type":"counter","value":1,"attributes":{"component":{"value":"SentryMetrics","type":"string"},"sentry.environment":{"value":"development","type":"string"},"sentry.sdk.name":{"value":"sentry.javascript.react","type":"string"},"sentry.sdk.version":{"value":"10.34.0","type":"string"},"sentry.replay_id":{"value":"e88e195d7a5249a2a0df5b3f2123816d","type":"string"}}},{"timestamp":1769353797.5262,"trace_id":"5becd2182ebd4b4bb5afe9fbc6b95866","name":"frontend.button.clicked","type":"counter","value":1,"attributes":{"component":{"value":"SentryMetrics","type":"string"},"sentry.environment":{"value":"development","type":"string"},"sentry.sdk.name":{"value":"sentry.javascript.react","type":"string"},"sentry.sdk.version":{"value":"10.34.0","type":"string"},"sentry.replay_id":{"value":"e88e195d7a5249a2a0df5b3f2123816d","type":"string"}}},{"timestamp":1769353797.5263999,"trace_id":"5becd2182ebd4b4bb5afe9fbc6b95866","name":"frontend.button2.clicked","type":"counter","value":1,"attributes":{"component":{"value":"SentryMetrics","type":"string"},"sentry.environment":{"value":"development","type":"string"},"sentry.sdk.name":{"value":"sentry.javascript.react","type":"string"},"sentry.sdk.version":{"value":"10.34.0","type":"string"},"sentry.replay_id":{"value":"e88e195d7a5249a2a0df5b3f2123816d","type":"string"}}},{"timestamp":1769353798.102,"trace_id":"5becd2182ebd4b4bb5afe9fbc6b95866","name":"frontend.random.value","type":"gauge","value":60,"attributes":{"component":{"value":"SentryMetrics","type":"string"},"sentry.environment":{"value":"development","type":"string"},"sentry.sdk.name":{"value":"sentry.javascript.react","type":"string"},"sentry.sdk.version":{"value":"10.34.0","type":"string"},"sentry.replay_id":{"value":"e88e195d7a5249a2a0df5b3f2123816d","type":"string"}}},{"timestamp":1769353798.3725,"trace_id":"5becd2182ebd4b4bb5afe9fbc6b95866","name":"frontend.random.value","type":"gauge","value":69,"attributes":{"component":{"value":"SentryMetrics","type":"string"},"sentry.environment":{"value":"development","type":"string"},"sentry.sdk.name":{"value":"sentry.javascript.react","type":"string"},"sentry.sdk.version":{"value":"10.34.0","type":"string"},"sentry.replay_id":{"value":"e88e195d7a5249a2a0df5b3f2123816d","type":"string"}}},{"timestamp":1769353799.0937998,"trace_id":"5becd2182ebd4b4bb5afe9fbc6b95866","name":"frontend.response.time","type":"distribution","unit":"millisecond","value":125.4894445940432,"attributes":{"component":{"value":"SentryMetrics","type":"string"},"sentry.environment":{"value":"development","type":"string"},"sentry.sdk.name":{"value":"sentry.javascript.react","type":"string"},"sentry.sdk.version":{"value":"10.34.0","type":"string"},"sentry.replay_id":{"value":"e88e195d7a5249a2a0df5b3f2123816d","type":"string"}}},{"timestamp":1769353799.2722998,"trace_id":"5becd2182ebd4b4bb5afe9fbc6b95866","name":"frontend.response.time","type":"distribution","unit":"millisecond","value":880.0890658738737,"attributes":{"component":{"value":"SentryMetrics","type":"string"},"sentry.environment":{"value":"development","type":"string"},"sentry.sdk.name":{"value":"sentry.javascript.react","type":"string"},"sentry.sdk.version":{"value":"10.34.0","type":"string"},"sentry.replay_id":{"value":"e88e195d7a5249a2a0df5b3f2123816d","type":"string"}}},{"timestamp":1769353799.5245998,"trace_id":"5becd2182ebd4b4bb5afe9fbc6b95866","name":"frontend.response.time","type":"distribution","unit":"millisecond","value":350.38026520290623,"attributes":{"component":{"value":"SentryMetrics","type":"string"},"sentry.environment":{"value":"development","type":"string"},"sentry.sdk.name":{"value":"sentry.javascript.react","type":"string"},"sentry.sdk.version":{"value":"10.34.0","type":"string"},"sentry.replay_id":{"value":"e88e195d7a5249a2a0df5b3f2123816d","type":"string"}}},{"timestamp":1769353799.7902,"trace_id":"5becd2182ebd4b4bb5afe9fbc6b95866","name":"frontend.response.time","type":"distribution","unit":"millisecond","value":854.05904566557,"attributes":{"component":{"value":"SentryMetrics","type":"string"},"sentry.environment":{"value":"development","type":"string"},"sentry.sdk.name":{"value":"sentry.javascript.react","type":"string"},"sentry.sdk.version":{"value":"10.34.0","type":"string"},"sentry.replay_id":{"value":"e88e195d7a5249a2a0df5b3f2123816d","type":"string"}}}]}
3 changes: 3 additions & 0 deletions packages/spotlight/_fixtures/metrics/transaction_envelope.bin

Large diffs are not rendered by default.

8 changes: 3 additions & 5 deletions packages/spotlight/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@
"test:e2e:electron": "playwright test tests/electron.test.ts",
"sample": "node ./_fixtures/send_to_sidecar.cjs"
},
"files": [
"dist"
],
"files": ["dist"],
"bin": {
"spotlight": "./dist/run.js"
},
Expand Down Expand Up @@ -79,9 +77,9 @@
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@sentry/browser": "^10.31.0",
"@sentry/browser": "^10.36.0",
"@sentry/electron": "^7.5.0",
"@sentry/react": "^10.31.0",
"@sentry/react": "^10.36.0",
"@sentry/vite-plugin": "^2.22.5",
"@shikijs/transformers": "^3.13.0",
"@tailwindcss/vite": "catalog:",
Expand Down
1 change: 1 addition & 0 deletions packages/spotlight/src/server/cli/tail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const NAME_TO_TYPE_MAPPING: Record<string, string[]> = Object.freeze({
traces: ["transaction", "span"],
// profiles: ["profile"],
logs: ["log"],
metrics: ["trace_metric"],
attachments: ["attachment"],
errors: ["event"],
// sessions: ["session"],
Expand Down
4 changes: 3 additions & 1 deletion packages/spotlight/src/server/formatters/human/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { isErrorEvent, isLogEvent, isTraceEvent } from "../../parser/helpers.ts";
import { isErrorEvent, isLogEvent, isMetricEvent, isTraceEvent } from "../../parser/helpers.ts";
import type { FormatterRegistry } from "../types.ts";
import { formatError } from "./errors.ts";
import { formatLog } from "./logs.ts";
import { formatMetric } from "./metrics.ts";
import { formatTrace } from "./traces.ts";

export const formatters: FormatterRegistry = {
event: { typeGuard: isErrorEvent, format: formatError },
log: { typeGuard: isLogEvent, format: formatLog },
metric: { typeGuard: isMetricEvent, format: formatMetric },
transaction: { typeGuard: isTraceEvent, format: formatTrace },
};
41 changes: 41 additions & 0 deletions packages/spotlight/src/server/formatters/human/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Envelope } from "@sentry/core";
import type { SentryMetricEvent, SentryMetricPayload } from "../../parser/index.ts";
import { formatTimestamp } from "../utils.ts";

function formatSingleMetric(metric: SentryMetricPayload): string {
const parts: string[] = [];

parts.push(`name=${metric.name}`);
parts.push(`type=${metric.type}`);
parts.push(`value=${metric.value}`);

if (metric.unit) {
parts.push(`unit=${metric.unit}`);
}

if (metric.trace_id) {
parts.push(`trace_id=${metric.trace_id}`);
}

if (metric.span_id) {
parts.push(`span_id=${metric.span_id}`);
}

if (metric.attributes) {
for (const [key, attr] of Object.entries(metric.attributes)) {
if (attr.value !== undefined && attr.value !== null) {
parts.push(`${key}=${attr.value}`);
}
}
}

// Use metric timestamp as primary timestamp
const ts = formatTimestamp(metric.timestamp);
parts.push(`@${ts}`);

return parts.join(" ");
}

export function formatMetric(event: SentryMetricEvent, _envelopeHeader: Envelope[0]): string[] {
return event.items.map(formatSingleMetric);
}
4 changes: 3 additions & 1 deletion packages/spotlight/src/server/formatters/json/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { isErrorEvent, isLogEvent, isTraceEvent } from "../../parser/helpers.ts";
import { isErrorEvent, isLogEvent, isMetricEvent, isTraceEvent } from "../../parser/helpers.ts";
import type { FormatterRegistry } from "../types.ts";
import { formatError } from "./errors.ts";
import { formatLog } from "./logs.ts";
import { formatMetric } from "./metrics.ts";
import { formatTrace } from "./traces.ts";

export const formatters: FormatterRegistry = {
event: { typeGuard: isErrorEvent, format: formatError },
log: { typeGuard: isLogEvent, format: formatLog },
metric: { typeGuard: isMetricEvent, format: formatMetric },
transaction: { typeGuard: isTraceEvent, format: formatTrace },
};
6 changes: 6 additions & 0 deletions packages/spotlight/src/server/formatters/json/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { Envelope } from "@sentry/core";
import type { SentryMetricEvent } from "../../parser/index.ts";

export function formatMetric(event: SentryMetricEvent, _envelopeHeader: Envelope[0]): string[] {
return [JSON.stringify(event)];
}
4 changes: 3 additions & 1 deletion packages/spotlight/src/server/formatters/logfmt/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { isErrorEvent, isLogEvent, isTraceEvent } from "../../parser/helpers.ts";
import { isErrorEvent, isLogEvent, isMetricEvent, isTraceEvent } from "../../parser/helpers.ts";
import type { FormatterRegistry } from "../types.ts";
import { formatError } from "./errors.ts";
import { formatLog } from "./logs.ts";
import { formatMetric } from "./metrics.ts";
import { formatTrace } from "./traces.ts";

export const formatters: FormatterRegistry = {
event: { typeGuard: isErrorEvent, format: formatError },
log: { typeGuard: isLogEvent, format: formatLog },
metric: { typeGuard: isMetricEvent, format: formatMetric },
transaction: { typeGuard: isTraceEvent, format: formatTrace },
};
38 changes: 38 additions & 0 deletions packages/spotlight/src/server/formatters/logfmt/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Envelope } from "@sentry/core";
import logfmt from "logfmt";
import type { SentryMetricEvent, SentryMetricPayload } from "../../parser/index.ts";
import { formatTimestamp } from "../utils.ts";

function formatSingleMetric(metric: SentryMetricPayload): string {
const data: Record<string, any> = {
timestamp: formatTimestamp(metric.timestamp),
type: "trace_metric",
name: metric.name,
metric_type: metric.type,
value: metric.value,
};

if (metric.unit) {
data.unit = metric.unit;
}

if (metric.trace_id) {
data.trace_id = metric.trace_id;
}

if (metric.span_id) {
data.span_id = metric.span_id;
}

if (metric.attributes) {
for (const [key, attr] of Object.entries(metric.attributes)) {
data[`attr.${key}`] = attr.value;
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent null/undefined handling across metric formatters

Low Severity

The human formatter defensively checks attr.value !== undefined && attr.value !== null before including attribute values, but the logfmt and md formatters access attr.value directly without any null/undefined guards. If an attribute has a missing or null value, logfmt will include undefined values in the output, and md will render literal "undefined" or "null" strings in the markdown table.

Additional Locations (1)

Fix in Cursor Fix in Web


return logfmt.stringify(data);
}

export function formatMetric(event: SentryMetricEvent, _envelopeHeader: Envelope[0]): string[] {
return event.items.map(formatSingleMetric);
}
4 changes: 3 additions & 1 deletion packages/spotlight/src/server/formatters/md/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { isErrorEvent, isLogEvent, isTraceEvent } from "../../parser/helpers.ts";
import { isErrorEvent, isLogEvent, isMetricEvent, isTraceEvent } from "../../parser/helpers.ts";
import type { FormatterRegistry } from "../types.ts";
import { formatError } from "./errors.ts";
import { formatLog } from "./logs.ts";
import { formatMetric } from "./metrics.ts";
import { formatTrace } from "./traces.ts";

export const formatters: FormatterRegistry = {
event: { typeGuard: isErrorEvent, format: formatError },
log: { typeGuard: isLogEvent, format: formatLog },
metric: { typeGuard: isMetricEvent, format: formatMetric },
transaction: { typeGuard: isTraceEvent, format: formatTrace },
};
32 changes: 32 additions & 0 deletions packages/spotlight/src/server/formatters/md/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Envelope } from "@sentry/core";
import type { SentryMetricEvent, SentryMetricPayload } from "../../parser/index.ts";
import { formatTimestamp } from "../utils.ts";

function formatMetricRow(metric: SentryMetricPayload): string {
const timestamp = formatTimestamp(metric.timestamp);
const attributes =
metric.attributes && Object.keys(metric.attributes).length > 0
? Object.entries(metric.attributes)
.map(([key, attr]) => `${key}=${attr.value}`)
.join(", ")
: "";

const traceInfo = metric.trace_id ? metric.trace_id.substring(0, 8) : "";

return `| ${timestamp} | ${metric.name} | ${metric.type} | ${metric.value} | ${metric.unit ?? ""} | ${traceInfo} | ${attributes} |`;
}

export function formatMetric(event: SentryMetricEvent, _envelopeHeader: Envelope[0]): string[] {
const lines: string[] = [];

lines.push("## Metrics");
lines.push("");
lines.push("| Timestamp | Name | Type | Value | Unit | Trace | Attributes |");
lines.push("|-----------|------|------|-------|------|-------|------------|");

for (const metric of event.items) {
lines.push(formatMetricRow(metric));
}

return lines;
}
10 changes: 9 additions & 1 deletion packages/spotlight/src/server/formatters/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import type { Envelope } from "@sentry/core";
import type { SentryErrorEvent, SentryEvent, SentryLogEvent, SentryTransactionEvent } from "../parser/types.ts";
import type {
SentryErrorEvent,
SentryEvent,
SentryLogEvent,
SentryMetricEvent,
SentryTransactionEvent,
} from "../parser/types.ts";

/**
* Strongly-typed formatter functions (no type guards needed in implementation)
*/
export type ErrorFormatterFn = (event: SentryErrorEvent, envelopeHeader: Envelope[0]) => string[];
export type LogFormatterFn = (event: SentryLogEvent, envelopeHeader: Envelope[0]) => string[];
export type MetricFormatterFn = (event: SentryMetricEvent, envelopeHeader: Envelope[0]) => string[];
export type TraceFormatterFn = (event: SentryTransactionEvent, envelopeHeader: Envelope[0]) => string[];

/**
Expand All @@ -22,6 +29,7 @@ export type FormatterEntry<T extends SentryEvent> = {
export type FormatterRegistry = {
event: FormatterEntry<SentryErrorEvent>;
log: FormatterEntry<SentryLogEvent>;
metric: FormatterEntry<SentryMetricEvent>;
transaction: FormatterEntry<SentryTransactionEvent>;
};

Expand Down
42 changes: 42 additions & 0 deletions packages/spotlight/src/server/mcp/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,45 @@ export const NO_LOGS_CONTENT: CallToolResult = {
},
],
};

export const NO_METRICS_CONTENT: CallToolResult = {
content: [
{
type: "text",
text: `**No metrics detected in Spotlight**

**This means:**
- Application hasn't generated any metrics in the recent timeframe
- No counter, gauge, or distribution metrics were captured
- Application might not be instrumented with Sentry metrics SDK

**Next debugging steps:**

1. **If investigating application metrics:**
- Ensure your Sentry SDK has metrics enabled (JavaScript 10.25.0+, Python 2.44.0+)
- Verify metrics are being sent via \`trace_metric\` envelope items
- Check that Spotlight is correctly capturing metric envelopes

2. **If checking for specific functionality:**
- Trigger the feature or workflow you're investigating
- Look for custom metric instrumentation in your code
- Consider adding metrics to critical paths if needed

3. **If monitoring general health:**
- Check that metrics SDK is properly configured
- Verify that metrics are being emitted (check SDK logs)
- Test with known metric-generating actions (API calls, database operations)

4. **Expand search timeframe:**
- Use a longer duration (300+ seconds) to capture older metrics
- Consider that some operations might generate metrics less frequently

**Metric Types Available:**
- **COUNTER**: Incrementing counts (e.g., request counts)
- **GAUGE**: Fluctuating values (e.g., queue depth)
- **DISTRIBUTION**: Statistical distributions (e.g., response times)

**Pro tip:** Metrics are trace-connected in Sentry - every metric can be linked to a trace for enhanced debugging. This is Sentry's key differentiator for metrics!`,
},
],
};
Loading
Loading