Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/databuddy-internal/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin
- `autumn-js` v1.2.2+ — import `autumnHandler` from `autumn-js/fetch` (NOT `autumn-js/elysia`, that export was removed in v1.0)
- For Elysia, mount with `.mount(autumnHandler(...))` — NOT `.use()`
- `identify` callback receives `(request: Request)` directly, not `({ request })`
- Transactional billing email identity has three separate concepts: Autumn customer/billing owner, organization, and actual `to` recipient. Only personalize from the actual recipient record; if it is unavailable, omit the greeting rather than using the owner name. Keep `agent_credits` as an internal feature ID, but describe it to customers as Databunny usage or an AI analysis allowance and explain what it enables.
- Webhook event types: `balances.limit_reached` (replaces old `customer.threshold_reached`), `customer.products.updated`, `balances.usage_alert_triggered`
- `balances.limit_reached` payload is flat: `{ customer_id, feature_id, entity_id?, limit_type }` — no full customer object
- SDK `Customer` type uses camelCase (`balances`, `subscriptions`, `overageAllowed`), but **webhook payloads are snake_case** and use old field names (`features`, `products`, `included_usage`, `overage_allowed`) — do NOT use the SDK `Customer` type for webhooks
Expand Down Expand Up @@ -172,6 +173,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin
- Public SDK/tracker visitor ID privacy is only `anonymizeVisitorIds` (`true`/omitted = anonymized, `false` = raw IDs, `"auto"` = raw only in Databuddy's conservative country allowlist).
- Keep visitor ID privacy internals small and direct; avoid exported helper stacks or storage/hashing vocabulary for this option.
- If the user reports missing analytics events, inspect both the producer side and `apps/basket`
- When a retryable batch failure restores events to an in-memory queue, it must also restore an automatic retry timer with capped backoff; requeueing alone silently stalls delivery.

## Verification

Expand Down
45 changes: 38 additions & 7 deletions apps/api/src/http/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createError } from "evlog";
import { t, ValidationError } from "elysia";
import { afterEach, describe, expect, it } from "vitest";
import { handleAppError } from "./errors";

