diff --git a/packages/cli/src/lib/replay-search.ts b/packages/cli/src/lib/replay-search.ts index 8a85151ca..15fd9c8c8 100644 --- a/packages/cli/src/lib/replay-search.ts +++ b/packages/cli/src/lib/replay-search.ts @@ -5,6 +5,10 @@ * shared by replay commands and explore --dataset replays. */ +import { + vListOrganizationReplaysData, + vListReplays, +} from "@sentry/api/valibot"; import type { ReplayDetails, ReplayListItem, @@ -13,23 +17,13 @@ import type { import { tryNormalizeHexId } from "./hex-id.js"; type ReplayLike = ReplayListItem | ReplayDetails; -type ReplayFieldResolver = (replay: ReplayLike) => unknown; -/** Maps user-facing field aliases to canonical replay API field names. */ -const REPLAY_FIELD_ALIASES = { - count_screens: "count_urls", - screens: "urls", - seen_by_me: "has_viewed", - "user.ip_address": "user.ip", - viewed_by_me: "has_viewed", -} as const satisfies Record; - -/** Resolve a field alias to its canonical API name, or pass through as-is. */ -function normalizeReplayField(field: string): string { - return Object.hasOwn(REPLAY_FIELD_ALIASES, field) - ? REPLAY_FIELD_ALIASES[field as keyof typeof REPLAY_FIELD_ALIASES] - : field; -} +/** Minimal structural view of a Valibot schema, enough to walk object entries. */ +type SchemaNode = { + readonly type: string; + readonly wrapped?: SchemaNode; + readonly entries?: Readonly>; +}; /** Default field set for replay rows shown in `sentry explore --dataset replays`. */ export const DEFAULT_REPLAY_EXPLORE_FIELDS = [ @@ -78,130 +72,110 @@ export function getReplayUserLabel(replay: ReplayLike): string | undefined { ); } -const REPLAY_FIELD_RESOLVERS: Record = { - activity: (replay) => replay.activity, - browser: (replay) => replay.browser?.name, - "browser.name": (replay) => replay.browser?.name, - "browser.version": (replay) => replay.browser?.version, - count_dead_clicks: (replay) => replay.count_dead_clicks, - count_errors: (replay) => replay.count_errors, - count_infos: (replay) => replay.count_infos, - count_rage_clicks: (replay) => replay.count_rage_clicks, - count_screens: (replay) => replay.count_urls, - count_segments: (replay) => replay.count_segments, - count_traces: (replay) => replay.trace_ids?.length, - count_urls: (replay) => replay.count_urls, - count_warnings: (replay) => replay.count_warnings, - device: (replay) => replay.device?.name, - "device.brand": (replay) => replay.device?.brand, - "device.family": (replay) => replay.device?.family, - "device.model": (replay) => replay.device?.model, - "device.model_id": (replay) => replay.device?.model_id, - "device.name": (replay) => replay.device?.name, - dist: (replay) => replay.dist, - duration: (replay) => replay.duration, - environment: (replay) => replay.environment, - error_id: (replay) => firstValue(replay.error_ids), - error_ids: (replay) => replay.error_ids, - finished_at: (replay) => replay.finished_at, - has_viewed: (replay) => replay.has_viewed, - id: (replay) => replay.id, - info_id: (replay) => firstValue(replay.info_ids), - info_ids: (replay) => replay.info_ids, - is_archived: (replay) => replay.is_archived, - os: (replay) => replay.os?.name, - "os.name": (replay) => replay.os?.name, - "os.version": (replay) => replay.os?.version, - platform: (replay) => replay.platform, - project_id: (replay) => replay.project_id, - release: (replay) => firstValue(replay.releases), - releases: (replay) => replay.releases, - screen: (replay) => firstValue(replay.urls), - screens: (replay) => replay.urls, - sdk: (replay) => replay.sdk?.name, - "sdk.name": (replay) => replay.sdk?.name, - "sdk.version": (replay) => replay.sdk?.version, - seen_by_me: (replay) => replay.has_viewed, - started_at: (replay) => replay.started_at, - trace: (replay) => firstValue(replay.trace_ids), - trace_id: (replay) => firstValue(replay.trace_ids), - trace_ids: (replay) => replay.trace_ids, - url: (replay) => firstValue(replay.urls), - urls: (replay) => replay.urls, - user: (replay) => getReplayUserLabel(replay), - "user.display": (replay) => getReplayUserLabel(replay), - "user.email": (replay) => replay.user?.email, - "user.geo.city": (replay) => replay.user?.geo?.city, - "user.geo.country_code": (replay) => replay.user?.geo?.country_code, - "user.geo.region": (replay) => replay.user?.geo?.region, - "user.geo.subdivision": (replay) => replay.user?.geo?.subdivision, - "user.id": (replay) => replay.user?.id, - "user.ip": (replay) => replay.user?.ip, - "user.ip_address": (replay) => replay.user?.ip, - "user.username": (replay) => replay.user?.username, - viewed_by_me: (replay) => replay.has_viewed, - warning_id: (replay) => firstValue(replay.warning_ids), - warning_ids: (replay) => replay.warning_ids, +/** Collect dotted leaf paths of an object schema, descending into optional wrappers. */ +function collectLeafPaths( + schema: SchemaNode, + prefix: string, + paths: string[], +): string[] { + let inner = schema; + while (inner.wrapped) { + inner = inner.wrapped; + } + if (inner.type !== "object" || !inner.entries) { + paths.push(prefix); + return paths; + } + for (const [key, child] of Object.entries(inner.entries)) { + collectLeafPaths(child, prefix ? `${prefix}.${key}` : key, paths); + } + return paths; +} + +/** Root fields the replay index endpoint accepts in repeated `field=` params. */ +const REPLAY_REQUEST_ROOTS: ReadonlySet = new Set( + vListOrganizationReplaysData.entries.query.wrapped.entries.field.wrapped.item + .options, +); + +function replayFieldRoot(path: string): string { + return path.split(".", 1)[0] ?? path; +} + +/** + * Replay fields taken from the published `@sentry/api` schema: every dotted + * path in a replay list row whose root the index endpoint accepts. + */ +const REPLAY_API_FIELDS: ReadonlySet = new Set( + collectLeafPaths(vListReplays.entries.data.item, "", []).filter((path) => + REPLAY_REQUEST_ROOTS.has(replayFieldRoot(path)), + ), +); + +/** A CLI-only replay field derived from one API root field. */ +type DerivedReplayField = { + root: string; + resolve: (replay: ReplayLike) => unknown; }; -function replayRequestRoot(field: string): string { - const normalized = normalizeReplayField(field); +/** + * Aliases and convenience columns the CLI adds on top of the API fields, + * mostly to match Discover-style names agents already use. + */ +const DERIVED_REPLAY_FIELDS: Record = { + browser: { root: "browser", resolve: (replay) => replay.browser?.name }, + count_screens: { root: "count_urls", resolve: (replay) => replay.count_urls }, + count_traces: { + root: "trace_ids", + resolve: (replay) => replay.trace_ids?.length, + }, + device: { root: "device", resolve: (replay) => replay.device?.name }, + error_id: { + root: "error_ids", + resolve: (replay) => firstValue(replay.error_ids), + }, + os: { root: "os", resolve: (replay) => replay.os?.name }, + release: { + root: "releases", + resolve: (replay) => firstValue(replay.releases), + }, + screen: { root: "urls", resolve: (replay) => firstValue(replay.urls) }, + screens: { root: "urls", resolve: (replay) => replay.urls }, + sdk: { root: "sdk", resolve: (replay) => replay.sdk?.name }, + seen_by_me: { root: "has_viewed", resolve: (replay) => replay.has_viewed }, + trace: { + root: "trace_ids", + resolve: (replay) => firstValue(replay.trace_ids), + }, + trace_id: { + root: "trace_ids", + resolve: (replay) => firstValue(replay.trace_ids), + }, + url: { root: "urls", resolve: (replay) => firstValue(replay.urls) }, + user: { root: "user", resolve: getReplayUserLabel }, + "user.display": { root: "user", resolve: getReplayUserLabel }, + "user.ip_address": { root: "user", resolve: (replay) => replay.user?.ip }, + viewed_by_me: { root: "has_viewed", resolve: (replay) => replay.has_viewed }, +}; - switch (normalized) { - case "browser.name": - case "browser.version": - return "browser"; - case "device.brand": - case "device.family": - case "device.model": - case "device.model_id": - case "device.name": - return "device"; - case "os.name": - case "os.version": - return "os"; - case "sdk.name": - case "sdk.version": - return "sdk"; - case "count_traces": - return "trace_ids"; - case "error_id": - return "error_ids"; - case "info_id": - return "info_ids"; - case "release": - return "releases"; - case "screen": - case "url": - return "urls"; - case "trace": - case "trace_id": - return "trace_ids"; - case "user.display": - case "user.email": - case "user.geo.city": - case "user.geo.country_code": - case "user.geo.region": - case "user.geo.subdivision": - case "user.id": - case "user.ip": - case "user.username": - return "user"; - case "warning_id": - return "warning_ids"; - default: - return normalized; - } +function getDerivedReplayField(field: string): DerivedReplayField | undefined { + return Object.hasOwn(DERIVED_REPLAY_FIELDS, field) + ? DERIVED_REPLAY_FIELDS[field] + : undefined; } /** Return whether the CLI can render a replay field in replay search outputs. */ export function isSupportedReplayField(field: string): boolean { - return field in REPLAY_FIELD_RESOLVERS; + return ( + REPLAY_API_FIELDS.has(field) || getDerivedReplayField(field) !== undefined + ); } /** List the replay fields the CLI can render in replay search outputs. */ export function listSupportedReplayFields(): string[] { - return Object.keys(REPLAY_FIELD_RESOLVERS).sort(); + return [ + ...new Set([...REPLAY_API_FIELDS, ...Object.keys(DERIVED_REPLAY_FIELDS)]), + ].sort(); } /** @@ -212,22 +186,37 @@ export function getReplayRequestFields(fields: string[]): string[] { const roots = new Set(["id"]); for (const field of fields) { - roots.add(replayRequestRoot(field)); + roots.add(getDerivedReplayField(field)?.root ?? replayFieldRoot(field)); } return [...roots]; } +/** Read a dotted path such as `user.geo.city` from a replay row. */ +function getPathValue(value: unknown, path: string): unknown { + let current = value; + for (const key of path.split(".")) { + if (current === null || typeof current !== "object") { + return; + } + current = Reflect.get(current, key); + } + return current; +} + /** Extract a replay field value for CLI search/display output. */ export function getReplayFieldValue( replay: ReplayLike, field: string, ): unknown { - const resolver = REPLAY_FIELD_RESOLVERS[field]; - if (!resolver) { + const derived = getDerivedReplayField(field); + if (derived) { + return derived.resolve(replay); + } + if (!REPLAY_API_FIELDS.has(field)) { throw new Error(`Unsupported replay field: ${field}`); } - return resolver(replay); + return getPathValue(replay, field); } // --------------------------------------------------------------------------- diff --git a/packages/cli/test/lib/replay-search.test.ts b/packages/cli/test/lib/replay-search.test.ts index d95942c5c..00e7e14b7 100644 --- a/packages/cli/test/lib/replay-search.test.ts +++ b/packages/cli/test/lib/replay-search.test.ts @@ -1,10 +1,17 @@ +import { vListOrganizationReplaysData } from "@sentry/api/valibot"; import { describe, expect, test } from "vitest"; import { getReplayFieldValue, getReplayRequestFields, isSupportedReplayField, + listSupportedReplayFields, } from "../../src/lib/replay-search.js"; +const requestableRoots = new Set( + vListOrganizationReplaysData.entries.query.wrapped.entries.field.wrapped.item + .options, +); + describe("getReplayRequestFields", () => { test("normalizes replay field aliases for API requests", () => { expect(getReplayRequestFields(["url", "trace_id"])).toEqual([ @@ -15,53 +22,74 @@ describe("getReplayRequestFields", () => { }); test("requests backing array fields for convenience replay columns", () => { - expect( - getReplayRequestFields([ - "error_id", - "info_id", - "release", - "screen", - "warning_id", - ]), - ).toEqual([ + expect(getReplayRequestFields(["error_id", "release", "screen"])).toEqual([ "id", "error_ids", - "info_ids", "releases", "urls", - "warning_ids", ]); }); + + test("requests the root of nested API fields", () => { + expect( + getReplayRequestFields(["user.geo.city", "browser.version"]), + ).toEqual(["id", "user", "browser"]); + }); + + test("maps user.display to the user API root field", () => { + expect(getReplayRequestFields(["user.display"])).toEqual(["id", "user"]); + }); + + test("only requests roots the published schema accepts", () => { + const roots = getReplayRequestFields(listSupportedReplayFields()); + expect(roots.filter((root) => !requestableRoots.has(root))).toEqual([]); + }); }); describe("isSupportedReplayField", () => { - test("does not expose replay detail-only fields in replay explore", () => { - expect(isSupportedReplayField("replay_type")).toBe(false); + test("accepts nested fields from the published replay schema", () => { + expect(isSupportedReplayField("user.geo.country_code")).toBe(true); + expect(isSupportedReplayField("user.display_name")).toBe(true); }); test("recognizes user.display as a supported replay field", () => { expect(isSupportedReplayField("user.display")).toBe(true); }); -}); -describe("getReplayRequestFields", () => { - test("maps user.display to the user API root field", () => { - expect(getReplayRequestFields(["user.display"])).toEqual(["id", "user"]); + test("rejects fields missing from the published replay schema", () => { + expect(isSupportedReplayField("device.model_id")).toBe(false); + expect(isSupportedReplayField("warning_ids")).toBe(false); + }); + + test("rejects response fields the replay index does not accept", () => { + expect(isSupportedReplayField("replay_type")).toBe(false); }); }); describe("getReplayFieldValue", () => { + const replay = { + id: "abc123", + urls: ["https://example.com/a", "https://example.com/b"], + user: { + display_name: "Jane Doe", + username: "jane", + email: "jane@example.com", + id: "1", + ip: "1.2.3.4", + geo: { city: "Vienna" }, + }, + } as Parameters[0]; + test("resolves user.display to the best available user label", () => { - const replay = { - id: "abc123", - user: { - display_name: "Jane Doe", - username: "jane", - email: "jane@example.com", - id: "1", - ip: "1.2.3.4", - }, - } as Parameters[0]; expect(getReplayFieldValue(replay, "user.display")).toBe("Jane Doe"); }); + + test("reads nested API fields by path", () => { + expect(getReplayFieldValue(replay, "user.geo.city")).toBe("Vienna"); + expect(getReplayFieldValue(replay, "browser.name")).toBeUndefined(); + }); + + test("resolves convenience columns from their backing field", () => { + expect(getReplayFieldValue(replay, "url")).toBe("https://example.com/a"); + }); });