Skip to content

Release: staging → main#383

Merged
izadoesdev merged 148 commits intomainfrom
staging
Apr 8, 2026
Merged

Release: staging → main#383
izadoesdev merged 148 commits intomainfrom
staging

Conversation

@izadoesdev
Copy link
Copy Markdown
Member

@izadoesdev izadoesdev commented Apr 5, 2026

Summary

Dashboard sidebar refactor

  • Added SidebarNavigationProvider and NavigationRenderer for cleaner sidebar architecture
  • Merged Websites into Overview section, united Monitors and Status Pages into a single section
  • Made sidebar navigation fully static for improved performance
  • Fixed multiple hydration mismatches (loading nav, session dedup, icon imports, gated queries)

Performance optimizations

  • Bundle size and rendering optimizations across the dashboard
  • Deferred command search, deduplicated session calls, removed unnecessary searchParams
  • Codemod to direct SSR icon imports, then reverted to barrel imports where hydration issues occurred

Auth hardening

  • Added Better-Auth built-in rate limiting backed by Redis customStorage with per-route custom rules (sign-up, sign-in, forget-password, magic-link, email-otp)
  • Added manual rateLimit() guards to all email-sending callbacks (reset password, verify email, OTP, magic link, invitation) with evlog log.warn coverage so throttled events are visible in traces
  • Customstorage TTL set to 120s (2x longest window) to balance counter longevity with cleanup
  • Removed secondaryStorage from Better-Auth config to fix 401s on staging; Redis is now only used for rate limiting, sessions stay in PostgreSQL

Basket (ingestion service)

  • Comprehensive test suite expansion: 41 → 414 tests
  • Fixed billing enforcement and cleaned up routes and event service
  • Improved bot-detection mocking and geo-IP test resilience for CI

Docker & self-hosting

SDK & tracker

  • Switched to automatic JSX runtime in SDK build config
  • Dashboard now passes apiKey instead of clientId to the SDK; updated flag method names to match current SDK surface (SDK itself was not renamed, dashboard was brought in line)
  • Tracker audit fixes: cleanup, flush safety, unload reliability

CI/CD & tooling

  • Optimized all GitHub Actions workflows
  • Replaced tsgo with turbo check-types, made lint non-blocking
  • Bumped Bun to 1.3.11, Turbo to 2.9.3 with future flags enabled
  • Upgraded to Ultracite v7 (Biome-based), applied lint/format fixes

Code quality & misc

  • Improved type safety and code quality across codebase
  • Simplified mapper import system and adapter API
  • Standardized script names, cleaned root deps, modernized turbo.json
  • Added dependabot configuration, status page OG image fixes, evals redesign

Review fixes

Review caught and fixed in this PR:

  • Auth rate-limit TTL mismatch: customStorage TTL was 300s vs 60s window. Trimmed to 120s.
  • Silent rate-limit failures: email callbacks returned on throttle with no log. Added log.warn with auth_rate_limited wide-event fields.
  • Stray unrelated commit: a gstack routing rules commit landed on staging by accident. Rebased off and preserved on chore/gstack-routing branch for follow-up.
  • Misleading SDK rename line: PR body previously claimed SDK clientId was renamed to apiKey. Corrected to reflect that only the dashboard callsite changed.

Test plan