Expand All @@ -16,6 +17,7 @@ describe("handleAppError", () => {
it("masks structured 5xx details in production", async () => {
process.env.NODE_ENV = "production";
const response = handleAppError({
requestId: "req_test_5xx",
error: createError({
code: "api.SECRET_FAILURE",
message: "Database password leaked into error",
Expand All @@ -27,16 +29,19 @@ describe("handleAppError", () => {
});

expect(response.status).toBe(500);
expect(await readPayload(response)).toEqual({
success: false,
error: "An internal server error occurred",
code: "api.SECRET_FAILURE",
});
expect(await readPayload(response)).toEqual({
success: false,
error: "An internal server error occurred",
code: "api.SECRET_FAILURE",
requestId: "req_test_5xx",
});
expect(response.headers.get("X-Request-ID")).toBe("req_test_5xx");
});

it("keeps structured 4xx details visible in production", async () => {
process.env.NODE_ENV = "production";
const response = handleAppError({
requestId: "req_test_4xx",
error: createError({
code: "api.BAD_INPUT",
message: "Invalid filter",
Expand All @@ -47,12 +52,38 @@ describe("handleAppError", () => {
});

expect(response.status).toBe(400);
expect(await readPayload(response)).toEqual({
expect(await readPayload(response)).toEqual({
success: false,
error: "Invalid filter",
code: "api.BAD_INPUT",
why: "The filter operator is unsupported.",
fix: "Use one of the documented operators.",
fix: "Use one of the documented operators.",
requestId: "req_test_4xx",
});
});

it("returns safe field details for request validation errors", async () => {
process.env.NODE_ENV = "production";
const response = handleAppError({
code: "VALIDATION",
requestId: "req_test_validation",
error: new ValidationError(
"body",
t.Object({ name: t.String({ minLength: 1 }) }),
{}
),
});
const payload = await readPayload(response);

expect(response.status).toBe(422);
expect(payload).toMatchObject({
success: false,
error: "Invalid request",
code: "VALIDATION",
requestId: "req_test_validation",
});
expect(payload.details).toEqual([
expect.objectContaining({ field: "body.name" }),
]);
});
});
56 changes: 54 additions & 2 deletions apps/api/src/http/errors.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { config } from "@databuddy/env/app";
import { ValidationError } from "elysia";
import { EvlogError, parseError } from "evlog";
import { getRequestId } from "./request-id";

interface AppErrorContext {
code?: string | number;
error: unknown;
request?: Request;
requestId?: string;
}

const HTTP_STATUS_BY_ERROR_CODE: Record<string, number> = {
AUTH_REQUIRED: 401,
BAD_REQUEST: 400,
CONFLICT: 409,
FEATURE_UNAVAILABLE: 403,
FEATURE_UNAVAILABLE: 402,
FORBIDDEN: 403,
INTERNAL_SERVER_ERROR: 500,
INVALID_COOKIE_SIGNATURE: 400,
Expand All @@ -25,8 +29,15 @@ const HTTP_STATUS_BY_ERROR_CODE: Record<string, number> = {
};

const PROTECTED_RESOURCE_METADATA_URL = `${config.urls.api}/.well-known/oauth-protected-resource`;
const LEADING_SLASH_PATTERN = /^\//;

export function handleAppError({ error, code }: AppErrorContext) {
export function handleAppError({
error,
code,
request,
requestId,
}: AppErrorContext) {
const responseRequestId = requestId ?? getRequestId(request);
const parsed = parseError(error);
const statusCode = getStatusCode({
code,
Expand All @@ -48,8 +59,10 @@ export function handleAppError({ error, code }: AppErrorContext) {
isClientError,
statusCode,
});
const validationDetails = getValidationDetails(error);
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Request-ID": responseRequestId,
};
if (statusCode === 401) {
headers["WWW-Authenticate"] =
Expand All @@ -61,16 +74,55 @@ export function handleAppError({ error, code }: AppErrorContext) {
success: false,
error: safeClientError,
code: errorCode,
requestId: responseRequestId,
...(hasValue(parsed.why) && exposeStructured ? { why: parsed.why } : {}),
...(hasValue(parsed.fix) && exposeStructured ? { fix: parsed.fix } : {}),
...(hasValue(parsed.link) && exposeStructured
? { link: parsed.link }
: {}),
...(validationDetails.length > 0 ? { details: validationDetails } : {}),
}),
{ status: statusCode, headers }
);
}

interface ValidationDetail {
field: string;
message: string;
}

function getValidationDetails(error: unknown): ValidationDetail[] {
if (!(error instanceof ValidationError) || error.type === "response") {
return [];
}

const details: ValidationDetail[] = [];
const seenFields = new Set<string>();
for (const issue of error.all) {
const path = issue.path
.replace(LEADING_SLASH_PATTERN, "")
.split("/")
.filter(Boolean)
.join(".");
const field = path ? `${error.type}.${path}` : error.type;
if (seenFields.has(field)) {
continue;
}
seenFields.add(field);
details.push({
field,
message:
typeof issue.summary === "string" && issue.summary
? issue.summary
: issue.message,
});
if (details.length === 20) {
break;
}
}
return details;
}

function getErrorCode({
explicitCode,
parsedCode,
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/http/request-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { getRequestId } from "./request-id";

describe("getRequestId", () => {
it("returns a stable generated ID for the same request", () => {
const request = new Request("https://api.example.com/test");
const requestId = getRequestId(request);

expect(requestId).toMatch(/^req_[a-f0-9]{16}$/);
expect(getRequestId(request)).toBe(requestId);
});

it("preserves a safe caller-supplied ID", () => {
const request = new Request("https://api.example.com/test", {
headers: { "x-request-id": "trace_partner-123" },
});

expect(getRequestId(request)).toBe("trace_partner-123");
});

it("replaces unsafe caller-supplied IDs", () => {
const request = new Request("https://api.example.com/test", {
headers: { "x-request-id": "not safe for logs" },
});

expect(getRequestId(request)).toMatch(/^req_[a-f0-9]{16}$/);
});
});
25 changes: 25 additions & 0 deletions apps/api/src/http/request-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
const requestIds = new WeakMap<Request, string>();

function createRequestId(): string {
return `req_${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`;
}

export function getRequestId(request?: Request): string {
if (!request) {
return createRequestId();
}

const existing = requestIds.get(request);
if (existing) {
return existing;
}

const supplied = request.headers.get("x-request-id")?.trim();
const requestId =
supplied && REQUEST_ID_PATTERN.test(supplied)
? supplied
: createRequestId();
requestIds.set(request, requestId);
return requestId;
}
11 changes: 11 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { registerProcessErrorHandlers } from "@/bootstrap/process-errors";
import { registerShutdownHooks, warmPostgresPool } from "@/bootstrap/shutdown";
import { isAllowedApiOrigin } from "@/http/cors";
import { handleAppError } from "@/http/errors";
import { getRequestId } from "@/http/request-id";
import { AUTUMN_API_PREFIX } from "@/lib/autumn-mount";
import { enrichApiWideEvent } from "@/lib/evlog-api";
import { enrichRequestAuthWideEvent } from "@/middleware/auth-wide-event";
Expand Down Expand Up @@ -81,10 +82,20 @@ const app = new Elysia({ precompile: true })
enrich: enrichApiWideEvent,
})
)
.onBeforeHandle(({ request, set }) => {
set.headers["X-Request-ID"] = getRequestId(request);
})
.onBeforeHandle(({ request }) => enrichRequestAuthWideEvent(request))
.use(
cors({
credentials: true,
exposeHeaders: [
"X-Request-ID",
"Retry-After",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
],
origin: isAllowedApiOrigin,
})
)
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/routes/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
return jsonError(
400,
"WORKSPACE_REQUIRED",
"No active workspace. Select an organization and try again."
"No active organization. Select an organization and try again."
);
}

Expand Down Expand Up @@ -681,7 +681,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
return jsonError(
403,
"ACCESS_DENIED",
"No accessible websites in this workspace"
"No accessible websites in this organization"
);
}

Expand Down Expand Up @@ -832,7 +832,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
return jsonError(
402,
"OUT_OF_CREDITS",
"You're out of Databunny credits this month. Upgrade or wait for the monthly reset."
"You've used your Databunny allowance for this month. Add more usage, upgrade, or wait for the monthly reset."
);
}

Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/routes/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,12 @@ export const integrations = new Elysia({ prefix: "/v1/integrations" })
slack_oauth: "callback",
provider_error: query.error,
});
return integrationsRedirect("error", "Slack authorization failed");
return integrationsRedirect(
"error",
query.error === "access_denied"
? "Slack wasn't connected because authorization was canceled"
: "Slack authorization failed"
);
}
try {
if (!(query.code && query.state)) {
Expand Down
Loading
Loading