Skip to content

Commit 0b703e8

Browse files
authored
Merge pull request #466 from databuddy-analytics/staging
feat: conversational insight digest routing
2 parents c47f434 + e47e5c0 commit 0b703e8

19 files changed

Lines changed: 864 additions & 86 deletions

File tree

apps/api/src/lib/evlog-api.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,5 @@ export function enrichApiWideEvent(ctx: EnrichContext): void {
102102
}
103103

104104
export async function flushBatchedApiDrain(): Promise<void> {
105-
await Promise.all([
106-
batchedAxiomDrain.flush(),
107-
batchedSuperlogDrain?.flush(),
108-
]);
105+
await Promise.all([batchedAxiomDrain.flush(), batchedSuperlogDrain?.flush()]);
109106
}

apps/basket/src/lib/evlog-basket.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,5 @@ export function enrichBasketWideEvent(ctx: EnrichContext): void {
103103
}
104104

105105
export async function flushBatchedAxiomDrain(): Promise<void> {
106-
await Promise.all([
107-
batchedAxiomDrain.flush(),
108-
batchedSuperlogDrain?.flush(),
109-
]);
106+
await Promise.all([batchedAxiomDrain.flush(), batchedSuperlogDrain?.flush()]);
110107
}

apps/insights/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"dependencies": {
1313
"@databuddy/ai": "workspace:*",
1414
"@databuddy/db": "workspace:*",
15+
"@databuddy/encryption": "workspace:*",
1516
"@databuddy/env": "workspace:*",
1617
"@databuddy/redis": "workspace:*",
1718
"@databuddy/rpc": "workspace:*",

apps/insights/src/delivery.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { and, db, eq, isNull } from "@databuddy/db";
2+
import {
3+
type InsightDelivery,
4+
insightGenerationConfigs,
5+
slackIntegrations,
6+
} from "@databuddy/db/schema";
7+
import { decrypt } from "@databuddy/encryption";
8+
import { env } from "@databuddy/env/insights";
9+
import { captureInsightsError, emitInsightsEvent } from "./lib/evlog-insights";
10+
11+
const SLACK_POST_URL = "https://slack.com/api/chat.postMessage";
12+
const MAX_DIGEST_INSIGHTS = 5;
13+
const SLACK_HEADER_MAX = 150;
14+
const SLACK_SECTION_TEXT_MAX = 3000;
15+
16+
function truncate(value: string, max: number): string {
17+
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
18+
}
19+
20+
interface DigestInsight {
21+
description: string;
22+
severity: string;
23+
suggestion: string;
24+
title: string;
25+
}
26+
27+
interface SlackBlock {
28+
text?: { text: string; type: string };
29+
type: string;
30+
}
31+
32+
function escapeMrkdwn(value: string): string {
33+
return value
34+
.replaceAll("&", "&amp;")
35+
.replaceAll("<", "&lt;")
36+
.replaceAll(">", "&gt;");
37+
}
38+
39+
async function resolveDeliveries(
40+
organizationId: string,
41+
websiteId: string
42+
): Promise<InsightDelivery[]> {
43+
const [websiteConfig] = await db
44+
.select({ deliveries: insightGenerationConfigs.deliveries })
45+
.from(insightGenerationConfigs)
46+
.where(
47+
and(
48+
eq(insightGenerationConfigs.organizationId, organizationId),
49+
eq(insightGenerationConfigs.websiteId, websiteId)
50+
)
51+
)
52+
.limit(1);
53+
if (websiteConfig) {
54+
return websiteConfig.deliveries;
55+
}
56+
57+
const [orgConfig] = await db
58+
.select({ deliveries: insightGenerationConfigs.deliveries })
59+
.from(insightGenerationConfigs)
60+
.where(
61+
and(
62+
eq(insightGenerationConfigs.organizationId, organizationId),
63+
isNull(insightGenerationConfigs.websiteId)
64+
)
65+
)
66+
.limit(1);
67+
return orgConfig?.deliveries ?? [];
68+
}
69+
70+
async function loadBotToken(organizationId: string): Promise<string | null> {
71+
const key = env.DATABUDDY_ENCRYPTION_KEY;
72+
if (!key) {
73+
return null;
74+
}
75+
const [integration] = await db
76+
.select({ ciphertext: slackIntegrations.botTokenCiphertext })
77+
.from(slackIntegrations)
78+
.where(
79+
and(
80+
eq(slackIntegrations.organizationId, organizationId),
81+
eq(slackIntegrations.status, "active")
82+
)
83+
)
84+
.limit(1);
85+
if (!integration) {
86+
return null;
87+
}
88+
return decrypt(integration.ciphertext, key);
89+
}
90+
91+
function buildBlocks(
92+
websiteDomain: string,
93+
insights: DigestInsight[]
94+
): SlackBlock[] {
95+
const blocks: SlackBlock[] = [
96+
{
97+
type: "header",
98+
text: {
99+
type: "plain_text",
100+
text: truncate(`Insights for ${websiteDomain}`, SLACK_HEADER_MAX),
101+
},
102+
},
103+
];
104+
for (const insight of insights.slice(0, MAX_DIGEST_INSIGHTS)) {
105+
blocks.push({
106+
type: "section",
107+
text: {
108+
type: "mrkdwn",
109+
text: truncate(
110+
`*${escapeMrkdwn(insight.title)}*\n${escapeMrkdwn(insight.description)}\n_${escapeMrkdwn(insight.suggestion)}_`,
111+
SLACK_SECTION_TEXT_MAX
112+
),
113+
},
114+
});
115+
}
116+
return blocks;
117+
}
118+
119+
async function postToSlack(
120+
token: string,
121+
channelId: string,
122+
blocks: SlackBlock[],
123+
text: string
124+
): Promise<void> {
125+
const res = await fetch(SLACK_POST_URL, {
126+
method: "POST",
127+
headers: {
128+
"Content-Type": "application/json",
129+
Authorization: `Bearer ${token}`,
130+
},
131+
body: JSON.stringify({ channel: channelId, blocks, text }),
132+
});
133+
const body = (await res.json()) as { ok: boolean; error?: string };
134+
if (!body.ok) {
135+
throw new Error(
136+
`slack chat.postMessage failed: ${body.error ?? "unknown_error"}`
137+
);
138+
}
139+
}
140+
141+
export async function deliverInsightDigests(params: {
142+
insights: DigestInsight[];
143+
organizationId: string;
144+
websiteDomain: string;
145+
websiteId: string;
146+
}): Promise<void> {
147+
if (params.insights.length === 0) {
148+
return;
149+
}
150+
151+
const deliveries = await resolveDeliveries(
152+
params.organizationId,
153+
params.websiteId
154+
);
155+
const slackChannelIds = [
156+
...new Set(
157+
deliveries.filter((d) => d.type === "slack").map((d) => d.channelId)
158+
),
159+
];
160+
if (slackChannelIds.length === 0) {
161+
return;
162+
}
163+
164+
const token = await loadBotToken(params.organizationId);
165+
if (!token) {
166+
emitInsightsEvent("warn", "delivery.slack.skipped_no_integration", {
167+
organization_id: params.organizationId,
168+
website_id: params.websiteId,
169+
delivery_count: slackChannelIds.length,
170+
});
171+
return;
172+
}
173+
174+
const blocks = buildBlocks(params.websiteDomain, params.insights);
175+
const text = `Insights for ${params.websiteDomain}`;
176+
for (const channelId of slackChannelIds) {
177+
try {
178+
await postToSlack(token, channelId, blocks, text);
179+
emitInsightsEvent("info", "delivery.slack.posted", {
180+
organization_id: params.organizationId,
181+
website_id: params.websiteId,
182+
slack_channel_id: channelId,
183+
insight_count: Math.min(params.insights.length, MAX_DIGEST_INSIGHTS),
184+
});
185+
} catch (error) {
186+
captureInsightsError(error, "delivery.slack.failed", {
187+
organization_id: params.organizationId,
188+
website_id: params.websiteId,
189+
slack_channel_id: channelId,
190+
});
191+
}
192+
}
193+
}

apps/insights/src/generation.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { stepCountIs, tool, ToolLoopAgent, type ToolSet } from "ai";
2525
import { randomUUIDv7 } from "bun";
2626
import dayjs from "dayjs";
2727
import { resolveInsightsBilling } from "./billing";
28+
import { deliverInsightDigests } from "./delivery";
2829
import { type DetectedSignal, detectSignals, wowWindow } from "./detection";
2930
import { detectFunnelGoalSignals } from "./funnel-detection";
3031
import { enrichSignals } from "./enrichment";
@@ -609,6 +610,23 @@ export async function generateWebsiteInsights(
609610

610611
storeWebsiteSummary(site, saved);
611612

613+
if (saved.length > 0) {
614+
try {
615+
await deliverInsightDigests({
616+
organizationId: input.organizationId,
617+
websiteId: site.id,
618+
websiteDomain: site.domain,
619+
insights: saved,
620+
});
621+
} catch (error) {
622+
captureInsightsError(error, "generation.delivery.failed", {
623+
organization_id: input.organizationId,
624+
website_id: site.id,
625+
run_id: input.runId,
626+
});
627+
}
628+
}
629+
612630
emitInsightsEvent("info", "generation.website.completed", {
613631
organization_id: input.organizationId,
614632
website_id: input.websiteId,

apps/uptime/src/lib/evlog-uptime.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,5 @@ export function enrichUptimeWideEvent(ctx: EnrichContext): void {
108108
}
109109

110110
export async function flushBatchedUptimeDrain(): Promise<void> {
111-
await Promise.all([
112-
batchedAxiomDrain.flush(),
113-
batchedSuperlogDrain?.flush(),
114-
]);
111+
await Promise.all([batchedAxiomDrain.flush(), batchedSuperlogDrain?.flush()]);
115112
}

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/ai/src/ai/mcp/agent-tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { createAnnotationTools } from "../tools/annotations";
1010
import { createFlagTools } from "../tools/flags";
1111
import { createFunnelTools } from "../tools/funnels";
1212
import { createGoalTools } from "../tools/goals";
13+
import { createInsightDigestTools } from "../tools/insight-digest";
1314
import { createLinksTools } from "../tools/links";
1415
import { createMemoryTools } from "../tools/memory";
1516
import { executeAgentSqlForWebsite } from "../tools/execute-sql-query";
@@ -241,6 +242,7 @@ Critical schema footguns: website id column is client_id (not website_id); times
241242
...createGoalTools(),
242243
...createAnnotationTools(),
243244
...createLinksTools(),
245+
...createInsightDigestTools(),
244246
...createSlackConversationTools(options.slackContext),
245247
};
246248
}

packages/ai/src/ai/mcp/run-agent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ const THREAD_REFERENCE_PATTERN =
325325
const ANALYTICS_REQUEST_PATTERN =
326326
/\b(analytics?|metrics?|traffic|visitors?|sessions?|page\s*views?|pageviews?|top pages?|pages?|referrers?|sources?|campaigns?|conversions?|events?|errors?|vitals?|performance|uptime|revenue|transactions?|llm|latency|bounce|countries|country|regions?|cities|devices?|browsers?|operating systems?|utm|fresh|current|latest|live|rerun|last \d+|last week|last month|today|yesterday)\b/i;
327327
const NON_ANALYTICS_TOOL_PATTERN =
328-
/\b(remember|memory|forget|profile|profiles|flag|flags|feature flag|feature flags|funnel|funnels|goal|goals|annotation|annotations|link|links|short link|short links|create|update|delete|archive|enable|disable|rollout|target|folder|folders|navigate|open|go to|take me)\b/i;
328+
/\b(remember|memory|forget|profile|profiles|flag|flags|feature flag|feature flags|funnel|funnels|goal|goals|annotation|annotations|link|links|short link|short links|digest|digests|subscribe|unsubscribe|create|update|delete|archive|enable|disable|rollout|target|folder|folders|navigate|open|go to|take me)\b/i;
329329
const COPY_ONLY_PATTERN = /\b(exact copy|copy only)\b/i;
330330
const SLACK_FOLLOW_UP_OPEN_TAG = "<slack_follow_up";
331331
const SLACK_FOLLOW_UP_CLOSE_TAG = "</slack_follow_up>";

0 commit comments

Comments
 (0)