Skip to content

Commit bd86a7f

Browse files
authored
Merge pull request #538 from databuddy-analytics/staging
Trait history, billing lifecycle events, and ingestion credential scrubbing
2 parents d0d6368 + 4422b2e commit bd86a7f

31 files changed

Lines changed: 831 additions & 150 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,7 @@ NOTRA_API_KEY=
8787
# Superlog OTLP logs export. When set, evlog drains in api/basket/insights/slack/uptime
8888
# also ship wide events to https://intake.superlog.sh/v1/logs.
8989
SUPERLOG_API_KEY=""
90+
91+
# Website id used for dogfooding our own product analytics (billing lifecycle
92+
# events and plan traits written server-side). Leave empty to disable.
93+
SELF_ANALYTICS_WEBSITE_ID=""

.github/workflows/ci.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,19 @@ jobs:
111111
--health-interval 10s
112112
--health-timeout 5s
113113
--health-retries 5
114+
clickhouse:
115+
image: clickhouse/clickhouse-server:25.5.1-alpine
116+
env:
117+
CLICKHOUSE_DB: databuddy_analytics
118+
CLICKHOUSE_USER: default
119+
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
120+
ports:
121+
- 8123:8123
122+
options: >-
123+
--health-cmd "clickhouse-client --query 'SELECT 1'"
124+
--health-interval 10s
125+
--health-timeout 5s
126+
--health-retries 5
114127
115128
steps:
116129
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -132,6 +145,10 @@ jobs:
132145
- run: bun install --frozen-lockfile --ignore-scripts
133146
- name: Push test schema
134147
run: cd packages/db && bunx drizzle-kit push
148+
- name: Init ClickHouse schema
149+
env:
150+
CLICKHOUSE_URL: http://default:@localhost:8123
151+
run: bun run --cwd packages/db clickhouse:init
135152
- name: Test
136153
env:
137154
NODE_ENV: test

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@databuddy/redis": "workspace:*",
2424
"@databuddy/rpc": "workspace:*",
2525
"@databuddy/sdk": "workspace:*",
26+
"@databuddy/services": "workspace:*",
2627
"@databuddy/shared": "workspace:*",
2728
"@databuddy/validation": "workspace:*",
2829
"@elysiajs/cors": "^1.4.1",

apps/api/src/routes/query.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import {
77
hasKeyScope,
88
isApiKeyPresent,
99
} from "@databuddy/api-keys/resolve";
10-
import { db } from "@databuddy/db";
10+
import { and, db, eq, inArray } from "@databuddy/db";
11+
import { profiles } from "@databuddy/db/schema";
1112
import { config } from "@databuddy/env/app";
13+
import { revealPii } from "@databuddy/services/identity";
1214
import { validateTimezone } from "@databuddy/validation";
1315
import { readBooleanEnv } from "@databuddy/env/boolean";
1416
import { ratelimit } from "@databuddy/redis/rate-limit";
@@ -880,6 +882,53 @@ interface QueryResult {
880882
success: boolean;
881883
}
882884

