diff --git a/packages/junior/src/chat/slack/footer.ts b/packages/junior/src/chat/slack/footer.ts index ef32c7d5..78ba2a6d 100644 --- a/packages/junior/src/chat/slack/footer.ts +++ b/packages/junior/src/chat/slack/footer.ts @@ -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"; @@ -29,6 +33,7 @@ export type SlackMessageBlock = interface SlackReplyFooterItem { label: string; + url?: string; value: string; } @@ -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; +} + +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; @@ -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); @@ -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)}`, })), }); } diff --git a/packages/junior/tests/unit/slack/footer-sentry-link.test.ts b/packages/junior/tests/unit/slack/footer-sentry-link.test.ts new file mode 100644 index 00000000..747d481a --- /dev/null +++ b/packages/junior/tests/unit/slack/footer-sentry-link.test.ts @@ -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:* ", + }, + ], + }, + ]); + }); + + 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", + }, + ], + }, + ); + }); +}); diff --git a/packages/junior/tests/unit/slack/footer.test.ts b/packages/junior/tests/unit/slack/footer.test.ts index b471e70f..121bd021 100644 --- a/packages/junior/tests/unit/slack/footer.test.ts +++ b/packages/junior/tests/unit/slack/footer.test.ts @@ -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(); });