Skip to content
Open
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
85 changes: 82 additions & 3 deletions packages/junior/src/chat/slack/footer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import * as Sentry from "@/chat/sentry";
import type { TurnThinkingSelection } from "@/chat/services/turn-thinking-level";
import type { AgentTurnUsage } from "@/chat/usage";

const SENTRY_CONVERSATION_SEARCH_STATS_PERIOD = "14d";
const ORG_ID_HOST_RE = /^o(\d+)\./;

interface SlackMrkdwnTextObject {
text: string;
type: "mrkdwn";
Expand Down Expand Up @@ -29,6 +33,7 @@ export type SlackMessageBlock =

interface SlackReplyFooterItem {
label: string;
url?: string;
value: string;
}

Expand All @@ -43,6 +48,73 @@ function escapeSlackMrkdwn(text: string): string {
.replaceAll(">", ">");
}

function escapeSlackLinkUrl(url: string): string {
return url
.replaceAll("&", "&")
.replaceAll("<", "%3C")
.replaceAll(">", "%3E");
}

function toOptionalString(value: unknown): string | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same-named function with different semantics risks silent breakage

Low Severity

A private toOptionalString is introduced in footer.ts that shares its name with the exported toOptionalString in coerce.ts, but their behavior diverges: the footer version handles number inputs (converting them via String()) and trims returned strings, while the coerce version only accepts strings and returns them untrimmed. If a maintainer later consolidates by importing from coerce.ts, numeric orgId values from the Sentry SDK would silently become undefined, breaking link generation.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8965643. Configure here.


function quoteSentrySearchValue(value: string): string {
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
}

function getDsnOrgId(host: string | undefined): string | undefined {
return host?.match(ORG_ID_HOST_RE)?.[1];
}

function isSentrySaasDsnHost(host: string): boolean {
return host === "sentry.io" || host.endsWith(".sentry.io");
}

function buildSentryWebBaseUrl(dsn: {
host: string;
path?: string;
port?: string;
protocol: string;
}): string {
if (isSentrySaasDsnHost(dsn.host)) {
return "https://sentry.io";
}

const port = dsn.port ? `:${dsn.port}` : "";
const path = dsn.path ? `/${dsn.path}` : "";
return `${dsn.protocol}://${dsn.host}${port}${path}`;
}

function getSentryConversationSearchUrl(
conversationId: string,
): string | undefined {
const client = Sentry.getClient();
const dsn = client?.getDsn();
if (!dsn?.host || !dsn.projectId) {
return undefined;
}

const orgId =
toOptionalString(client?.getOptions().orgId) ?? getDsnOrgId(dsn.host);
if (!orgId) {
return undefined;
}

const params = new URLSearchParams();
params.set(
"query",
`gen_ai.conversation.id:${quoteSentrySearchValue(conversationId)}`,
);
params.set("project", dsn.projectId);
params.set("statsPeriod", SENTRY_CONVERSATION_SEARCH_STATS_PERIOD);

return `${buildSentryWebBaseUrl(dsn)}/organizations/${orgId}/explore/traces/?${params.toString()}`;
}

function formatSlackTokenCount(value: number): string {
if (value >= 1_000_000) {
const millions = value / 1_000_000;
Expand Down Expand Up @@ -120,10 +192,15 @@ export function buildSlackReplyFooter(args: {

const conversationId = args.conversationId?.trim();
if (conversationId) {
items.push({
const idItem: SlackReplyFooterItem = {
label: "ID",
value: conversationId,
});
};
const conversationUrl = getSentryConversationSearchUrl(conversationId);
if (conversationUrl) {
idItem.url = conversationUrl;
}
items.push(idItem);
}

const totalTokens = resolveTotalTokens(args.usage);
Expand Down Expand Up @@ -173,7 +250,9 @@ export function buildSlackReplyBlocks(
type: "context",
elements: footer.items.map((item) => ({
type: "mrkdwn",
text: `*${escapeSlackMrkdwn(item.label)}:* ${escapeSlackMrkdwn(item.value)}`,
text: item.url
? `*${escapeSlackMrkdwn(item.label)}:* <${escapeSlackLinkUrl(item.url)}|${escapeSlackMrkdwn(item.value)}>`
: `*${escapeSlackMrkdwn(item.label)}:* ${escapeSlackMrkdwn(item.value)}`,
})),
});
}
Expand Down
110 changes: 110 additions & 0 deletions packages/junior/tests/unit/slack/footer-sentry-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it, vi } from "vitest";

type MockDsn = {
host: string;
path?: string;
port?: string;
projectId: string;
protocol: "http" | "https";
};

function mockSentryClient(args: { dsn?: MockDsn; orgId?: number | string }) {
vi.doMock("@/chat/sentry", () => ({
getClient: () => ({
getDsn: () => args.dsn,
getOptions: () => ({
orgId: args.orgId,
}),
}),
}));
}

async function loadFooter() {
return await import("@/chat/slack/footer");
}

afterEach(() => {
vi.doUnmock("@/chat/sentry");
vi.resetModules();
});

describe("Slack footer Sentry links", () => {
it("links the ID to an Explore traces search from the active SaaS DSN", async () => {
mockSentryClient({
dsn: {
protocol: "https",
host: "o123.ingest.us.sentry.io",
projectId: "4501",
},
});

const { buildSlackReplyBlocks, buildSlackReplyFooter } = await loadFooter();
const footer = buildSlackReplyFooter({
conversationId: "slack:C123:1700000000.000100",
});

expect(buildSlackReplyBlocks("Hello world", footer)).toEqual([
{
type: "markdown",
text: "Hello world",
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: "*ID:* <https://sentry.io/organizations/123/explore/traces/?query=gen_ai.conversation.id%3A%22slack%3AC123%3A1700000000.000100%22&amp;project=4501&amp;statsPeriod=14d|slack:C123:1700000000.000100>",
},
],
},
]);
});

it("uses an explicit SDK orgId before the DSN host org ID", async () => {
mockSentryClient({
dsn: {
protocol: "https",
host: "o123.ingest.sentry.io",
projectId: "4501",
},
orgId: 456,
});

const { buildSlackReplyFooter } = await loadFooter();

expect(buildSlackReplyFooter({ conversationId: "conversation-1" })).toEqual(
{
items: [
{
label: "ID",
url: "https://sentry.io/organizations/456/explore/traces/?query=gen_ai.conversation.id%3A%22conversation-1%22&project=4501&statsPeriod=14d",
value: "conversation-1",
},
],
},
);
});

it("leaves the ID plain when the active DSN has no organization target", async () => {
mockSentryClient({
dsn: {
protocol: "https",
host: "sentry.example.com",
projectId: "4501",
},
});

const { buildSlackReplyFooter } = await loadFooter();

expect(buildSlackReplyFooter({ conversationId: "conversation-1" })).toEqual(
{
items: [
{
label: "ID",
value: "conversation-1",
},
],
},
);
});
});
15 changes: 15 additions & 0 deletions packages/junior/tests/unit/slack/footer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ describe("buildSlackReplyFooter", () => {
});
});

it("keeps ID as plain text when no conversation URL is available", () => {
expect(
buildSlackReplyFooter({
conversationId: "slack:C123:1700000000.000100",
}),
).toEqual({
items: [
{
label: "ID",
value: "slack:C123:1700000000.000100",
},
],
});
});

it("omits the footer when no items are available", () => {
expect(buildSlackReplyFooter({})).toBeUndefined();
});
Expand Down
Loading