Verified

  • Basket test suite: 41 → 414 tests passing (this PR's own claim)
  • Ultracite v7 lint/format clean on staging HEAD (enforced by lint-staged pre-commit)
  • packages/auth typechecks after rate-limit + evlog changes

To verify before merging to main

  • Dashboard sidebar navigation renders correctly with new section groupings on staging.databuddy.cc
  • No hydration mismatches in dashboard (check browser console on key routes)
  • Self-hosting works end-to-end via docker-compose -f docker-compose.selfhost.yml up
  • SDK builds and tracks events correctly against latest dashboard
  • Auth: password reset, magic link, and email verification flows work on staging (validates Better-Auth rate limit customStorage + secondaryStorage removal)
  • Existing prod sessions remain valid after the secondaryStorage removal lands on main
  • CI green on this PR

izadoesdev and others added 30 commits April 3, 2026 19:04
- Fix outgoing links bypassing shouldSkipTracking when disabled/bot
- All plugins (interactions, scroll-depth, errors) now return cleanup
  functions, wired into destroy() via cleanupFns
- Fix HttpClient double-read of response body (use text+parse instead)
- Fix flush race condition: check isFlushing before clearing timer
- destroy() flushes all queues via sendBeacon before clearing
- handlePageUnload uses sendBeacon with fetch fallback for all queues
- databuddyOptIn reinitializes tracker without requiring page reload
- Cache timezone at init instead of creating Intl.DateTimeFormat per event
- Add regression tests for all fixed bugs
Remove unused R2 storage and Logtail env vars.
Upgrade to schema v2, switch to strict envMode with explicit globalEnv,
simplify task configs by removing redundant fields.
Rename typecheck/type-check to check-types across packages, use turbo
for test runner, remove unused root dependencies (opentelemetry, maxmind).
Replace adapter class pattern with plain mapUmamiRow function. Add
createImport helper that handles session exit detection. Remove old
test script, csv-parse/zod/drizzle deps, and utils-map-events.
Align with bun test glob pattern (tests/*.test.ts).
Enable affectedUsingTaskInputs, watchUsingTaskInputs, and
filterUsingTasks to prepare for Turbo 3.0.
- ci.yml: split into 3 parallel jobs (lint, check-types, test), add
  concurrency group, path-ignore for docs, pin bun to 1.3.4, add
  postgres service, remove redundant full build step
- health-check.yml: add concurrency group, restrict triggers to
  Dockerfile and app source changes only
- docker-publish.yml: switch to Blacksmith Docker tools
  (setup-docker-builder, build-push-action, stickydisk), use native
  arm64 runners instead of QEMU emulation, add concurrency group,
  downsize manifest runners to 2vcpu
- codeql.yml: use Blacksmith runner, add staging branch, add
  concurrency group, remove boilerplate
- dependency-review.yml: add staging branch, use Blacksmith runner
- Root check-types now delegates to turbo (packages already use tsc)
- Lint set to continue-on-error until 166 pre-existing errors are fixed
check-types needs package dist outputs to resolve cross-package types
…board

turbo build --filter=@databuddy/api... was resolving to 21 packages
(including dashboard) due to ^build dependency traversal. turbo prune
correctly scopes to only the 14 actual API dependencies.
* chore(notifications): add test scripts to package.json

* test(notifications): add BaseProvider unit tests

* test(notifications): add uptime template tests

* test(notifications): add anomaly template tests
…ion (#375)

* feat(docker): add self-hosting support with docker-compose configuration

* feat(docker): update docker-compose for production readiness and security enhancements

* feat(docker): enhance docker-compose with required APP_URL and GEOIP_DB_URL configurations
- basket: loop HTML tag strip in sanitizeString to defeat stacked-tag
  bypass (js/incomplete-multi-character-sanitization, alert #43)
- tracker: replace Math.random() fallback in generateUUIDv4 with
  crypto.getRandomValues() so downstream IDs are cryptographically
  random (js/insecure-randomness, alerts #59 and #60)
@vercel vercel bot temporarily deployed to staging – dashboard April 7, 2026 11:46 Inactive
@vercel vercel bot temporarily deployed to staging – documentation April 7, 2026 11:46 Inactive
@railway-app railway-app bot temporarily deployed to Databuddy / production April 7, 2026 11:46 Inactive
@unkey-deploy unkey-deploy bot temporarily deployed to staging-api - production April 7, 2026 11:46 Inactive
…oute

The outgoing-links plugin has been POSTing to `/outgoing` since 2025-12-27.
basket has no such route — it serves outgoing-link events at `POST /` with
`type: "outgoing_link"` in the body. Every external link click was 404'ing
silently in production.

Confirmed via direct ClickHouse query: zero rows in `analytics.outgoing_links`
for any site that has `trackOutgoingLinks: true` enabled.

Switch the plugin to:
- POST `/` with `type: "outgoing_link"` (the route basket actually serves)
- Send `client_id` as a query param so beacon transport works
  (sendBeacon strips custom headers, including `databuddy-client-id`)
- Include `anonymousId`, `sessionId`, `timestamp` so clicks are attributed
  to a session — basket's insertOutgoingLink reads these but the old
  payload never sent them, so all click rows would have been anonymous
  even if the route had worked

Verified end-to-end against prod basket: probe POST returned 200 success
and the row landed in `analytics.outgoing_links` with the correct
href/text/session_id (the first row ever for a real customer site).
Why this change:
The /outgoing tracker bug fixed in 918d196 hid in green E2E tests for
3+ months because every spec used the same blanket mock pattern:

  await page.route("**/basket.databuddy.cc/*", async (route) => {
    await route.fulfill({ status: 200, ... });
  });

A catch-all that returns 200 for any path. Assertions only checked
tracker-side behaviour (`req.url().includes("/outgoing")`), so the
contract between tracker and basket was never tested.

What this adds:
- BASKET_ROUTES allowlist in tests/test-utils.ts mirroring the routes
  basket actually serves (apps/basket/src/routes/{basket,track,llm}.ts)
- A custom Playwright `test` exported from test-utils with an `auto: true`
  fixture that:
    1. Tracks every basket request via `page.on("request", ...)` so the
       observer fires before any test mock can intercept and shadow it
    2. Default-fulfills known routes 200, unknown routes 404
    3. Throws at teardown if any unknown route was hit, with a clear
       message pointing at BASKET_ROUTES so the next dev knows where
       to update if a new route is genuinely added

Migrating the tests:
- All 16 spec files now `import { test } from "./test-utils"` instead of
  `@playwright/test` — auto-fixtures run for every test, no opt-in
- Deleted ~15 redundant `**/basket.databuddy.cc/*` catch-all beforeEach
  blocks (the fixture handles them)
- Refactored one test in audit-bugs.spec.ts that was using `page.route`
  as a request observer — now uses `page.on("request")` so it doesn't
  shadow the fixture's tracking
- Updated outgoing-links spec predicates from `req.url().includes("/outgoing")`
  to `e.type === "outgoing_link"` (matches the new payload from the plugin
  fix and is the body-shape pattern the hardened mocks expect)
- Marked edge-cases pixel-mode test as `test.fixme()` — the hardening
  immediately uncovered a separate real bug in pixel.ts (it routes
  `/batch`/`/track`/`/vitals`/`/errors` as GET image loads to paths basket
  only serves as POST). Tracked separately, fix is out of scope here.

Verification:
- 126 passed, 6 skipped, 0 failed on chromium
- Temporarily reverted the outgoing-links fix and re-ran the spec: 6 of
  13 outgoing-links tests fail with the exact "Tracker hit unknown basket
  route(s): POST /outgoing" message — proving the regression guard catches
  the bug class it's meant to catch
basket only serves pixel transport at GET /px.jpg — there is no
GET /batch, /track, /vitals, /errors. The pixel plugin only translated
the `/` endpoint to `/px.jpg` and left every other endpoint alone, so
batched screen views, custom track events, vitals and errors all fired
GET image loads to dead routes in pixel mode (`usePixel: true`). The
new strict basket route allowlist in tests/test-utils.ts caught this
the moment the hardening landed.

basket's parsePixelQuery (apps/basket/src/utils/pixel.ts) dispatches on
a `type` query param and only handles `track` / `outgoing_link`. So:

- Always route to `/px.jpg`, never the original endpoint
- Add a `pixelEventTypeFor()` mapping that translates `/`, `/batch`,
  `/track` → `track` and `/outgoing` → `outgoing_link`
- /vitals and /errors return null and silently no-op — they have no
  pixel equivalent and the pre-existing GET /vitals, GET /errors
  behaviour was already broken in production
- Set `type` as a query param so basket's parsePixelQuery dispatches
  correctly (overridable by an explicit `type` field on the data)
- Split batched arrays into one pixel call per event since /px.jpg
  only accepts a single event per GET. Pre-existing behaviour silently
  flattened the array indices into garbage params like `0[name]=...`

Updated the pixel test to assert the request actually lands at /px.jpg
(not just "any GET"), which would have caught this bug if it had been
written that way originally.

Verified: 127 passed, 5 skipped (WebKit only), 0 failed. The pixel test
that was test.fixme'd in the previous commit now passes.
Net -112 LOC across 3 files (101 added, 213 removed).

test-utils.ts (259 → 170):
- Inline setupBasketMock into the auto-fixture; nothing imports it
  externally so the indirection added zero value
- Inline isKnownBasketRoute; replace the regex array with a Set<string>
  keyed on `${method} ${path}` — the routes are exact matches, regex
  was overkill
- Drop UnknownBasketRouteHit and BasketMock interfaces; use inline
  type literals (also unused externally)
- Hoist CORS_HEADERS to module scope, it's static
- Drop the try/catch around `new URL(req.url())` — Playwright requests
  always have valid URLs
- Collapse the duplicate 200/404 fulfill blocks into a single call
- Strip JSDoc walls that just restated function signatures

pixel.ts (143 → 121):
- Replace pixelEventTypeFor() with a PIXEL_TYPE_BY_ENDPOINT lookup
  table — three branches into a four-line const
- Hoist flatten() out of the closure into a module-level
  flattenIntoParams(); doesn't depend on any closure state
- Delete the dead `prefix === "" && key === "properties"` branch — it
  called the same code path as the else branch (when prefix is "",
  newKey === key, so both branches were identical)
- Strip the wall comment explaining the endpoint mapping; the const
  table speaks for itself

edge-cases.spec.ts:
- Drop the wall comment on the pixel test assertion

Verified: 127 passed, 5 skipped, 0 failed. tsc + ultracite clean.
@vercel vercel bot temporarily deployed to staging – dashboard April 7, 2026 19:51 Inactive
@vercel vercel bot temporarily deployed to staging – documentation April 7, 2026 19:51 Inactive
Pulls in tokenlens@1.3.1 — a small registry of LLM model metadata
(context windows + per-token pricing) for the Vercel AI Gateway
catalog. Used by the upcoming agent route telemetry to compute
USD cost per chat turn from result.totalUsage.
Add a small summarizeAgentUsage helper that reads result.totalUsage
from the agent stream and emits raw token counts (input, output,
cache read/write, reasoning) plus best-effort USD cost via the
tokenlens Vercel AI Gateway catalog. Falls back to anthropic/claude-4-sonnet
when the exact gateway model id isn't in the catalog yet — directionally
correct, with cost_fallback flagged so analytics can correct estimated
rows later.

The agent route fires the telemetry as a parallel side effect after
result.consumeStream() — the totalUsage promise resolves once the stream
finishes, so awaiting it never blocks the response. Output goes to:
- mergeWideEvent (evlog wide-event coverage rule)
- trackAgentEvent("agent_activity", { action: "chat_usage", ... })

Failures are captured via captureError and never break the chat flow.

Also export modelNames from ai/config/models so the telemetry helper
can resolve the canonical id without re-declaring it.
- usePendingQueue / useChatLoading: remove JSDoc that just restated
  the function signature.
- useChatLoading JSDoc referenced "post-stream metadata sync" which was
  removed alongside the followup suggestions earlier this session —
  drop it.
- Trim the verbose 4-line "Token + cost telemetry. Fire-and-forget side
  effect..." comment in agent.ts to a single sentence; the helper name
  and Promise.resolve pattern carry the meaning already.
…ated features

Adds full feature coverage to autumn so every billable resource is declared
in one place. Server-side enforcement is wired up in follow-up tickets;
this commit is config-only.

New features:
- monitors  — already declared, now used in main plans (Free 1 / Hobby 5 /
  Pro 25 / Scale 50) plus expanded Pulse counts (Pulse Hobby 25 → was 10,
  Pulse Pro 150 → was 50). Cost driver is checks not monitors, so frequency
  is gated separately.
- uptime_minute_checks (boolean) — gates sub-5-minute granularity. Pro+ on
  main plans, all Pulse plans. Free/Hobby capped at 10-min granularity.
- status_pages (metered) — count cap. Hobby 1 / Pro 3 / Scale 5 / Pulse
  Hobby 3 / Pulse Pro 10.
- status_page_custom_branding (boolean) — paid feature, was free for all.
- status_page_custom_domain (boolean) — Pulse Pro only.
- alarms (metered) — count cap. Hobby 5 / Pro 50 / Scale unlimited /
  Pulse Hobby 25 / Pulse Pro unlimited.
- webhook_alert_destinations (boolean) — Pro+ and all Pulse plans. Free
  and Hobby get email-only.
- funnels, goals, feature_flags, target_groups (metered) — moved from
  shared/features.ts hardcoded limits into autumn so billing is the
  single source of truth.
- retention_analytics, error_tracking (boolean) — Hobby+, was previously
  only enforced in shared/features.ts.

Existing features wired up properly:
- seats — was declared but never attached to any plan. Now Free 2 /
  Hobby 5 / Pro 25 / Scale unlimited.
- rbac — was declared but never attached. Now Pro+ and Scale.
- sso — sso_plan addon now actually has the boolean item.

Plan structure preserved:
- Free $0, Hobby $9.99, Pro $49.99, Scale $99.99 (phasing out — no
  expansion), Pulse Hobby $14.99, Pulse Pro $49.99, SSO addon $100.
- All existing event tier overage pricing kept identical.
- Agent credits + rollover on Pro unchanged from prior commit.
Wire the agent route to autumn for credit enforcement now that the
config is pushed.

- Resolve billing customer id via getBillingCustomerId once user auth
  succeeds. Skipped for API-key flows (no clear billing owner).
- Pre-stream check on agent_credits returns 402 OUT_OF_CREDITS if the
  customer has no remaining balance and no overage allowance.
- Post-stream telemetry side effect now also fires autumn.track for the
  4 metered token features (input/output/cache_read/cache_write). Autumn
  auto-deducts credits via the credit_system creditSchema mapping.
- Track failures captured via captureError, never block the chat flow
  (Promise.allSettled around the 4 tracks).
- Zero-value tracks filtered out so we don't pollute autumn with no-op
  events.
Drops the seats metered feature entirely from autumn.config.ts (was
Free 2 / Hobby 5 / Pro 25 / Scale unlimited) and removes all seats
items from every plan.

Also sets PLAN_FEATURE_LIMITS.TEAM_ROLES to "unlimited" across all
tiers in packages/shared/src/types/features.ts so the dashboard UI
stops gating team size. Upgrade message updated to "Team members are
unlimited on all plans".

Seat-based pricing is off the table for Databuddy.
Add an AgentCreditBalance pill next to the chat history and new-chat
buttons in the agent header. Uses the existing useUsageFeature hook
from billing-provider (which proxies autumn's useCustomer). Shows:

- "234 / 5,000" format with tabular-nums
- Amber warning state at <20% remaining
- Destructive state at 0 remaining
- ∞ when the plan grants unlimited (Scale, comped orgs)
- Click → /billing
- Skeleton during first load
- Auto-refetches 1.5s after stream finish to pick up the post-turn
  autumn.track decrement
Latest day appeared empty on pulse and status pages for users west of
UTC because the client re-parsed backend UTC date strings through
localDayjs, shifting them back a day. Uptime time-series queries also
bucketed in server tz instead of user tz.
Compose `coerceMcpInput` into the schema once with `z.preprocess`
instead of calling it inline at parse time. Drop redundant explanatory
comments now that the wiring is the obvious shape.
Pull in `atmn` so we can `atmn push` autumn config from this repo.
The companion `@useautumn-sdk.d.ts` file is regenerated on every
`atmn pull` and would otherwise drift constantly — gitignore it.
Old schema: 1 credit ≈ \$0.005 of LLM compute. Free tier of 100 credits
covered ~13 first-turn chats — felt stingy. New schema: 1 credit ≈ \$0.001,
free 500, hobby 2.5k, pro 25k (with rollover + rescaled overage tiers).
Same USD margin per plan, ~5x more perceived value.

Also fold the comment blocks into /* */ syntax — atmn pull was
shredding consecutive // lines into single-line paragraphs separated
by blank lines, and biome was happy to keep that mess.
Telemetry: bill fresh input tokens, not the full input count. Cache
read/write were tracked as their own metered features, so the prior
code was charging cached tokens twice (once at the input rate, once
at the cache rate). UsageTelemetry now exposes fresh_input_tokens
from inputTokenDetails.noCacheTokens and the route bills that.

Tools: delete redundant tools that get_data already covers
(execute_query_builder, get_top_pages, get_link, search_links,
get_funnel/goal/annotation_by_id) and trim every remaining tool's
description + parameter describe() calls. The 151-builder list dump
moves out of the schema and into a discoverable error on unknown type.

Prompts: drop CLICKHOUSE_SCHEMA_DOCS from the always-on system prompt
and inline a minimal table hint into execute_sql_query (the only tool
that needs schema knowledge). Condense analytics rules, examples, and
chart guide to the essentials.

Dead code: triage and reflection agents were never invoked from
production — only createAgentConfig("analytics", ...) is called.
Delete the agent files, their prompts, the tool files only they
imported, and collapse createAgentConfig to a passthrough.

Result on a "hi" baseline turn: cache_write 17,322 → 7,683 tokens
(-56%), credits 13.27 → 6.38 (-52%) under the old schema. Stacked
with the credit rescale, free tier delivers ~200 turns vs ~13.

Thinking: user-selectable extended thinking via a new compact control
in the agent input footer. AgentThinking = "off" | "low" | "medium"
| "high" flows from a jotai atom (atomWithStorage so it persists)
through the chat transport into the route body, into AgentContext,
and into Anthropic's thinking.budgetTokens via providerOptions. The
route now drops temperature when thinking is enabled because Anthropic
rejects the combination. createToolLoopAgent actually threads
providerOptions through now — it was dead code before.

Layout fix: agent input footer was shifting on every submit because
KeyboardHints returned null while loading and the Stop button got
inserted between the Thinking control and Send. Hints now swap to
"Generating…" in the same slot, and Send/Stop share one slot that
toggles by state. Footer is pixel-stable across both states.

Probe harness: new apps/api/scripts/agent-cost-probe.ts that runs the
real agent pipeline (same model, same tools, same providerOptions),
supports multi-turn chats and --thinking=off|low|medium|high, and
prints token + credit breakdowns under both schemas.

Cleanup: drop the double cast on apiKey.organizationId, drop the as
LanguageModel cast on models.analytics, drop unused textareaRef in
agent-input, fold get-data's truncation into the mapping pass, remove
a leftover formatLinkForDisplay helper that only existed for the
deleted search_links output.
Back to 1 credit ≈ \$0.005 of compute (input 0.000_6, output 0.003,
cache read 0.000_06, cache write 0.000_75). Plan budgets stay at the
bigger 500/2500/25k numbers — users still get meaningful headroom,
we just no longer subsidize the per-token math on top of that.

Probe now prints runway against the three real plan sizes (free,
hobby, pro) instead of comparing against a theoretical proposed
schema.
Items and sections inside the sidebar nav memo were filtered by
getFlag() directly, with no isHydrated guard. The FlagsProvider reads
from localStorage synchronously on the first client render, so the
flag store was populated on the client but empty on the server. Any
flag-gated nav item (e.g. Home > Insights) was rendered on the client
and absent on the server, producing React error #418 hydration
mismatches on every page sharing the (main) layout: /websites,
/onboarding, /demo/*, /status/*, and nested website routes.

Mirror the existing filterCategoriesByFlags pattern: treat all flags
as off until isHydrated is true. Server and first client paint now
agree, and flag-gated items appear on the next render once hydration
completes.
Thinking picker moves from Popover + button grid to DropdownMenu
+ DropdownMenuRadioGroup so keyboard nav + selected state come from
the primitive. Header drops the inline favicon/domain and adds a
thin separator before the right-side action cluster.
@izadoesdev izadoesdev mentioned this pull request Apr 8, 2026
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants