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
255 changes: 122 additions & 133 deletions packages/cli/src/lib/replay-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
* shared by replay commands and explore --dataset replays.
*/

import {
vListOrganizationReplaysData,
vListReplays,
} from "@sentry/api/valibot";
import type {
ReplayDetails,
ReplayListItem,
Expand All @@ -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<string, string>;

/** 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<Record<string, SchemaNode>>;
};

/** Default field set for replay rows shown in `sentry explore --dataset replays`. */
export const DEFAULT_REPLAY_EXPLORE_FIELDS = [
Expand Down Expand Up @@ -78,130 +72,110 @@ export function getReplayUserLabel(replay: ReplayLike): string | undefined {
);
}

const REPLAY_FIELD_RESOLVERS: Record<string, ReplayFieldResolver> = {
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<string> = 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<string> = 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<string, DerivedReplayField> = {
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();
}

/**
Expand All @@ -212,22 +186,37 @@ export function getReplayRequestFields(fields: string[]): string[] {
const roots = new Set<string>(["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);
}

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading