Skip to content
Merged
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
15 changes: 15 additions & 0 deletions apps/cli-docs/src/fragments/commands/trace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## Field selection

With `trace view --json --fields`, summary fields such as `traceId` and
`duration` select trace metadata. Bare attribute names such as `http.method`
request that attribute on every span and return it under each span's
`additional_attributes`. The output retains `span_id` and `children` so that
attributes on nested spans stay connected to the trace. Include summary fields
explicitly when you need them.

Selecting `spans` keeps the full span tree, including any requested attributes.
Paths starting with `spans.` select the corresponding JSON structure. They do
not implicitly recurse into `children`; include `children` in the path to
select that level. Attribute names keep their literal dots in the output.
To request an attribute whose name starts with `spans.`, use its full path,
such as `spans.additional_attributes.spans.foo`.
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@ sentry trace view abc123def456abc123def456abc12345 -w

# Find a project across organizations
sentry trace view cli-server abc123def456abc123def456abc12345

# Select the trace ID and an attribute across the whole span tree
sentry trace view abc123def456abc123def456abc12345 --json --fields traceId,http.method

# Keep full spans while requesting an additional attribute
sentry trace view abc123def456abc123def456abc12345 --json --fields spans,http.method

# Select IDs and detail attributes from root spans only
sentry trace view abc123def456abc123def456abc12345 --json --fields spans.span_id,spans.data.http.method