885+
const PROFILE_ENRICHED_TYPES = new Set(["profile_list"]);
886+
887+
async function attachProfileIdentities(
888+
websiteId: string,
889+
results: QueryResult[]
890+
) {
891+
const profileIds = new Set<string>();
892+
for (const result of results) {
893+
for (const row of result.data) {
894+
if (typeof row.profile_id === "string" && row.profile_id) {
895+
profileIds.add(row.profile_id);
896+
}
897+
}
898+
}
899+
if (profileIds.size === 0) {
900+
return;
901+
}
902+
903+
const identities = await db
904+
.select({
905+
profileId: profiles.profileId,
906+
displayName: profiles.displayName,
907+
email: profiles.email,
908+
})
909+
.from(profiles)
910+
.where(
911+
and(
912+
eq(profiles.websiteId, websiteId),
913+
inArray(profiles.profileId, [...profileIds])
914+
)
915+
);
916+
const identityByProfileId = new Map(
917+
identities.map((identity) => [identity.profileId, identity])
918+
);
919+
920+
for (const result of results) {
921+
for (const row of result.data) {
922+
const identity =
923+
typeof row.profile_id === "string"
924+
? identityByProfileId.get(row.profile_id)
925+
: undefined;
926+
row.display_name = identity ? revealPii(identity.displayName) : null;
927+
row.email = identity ? revealPii(identity.email) : null;
928+
}
929+
}
930+
}
931+
883932
async function executeDynamicQuery(
884933
request: DynamicQueryRequestType,
885934
projectId: string,
@@ -1026,6 +1075,22 @@ async function executeDynamicQuery(
10261075
});
10271076
}
10281077
}
1078+
1079+
if (projectType === "website") {
1080+
const profileResults = validParameters
1081+
.filter((p) => PROFILE_ENRICHED_TYPES.has(p.request.type))
1082+
.flatMap((p) => resultMap.get(p.id) ?? []);
1083+
if (profileResults.length > 0) {
1084+
await attachProfileIdentities(projectId, profileResults).catch(
1085+
(error) => {
1086+
captureError(error, {
1087+
route: "v1/query",
1088+
step: "attach_profile_identities",
1089+
});
1090+
}
1091+
);
1092+
}
1093+
}
10291094
}
10301095

