From bf604eb039de4fcfad813f7c0e83249abdda77c8 Mon Sep 17 00:00:00 2001 From: betegon Date: Sun, 25 Oct 2026 23:20:12 +0100 Subject: [PATCH] fix(cli): preserve trace attributes in field projections --- apps/cli-docs/src/fragments/commands/trace.md | 15 + .../skills/sentry-cli/references/trace.md | 12 + packages/cli/src/commands/trace/view.ts | 89 +++--- packages/cli/src/lib/formatters/json.ts | 173 +++++++---- packages/cli/src/lib/formatters/trace.ts | 100 ++++++ .../cli/test/commands/trace/view.func.test.ts | 294 ++++++++++++++++++ .../test/lib/formatters/json.property.test.ts | 32 ++ packages/cli/test/lib/formatters/json.test.ts | 136 ++++++++ 8 files changed, 744 insertions(+), 107 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/trace.md b/apps/cli-docs/src/fragments/commands/trace.md index e69de29bb..a241de895 100644 --- a/apps/cli-docs/src/fragments/commands/trace.md +++ b/apps/cli-docs/src/fragments/commands/trace.md @@ -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`. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trace.md index 969764622..f242ad34b 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -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 ` diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index 94e42ba5e..3aa370eff 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -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, @@ -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. * @@ -198,6 +162,8 @@ export type TraceViewData = { spanTreeLines?: string[]; /** Per-span attribute details from trace-items endpoint (when --full or --json) */ details?: Map; + /** Shared interpretation used by the request and JSON projection. */ + fieldSelection?: TraceFieldSelection; }; /** @@ -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`. @@ -374,11 +341,9 @@ function jsonTransformTraceView( ? mergeSpanDetails(cleaned, details) : cleaned; }); - const result: Record = { ...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 */ @@ -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: { @@ -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; @@ -627,6 +615,7 @@ export const viewCommand = buildCommand({ spans, spanTreeLines, details: spanDetails, + fieldSelection, }); return { hint: buildViewHint(traceId, org, projectFilter, summary) }; }, diff --git a/packages/cli/src/lib/formatters/json.ts b/packages/cli/src/lib/formatters/json.ts index 0ef4b5cdd..fa21bf627 100644 --- a/packages/cli/src/lib/formatters/json.ts +++ b/packages/cli/src/lib/formatters/json.ts @@ -53,65 +53,138 @@ 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, - 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(); + 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 = 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 { + const selections = new Map(); + 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; + 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)[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(data: T, fields: string[]): Partial { if (Array.isArray(data)) { @@ -119,25 +192,11 @@ export function filterFields(data: T, fields: string[]): Partial { filterFields(item, fields), ) as unknown as Partial; } - if (data === null || data === undefined || typeof data !== "object") { return data as Partial; } - - const result: Record = {}; - - 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; + const projection = projectFields(data, fields); + return (projection.found ? projection.value : {}) as Partial; } /** diff --git a/packages/cli/src/lib/formatters/trace.ts b/packages/cli/src/lib/formatters/trace.ts index 1b94b9dfd..b3f38dbaf 100644 --- a/packages/cli/src/lib/formatters/trace.ts +++ b/packages/cli/src/lib/formatters/trace.ts @@ -13,6 +13,7 @@ import type { import type { TraceItemDetail } from "../api/traces.js"; import { REDUNDANT_DETAIL_ATTRS } from "../api/traces.js"; import { stringifyUnknown } from "../errors.js"; +import { filterFields } from "./json.js"; import { colorTag, escapeMarkdownCell, @@ -151,6 +152,105 @@ export type TraceSummary = { startTimestamp: number; }; +/** JSON paths and trace-wide attributes selected by `trace view --fields`. */ +export type TraceFieldSelection = { + /** Structural paths projected from the trace JSON. */ + paths: string[]; + /** Bare attribute names selected across the entire span tree. */ + attributes: string[]; + /** Deduplicated attribute names requested from the trace API. */ + additionalAttributes: string[]; +}; + +const TRACE_FIELD_ROOTS = new Set([ + "traceId", + "duration", + "spanCount", + "projects", + "rootTransaction", + "rootOp", + "startTimestamp", + "spans", + "spanTreeLines", +]); + +// Only the attribute suffix belongs to the trace API. Children and indices +// describe the output tree, while data.* is supplied by span detail requests. +const TRACE_ATTRIBUTE_PATH = + /^spans\.(?:\d+\.)?(?:children\.(?:\d+\.)?)*additional_attributes\.(.+)$/; + +/** Interpret selectors once for both fetching attributes and projecting JSON. */ +export function parseTraceFields( + fields: string[] | undefined, +): TraceFieldSelection | undefined { + if (!fields?.length) { + return; + } + const paths: string[] = []; + const attributes: string[] = []; + const additionalAttributes = new Set(); + for (const field of fields) { + const root = field.split(".", 1)[0] ?? field; + if (TRACE_FIELD_ROOTS.has(root)) { + paths.push(field); + const attribute = TRACE_ATTRIBUTE_PATH.exec(field)?.[1]; + if (attribute) { + additionalAttributes.add(attribute); + } + } else { + attributes.push(field); + additionalAttributes.add(field); + } + } + return { paths, attributes, additionalAttributes: [...additionalAttributes] }; +} + +type ProjectedTraceSpan = Partial> & { + children?: ProjectedTraceSpan[]; +}; + +/** Add trace-wide attributes without losing explicit selections or tree shape. */ +function includeTraceAttributes( + span: TraceSpan, + selected: ProjectedTraceSpan | undefined, + attributes: string[], +): ProjectedTraceSpan { + const result: ProjectedTraceSpan = { ...selected, span_id: span.span_id }; + const sourceAttributes = span.additional_attributes ?? {}; + const requested = Object.fromEntries( + attributes + .filter((attribute) => Object.hasOwn(sourceAttributes, attribute)) + .map((attribute) => [attribute, sourceAttributes[attribute]]), + ); + if (Object.keys(requested).length > 0) { + result.additional_attributes = { + ...selected?.additional_attributes, + ...requested, + }; + } + if (span.children) { + result.children = span.children.map((child, index) => + includeTraceAttributes(child, selected?.children?.[index], attributes), + ); + } + return result; +} + +/** Project trace JSON, retaining span IDs and topology for bare attributes. */ +export function projectTraceFields( + trace: TraceSummary & { spans: TraceSpan[] }, + selection: TraceFieldSelection, +): Partial & { spans?: ProjectedTraceSpan[] } { + const result: Partial & { spans?: ProjectedTraceSpan[] } = + filterFields(trace, selection.paths); + if (selection.attributes.length > 0 && !selection.paths.includes("spans")) { + result.spans = trace.spans.map((span, index) => + includeTraceAttributes(span, result.spans?.[index], selection.attributes), + ); + } + return result; +} + /** * Check whether a timestamp from a span is usable for duration calculations. * Filters out zero, negative, NaN, and non-finite values that would corrupt diff --git a/packages/cli/test/commands/trace/view.func.test.ts b/packages/cli/test/commands/trace/view.func.test.ts index d7322b3cd..b5652c3ff 100644 --- a/packages/cli/test/commands/trace/view.func.test.ts +++ b/packages/cli/test/commands/trace/view.func.test.ts @@ -152,6 +152,38 @@ describe("viewCommand.func", () => { }, ]; + const attributeSpans: TraceSpan[] = [ + { + span_id: "root", + op: "http.server", + start_timestamp: 1, + end_timestamp: 2, + project_slug: "test-project", + additional_attributes: { "http.method": "GET", unused: "root value" }, + children: [ + { + span_id: "child", + op: "http.client", + start_timestamp: 1.1, + end_timestamp: 1.9, + additional_attributes: { + "http.method": "POST", + unused: "child value", + }, + children: [ + { + span_id: "grandchild", + start_timestamp: 1.2, + end_timestamp: 1.8, + additional_attributes: { "http.method": "PUT" }, + children: [], + }, + ], + }, + ], + }, + ]; + function createMockContext() { const stdoutWrite = vi.fn(() => true); return { @@ -164,6 +196,22 @@ describe("viewCommand.func", () => { }; } + async function renderTraceJson( + fields: string, + spans: TraceSpan[] = attributeSpans, + ): Promise { + getDetailedTraceSpy.mockResolvedValue(spans); + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { json: true, web: false, spans: 100, fields }, + "test-org/test-project", + "aaaa1111bbbb2222cccc3333dddd4444", + ); + return JSON.parse(stdoutWrite.mock.calls.map((call) => call[0]).join("")); + } + beforeEach(async () => { getDetailedTraceSpy = vi.spyOn(apiClient, "getDetailedTrace"); fetchMultiSpanDetailsSpy = vi @@ -217,6 +265,252 @@ describe("viewCommand.func", () => { expect(parsed).toHaveProperty("spans"); expect(parsed.traceId).toBe("aaaa1111bbbb2222cccc3333dddd4444"); expect(parsed.spans).toHaveLength(1); + expect(parsed.spans).toEqual(sampleSpans); + }); + + test("--fields returns requested attributes throughout the tree with explicit summary fields", async () => { + const output = await renderTraceJson("traceId,http.method"); + + expect(getDetailedTraceSpy).toHaveBeenCalledWith( + "test-org", + "aaaa1111bbbb2222cccc3333dddd4444", + { additionalAttributes: ["http.method"], projectId: 42 }, + ); + expect(output).toEqual({ + traceId: "aaaa1111bbbb2222cccc3333dddd4444", + spans: [ + { + span_id: "root", + additional_attributes: { "http.method": "GET" }, + children: [ + { + span_id: "child", + additional_attributes: { "http.method": "POST" }, + children: [ + { + span_id: "grandchild", + additional_attributes: { "http.method": "PUT" }, + children: [], + }, + ], + }, + ], + }, + ], + }); + }); + + test("--fields retains ancestors when only a child has the requested attribute", async () => { + const spans: TraceSpan[] = [ + { + span_id: "root", + start_timestamp: 1, + children: [ + { + span_id: "child", + start_timestamp: 2, + additional_attributes: { "http.method": "GET" }, + children: [], + }, + ], + }, + ]; + + expect(await renderTraceJson("http.method", spans)).toEqual({ + spans: [ + { + span_id: "root", + children: [ + { + span_id: "child", + additional_attributes: { "http.method": "GET" }, + children: [], + }, + ], + }, + ], + }); + }); + + test("--fields preserves falsey attributes and tolerates attributes absent from the trace", async () => { + const attributes = { + "http.method": "", + retries: 0, + optional: null, + cached: false, + }; + const spans: TraceSpan[] = [ + { + span_id: "root", + start_timestamp: 1, + additional_attributes: attributes, + }, + ]; + + expect( + await renderTraceJson( + "http.method,retries,optional,cached,missing", + spans, + ), + ).toEqual({ + spans: [{ span_id: "root", additional_attributes: attributes }], + }); + }); + + test.each(["spans,http.method", "http.method,spans"])( + "--fields %s keeps the complete tree and still requests the attribute", + async (fields) => { + expect(await renderTraceJson(fields)).toEqual({ spans: attributeSpans }); + expect(getDetailedTraceSpy).toHaveBeenCalledWith( + "test-org", + "aaaa1111bbbb2222cccc3333dddd4444", + { additionalAttributes: ["http.method"], projectId: 42 }, + ); + }, + ); + + test.each([ + { + fields: "spans.span_id", + additionalAttributes: undefined, + expected: { spans: [{ span_id: "root" }] }, + }, + { + fields: "spans.additional_attributes.http.method", + additionalAttributes: ["http.method"], + expected: { + spans: [{ additional_attributes: { "http.method": "GET" } }], + }, + }, + { + fields: "spans.children.additional_attributes.http.method", + additionalAttributes: ["http.method"], + expected: { + spans: [ + { children: [{ additional_attributes: { "http.method": "POST" } }] }, + ], + }, + }, + ])( + "--fields $fields projects only the explicit structural path", + async ({ fields, additionalAttributes, expected }) => { + expect(await renderTraceJson(fields)).toEqual(expected); + expect(getDetailedTraceSpy).toHaveBeenCalledWith( + "test-org", + "aaaa1111bbbb2222cccc3333dddd4444", + { additionalAttributes, projectId: 42 }, + ); + }, + ); + + test("--fields combines explicit root fields and recursive attributes on the same nodes", async () => { + expect(await renderTraceJson("spans.op,http.method")).toEqual({ + spans: [ + { + span_id: "root", + op: "http.server", + additional_attributes: { "http.method": "GET" }, + children: [ + { + span_id: "child", + additional_attributes: { "http.method": "POST" }, + children: [ + { + span_id: "grandchild", + additional_attributes: { "http.method": "PUT" }, + children: [], + }, + ], + }, + ], + }, + ], + }); + }); + + test("--fields selects nested detail attributes without forwarding the JSON path", async () => { + fetchMultiSpanDetailsSpy.mockResolvedValue( + new Map([ + [ + "root", + { + itemId: "root", + timestamp: "2024-01-30T13:32:15Z", + attributes: [ + { name: "http.method", type: "str", value: "GET" }, + { name: "http.url", type: "str", value: "https://example.com" }, + ], + meta: {}, + links: null, + }, + ], + ]), + ); + + expect(await renderTraceJson("spans.data.http.method")).toEqual({ + spans: [{ data: { "http.method": "GET" } }], + }); + expect(getDetailedTraceSpy).toHaveBeenCalledWith( + "test-org", + "aaaa1111bbbb2222cccc3333dddd4444", + { additionalAttributes: undefined, projectId: 42 }, + ); + expect(fetchMultiSpanDetailsSpy).toHaveBeenCalled(); + }); + + test("--fields whole attributes dominate explicit partial selections", async () => { + const spans: TraceSpan[] = [ + { + span_id: "root", + start_timestamp: 1, + additional_attributes: { values: ["first", "second"] }, + }, + ]; + + expect( + await renderTraceJson( + "values,spans.additional_attributes.values.0", + spans, + ), + ).toEqual({ + spans: [ + { + span_id: "root", + additional_attributes: { values: ["first", "second"] }, + }, + ], + }); + }); + + test("--fields supports literal attribute names beginning with spans via the canonical path", async () => { + const spans: TraceSpan[] = [ + { + span_id: "root", + start_timestamp: 1, + additional_attributes: { "spans.foo": "value" }, + }, + ]; + + expect( + await renderTraceJson("spans.additional_attributes.spans.foo", spans), + ).toEqual({ spans: [{ additional_attributes: { "spans.foo": "value" } }] }); + expect(getDetailedTraceSpy).toHaveBeenCalledWith( + "test-org", + "aaaa1111bbbb2222cccc3333dddd4444", + { additionalAttributes: ["spans.foo"], projectId: 42 }, + ); + }); + + test("--fields summary-only selection preserves JSON detail fetching", async () => { + expect(await renderTraceJson("traceId")).toEqual({ + traceId: "aaaa1111bbbb2222cccc3333dddd4444", + }); + expect(getDetailedTraceSpy).toHaveBeenCalledWith( + "test-org", + "aaaa1111bbbb2222cccc3333dddd4444", + { additionalAttributes: undefined, projectId: 42 }, + ); + expect(fetchMultiSpanDetailsSpy).toHaveBeenCalled(); }); test("writes human output with summary and span tree", async () => { diff --git a/packages/cli/test/lib/formatters/json.property.test.ts b/packages/cli/test/lib/formatters/json.property.test.ts index bf1d697e8..a8990df75 100644 --- a/packages/cli/test/lib/formatters/json.property.test.ts +++ b/packages/cli/test/lib/formatters/json.property.test.ts @@ -186,6 +186,38 @@ describe("property: filterFields", () => { ); }); + test("nested projections are order-independent, idempotent, and do not mutate data", () => { + const items = array(flatObjectArb, { maxLength: 5 }); + const selectors = uniqueArray( + constantFrom( + "items", + "items.id", + "items.data", + "items.data.http.method", + "items.0", + "items.0.data.http.method", + "missing.path", + ), + { minLength: 1 }, + ); + fcAssert( + property(items, selectors, (values, fields) => { + const data = { + items: values.map((value, index) => ({ + id: index, + data: { "http.method": value }, + })), + }; + const original = JSON.stringify(data); + const result = filterFields(data, fields); + expect(filterFields(data, fields.toReversed())).toEqual(result); + expect(filterFields(result, fields)).toEqual(result); + expect(JSON.stringify(data)).toBe(original); + }), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); + test("primitives pass through unchanged", () => { fcAssert( property( diff --git a/packages/cli/test/lib/formatters/json.test.ts b/packages/cli/test/lib/formatters/json.test.ts index 8d95838c8..5fc4e7fad 100644 --- a/packages/cli/test/lib/formatters/json.test.ts +++ b/packages/cli/test/lib/formatters/json.test.ts @@ -26,6 +26,12 @@ function filter(data: unknown, fields: string[]): unknown { } describe("filterFields edge cases", () => { + test("preserves unselected nested array positions for an indexed path", () => { + expect( + filter({ items: [[{ id: 1 }], [{ id: 2 }]] }, ["items.1.id"]), + ).toEqual({ items: [[{}], [{ id: 2 }]] }); + }); + test("picks nested fields via dot-notation", () => { const data = { id: 1, @@ -41,6 +47,136 @@ describe("filterFields edge cases", () => { }); }); + test("projects dotted attributes through nested arrays without changing keys", () => { + const data = { + spans: [ + { + span_id: "root", + data: { "http.method": "GET", unrelated: true }, + children: [{ data: { "http.method": "POST" } }], + }, + { span_id: "other", data: { unrelated: true } }, + ], + }; + expect(filter(data, ["spans.span_id", "spans.data.http.method"])).toEqual({ + spans: [ + { span_id: "root", data: { "http.method": "GET" } }, + { span_id: "other" }, + ], + }); + expect(filter(data, ["spans.children.data.http.method"])).toEqual({ + spans: [{ children: [{ data: { "http.method": "POST" } }] }, {}], + }); + }); + + test("preserves literal key precedence at every nesting level", () => { + const data = { + "data.http.method": "root literal", + data: { + "http.method": "nested literal", + http: { method: "nested object", status: 200 }, + }, + }; + expect(filter(data, ["data.http.method"])).toEqual({ + "data.http.method": "root literal", + }); + expect( + filter({ item: data.data }, ["item.http.method", "item.http.status"]), + ).toEqual({ + item: { "http.method": "nested literal", http: { status: 200 } }, + }); + }); + + test("unions nested selections and whole parents independently of order", () => { + const data = { + spans: [ + { span_id: "first", data: { "http.method": "GET", count: 0 } }, + { span_id: "second", data: { "http.method": "POST", count: 1 } }, + ], + }; + const fields = ["spans.data.http.method", "spans.span_id", "spans.data"]; + expect(filter(data, fields)).toEqual(data); + expect(filter(data, fields.toReversed())).toEqual(data); + expect(filter(data, ["spans.data.http.method", "spans"])).toEqual(data); + expect(filter(data, ["spans", "spans.data.http.method"])).toEqual(data); + }); + + test("does not mutate frozen nested source objects for overlapping fields", () => { + const attributes = Object.freeze({ "http.method": "GET", count: 0 }); + const span = Object.freeze({ span_id: "first", data: attributes }); + const data = Object.freeze({ spans: Object.freeze([span]) }); + expect( + filter(data, ["spans.data", "spans.data.http.method", "spans.span_id"]), + ).toEqual(data); + expect(data.spans[0]?.data).toEqual({ "http.method": "GET", count: 0 }); + }); + + test("keeps nested array positions and empty arrays when fields are absent", () => { + expect( + filter({ spans: [{ id: "one" }, { other: true }], empty: [] }, [ + "spans.id", + "empty.id", + "missing.id", + ]), + ).toEqual({ spans: [{ id: "one" }, {}], empty: [] }); + expect(filter({ spans: [{ other: true }] }, ["spans.id"])).toEqual({ + spans: [{}], + }); + }); + + test("preserves array indices when mixing numeric and per-item selectors", () => { + const data = { + spans: [ + { id: "one", data: { "http.method": "GET" } }, + { id: "two", data: { "http.method": "POST" } }, + { id: "three" }, + ], + }; + expect(filter(data, ["spans.1.data.http.method"])).toEqual({ + spans: [{}, { data: { "http.method": "POST" } }, {}], + }); + const fields = ["spans.id", "spans.1.data.http.method", "spans.1"]; + const expected = { + spans: [ + { id: "one" }, + { id: "two", data: { "http.method": "POST" } }, + { id: "three" }, + ], + }; + expect(filter(data, fields)).toEqual(expected); + expect(filter(data, fields.toReversed())).toEqual(expected); + expect(filter(data, ["spans.10.id"])).toEqual({}); + }); + + test("retains selected falsey values and omits null intermediate fields", () => { + expect( + filter( + { + spans: [ + { data: { count: 0, label: "", optional: null, unset: undefined } }, + { data: null }, + null, + ], + }, + [ + "spans.data.count", + "spans.data.label", + "spans.data.optional", + "spans.data.unset", + ], + ), + ).toEqual({ + spans: [ + { data: { count: 0, label: "", optional: null, unset: undefined } }, + {}, + null, + ], + }); + expect(filter({ spans: [{ data: null }] }, ["spans.data"])).toEqual({ + spans: [{ data: null }], + }); + }); + test("preserves null values", () => { expect(filter({ id: 1, value: null }, ["id", "value"])).toEqual({ id: 1,