Skip to content

Commit 177f068

Browse files
authored
Merge pull request #492 from databuddy-analytics/staging
Release: staging -> main (privacy controls, insights, AI investigation pipeline)
2 parents c55faf5 + 261fab5 commit 177f068

141 files changed

Lines changed: 4192 additions & 1338 deletions

File tree

Some content is hidden

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

.agents/skills/databuddy-internal/SKILL.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ When a mistake could have been avoided with better repo context (wrong app, pack
1515

1616
Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—enough that the next session does not repeat it. If the lesson is for SDK/API customers, add it under `.agents/skills/databuddy/` instead.
1717

18+
- Repo-local `.agents/skills` is versioned project guidance. Put new personal or experimental agent skills under `/Users/iza/.agents/skills` unless the user explicitly wants the skill committed with Databuddy.
19+
1820
## Quick Map
1921

2022
- Prod infrastructure repo is local at `/Users/iza/Documents/GitHub/databuddy-infra` (`databuddy-analytics/infra`); ClickHouse cluster inventory is `clickhouse/ansible/inventory.yml`, not `/Users/iza/Dev/Databuddy/infra` or `DatabuddyOPS`.
@@ -24,6 +26,7 @@ Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—eno
2426
- Local E2E dashboard smokes that need `/api/test/e2e/*` should start the API/dashboard directly (or through Playwright's webServer command), not via `bun run dev:dashboard`; Turbo runs in strict env mode and drops `DATABUDDY_E2E_MODE`/`DATABUDDY_E2E_TEST_KEY` unless they are added to `turbo.json` `globalEnv`.
2527
- Dashboard Playwright public/demo analytics specs call API `/v1/query` anonymously from the browser; keep `DATABUDDY_E2E_MODE` query behavior isolated from production rate limits so CI retries do not exhaust `anon:unknown`.
2628
- `apps/api`: Elysia API on port `3001`
29+
- Public REST docs live in `apps/api/src/rpc/openapi.ts`: `/spec.json` is the generated spec, `/` is the reference UI, and hiding a router there also makes its top-level REST paths return 404 because `/*` uses the same filtered docs router.
2730
- `apps/slack`: Slack agent adapter; Slack installs must resolve through org-scoped DB integration records, not a single env bot token/default website. Agent calls must use an encrypted per-integration Databuddy API key secret as a normal bearer token, never a global internal secret.
2831
- Slack OAuth lives in `apps/api`, but slash commands/events require `apps/slack` to be running too; local `bun run dev:dashboard` runs dashboard + API only, so use `bun run dev:slack` when working on Slack. The Slack package scripts read the root `.env`.
2932
- Slack routing is organization-scoped: OAuth binds a Slack workspace to a Databuddy organization, app mentions from the installed workspace auto-bind channels including Slack Connect, and `/bind` is now a manual fallback for unknown/unapproved channels. DMs/assistant threads work after workspace install. Analytics questions should go through app mentions/DMs using MCP-style website discovery inside the installed organization, never by fanning out across the message sender's user memberships. Slack emits evlog events under `apps/slack/.evlog/logs` in development/`SLACK_EVLOG_FS=1`; Axiom uses `AXIOM_TOKEN` with `SLACK_AXIOM_DATASET` defaulting to `slack`; and reactions need the `reactions:write` bot scope. Remote manifest updates need `SLACK_APP_ID` plus a Slack app configuration token in `SLACK_APP_CONFIG_TOKEN`; trust Slack API errors over token-prefix guesses.
@@ -32,6 +35,7 @@ Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—eno
3235
- Slack memory is separate from billing/auth: pass a Slack-scoped `memoryUserId` such as `slack-{team}-{user}` plus current-speaker context so one Slack user's saved name/preferences do not bleed into another user's replies.
3336
- Slack agent write tools need the integration automation API key to include the matching Databuddy API scopes (currently `read:data`, `read:links`, `write:links`, `manage:websites`, `manage:flags`); older installs may need reconnecting so a new key is minted.
3437
- Shared agent integrations should call `@databuddy/ai/agent` (`askDatabuddyAgent` / `streamDatabuddyAgent`) instead of importing internal MCP run/history helpers directly.
38+
- First-party ads attribution work should start by preserving UTMs into registration and signup events only; do not add RPC plumbing, conversion destinations, env hooks, tables, workers, or UI until explicitly needed.
3539
- Insights generation logic belongs in `apps/insights` and should reuse `@databuddy/ai`; `apps/api` should only read insight data or queue runs, not own prompts, model calls, tool loops, validation, or persistence orchestration.
3640
- Agent ClickHouse SQL must use the canonical analytics.events schema: `client_id`, `time`, `path`, `event_name`, and pageviews as `event_name = 'screen_view'`; never `website_id`, `created_at`, `page_path`, `event_type`, or `pageview`.
3741
- Slack agent evals live in `packages/evals`: use `bun run eval --surface slack` for the whole Slack surface. `--tag slack` is only a tiny smoke subset, and `cost_fallback` in agent telemetry is pricing-catalog fallback, not proof the model request fell back.
@@ -159,6 +163,8 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin
159163

160164
- Published SDK logic: `packages/sdk/src`
161165
- Browser tracker bundle: `packages/tracker/src`
166+
- Public SDK/tracker visitor ID privacy is only `anonymizeVisitorIds` (`true`/omitted = anonymized, `false` = raw IDs, `"auto"` = raw only in Databuddy's conservative country allowlist).
167+
- Keep visitor ID privacy internals small and direct; avoid exported helper stacks or storage/hashing vocabulary for this option.
162168
- If the user reports missing analytics events, inspect both the producer side and `apps/basket`
163169

164170
## Verification

.agents/skills/databuddy/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Read [troubleshooting.md](./references/troubleshooting.md) for CSP, blockers, wr
4747
- API auth accepts `x-api-key: dbdy_...` or `Authorization: Bearer dbdy_...`; prefer `x-api-key` for examples unless docs for that endpoint use Bearer.
4848
- `websiteId` in event tracking means the public website client id, the same kind of value used by `data-client-id`; it is not necessarily an internal UUID.
4949
- For server-side attribution, collect `{ anonId, sessionId }` in the browser with `getTrackingIds()`, pass them to your backend, then map them to Node SDK fields `anonymousId` and `sessionId`.
50+
- Visitor ID privacy is configured with `anonymizeVisitorIds` (`true`/omitted = anonymized, `false` = keeps raw IDs, `"auto"` = raw only in Databuddy's conservative country allowlist); avoid internal wording like storage or hashing in public examples.
5051
- Do not invent a Databuddy `identify()` helper or endpoint. If an app has an identify route, treat it as the user's own backend code carrying Databuddy tracking IDs.
5152
- Feature flag evaluation uses the main API host, not basket. It needs a website `clientId`, not API-key-only auth.
5253
- Feature flag management uses `manage:flags` with `x-api-key`; do not expose that key in browser source. DevTools accepts it at runtime for local/preview flag management.

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ OPR_API_KEY=""
2727
# Not necessary, disabled if node env is development
2828
AUTUMN_SECRET_KEY="autumn pls sponsor me ily"
2929

30-
# Important for local
30+
# Local dev default. Root build/start scripts force NODE_ENV=production.
3131
NODE_ENV=development
3232

3333
# Not necessary, except for github oauth

.github/workflows/dashboard-e2e.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,10 @@ jobs:
9999
- run: bun install --frozen-lockfile --ignore-scripts
100100
- name: Build dashboard runtime packages
101101
run: bunx turbo run build --filter @databuddy/cache --filter @databuddy/devtools --filter @databuddy/sdk
102-
- run: bunx playwright install --with-deps chromium
102+
- name: Install dashboard Playwright browser
103+
run: |
104+
bun run --cwd apps/dashboard playwright --version
105+
bun run --cwd apps/dashboard playwright install --with-deps chromium
103106
- name: Run dashboard E2E
104107
run: |
105108
if [[ "${{ github.event_name }}" == "pull_request" ]]; then

apps/api/src/bootstrap/logger.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { databuddyEvlogRedaction } from "@databuddy/shared/evlog-redaction";
12
import { initLogger } from "evlog";
23
import { apiLoggerDrain } from "@/lib/evlog-api";
34

45
export function configureApiLogger() {
56
initLogger({
67
env: { service: "api" },
8+
redact: databuddyEvlogRedaction,
79
drain: apiLoggerDrain,
810
sampling: {
911
rates: { info: 20, warn: 50, debug: 5 },

apps/api/src/http/errors.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { createError } from "evlog";
2+
import { afterEach, describe, expect, it } from "vitest";
3+
import { handleAppError } from "./errors";
4+
5+
const originalNodeEnv = process.env.NODE_ENV;
6+
7+
afterEach(() => {
8+
process.env.NODE_ENV = originalNodeEnv;
9+
});
10+
11+
async function readPayload(response: Response): Promise<Record<string, unknown>> {
12+
return response.json() as Promise<Record<string, unknown>>;
13+
}
14+
15+
describe("handleAppError", () => {
16+
it("masks structured 5xx details in production", async () => {
17+
process.env.NODE_ENV = "production";
18+
const response = handleAppError({
19+
error: createError({
20+
code: "api.SECRET_FAILURE",
21+
message: "Database password leaked into error",
22+
status: 500,
23+
why: "Internal connection string failed",
24+
fix: "Rotate credentials",
25+
link: "https://internal.example.com/runbook",
26+
}),
27+
});
28+
29+
expect(response.status).toBe(500);
30+
expect(await readPayload(response)).toEqual({
31+
success: false,
32+
error: "An internal server error occurred",
33+
code: "api.SECRET_FAILURE",
34+
});
35+
});
36+
37+
it("keeps structured 4xx details visible in production", async () => {
38+
process.env.NODE_ENV = "production";
39+
const response = handleAppError({
40+
error: createError({
41+
code: "api.BAD_INPUT",
42+
message: "Invalid filter",
43+
status: 400,
44+
why: "The filter operator is unsupported.",
45+
fix: "Use one of the documented operators.",
46+
}),
47+
});
48+
49+
expect(response.status).toBe(400);
50+
expect(await readPayload(response)).toEqual({
51+
success: false,
52+
error: "Invalid filter",
53+
code: "api.BAD_INPUT",
54+
why: "The filter operator is unsupported.",
55+
fix: "Use one of the documented operators.",
56+
});
57+
});
58+
});

apps/api/src/http/errors.ts

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parseError } from "evlog";
1+
import { EvlogError, parseError } from "evlog";
22

33
interface AppErrorContext {
44
code?: string | number;
@@ -30,15 +30,21 @@ export function handleAppError({ error, code }: AppErrorContext) {
3030
error,
3131
parsedStatus: parsed.status,
3232
});
33-
const errorCode = code == null ? "INTERNAL_SERVER_ERROR" : String(code);
33+
const errorCode = getErrorCode({
34+
explicitCode: code,
35+
parsedCode: parsed.code,
36+
});
3437
const isDevelopment = process.env.NODE_ENV === "development";
3538
const isClientError = statusCode >= 400 && statusCode < 500;
36-
const errorMessage = error instanceof Error ? error.message : String(error);
37-
const safeClientError =
38-
isDevelopment || isClientError
39-
? errorMessage
40-
: "An internal server error occurred";
41-
const exposeStructured = isDevelopment || isClientError;
39+
const exposeStructured =
40+
isDevelopment || (isClientError && isStructuredError(error));
41+
const safeClientError = getSafeErrorMessage({
42+
code: errorCode,
43+
error,
44+
isDevelopment,
45+
isClientError,
46+
statusCode,
47+
});
4248

4349
return new Response(
4450
JSON.stringify({
@@ -55,6 +61,96 @@ export function handleAppError({ error, code }: AppErrorContext) {
5561
);
5662
}
5763

64+
function getErrorCode({
65+
explicitCode,
66+
parsedCode,
67+
}: {
68+
explicitCode?: string | number;
69+
parsedCode: unknown;
70+
}): string {
71+
if (typeof parsedCode === "string" && parsedCode !== "") {
72+
return parsedCode;
73+
}
74+
return explicitCode == null ? "INTERNAL_SERVER_ERROR" : String(explicitCode);
75+
}
76+
77+
function getSafeErrorMessage({
78+
code,
79+
error,
80+
isClientError,
81+
isDevelopment,
82+
statusCode,
83+
}: {
84+
code: string;
85+
error: unknown;
86+
isClientError: boolean;
87+
isDevelopment: boolean;
88+
statusCode: number;
89+
}): string {
90+
if (isDevelopment) {
91+
return error instanceof Error ? error.message : String(error);
92+
}
93+
94+
if (isClientError && isStructuredError(error) && error instanceof Error) {
95+
return error.message;
96+
}
97+
98+
return SAFE_MESSAGE_BY_ERROR_CODE[code] ?? getSafeStatusMessage(statusCode);
99+
}
100+
101+
function isStructuredError(error: unknown): error is EvlogError {
102+
return error instanceof EvlogError;
103+
}
104+
105+
const SAFE_MESSAGE_BY_ERROR_CODE: Record<string, string> = {
106+
AUTH_REQUIRED: "Authentication required",
107+
BAD_REQUEST: "Invalid request",
108+
CONFLICT: "Conflict",
109+
FEATURE_UNAVAILABLE: "Feature unavailable",
110+
FORBIDDEN: "Forbidden",
111+
INTERNAL_SERVER_ERROR: "An internal server error occurred",
112+
INVALID_COOKIE_SIGNATURE: "Invalid request",
113+
NOT_FOUND: "Not found",
114+
PARSE: "Invalid request body",
115+
PLAN_LIMIT_EXCEEDED: "Plan limit exceeded",
116+
RATE_LIMITED: "Rate limit exceeded",
117+
TOO_MANY_REQUESTS: "Rate limit exceeded",
118+
UNAUTHORIZED: "Authentication required",
119+
UNKNOWN: "An internal server error occurred",
120+
VALIDATION: "Invalid request",
121+
};
122+
123+
function getSafeStatusMessage(statusCode: number): string {
124+
if (statusCode === 401) {
125+
return "Authentication required";
126+
}
127+
if (statusCode === 403) {
128+
return "Forbidden";
129+
}
130+
if (statusCode === 404) {
131+
return "Not found";
132+
}
133+
if (statusCode === 409) {
134+
return "Conflict";
135+
}
136+
if (statusCode === 413) {
137+
return "Payload too large";
138+
}
139+
if (statusCode === 422) {
140+
return "Invalid request";
141+
}
142+
if (statusCode === 429) {
143+
return "Rate limit exceeded";
144+
}
145+
if (statusCode === 503) {
146+
return "Service temporarily unavailable";
147+
}
148+
if (statusCode >= 400 && statusCode < 500) {
149+
return "Invalid request";
150+
}
151+
return "An internal server error occurred";
152+
}
153+
58154
function getStatusCode({
59155
code,
60156
error,

apps/api/src/integration/with-workspace.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,88 @@ describe("withWorkspace", () => {
584584
});
585585
});
586586

587+
describe("monitor resource", () => {
588+
iit("allows API key with read:monitors to read monitors", async () => {
589+
const org = await insertOrganization();
590+
591+
const ws = await withWorkspace(apiKeyContext(org.id, ["read:monitors"]), {
592+
organizationId: org.id,
593+
resource: "monitor",
594+
permissions: ["read"],
595+
});
596+
expect(ws.tier).toBe("authed");
597+
expect(ws.user).toBeNull();
598+
});
599+
600+
iit("denies API key with read:data from reading monitors", async () => {
601+
const org = await insertOrganization();
602+
603+
await expectCode(
604+
withWorkspace(apiKeyContext(org.id, ["read:data"]), {
605+
organizationId: org.id,
606+
resource: "monitor",
607+
permissions: ["read"],
608+
}),
609+
"FORBIDDEN",
610+
);
611+
});
612+
613+
iit("allows API key with write:monitors to write monitors", async () => {
614+
const org = await insertOrganization();
615+
616+
const ws = await withWorkspace(apiKeyContext(org.id, ["write:monitors"]), {
617+
organizationId: org.id,
618+
resource: "monitor",
619+
permissions: ["update"],
620+
});
621+
expect(ws.tier).toBe("authed");
622+
});
623+
});
624+
625+
describe("status page resource", () => {
626+
iit("allows API key with read:status_pages to read status pages", async () => {
627+
const org = await insertOrganization();
628+
629+
const ws = await withWorkspace(
630+
apiKeyContext(org.id, ["read:status_pages"]),
631+
{
632+
organizationId: org.id,
633+
resource: "status_page",
634+
permissions: ["read"],
635+
},
636+
);
637+
expect(ws.tier).toBe("authed");
638+
expect(ws.user).toBeNull();
639+
});
640+
641+
iit("denies API key with read:data from reading status pages", async () => {
642+
const org = await insertOrganization();
643+
644+
await expectCode(
645+
withWorkspace(apiKeyContext(org.id, ["read:data"]), {
646+
organizationId: org.id,
647+
resource: "status_page",
648+
permissions: ["read"],
649+
}),
650+
"FORBIDDEN",
651+
);
652+
});
653+
654+
iit("allows API key with write:status_pages to write status pages", async () => {
655+
const org = await insertOrganization();
656+
657+
const ws = await withWorkspace(
658+
apiKeyContext(org.id, ["write:status_pages"]),
659+
{
660+
organizationId: org.id,
661+
resource: "status_page",
662+
permissions: ["update"],
663+
},
664+
);
665+
expect(ws.tier).toBe("authed");
666+
});
667+
});
668+
587669
describe("plan gating", () => {
588670
iit("rejects when required plan not met", async () => {
589671
const user = await signUp();

apps/api/src/lib/auth-wide-event.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from "@databuddy/api-keys/resolve";
66
import { auth } from "@databuddy/auth";
77
import { mergeWideEvent } from "@databuddy/ai/lib/tracing";
8+
import type { ApiAuthWideEventFields } from "@databuddy/shared/evlog-fields";
89

910
export interface ResolvedAuth {
1011
apiKeyResult: ResolveApiKeyResult | null;
@@ -18,7 +19,7 @@ export function getResolvedAuth(headers: Headers): ResolvedAuth | undefined {
1819
}
1920

2021
export async function applyAuthWideEvent(headers: Headers): Promise<void> {
21-
const fields: Record<string, string | number | boolean> = {};
22+
const fields: Partial<ApiAuthWideEventFields> = {};
2223

2324
const hasKey = isApiKeyPresent(headers);
2425
const [session, apiKeyResult] = await Promise.all([
@@ -72,5 +73,5 @@ export async function applyAuthWideEvent(headers: Headers): Promise<void> {
7273
fields.organization_id = orgId;
7374
}
7475

75-
mergeWideEvent(fields);
76+
mergeWideEvent<ApiAuthWideEventFields>(fields);
7677
}

0 commit comments

Comments
 (0)