DaedalusExperimentation Repository

Level 1

What Daedalus does

For product managers, designers, and anyone who wants the big picture - no engineering knowledge required.

🗄️
Experiment Archive
A searchable record of every concluded A/B test and feature flag run across Mobile.de's three product clusters. Find what was tested, who ran it, and what happened.
📝
Team Learnings
Authenticated team members can add written learnings and Figma design links to any experiment. The history is append-only - nothing gets overwritten or lost.
🤖
Slack Bot
When an experiment concludes, the bot automatically posts an announcement to the right Slack channel. Admins control which clusters are announced and customize the message template.
🔍
FAQ Index
When a support thread in Slack is marked resolved, the conversation is automatically indexed so teams can search for answers to questions that have already been answered.
🔒
Open to Read, Gated to Write
Anyone can browse experiments without logging in. Adding learnings, editing metadata, or managing the bot requires signing in with your Mobile.de Okta account.
What Daedalus is NOT: It is not a live experiment dashboard. It is not connected to real-time Kameleoon data during browsing. It is an archival record- a memory system for concluded experiments. A future module for live experiment monitoring and automated Q&A is planned; at this time Daedalus strictly does not provide it.

Level 2

How it's organized

Major components, data flow, and how the layers connect. Hover any component for its source file.

Ingestion pipeline (external)
Storage (databases)
Data layer (lib/data/)
Presentation (pages & routes)
External services
EXTERNALINGESTIONSTORAGEDATA LAYERPRESENTATIONKameleoon APIA/B testing platformOkta OIDCIdentity providerSlackWorkspace + Events APIExternal CronPOST /api/bot/tickKameleoon PipelineKameleoon → BigQuery (Airflow, external)BigQueryexperiments + experiment_resultsPostgreSQL (Drizzle ORM)experiment_user_data · bot_config · campaignsfaq_entries · thread_registry · extractions · …experiments.tslist · get · patch · resultsadmin.tsgetBotConfig · listEventsbot.tsrunBotOnce · Slack clientsweep.tsthread registry sweep+ extraction.ts (LLM)faq.tsindex · search (FTS)kameleoon-auth.tstoken cache + fetch(internal tools only)/experimentslist · search · filter · CSV/experiments/[id]detail · results · learning write/adminbot config · activity logAPI Routesapp/api/ (19 handlers)NextAuth v5Okta OIDC · session cookiesInternal Toolsmonitoring · explorer

Level 3

Under the hood

Four concrete request traces through the system. Click to expand each flow.

