Skip to content

Commit 676b576

Browse files
authored
Merge pull request #540 from databuddy-analytics/staging
Release: identity segmentation, SDK reliability, ingest gates, OG rebrand
2 parents f10200e + 3eb00a3 commit 676b576

55 files changed

Lines changed: 2381 additions & 375 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/src/billing/autumn.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { auth } from "@databuddy/auth";
2+
import { getRedisCache } from "@databuddy/redis";
23
import { getBillingCustomerId, getMemberRole } from "@databuddy/rpc";
34
import { autumnHandler } from "autumn-js/fetch";
45
import { useLogger } from "evlog/elysia";
@@ -66,9 +67,102 @@ async function stripPrivilegedBody(request: Request): Promise<Request> {
6667

6768
const autumn = autumnHandler({ identify: identifyAutumnCustomer });
6869

70+
const AUTUMN_CACHE_TTL_SEC: Record<string, number> = {
71+
getOrCreateCustomer: 30,
72+
listPlans: 300,
73+
};
74+
75+
function autumnPathSegment(request: Request): string {
76+
const { pathname } = new URL(request.url);
77+
return pathname.split("/").at(-1) ?? "";
78+
}
79+
80+
function autumnCacheKey(segment: string, customerId: string): string {
81+
return `autumn:proxy:${segment}:${customerId}`;
82+
}
83+
84+
async function invalidateAutumnCustomerCache(request: Request) {
85+
const identity = await identifyAutumnCustomer(request).catch(() => null);
86+
if (!identity?.customerId) {
87+
return;
88+
}
89+
await getRedisCache()
90+
.del(autumnCacheKey("getOrCreateCustomer", identity.customerId))
91+
.catch(() => {});
92+
}
93+
94+
async function readAutumnCache(key: string): Promise<Response | null> {
95+
const hit = await getRedisCache()
96+
.get(key)
97+
.catch(() => null);
98+
if (!hit) {
99+
return null;
100+
}
101+
try {
102+
const { body, contentType } = JSON.parse(hit) as {
103+
body: string;
104+
contentType: string;
105+
};
106+
return new Response(body, {
107+
status: 200,
108+
headers: { "content-type": contentType },
109+
});
110+
} catch {
111+
return null;
112+
}
113+
}
114+
115+
async function writeAutumnCache(
116+
key: string,
117+
ttlSec: number,
118+
response: Response
119+
) {
120+
const body = await response.clone().text();
121+
await getRedisCache()
122+
.setex(
123+
key,
124+
ttlSec,
125+
JSON.stringify({
126+
body,
127+
contentType: response.headers.get("content-type") ?? "application/json",
128+
})
129+
)
130+
.catch(() => {});
131+
}
132+
69133
export async function handleAutumnRequest(request: Request) {
70134
const sanitized = await stripPrivilegedBody(request);
71-
return autumn(withAutumnApiPath(sanitized));
135+
const segment = autumnPathSegment(sanitized);
136+
const ttlSec = AUTUMN_CACHE_TTL_SEC[segment];
137+
138+
if (ttlSec === undefined) {
139+
const response = await autumn(withAutumnApiPath(sanitized));
140+
if (
141+
response.ok &&
142+
sanitized.method !== "GET" &&
143+
sanitized.method !== "HEAD"
144+
) {
145+
await invalidateAutumnCustomerCache(sanitized);
146+
}
147+
return response;
148+
}
149+
150+
const identity = await identifyAutumnCustomer(sanitized).catch(() => null);
151+
if (!identity?.customerId) {
152+
return autumn(withAutumnApiPath(sanitized));
153+
}
154+
155+
const key = autumnCacheKey(segment, identity.customerId);
156+
const cached = await readAutumnCache(key);
157+
if (cached) {
158+
return cached;
159+
}
160+
161+
const response = await autumn(withAutumnApiPath(sanitized));
162+
if (response.status === 200) {
163+
await writeAutumnCache(key, ttlSec, response);
164+
}
165+
return response;
72166
}
73167

74168
async function loadSession(request: Request) {

apps/api/src/integration/profile-handlers.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import {
77
} from "@databuddy/db/schema";
88
import { eq } from "@databuddy/db";
99
import { appRouter, type Context } from "@databuddy/rpc";
10-
import { splitTraits, upsertProfile } from "@databuddy/services/identity";
10+
import {
11+
resolveTraitSegment,
12+
splitTraits,
13+
upsertProfile,
14+
} from "@databuddy/services/identity";
1115
import {
1216
addToOrganization,
1317
cleanup,
@@ -246,3 +250,89 @@ describe("upsertProfile trait history", () => {
246250
).toHaveLength(0);
247251
});
248252
});
253+
254+
describe("profiles.traitKeys / traitValues", () => {
255+
iit("lists distinct keys and values for the website only", async () => {
256+
const user = await signUp();
257+
const org = await insertOrganization();
258+
await addToOrganization(user.id, org.id, "member");
259+
const website = await insertWebsite({ organizationId: org.id });
260+
const otherWebsite = await insertWebsite({ organizationId: org.id });
261+
await seedProfile(website.id, "user_1", {
262+
traits: { plan: "pro", beta: true },
263+
});
264+
await seedProfile(website.id, "user_2", { traits: { plan: "free" } });
265+
await seedProfile(otherWebsite.id, "user_3", {
266+
traits: { region: "eu" },
267+
});
268+
const ctx = userContext(user, org.id);
269+
270+
const keys = await call(appRouter.profiles.traitKeys, ctx)({
271+
websiteId: website.id,
272+
});
273+
expect(keys).toEqual(["beta", "plan"]);
274+
275+
const values = await call(appRouter.profiles.traitValues, ctx)({
276+
websiteId: website.id,
277+
key: "plan",
278+
});
279+
expect(values).toEqual(["free", "pro"]);
280+
});
281+
282+
iit("rejects non-members", async () => {
283+
const user = await signUp();
284+
const org = await insertOrganization();
285+
const website = await insertWebsite({ organizationId: org.id });
286+
const ctx = userContext(user, org.id);
287+
288+
await expectCode(
289+
call(appRouter.profiles.traitKeys, ctx)({ websiteId: website.id }),
290+
"FORBIDDEN"
291+
);
292+
});
293+
});
294+
295+
describe("resolveTraitSegment", () => {
296+
iit("resolves profile ids by trait predicate scoped to the website", async () => {
297+
const org = await insertOrganization();
298+
const website = await insertWebsite({ organizationId: org.id });
299+
const otherWebsite = await insertWebsite({ organizationId: org.id });
300+
await seedProfile(website.id, "user_1", { traits: { plan: "pro" } });
301+
await seedProfile(website.id, "user_2", { traits: { plan: "free" } });
302+
await seedProfile(website.id, "user_3", {
303+
traits: { plan: "pro", beta: true },
304+
});
305+
await seedProfile(otherWebsite.id, "user_4", { traits: { plan: "pro" } });
306+
307+
const pro = await resolveTraitSegment(website.id, [
308+
{ field: "trait:plan", op: "eq", value: "pro" },
309+
]);
310+
expect(pro.sort()).toEqual(["user_1", "user_3"]);
311+
312+
const proBeta = await resolveTraitSegment(website.id, [
313+
{ field: "trait:plan", op: "eq", value: "pro" },
314+
{ field: "trait:beta", op: "eq", value: "true" },
315+
]);
316+
expect(proBeta).toEqual(["user_3"]);
317+
318+
const notPro = await resolveTraitSegment(website.id, [
319+
{ field: "trait:plan", op: "ne", value: "pro" },
320+
]);
321+
expect(notPro).toEqual(["user_2"]);
322+
323+
const inList = await resolveTraitSegment(website.id, [
324+
{ field: "trait:plan", op: "in", value: ["free", "trial"] },
325+
]);
326+
expect(inList).toEqual(["user_2"]);
327+
328+
const emptyIn = await resolveTraitSegment(website.id, [
329+
{ field: "trait:plan", op: "in", value: [] },
330+
]);
331+
expect(emptyIn).toEqual([]);
332+
333+
const emptyNotIn = await resolveTraitSegment(website.id, [
334+
{ field: "trait:plan", op: "not_in", value: [] },
335+
]);
336+
expect(emptyNotIn.sort()).toEqual(["user_1", "user_2", "user_3"]);
337+
});
338+
});

