From 896564394cd0c43dd2f45228c57bd6eb06316122 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 7 May 2026 15:42:56 -0700 Subject: [PATCH] feat(slack): Link footer IDs to Sentry traces Use the active Sentry SDK DSN to build Explore links for Slack footer conversation IDs. This points reviewers and operators at the telemetry project that receives Junior spans instead of using provider configuration meant for user-facing Sentry queries. Co-Authored-By: GPT-5 Codex --- packages/junior/src/chat/slack/footer.ts | 85 +++++++++++++- .../unit/slack/footer-sentry-link.test.ts | 110 ++++++++++++++++++ .../junior/tests/unit/slack/footer.test.ts | 15 +++ 3 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 packages/junior/tests/unit/slack/footer-sentry-link.test.ts 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(); });