1
Browsing the experiment archive
A PM visits /experiments, searches, and clicks into a result
1
Browser
User navigates to /experiments. Request hits the Next.js server.
2
Server Component
ExperimentsPage (Server Component) calls listExperiments({page:1, per_page:20}) directly - no HTTP hop to a separate API server.
app/experiments/page.tsx:7
3
Data Layer
listExperiments checks for GOOGLE_CREDENTIALS_JSON. If absent - returns in-memory mock data and skips steps 4-6.
lib/data/experiments.ts:9
4
BigQuery
Two parallel BigQuery queries: a COUNT for pagination and a SELECT for the first page of results, ordered by date_ended DESC.
lib/data/experiments.ts:113
5
PostgreSQL
Postgres overlay: experiment IDs from BQ results are used to bulk-select matching rows from experiment_user_data. Postgres touchpoint and decision values overwrite the BQ originals for each matched row.
lib/data/experiments.ts:122-137
6
Server → Browser
HTML is server-rendered with the first page of results, then hydrated as ExperimentsClient (Client Component). Initial data is passed as props - no extra fetch on hydration.
app/experiments/_client.tsx:454
7
Browser (filter change)
User applies a cluster filter. ExperimentsClient fires GET /api/experiments?cluster=COS&page=1&per_page=20 via fetch(). Filter state lives in useState - no URL sync.
app/experiments/_client.tsx:484
8
User → Detail
User clicks a row - navigates to /experiments/[id]. The detail Server Component fires three parallel fetches: getExperiment, getExperimentResults, and auth() session check.
app/experiments/[id]/page.tsx:14
9
BigQuery + PostgreSQL
getExperiment fetches the full experiment row from BigQuery, then LEFT JOINs Postgres experiment_user_data - Postgres wins when a field is present (learning, figma_links, touchpoint, decision).
lib/data/experiments.ts:142-165
10
Render
ExperimentDetailView renders. If session is present, edit affordances appear. If results is null, an empty state renders - not an error.
lib/experiment-detail-view.tsx
2
Adding a learning (authenticated write)
A team member submits post-experiment notes - the most important write path
1
Browser
Authenticated user types a learning and clicks Save. UI immediately shows the new entry - optimistic update before the network call.
lib/experiment-detail-view.tsx
2
Browser → API
Browser sends PATCH /api/experiments/:id with body {"learning":"text here"}. The client sends only the text - author and timestamp are derived server-side.
3
API Route
requireAuth(req) is called first. If the NextAuth session cookie is absent or invalid - 401 immediately, no further processing. Returns { email } if valid.
app/api/experiments/[id]/route.ts · lib/server-auth.ts
4
Data Layer
patchExperiment(id, {learning}, authorEmail): reads current learning array from Postgres (or BigQuery if no Postgres row exists), prepends a new { text, author_email, edited_at } entry.
lib/data/experiments.ts:189
5
PostgreSQL
Drizzle INSERT ON CONFLICT DO UPDATE upserts into experiment_user_data. Then immediately re-reads the committed row to return consistent state under concurrent writes.
lib/data/experiments.ts:233, 252
6
Browser
On 200: optimistic update confirmed, UI stays. On error: optimistic update reverted, error message shown to user. On 401: login prompt appears.
3
Bot announces a stopped experiment
Automated background flow triggered by an external cron scheduler
1
External Cron
Scheduler sends POST /api/bot/tick with header x-cron-secret: <CRON_SECRET>. Route validates the secret; refuses all requests if CRON_SECRET env var is unset.
app/api/bot/tick/route.ts:4
2
Data Layer
runBotOnce() reads bot_config from Postgres. If the enabled flag is false - returns immediately with no Slack activity.
lib/data/bot.ts:81
3
Data Layer
listEnabledCampaigns() loads all enabled campaign rows from Postgres. Each campaign defines its own template, recipient, delay, and condition.
lib/data/campaigns.ts
4
BigQuery
fetchStoppedExperiments() pages through all experiments with a date_ended value, filtering by NOTIFY_SINCE_DATE env var if set.
lib/data/bot.ts:17
5
Data Layer - for each (campaign, experiment)
isAlreadyNotified() checks notified_experiments. checkCondition() evaluates optional field-completeness rules. Skips if already sent or condition not met.
lib/data/campaigns.ts
6
Slack API
renderCampaignBlocks() substitutes {{name}}, {{team}}, {{url}} etc. into the campaign block template. chat.postMessage posts to the resolved recipient.
lib/data/campaigns.ts
7
PostgreSQL - audit
recordNotified() inserts into notified_experiments. insertBotEvent() writes a row to bot_events with status sent or failed. Visible in the Admin activity log.
lib/data/campaigns.ts
4
FAQ indexing from a resolved Slack thread
Slack sends an event; the thread is indexed for full-text search
1
Slack
A team member replies to a support thread with a message containing "resolved" or ✅. Slack fires an event_callback to POST /api/slack/events.
app/api/slack/events/route.ts
2
API Route
Raw request body is read as text. HMAC-SHA256 signature is verified against SLACK_SIGNING_SECRET. Rejects with 403 if signature is invalid.
lib/slack/verify-signature.ts
3
API Route
Route checks event type. Message text is tested against /resolved|✅/i and thread_ts must be present. Schedules indexFromResolvedThread as fire-and-forget via Next.js after(). Returns 200 {ok:true} immediately - Slack's 3-second timeout is not a concern.
app/api/slack/events/route.ts:31
4
Slack API (async)
conversations.replies({channel, ts}) fetches the full thread. Bot messages and system subtypes are filtered out - only human messages are indexed.
lib/data/faq.ts:76
5
PostgreSQL
Thread text is SHA-256 hashed for deduplication. INSERT ON CONFLICT DO UPDATE upserts into faq_entries with to_tsvector('english', fullText) for PostgreSQL full-text search.
lib/data/faq.ts:91

Reference

Complete reference

All API routes, database tables, and environment variables.