# Select an additional attribute from immediate child spans
sentry trace view abc123def456abc123def456abc12345 --json --fields spans.children.additional_attributes.http.method
```

### `sentry trace logs <org/project/trace-id...>`
Expand Down
89 changes: 39 additions & 50 deletions packages/cli/src/commands/trace/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ import {
formatSimpleSpanTree,
formatTraceSummary,
} from "../../lib/formatters/index.js";
import { filterFields } from "../../lib/formatters/json.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import {
parseTraceFields,
projectTraceFields,
type TraceFieldSelection,
} from "../../lib/formatters/trace.js";
import {
applyFreshFlag,
FRESH_ALIASES,
Expand Down Expand Up @@ -82,46 +86,6 @@ function buildViewHint(
return `Tip: Open in browser with 'sentry trace view --web ${traceId}'`;
}

/**
* Standard field names in trace view output (summary keys + "spans").
*
* When `--fields` includes names beyond these, the extras are forwarded
* to the API as `additional_attributes` so the server populates them on
* each span.
*/
const STANDARD_TRACE_VIEW_FIELDS = new Set([
"traceId",
"duration",
"spanCount",
"projects",
"rootTransaction",
"rootOp",
"startTimestamp",
"spans",
"spanTreeLines",
]);

/**
* Extract non-standard field names from `--fields` to pass as
* `additional_attributes` to the trace detail API.
*
* Standard fields (summary keys, "spans") are filtered out because
* they are already present in the response. Only extra names that
* correspond to span attributes need to be requested from the API.
*
* @param fields - Parsed `--fields` value (may be undefined)
* @returns Attribute names to request, or undefined when none are needed
*/
export function extractAdditionalAttributes(
fields: string[] | undefined,
): string[] | undefined {
if (!fields || fields.length === 0) {
return;
}
const extra = fields.filter((f) => !STANDARD_TRACE_VIEW_FIELDS.has(f));
return extra.length > 0 ? extra : undefined;
}

/**
* Detect UX issues in raw positional args before trace-target parsing.
*
Expand Down Expand Up @@ -198,6 +162,8 @@ export type TraceViewData = {
spanTreeLines?: string[];
/** Per-span attribute details from trace-items endpoint (when --full or --json) */
details?: Map<string, TraceItemDetail>;
/** Shared interpretation used by the request and JSON projection. */
fieldSelection?: TraceFieldSelection;
};

/**
Expand Down Expand Up @@ -355,7 +321,8 @@ function filterSpanMeasurements(span: TraceSpan): TraceSpan {
*
* Flattens the summary as the primary object so that `--fields traceId,duration`
* works directly on summary properties. The raw `spans` array is preserved as
* a nested key, accessible via `--fields spans`.
* a nested key, accessible via `--fields spans`. Bare attribute selectors
* retain span IDs and children so their values remain attributable.
*
* Without this transform, `--fields traceId` would return `{}` because
* the raw yield shape is `{ summary, spans }` and `traceId` lives inside `summary`.
Expand All @@ -374,11 +341,9 @@ function jsonTransformTraceView(
? mergeSpanDetails(cleaned, details)
: cleaned;
});
const result: Record<string, unknown> = { ...summary, spans: cleanedSpans };
if (fields && fields.length > 0) {
return filterFields(result, fields);
}
return result;
const result = { ...summary, spans: cleanedSpans };
const selection = data.fieldSelection ?? parseTraceFields(fields);
return selection ? projectTraceFields(result, selection) : result;
}

/** Resolved trace target: org, project (optional), and trace ID */
Expand Down Expand Up @@ -498,6 +463,28 @@ export const viewCommand = buildCommand({
command:
"sentry trace view cli-server abc123def456abc123def456abc12345",
},
{
description:
"Select the trace ID and an attribute across the whole span tree",
command:
"sentry trace view abc123def456abc123def456abc12345 --json --fields traceId,http.method",
},
{
description: "Keep full spans while requesting an additional attribute",
command:
"sentry trace view abc123def456abc123def456abc12345 --json --fields spans,http.method",
},
{
description: "Select IDs and detail attributes from root spans only",
command:
"sentry trace view abc123def456abc123def456abc12345 --json --fields spans.span_id,spans.data.http.method",
},
{
description:
"Select an additional attribute from immediate child spans",
command:
"sentry trace view abc123def456abc123def456abc12345 --json --fields spans.children.additional_attributes.http.method",
},
],
},
output: {
Expand Down Expand Up @@ -574,9 +561,10 @@ export const viewCommand = buildCommand({
return;
}

// Forward non-standard --fields as additional_attributes so the
// trace API populates them on each span for JSON consumers.
const additionalAttributes = extractAdditionalAttributes(flags.fields);
const fieldSelection = parseTraceFields(flags.fields);
const additionalAttributes = fieldSelection?.additionalAttributes.length
? fieldSelection.additionalAttributes
: undefined;

// Resolve numeric project ID for API-level filtering
let numericProjectId: number | undefined;
Expand Down Expand Up @@ -627,6 +615,7 @@ export const viewCommand = buildCommand({
spans,
spanTreeLines,
details: spanDetails,
fieldSelection,
});
return { hint: buildViewHint(traceId, org, projectFilter, summary) };
},
Expand Down
173 changes: 116 additions & 57 deletions packages/cli/src/lib/formatters/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,91 +53,150 @@ export function getNestedValue(
return { found: true, value: current };
}

type FieldProjection = { found: true; value: unknown } | { found: false };

const INDEXED_FIELD_PATTERN = /^(0|[1-9]\d*)(?:\.(.*))?$/;

/** Keep array positions without exposing unselected object properties. */
function emptyArrayItem(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(emptyArrayItem);
}
return value !== null && typeof value === "object" ? {} : null;
}

/**
* Set a nested value in an object, creating intermediate objects as needed.
*
* When `literalKey` is true, the path is used as a literal property name
* (no dot splitting). This preserves dotted attribute names like
* `"gen_ai.usage.input_tokens"` as flat keys in the output.
*
* @param target - Target object to write into (mutated in place)
* @param path - Key path: literal name or dot-separated nesting
* @param value - Value to set at the leaf
* @param literalKey - When true, skip dot splitting
* Project nested arrays without turning numeric paths into object keys.
* Named paths apply to every item; explicit indices retain their position.
*/
function setNestedValue(
target: Record<string, unknown>,
path: string,
value: unknown,
literalKey = false,
): void {
if (literalKey) {
target[path] = value;
return;
function projectArray(data: unknown[], fields: string[]): FieldProjection {
const shared: string[] = [];
const indexed = new Map<number, string[] | null>();
for (const field of fields) {
const match = INDEXED_FIELD_PATTERN.exec(field);
if (!match) {
shared.push(field);
continue;
}
const index = Number(match[1]);
if (index >= data.length) {
continue;
}
const suffix = match[2];
if (suffix === undefined) {
indexed.set(index, null);
} else if (indexed.get(index) !== null) {
indexed.set(index, [...(indexed.get(index) ?? []), suffix]);
}
}
const segments = path.split(".");
let current: Record<string, unknown> = target;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments.at(i);
if (seg === undefined) {

if (shared.length === 0 && indexed.size === 0) {
return { found: false };
}
return {
found: true,
value: data.map((item, index) => {
const specific = indexed.get(index);
if (specific === null) {
return item;
}
const itemFields = [...shared, ...(specific ?? [])];
const projection = projectFields(item, itemFields);
return projection.found ? projection.value : emptyArrayItem(item);
}),
};
}

/**
* Group selections by the actual source key before descending. Selecting a
* whole property dominates its descendants, regardless of selector order.
* Checking literal keys at every level preserves dotted attribute names.
*/
function groupObjectFields(
data: object,
fields: string[],
): Map<string, string[] | null> {
const selections = new Map<string, string[] | null>();
for (const field of fields) {
if (Object.hasOwn(data, field)) {
selections.set(field, null);
continue;
}
if (
current[seg] === undefined ||
current[seg] === null ||
typeof current[seg] !== "object"
) {
current[seg] = {};
const separator = field.indexOf(".");
if (separator < 0) {
continue;
}
const key = field.slice(0, separator);
if (!Object.hasOwn(data, key) || selections.get(key) === null) {
continue;
}
current = current[seg] as Record<string, unknown>;
selections.set(key, [
...(selections.get(key) ?? []),
field.slice(separator + 1),
]);
}

return selections;
}

/** Recursively project the selected source containers and literal keys. */
function projectFields(data: unknown, fields: string[]): FieldProjection {
if (Array.isArray(data)) {
return projectArray(data, fields);
}
const lastSeg = segments.at(-1);
if (lastSeg !== undefined) {
current[lastSeg] = value;
if (data === null || typeof data !== "object") {
return { found: false };
}

const selections = groupObjectFields(data, fields);
const entries: [string, unknown][] = [];
for (const [key, nestedFields] of selections) {
const value = (data as Record<string, unknown>)[key];
const projection =
nestedFields === null
? { found: true, value }
: projectFields(value, nestedFields);
if (projection.found) {
entries.push([key, projection.value]);
}
}
return entries.length > 0
? { found: true, value: Object.fromEntries(entries) }
: { found: false };
}

/**
* Filter an object (or array of objects) to include only the specified fields.
*
* Supports dot-notation for nested field access:
* Supports dot-notation for nested field access, including array elements:
* ```ts
* filterFields({ a: 1, b: { c: 2, d: 3 } }, ["a", "b.c"])
* // → { a: 1, b: { c: 2 } }
* filterFields({ spans: [{ data: { "http.method": "GET" } }] }, [
* "spans.data.http.method",
* ])
* // → { spans: [{ data: { "http.method": "GET" } }] }
* ```
*
* When `data` is an array, each element is filtered independently.
* Fields that don't exist in the source are silently skipped.
* Literal keys take precedence over dot-separated paths at each level.
* Nested arrays keep their order and shape; absent object fields yield `{}`
* at their original position. Numeric paths select an explicit array index.
* When `data` itself is an array, each element is filtered independently.
* Missing fields are skipped and the original data is never mutated.
*
* @param data - Source data to filter
* @param fields - List of field paths to include (dot-separated for nesting)
* @returns Filtered copy of the data — original is never mutated
* @param fields - Field paths to include (dot-separated for nesting)
* @returns Filtered copy of the data
*/
export function filterFields<T>(data: T, fields: string[]): Partial<T> {
if (Array.isArray(data)) {
return data.map((item) =>
filterFields(item, fields),
) as unknown as Partial<T>;
}

if (data === null || data === undefined || typeof data !== "object") {
return data as Partial<T>;
}

const result: Record<string, unknown> = {};

for (const field of fields) {
const lookup = getNestedValue(data, field);
if (lookup.found) {
// Use literal key when the field name exists as a direct property
// (e.g., "gen_ai.usage.input_tokens" as a flat key)
const isLiteral =
typeof data === "object" && data !== null && Object.hasOwn(data, field);
setNestedValue(result, field, lookup.value, isLiteral);
}
}

return result as Partial<T>;
const projection = projectFields(data, fields);
return (projection.found ? projection.value : {}) as Partial<T>;
}

/**
Expand Down
Loading
Loading