apps/api/src/routes/query.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import {
1010
import { and, db, eq, inArray } from "@databuddy/db";
1111
import { profiles } from "@databuddy/db/schema";
1212
import { config } from "@databuddy/env/app";
13-
import { revealPii } from "@databuddy/services/identity";
13+
import {
14+
isTraitFilterField,
15+
resolveTraitSegment,
16+
revealPii,
17+
type TraitFilter,
18+
TraitFilterError,
19+
} from "@databuddy/services/identity";
1420
import { validateTimezone } from "@databuddy/validation";
1521
import { readBooleanEnv } from "@databuddy/env/boolean";
1622
import { ratelimit } from "@databuddy/redis/rate-limit";
@@ -882,8 +888,6 @@ interface QueryResult {
882888
success: boolean;
883889
}
884890

885-
const PROFILE_ENRICHED_TYPES = new Set(["profile_list"]);
886-
887891
async function attachProfileIdentities(
888892
websiteId: string,
889893
results: QueryResult[]
@@ -959,6 +963,39 @@ async function executeDynamicQuery(
959963
? (scope?.organizationWebsiteIds ?? [])
960964
: undefined;
961965

966+
const requestFilters = (request.filters || []) as Filter[];
967+
const traitFilters = requestFilters.filter((f) =>
968+
isTraitFilterField(f.field)
969+
);
970+
let effectiveFilters = requestFilters;
971+
let traitError: string | null = null;
972+
if (traitFilters.length > 0) {
973+
if (projectType === "website") {
974+
try {
975+
const segment = await resolveTraitSegment(
976+
projectId,
977+
traitFilters as TraitFilter[]
978+
);
979+
effectiveFilters = [
980+
...requestFilters.filter((f) => !isTraitFilterField(f.field)),
981+
{ field: "profile_id", op: "in", value: segment },
982+
];
983+
} catch (error) {
984+
if (error instanceof TraitFilterError) {
985+
traitError = error.message;
986+
} else {
987+
captureError(error, {
988+
route: "v1/query",
989+
step: "resolve_trait_segment",
990+
});
991+
traitError = "Trait filter failed";
992+
}
993+
}
994+
} else {
995+
traitError = "Trait filters are only supported for website queries";
996+
}
997+
}
998+
962999
// Org-level custom_events queries: builder scans by owner_id (= organizationId
9631000
// set at ingestion) via primary key instead of matching website_id.
9641001
const hasCustomEventsQueries = request.parameters.some((param) => {
@@ -983,6 +1020,16 @@ async function executeDynamicQuery(
9831020
return { id, error: `Unknown query type: ${name}` };
9841021
}
9851022

1023+
if (traitError) {
1024+
return { id, error: traitError };
1025+
}
1026+
if (
1027+
traitFilters.length > 0 &&
1028+
!isFilterFieldAllowed(config, "profile_id")
1029+
) {
1030+
return { id, error: `Trait filters are not supported for ${name}` };
1031+
}
1032+
9861033
if (
9871034
(paramFrom && !isNormalizedQueryDate(paramFrom)) ||
9881035
(paramTo && !isNormalizedQueryDate(paramTo))
@@ -1015,7 +1062,7 @@ async function executeDynamicQuery(
10151062
paramFrom,
10161063
paramTo
10171064
),
1018-
filters: ((request.filters || []) as Filter[]).filter((f) =>
1065+
filters: effectiveFilters.filter((f) =>
10191066
isFilterFieldAllowed(config, f.field)
10201067
),
10211068
limit: request.limit || 100,
@@ -1078,7 +1125,7 @@ async function executeDynamicQuery(
10781125

10791126
if (projectType === "website") {
10801127
const profileResults = validParameters
1081-
.filter((p) => PROFILE_ENRICHED_TYPES.has(p.request.type))
1128+
.filter((p) => p.request.type === "profile_list")
10821129
.flatMap((p) => resultMap.get(p.id) ?? []);
10831130
if (profileResults.length > 0) {
10841131
await attachProfileIdentities(projectId, profileResults).catch(

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ describe("pii protection", () => {
184184

185185
describe("denyApiKeyIdentify", () => {
186186
const orgKey = { id: "key_1", organizationId: "org_1" } as ApiKeyRow;
187-
const website = { organizationId: "org_1" };
187+
const website = { organizationId: "org_1", status: "ACTIVE" };
188188
const scopeMock = vi.mocked(hasWebsiteScope);
189189

190190
test("requires a websiteId", () => {
@@ -211,10 +211,23 @@ describe("denyApiKeyIdentify", () => {
211211
test("rejects websites from another organization", () => {
212212
scopeMock.mockReturnValueOnce(true);
213213
expect(
214-
denyApiKeyIdentify(orgKey, "site_1", { organizationId: "org_2" })
214+
denyApiKeyIdentify(orgKey, "site_1", {
215+
organizationId: "org_2",
216+
status: "ACTIVE",
217+
})
215218
).toBe("website_scope_mismatch");
216219
});
217220

221+
test("rejects websites that are not active", () => {
222+
scopeMock.mockReturnValueOnce(true);
223+
expect(
224+
denyApiKeyIdentify(orgKey, "site_1", {
225+
organizationId: "org_1",
226+
status: "INACTIVE",
227+
})
228+
).toBe("website_not_active");
229+
});
230+
218231
test("rejects keys without an organization", () => {
219232
scopeMock.mockReturnValueOnce(true);
220233
const userKey = { id: "key_2", organizationId: null } as ApiKeyRow;

apps/basket/src/routes/identify.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,13 @@ export type ApiKeyIdentifyDenial =
2525
| "missing_website_id"
2626
| "missing_scope"
2727
| "website_not_found"
28-
| "website_scope_mismatch";
28+
| "website_scope_mismatch"
29+
| "website_not_active";
2930

3031
export function denyApiKeyIdentify(
3132
apiKey: ApiKeyRow,
3233
websiteId: string | undefined,
33-
website: { organizationId: string | null } | null
34+
website: { organizationId: string | null; status: string } | null
3435
): ApiKeyIdentifyDenial | null {
3536
if (!websiteId) {
3637
return "missing_website_id";
@@ -47,6 +48,9 @@ export function denyApiKeyIdentify(
4748
) {
4849
return "website_scope_mismatch";
4950
}
51+
if (website.status !== "ACTIVE") {
52+
return "website_not_active";
53+
}
5054
return null;
5155
}
5256

@@ -55,6 +59,7 @@ const DENIAL_ERRORS: Record<ApiKeyIdentifyDenial, () => Error> = {
5559
missing_scope: basketErrors.trackMissingScope,
5660
website_not_found: basketErrors.trackWebsiteNotFound,
5761
website_scope_mismatch: basketErrors.trackWebsiteScopeMismatch,
62+
website_not_active: basketErrors.trackWebsiteNotFound,
5863
};
5964

6065
type IdentifyTarget =

0 commit comments

Comments
 (0)