Skip to content

Commit e757183

Browse files
committed
feat(profile): add transaction aliases for quick reference
Enable quick access to transactions via numeric indices or short aliases: - `sentry profile list` now shows # and ALIAS columns - `sentry profile view 1` - access by numeric index - `sentry profile view i` - access by alias (last unique segment) Implementation: - Add transaction_aliases SQLite table (schema v5) - Add TransactionAliasEntry type for cached aliases - Add buildTransactionAliases() using existing alias algorithm - Add resolveTransaction() with stale cache detection - Update formatters to show alias columns Aliases are fingerprinted by org:project:period and error with helpful messages when stale (e.g., different period) or unknown.
1 parent 4ac04d6 commit e757183

File tree

9 files changed

+636
-17
lines changed

9 files changed

+636
-17
lines changed

src/commands/profile/list.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { buildCommand, numberParser } from "@stricli/core";
99
import type { SentryContext } from "../../context.js";
1010
import { getProject, listProfiledTransactions } from "../../lib/api-client.js";
1111
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
12+
import {
13+
buildTransactionFingerprint,
14+
setTransactionAliases,
15+
} from "../../lib/db/transaction-aliases.js";
1216
import { ContextError } from "../../lib/errors.js";
1317
import {
1418
divider,
@@ -19,7 +23,8 @@ import {
1923
writeJson,
2024
} from "../../lib/formatters/index.js";
2125
import { resolveOrgAndProject } from "../../lib/resolve-target.js";
22-
import type { Writer } from "../../types/index.js";
26+
import { buildTransactionAliases } from "../../lib/transaction-alias.js";
27+
import type { TransactionAliasEntry, Writer } from "../../types/index.js";
2328

2429
type ListFlags = {
2530
readonly period: string;
@@ -160,6 +165,31 @@ export const listCommand = buildCommand({
160165

161166
const orgProject = `${resolvedTarget.org}/${resolvedTarget.project}`;
162167

168+
// Build and store transaction aliases for later use with profile view
169+
const transactionInputs = response.data
170+
.filter((row) => row.transaction)
171+
.map((row) => ({
172+
transaction: row.transaction as string,
173+
orgSlug: resolvedTarget.org,
174+
projectSlug: resolvedTarget.project,
175+
}));
176+
177+
const aliases = buildTransactionAliases(transactionInputs);
178+
179+
// Store aliases with fingerprint for cache validation
180+
const fingerprint = buildTransactionFingerprint(
181+
resolvedTarget.org,
182+
resolvedTarget.project,
183+
flags.period
184+
);
185+
setTransactionAliases(aliases, fingerprint);
186+
187+
// Build alias lookup map for formatting
188+
const aliasMap = new Map<string, TransactionAliasEntry>();
189+
for (const alias of aliases) {
190+
aliasMap.set(alias.transaction, alias);
191+
}
192+
163193
// JSON output
164194
if (flags.json) {
165195
writeJson(stdout, response.data);
@@ -172,16 +202,18 @@ export const listCommand = buildCommand({
172202
return;
173203
}
174204

175-
// Human-readable output
205+
// Human-readable output with aliases
206+
const hasAliases = aliases.length > 0;
176207
stdout.write(`${formatProfileListHeader(orgProject, flags.period)}\n\n`);
177-
stdout.write(`${formatProfileListTableHeader()}\n`);
178-
stdout.write(`${divider(76)}\n`);
208+
stdout.write(`${formatProfileListTableHeader(hasAliases)}\n`);
209+
stdout.write(`${divider(82)}\n`);
179210

180211
for (const row of response.data) {
181-
stdout.write(`${formatProfileListRow(row)}\n`);
212+
const alias = row.transaction ? aliasMap.get(row.transaction) : undefined;
213+
stdout.write(`${formatProfileListRow(row, alias)}\n`);
182214
}
183215

184-
stdout.write(formatProfileListFooter());
216+
stdout.write(formatProfileListFooter(hasAliases));
185217

186218
if (resolvedTarget.detectedFrom) {
187219
stdout.write(`\n\nDetected from ${resolvedTarget.detectedFrom}\n`);

src/commands/profile/view.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
hasProfileData,
2121
} from "../../lib/profile/analyzer.js";
2222
import { resolveOrgAndProject } from "../../lib/resolve-target.js";
23+
import { resolveTransaction } from "../../lib/resolve-transaction.js";
2324
import { buildProfileUrl } from "../../lib/sentry-urls.js";
2425

2526
type ViewFlags = {
@@ -68,7 +69,8 @@ export const viewCommand = buildCommand({
6869
parameters: [
6970
{
7071
placeholder: "transaction",
71-
brief: 'Transaction name (e.g., "/api/users", "POST /api/orders")',
72+
brief:
73+
'Transaction: index (1), alias (i), or full name ("/api/users")',
7274
parse: String,
7375
},
7476
],
@@ -119,25 +121,36 @@ export const viewCommand = buildCommand({
119121
async func(
120122
this: SentryContext,
121123
flags: ViewFlags,
122-
transactionName: string
124+
transactionRef: string
123125
): Promise<void> {
124126
const { stdout, cwd, setContext } = this;
125127

126-
// Resolve org and project
128+
// Resolve org and project from flags or detection
127129
const target = await resolveOrgAndProject({
128130
org: flags.org,
129131
project: flags.project,
130132
cwd,
131-
usageHint: `sentry profile view "${transactionName}" --org <org> --project <project>`,
133+
usageHint: `sentry profile view "${transactionRef}" --org <org> --project <project>`,
132134
});
133135

134136
if (!target) {
135137
throw new ContextError(
136138
"Organization and project",
137-
`sentry profile view "${transactionName}" --org <org-slug> --project <project-slug>`
139+
`sentry profile view "${transactionRef}" --org <org-slug> --project <project-slug>`
138140
);
139141
}
140142

143+
// Resolve transaction reference (alias, index, or full name)
144+
// This may throw ContextError if alias is stale or not found
145+
const resolved = resolveTransaction(transactionRef, {
146+
org: target.org,
147+
project: target.project,
148+
period: flags.period,
149+
});
150+
151+
// Use resolved transaction name for the rest of the command
152+
const transactionName = resolved.transaction;
153+
141154
// Set telemetry context
142155
setContext([target.org], [target.project]);
143156

src/lib/db/schema.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import type { Database } from "bun:sqlite";
66

7-
const CURRENT_SCHEMA_VERSION = 4;
7+
const CURRENT_SCHEMA_VERSION = 5;
88

99
/** User identity for telemetry (single row, id=1) */
1010
const USER_INFO_TABLE = `
@@ -48,6 +48,25 @@ const PROJECT_ROOT_CACHE_TABLE = `
4848
)
4949
`;
5050

51+
/** Transaction aliases for profile commands (1, i → full transaction name) */
52+
const TRANSACTION_ALIASES_TABLE = `
53+
CREATE TABLE IF NOT EXISTS transaction_aliases (
54+
idx INTEGER NOT NULL,
55+
alias TEXT NOT NULL,
56+
transaction_name TEXT NOT NULL,
57+
org_slug TEXT NOT NULL,
58+
project_slug TEXT NOT NULL,
59+
fingerprint TEXT NOT NULL,
60+
cached_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
61+
PRIMARY KEY (fingerprint, idx)
62+
)
63+
`;
64+
65+
const TRANSACTION_ALIASES_INDEX = `
66+
CREATE INDEX IF NOT EXISTS idx_txn_alias_lookup
67+
ON transaction_aliases(alias, fingerprint)
68+
`;
69+
5170
export function initSchema(db: Database): void {
5271
db.exec(`
5372
-- Schema version for future migrations
@@ -128,6 +147,8 @@ export function initSchema(db: Database): void {
128147
${USER_INFO_TABLE};
129148
${INSTANCE_INFO_TABLE};
130149
${PROJECT_ROOT_CACHE_TABLE};
150+
${TRANSACTION_ALIASES_TABLE};
151+
${TRANSACTION_ALIASES_INDEX};
131152
`);
132153

133154
const versionRow = db
@@ -205,6 +226,12 @@ export function runMigrations(db: Database): void {
205226
db.exec(PROJECT_ROOT_CACHE_TABLE);
206227
}
207228

229+
// Migration 4 -> 5: Add transaction_aliases table for profile commands
230+
if (currentVersion < 5) {
231+
db.exec(TRANSACTION_ALIASES_TABLE);
232+
db.exec(TRANSACTION_ALIASES_INDEX);
233+
}
234+
208235
// Update schema version if needed
209236
if (currentVersion < CURRENT_SCHEMA_VERSION) {
210237
db.query("UPDATE schema_version SET version = ?").run(

src/lib/db/transaction-aliases.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* Transaction aliases storage for profile commands.
3+
* Enables short references like "1" or "i" for transactions from `profile list`.
4+
*/
5+
6+
import type { TransactionAliasEntry } from "../../types/index.js";
7+
import { getDatabase } from "./index.js";
8+
9+
type TransactionAliasRow = {
10+
idx: number;
11+
alias: string;
12+
transaction_name: string;
13+
org_slug: string;
14+
project_slug: string;
15+
fingerprint: string;
16+
cached_at: number;
17+
};
18+
19+
/**
20+
* Build a fingerprint for cache validation.
21+
* Format: "orgSlug:projectSlug:period" or "orgSlug:*:period" for multi-project.
22+
*/
23+
export function buildTransactionFingerprint(
24+
orgSlug: string,
25+
projectSlug: string | null,
26+
period: string
27+
): string {
28+
return `${orgSlug}:${projectSlug ?? "*"}:${period}`;
29+
}
30+
31+
/**
32+
* Store transaction aliases from a profile list command.
33+
* Replaces any existing aliases for the same fingerprint.
34+
*/
35+
export function setTransactionAliases(
36+
aliases: TransactionAliasEntry[],
37+
fingerprint: string
38+
): void {
39+
const db = getDatabase();
40+
const now = Date.now();
41+
42+
db.exec("BEGIN TRANSACTION");
43+
44+
try {
45+
// Delete only aliases with the same fingerprint
46+
db.query("DELETE FROM transaction_aliases WHERE fingerprint = ?").run(
47+
fingerprint
48+
);
49+
50+
const insertStmt = db.query(`
51+
INSERT INTO transaction_aliases
52+
(idx, alias, transaction_name, org_slug, project_slug, fingerprint, cached_at)
53+
VALUES (?, ?, ?, ?, ?, ?, ?)
54+
`);
55+
56+
for (const entry of aliases) {
57+
insertStmt.run(
58+
entry.idx,
59+
entry.alias.toLowerCase(),
60+
entry.transaction,
61+
entry.orgSlug,
62+
entry.projectSlug,
63+
fingerprint,
64+
now
65+
);
66+
}
67+
68+
db.exec("COMMIT");
69+
} catch (error) {
70+
db.exec("ROLLBACK");
71+
throw error;
72+
}
73+
}
74+
75+
/**
76+
* Look up transaction by numeric index.
77+
* Returns null if not found or fingerprint doesn't match.
78+
*/
79+
export function getTransactionByIndex(
80+
idx: number,
81+
fingerprint: string
82+
): TransactionAliasEntry | null {
83+
const db = getDatabase();
84+
85+
const row = db
86+
.query(
87+
"SELECT * FROM transaction_aliases WHERE idx = ? AND fingerprint = ?"
88+
)
89+
.get(idx, fingerprint) as TransactionAliasRow | undefined;
90+
91+
if (!row) {
92+
return null;
93+
}
94+
95+
return {
96+
idx: row.idx,
97+
alias: row.alias,
98+
transaction: row.transaction_name,
99+
orgSlug: row.org_slug,
100+
projectSlug: row.project_slug,
101+
};
102+
}
103+
104+
/**
105+
* Look up transaction by alias.
106+
* Returns null if not found or fingerprint doesn't match.
107+
*/
108+
export function getTransactionByAlias(
109+
alias: string,
110+
fingerprint: string
111+
): TransactionAliasEntry | null {
112+
const db = getDatabase();
113+
114+
const row = db
115+
.query(
116+
"SELECT * FROM transaction_aliases WHERE alias = ? AND fingerprint = ?"
117+
)
118+
.get(alias.toLowerCase(), fingerprint) as TransactionAliasRow | undefined;
119+
120+
if (!row) {
121+
return null;
122+
}
123+
124+
return {
125+
idx: row.idx,
126+
alias: row.alias,
127+
transaction: row.transaction_name,
128+
orgSlug: row.org_slug,
129+
projectSlug: row.project_slug,
130+
};
131+
}
132+
133+
/**
134+
* Get all cached aliases for a fingerprint.
135+
*/
136+
export function getTransactionAliases(
137+
fingerprint: string
138+
): TransactionAliasEntry[] {
139+
const db = getDatabase();
140+
141+
const rows = db
142+
.query(
143+
"SELECT * FROM transaction_aliases WHERE fingerprint = ? ORDER BY idx"
144+
)
145+
.all(fingerprint) as TransactionAliasRow[];
146+
147+
return rows.map((row) => ({
148+
idx: row.idx,
149+
alias: row.alias,
150+
transaction: row.transaction_name,
151+
orgSlug: row.org_slug,
152+
projectSlug: row.project_slug,
153+
}));
154+
}
155+
156+
/**
157+
* Check if an alias exists for a different fingerprint (stale check).
158+
* Returns the stale fingerprint if found, null otherwise.
159+
*/
160+
export function getStaleFingerprint(alias: string): string | null {
161+
const db = getDatabase();
162+
163+
const row = db
164+
.query(
165+
"SELECT fingerprint FROM transaction_aliases WHERE alias = ? LIMIT 1"
166+
)
167+
.get(alias.toLowerCase()) as { fingerprint: string } | undefined;
168+
169+
return row?.fingerprint ?? null;
170+
}
171+
172+
/**
173+
* Check if an index exists for a different fingerprint (stale check).
174+
* Returns the stale fingerprint if found, null otherwise.
175+
*/
176+
export function getStaleIndexFingerprint(idx: number): string | null {
177+
const db = getDatabase();
178+
179+
const row = db
180+
.query("SELECT fingerprint FROM transaction_aliases WHERE idx = ? LIMIT 1")
181+
.get(idx) as { fingerprint: string } | undefined;
182+
183+
return row?.fingerprint ?? null;
184+
}
185+
186+
/**
187+
* Clear all transaction aliases.
188+
*/
189+
export function clearTransactionAliases(): void {
190+
const db = getDatabase();
191+
db.query("DELETE FROM transaction_aliases").run();
192+
}

0 commit comments

Comments
 (0)