API Routes
MethodPathGuardDescriptionSource
GET/api/experimentsPublicSearch & filter experiments. Params: q, cluster, team, type, touchpoint, decision, date_from, date_to, page, per_page, sortapp/api/experiments/route.ts
GET/api/experiments/:idPublicFull experiment record (no results)app/api/experiments/[id]/route.ts
GET/api/experiments/:id/resultsPublicPer-goal, per-variation statistical resultsapp/api/experiments/[id]/results/route.ts
PATCH/api/experiments/:idAuthWrite learning (appended), figma_links, touchpoint, decisionapp/api/experiments/[id]/route.ts
GET/api/admin/configAuthRead bot configurationapp/api/admin/config/route.ts
PATCH/api/admin/configAuthUpdate bot config fieldsapp/api/admin/config/route.ts
GET/api/admin/bot-eventsAuthPaginated bot activity logapp/api/admin/bot-events/route.ts
GET/api/admin/channelsAuthList Slack channels (max 500). Used by campaign admin UI for channel picker.app/api/admin/channels/route.ts
GET/api/admin/campaignsAuthList all enabled campaignsapp/api/admin/campaigns/route.ts
POST/api/admin/campaignsAuthCreate a new campaignapp/api/admin/campaigns/route.ts
PATCH/api/admin/campaigns/:idAuthUpdate a campaign (name, template, recipient, channel, delay, condition, enabled)app/api/admin/campaigns/[id]/route.ts
DELETE/api/admin/campaigns/:idAuthDelete a campaign by idapp/api/admin/campaigns/[id]/route.ts
POST/api/bot/tickCRON_SECRETRun one bot poll cycle. Called by external scheduler.app/api/bot/tick/route.ts
POST/api/bot/test-dmCRON_SECRETDev/test: send a real Slack DM to { email } using the first enabled campaign with a fake experiment, bypassing BigQuery.app/api/bot/test-dm/route.ts
POST/api/slack/eventsHMACInbound Slack Event Subscriptions. Triggers FAQ indexing on resolved threads. Also accepts plain { channel_id, thread_ts } webhooks via x-cron-secret.app/api/slack/events/route.ts
POST/api/slack/actionsHMACSlack interactive component callbacks. Stub - returns 200, no logic yet.app/api/slack/actions/route.ts
POST/api/slack/commandsHMACSlack slash command handler. /faq <query> returns up to 3 ephemeral Block Kit results.app/api/slack/commands/route.ts
GET/api/faq/searchAuthSearch FAQ entries. Param: q (empty returns all). Returns { entries }.app/api/faq/search/route.ts
POST/api/faq/sweepCRON_SECRETTrigger thread registry sweep via after(). Returns { ok: true }. Run nightly to backfill thread_registry.app/api/faq/sweep/route.ts
GET/api/v1/widget/configPublicFeedback widget init check. Returns { enabled: true, branding: false }. Guard: X-API-Key.app/api/v1/widget/config/route.ts
GET/api/v1/feedbackPublicReturns { feedback: [] }. Guard: X-API-Key.app/api/v1/feedback/route.ts
POST/api/v1/feedbackPublicSubmit feedback. Multipart: data JSON + optional screenshot PNG. Creates GitHub issue; deduplicates by hash. Guard: X-API-Key.app/api/v1/feedback/route.ts
PUT/api/v1/feedback/:hash/screenshotPublicUpload screenshot for a submitted feedback item. Patches GitHub issue body. Guard: X-API-Key.app/api/v1/feedback/[hash]/screenshot/route.ts
GET/api/kameleoon-explorerAuthStep-by-step Kameleoon API debug proxyapp/api/kameleoon-explorer/route.ts
GET/api/kameleoon-monitorAuthKameleoon data-quality auditapp/api/kameleoon-monitor/route.ts
PostgreSQL Tables
TableOwner / purposeKey columnsSource
experiment_user_dataUser-generated fields. Written by patchExperiment.experiment_id (PK), learning JSONB[], figma_links JSONB[], touchpoint, decision, updated_atlib/db/schema.ts:59
bot_configSlack bot runtime configuration. One row expected.id, announcement_channel_id, enabledlib/db/schema.ts:22
notified_experimentsDeduplication ledger. Prevents double-announcing experiments.(experiment_id, trigger_type, campaign_id) composite PK, notified_atlib/db/schema.ts:41
bot_eventsAudit log of every bot send attempt.id, experiment_id, event_type, sent_at, destination, status (sent|failed|skipped)lib/db/schema.ts:50
faq_entriesFull-text searchable index of resolved Slack support threads.id, experiment_id (unique), experiment_key, bq_experiment_id, question, answer, full_text, content_hash, search_vector (tsvector, GIN index), source, source_url, created_at, updated_atlib/db/schema.ts:68
campaignsCampaign definitions driving the bot notification loop.id, name, trigger_type, template JSONB, recipient_type, channel_id, delay_days, condition JSONB, enabled, created_atlib/db/schema.ts:28
thread_registryThread sweep registry. Tracks every support thread seen by the sweep job with resolution status.id, thread_ts (unique), channel_id, permalink, reporter_user_id, category, experiment_key, opened_at, last_activity_at, resolved_at, resolution_signal, status (open|resolved|extracted|ignored), raw_thread_json, ignored_reason, extracted_at, created_at, updated_atlib/db/schema.ts:86
extractionsLLM-extracted Q&A entries from resolved threads. Pending human review before surfacing in FAQ.id, thread_ts (FK), qa_type, question, scenario, answer, options JSONB, caveats JSONB, doc_links JSONB, platform JSONB, confidence, coe_note, status (pending_review|approved|rejected|superseded), review_channel_message_ts, reviewed_by, reviewed_at, prompt_version, model, search_vector, created_at, updated_atlib/db/schema.ts:111
provisioning_requestsKameleoon account provisioning attempts. Written by handleProvisioningRequest when a provisioning message arrives in the configured Slack channel.id, requester_email, requester_name, team, role, slack_message_ts (unique), kameleoon_status (created|duplicate|failed), kameleoon_account_id, error_message, created_atlib/db/schema.ts:143
Environment Variables
VariableRequired forNotes
AUTH_SECRETWrite accessNextAuth v5 session encryption key
OKTA_CLIENT_IDWrite accessOkta application client ID. Must be a Web/confidential app, not SPA.
OKTA_ISSUERWrite accessOkta authorization server issuer URL
OKTA_CLIENT_SECRETWrite accessUsed server-side only. Never reaches the browser.
DATABASE_URLPersistencePostgres connection string. Omit to use in-memory mock.
GOOGLE_CREDENTIALS_JSONReal experiment dataFull GCP service account JSON, single-line string. Omit to use mock data.
BIGQUERY_PROJECT_IDWith GCP credentialsGCP project ID
BIGQUERY_DATASETOptionalBigQuery dataset name. Defaults to daedalus.
REPO_GOLIVE_DATEOptionalISO date. Experiments after this date show learning CTA instead of silent empty state.
SLACK_BOT_TOKENSlack botBot token (xoxb-...) for posting announcements
DAEDALUS_BASE_URLSlack botPublic base URL used in Slack message links
CRON_SECRETSlack botValidated against x-cron-secret header on POST /api/bot/tick and POST /api/bot/test-dm
SLACK_SIGNING_SECRETSlack eventsHMAC-SHA256 signature verification for inbound Slack webhooks
NOTIFY_SINCE_DATEOptionalISO date. Bot skips experiments that ended before this date.
ANTHROPIC_API_KEYFAQ extractionRequired by lib/extraction/client.ts for LLM-based thread extraction. Model defaults to claude-sonnet-4-6.
EXTRACTION_MODELOptionalClaude model ID override for FAQ extraction. Defaults to claude-sonnet-4-6.
FAQ_REVIEW_CHANNEL_IDFAQ extractionPrivate review channel ID for posting extraction review messages (e.g. C0BJ453E0GN = #daedelus-faq-review). Bot must be a member.
FEEDBACK_API_KEYFeedback widgetAPI key for all /api/v1/* routes. Sent as X-API-Key header.
NEXT_PUBLIC_FEEDBACK_PROJECT_IDFeedback widgetBrowser-side project ID used as the X-API-Key value by the floating widget.
FEEDBACK_ALLOWED_DOMAINFeedback widgetEmail domain allowed to see the floating feedback widget (e.g. adevinta.com).
GITHUB_TOKENFeedback widgetGitHub personal access token for creating issues in the feedback repo.
GITHUB_OWNERFeedback widgetGitHub repo owner (org or user) for feedback issues.
GITHUB_REPOFeedback widgetGitHub repo name for feedback issues.
GITHUB_API_URLOptionalGitHub API base URL. Defaults to api.github.com. Override for GitHub Enterprise.
KAMELEOON_CLIENT_IDInternal toolsRequired for /monitoring and /kameleoon-explorer only
KAMELEOON_CLIENT_SECRETInternal toolsKameleoon API client secret
KAMELEOON_SITE_CODEInternal toolsKameleoon site code
Data Ownership Split

The two data stores have a clear ownership boundary. BigQuery is read-only from this codebase - writes only happen via the external Kameleoon pipeline. Postgres is fully owned by this app.

StoreWritten byFields
BigQueryExternal Kameleoon pipelinename, description, status, goals, variations, created_by_email, date_started, date_ended, runtime_days, team, cluster, site_code, environment_key, ingested_at
Postgres experiment_user_dataThis app - patchExperimentlearning (append-only JSONB[]), figma_links, touchpoint, decision
Postgres (bot tables)This app - bot poll cyclebot_config, campaigns, notified_experiments, bot_events
Postgres faq_entriesThis app - Slack event webhook + sweep cronfull_text, search_vector (tsvector), content_hash, experiment_key, bq_experiment_id, question, answer, source_url
Postgres thread_registry, extractionsThis app - sweep cron + LLM extraction pipelinethread_registry: thread lifecycle + resolution status. extractions: LLM-extracted Q&A pending review.
Scale conventions (Kameleoon API verbatim - do not normalize): improvementRate is percentage scale (4.2 = +4.2%). reliability is 0-100 scale (97.0 = 97%). conversion_rate is fraction scale (0.042 = 4.2%). These are stored raw; the frontend transforms for display.