10311096
const allResults = prepared.map(

apps/api/src/routes/webhooks/autumn.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ vi.mock("@databuddy/redis", () => ({
9292
invalidateBillingOwnerCaches: vi.fn(async () => ({ attempted: 0, failed: 0 })),
9393
}));
9494

95+
vi.mock("@databuddy/services/billing-lifecycle", () => ({
96+
recordPlanChange: vi.fn(async () => undefined),
97+
}));
98+
9599
vi.mock("elysia", () => ({
96100
Elysia: class {
97101
post() {

apps/api/src/routes/webhooks/autumn.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
invalidateAgentContextSnapshotsForOwner,
1818
invalidateBillingOwnerCaches,
1919
} from "@databuddy/redis";
20+
import { recordPlanChange } from "@databuddy/services/billing-lifecycle";
2021
import { Elysia } from "elysia";
2122
import { useLogger } from "evlog/elysia";
2223
import { Resend } from "resend";
@@ -324,9 +325,24 @@ async function handleProductsUpdated(
324325
});
325326
await invalidatePlanCaches(customer.id);
326327

327-
const shouldSkipSlack =
328-
!slack ||
329-
(process.env.NODE_ENV === "production" && customer.env === "sandbox");
328+
const isSandboxInProduction =
329+
process.env.NODE_ENV === "production" && customer.env === "sandbox";
330+
331+
if (customer.id && !isSandboxInProduction) {
332+
try {
333+
await recordPlanChange({
334+
customerId: customer.id,
335+
planId: updated_product.id,
336+
scenario,
337+
});
338+
} catch (error) {
339+
log.error(error instanceof Error ? error : new Error(String(error)), {
340+
autumn: { step: "record_plan_change", customerId: customer.id },
341+
});
342+
}
343+
}
344+
345+
const shouldSkipSlack = !slack || isSandboxInProduction;
330346

331347
if (shouldSkipSlack) {
332348
return { success: true, message: `Processed ${scenario}` };

apps/basket/src/lib/blocked-traffic.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { runFork, send } from "@lib/producer";
77
import { captureError } from "@lib/tracing";
88
import { extractIpFromRequest, getGeo } from "@utils/ip-geo";
99
import { parseUserAgent } from "@utils/user-agent";
10-
import { sanitizeString, VALIDATION_LIMITS } from "@utils/validation";
10+
import {
11+
sanitizeString,
12+
sanitizeUrl,
13+
VALIDATION_LIMITS,
14+
} from "@utils/validation";
1115
import { randomUUIDv7 } from "bun";
1216

1317
async function _logBlockedTrafficAsync(
@@ -41,12 +45,12 @@ async function _logBlockedTrafficAsync(
4145
client_id: clientId || "",
4246
timestamp: now,
4347

44-
path: sanitizeString(body?.path, VALIDATION_LIMITS.STRING_MAX_LENGTH),
45-
url: sanitizeString(
48+
path: sanitizeUrl(body?.path, VALIDATION_LIMITS.STRING_MAX_LENGTH),
49+
url: sanitizeUrl(
4650
body?.url || body?.href,
4751
VALIDATION_LIMITS.STRING_MAX_LENGTH
4852
),
49-
referrer: sanitizeString(
53+
referrer: sanitizeUrl(
5054
body?.referrer || request.headers.get("referer"),
5155
VALIDATION_LIMITS.STRING_MAX_LENGTH
5256
),

apps/basket/src/lib/event-service.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { extractTrustedClientIp, getGeo } from "@utils/ip-geo";
1717
import { parseUserAgent } from "@utils/user-agent";
1818
import {
1919
sanitizeString,
20+
sanitizeUrl,
2021
VALIDATION_LIMITS,
2122
validatePerformanceMetric,
2223
validateSessionId,
@@ -77,12 +78,12 @@ export function buildTrackEvent(
7778
event_id: ctx.eventId,
7879
session_start_time: sessionStartTime,
7980
timestamp,
80-
referrer: sanitizeString(
81+
referrer: sanitizeUrl(
8182
trackData.referrer,
8283
VALIDATION_LIMITS.STRING_MAX_LENGTH
8384
),
84-
url: sanitizeString(trackData.path, VALIDATION_LIMITS.STRING_MAX_LENGTH),
85-
path: sanitizeString(trackData.path, VALIDATION_LIMITS.STRING_MAX_LENGTH),
85+
url: sanitizeUrl(trackData.path, VALIDATION_LIMITS.STRING_MAX_LENGTH),
86+
path: sanitizeUrl(trackData.path, VALIDATION_LIMITS.STRING_MAX_LENGTH),
8687
title: sanitizeString(trackData.title, VALIDATION_LIMITS.STRING_MAX_LENGTH),
8788
ip: ctx.geo.anonymizedIP || "",
8889
user_agent: "",
@@ -242,7 +243,7 @@ export function insertOutgoingLink(
242243
salt
243244
),
244245
session_id: validateSessionId(linkData.sessionId),
245-
href: sanitizeString(linkData.href, VALIDATION_LIMITS.PATH_MAX_LENGTH),
246+
href: sanitizeUrl(linkData.href, VALIDATION_LIMITS.PATH_MAX_LENGTH),
246247
text: sanitizeString(linkData.text, VALIDATION_LIMITS.TEXT_MAX_LENGTH),
247248
properties: linkData.properties
248249
? JSON.stringify(linkData.properties)
@@ -293,7 +294,7 @@ export function insertErrorSpans(
293294
),
294295
session_id: validateSessionId(error.sessionId),
295296
timestamp: typeof error.timestamp === "number" ? error.timestamp : now,
296-
path: sanitizeString(error.path, VALIDATION_LIMITS.STRING_MAX_LENGTH),
297+
path: sanitizeUrl(error.path, VALIDATION_LIMITS.STRING_MAX_LENGTH),
297298
message: sanitizeString(
298299
error.message,
299300
VALIDATION_LIMITS.STRING_MAX_LENGTH
@@ -342,7 +343,7 @@ export function insertIndividualVitals(
342343
),
343344
session_id: validateSessionId(vital.sessionId),
344345
timestamp: typeof vital.timestamp === "number" ? vital.timestamp : now,
345-
path: sanitizeString(vital.path, VALIDATION_LIMITS.STRING_MAX_LENGTH),
346+
path: sanitizeUrl(vital.path, VALIDATION_LIMITS.STRING_MAX_LENGTH),
346347
metric_name: vital.metricName,
347348
metric_value: vital.metricValue,
348349
}));
@@ -407,7 +408,7 @@ export function insertCustomEvents(
407408
)
408409
: undefined,
409410
path: event.path
410-
? sanitizeString(event.path, VALIDATION_LIMITS.STRING_MAX_LENGTH)
411+
? sanitizeUrl(event.path, VALIDATION_LIMITS.STRING_MAX_LENGTH)
411412
: undefined,
412413
properties: event.properties ? JSON.stringify(event.properties) : "{}",
413414
anonymous_id: event.anonymous_id

apps/basket/src/routes/identify.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ vi.mock("@databuddy/db", () => ({
44
db: {},
55
profiles: {},
66
profileAliases: {},
7+
profileTraitChanges: {},
78
sql: () => {},
9+
and: vi.fn(),
10+
eq: vi.fn(),
811
}));
912
vi.mock("@databuddy/redis/rate-limit", () => ({ ratelimit: vi.fn() }));
1013
vi.mock("@lib/request-validation", () => ({
@@ -19,6 +22,7 @@ vi.mock("@lib/api-key", () => ({
1922
vi.mock("evlog/elysia", () => ({ useLogger: () => ({ set: vi.fn() }) }));
2023

2124
import {
25+
applyTraits,
2226
emailLookupHash,
2327
protectPii,
2428
revealPii,
@@ -95,6 +99,52 @@ describe("splitTraits", () => {
9599
});
96100
});
97101

102+
describe("applyTraits", () => {
103+
test("first identify reports every trait as a change from null", () => {
104+
const { changes, traits } = applyTraits({}, { plan: "free", seats: 1 }, []);
105+
expect(traits).toEqual({ plan: "free", seats: 1 });
106+
expect(changes).toEqual([
107+
{ traitKey: "plan", oldValue: null, newValue: "free" },
108+
{ traitKey: "seats", oldValue: null, newValue: 1 },
109+
]);
110+
});
111+
112+
test("changed value carries old and new, unchanged values are silent", () => {
113+
const { changes, traits } = applyTraits(
114+
{ plan: "free", seats: 1 },
115+
{ plan: "pro", seats: 1 },
116+
[]
117+
);
118+
expect(traits).toEqual({ plan: "pro", seats: 1 });
119+
expect(changes).toEqual([
120+
{ traitKey: "plan", oldValue: "free", newValue: "pro" },
121+
]);
122+
});
123+
124+
test("removed keys drop from the snapshot and report null", () => {
125+
const { changes, traits } = applyTraits(
126+
{ plan: "pro", beta: true },
127+
{},
128+
["beta"]
129+
);
130+
expect(traits).toEqual({ plan: "pro" });
131+
expect(changes).toEqual([
132+
{ traitKey: "beta", oldValue: true, newValue: null },
133+
]);
134+
});
135+
136+
test("removing an absent key changes nothing", () => {
137+
const { changes, traits } = applyTraits({ plan: "pro" }, {}, ["missing"]);
138+
expect(traits).toEqual({ plan: "pro" });
139+
expect(changes).toEqual([]);
140+
});
141+
142+
test("type changes between same-looking values are detected", () => {
143+
const { changes } = applyTraits({ seats: "1" }, { seats: 1 }, []);
144+
expect(changes).toEqual([{ traitKey: "seats", oldValue: "1", newValue: 1 }]);
145+
});
146+
});
147+
98148
describe("pii protection", () => {
99149
const KEY = "test-identity-encryption-key";
100150

apps/basket/src/routes/identify.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export const identifyRoute = new Elysia().post(
147147
hasAlias: Boolean(anonymousId),
148148
});
149149

150-
await Promise.all([
150+
const [update] = await Promise.all([
151151
record("upsertProfile", () =>
152152
upsertProfile(websiteId, profileId, splitTraits(traits))
153153
),
@@ -158,6 +158,10 @@ export const identifyRoute = new Elysia().post(
158158
: Promise.resolve(),
159159
]);
160160

161+
if (update && update.changes.length > 0) {
162+
log.set({ traitChanges: update.changes.length });
163+
}
164+
161165
return Response.json({ status: "success", type: "identify" });
162166
} catch (error) {
163167
rethrowOrWrap(error, log);

0 commit comments

Comments
 (0)