Commit Graph

30 Commits

Author SHA1 Message Date
Semih
928728dc66 feat(insights): Phase C — Sentry issues + events archive
Mirrors Sentry into our DB before the free tier prunes events (~30d).

- `SentryIssue` (aggregate state, upserted to latest) + `SentryEvent`
  (raw occurrences, lossless full payload JSONB, dedup by eventId, cold
  dump to MinIO `sentry-archive/{project}/YYYY/MM/DD.jsonl.gz`).
- `lib/sentry.ts`: read-only client, Link-header cursor pagination,
  listIssuesPage / listEventsPage. EU-region aware (SENTRY_API_BASE).
- `sentry-archive` job @hourly: issues upsert + events newest-first with
  skipDuplicates, stops once a page is all-duplicates (caught up).
- Config via env: SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT /
  SENTRY_API_BASE.

Verified live against otolog/python (EU): a test event archived on run 1,
0 inserts / 1 duplicate on run 2 (dedup), issue upserted idempotently.

Note: Sase API currently sends to an inaccessible org's DSN (hardcoded
fallback); repointing SENTRY_DSN to otolog/python is a separate deploy step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:32:50 +03:00
Semih
ab86dbdf59 feat(insights): Phase B — PostHog person + cohort snapshots
Append-only history of person properties and cohort definitions, since
the free tier overwrites the live person / prunes data. A new row is
written only when the payload changes (hash compare), so the tables stay
a compact change-timeline rather than a daily full copy.

- `PosthogPersonSnapshot` (personId join key to PosthogEvent.personId,
  all distinct_ids, properties JSONB, propertiesHash).
- `PosthogCohortSnapshot` (cohortId, name, count, filters, stateHash).
- `listPersons` / `listCohorts` REST helpers (next-pagination) in posthog.ts.
- `stableHash` (sorted-key JSON hash) in hash.ts for change detection.
- `posthog-identity-archive` job wired @03:00 daily. Latest-hash lookup
  via one DISTINCT ON query, inserts only changed rows via createMany.

Verified against prod: 242 persons → 242 snapshots on first run, 0 on
immediate re-run (change detection). 0 cohorts currently → graceful no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:47:20 +03:00
Semih
3941b7018f fix(insights): chunk rrweb blob fetch + rate-limit handling
Live verification surfaced two issues in archive-recordings:
- blob_v2 rejects wide blob-key ranges (a 23-key span 400s; ~5 is fine).
  Fetch in contiguous chunks of BLOB_CHUNK (default 10) and concatenate.
- PostHog's snapshot API is aggressively rate-limited; back-to-back
  sessions tripped 429. Pace requests (per-session + per-chunk delays)
  and stop the run on sustained 429 — unarchived rows keep
  rrwebArchivedAt=null and retry next cycle (oldest-first ordering
  protects soon-to-be-deleted recordings first). Result now carries
  rateLimited.

Verified against prod: 87- and 23-blob recordings reconstruct fully
(no 400), failed=0, graceful 429 stop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:40:43 +03:00
Semih
f663c0aa09 feat(insights): permanent archive of PostHog events + raw recordings
Phase A of the external observability archive. PostHog Cloud free tier
deletes data on a rolling window (events ~1yr, session recordings ~30d);
this mirrors both into our own infra permanently (hot Postgres + cold
MinIO gzip JSONL).

PostHog raw event archive:
- New `PosthogEvent` model (uuid PK → dedup, full properties JSONB,
  hot columns promoted + indexed).
- `posthog-event-archive` job: watermark-paged HogQL pull (cursor in the
  existing IngestionWatermark via a "sase:events" stream key), createMany
  + skipDuplicates for idempotency, 365d backfill. Daily closed-day cold
  dump to MinIO `posthog-archive/{project}/YYYY/MM/DD.jsonl.gz`.
- `hogqlQuery` helper added to posthog.ts. Timestamp cursor uses
  parseDateTimeBestEffort() — ClickHouse 500s on a raw ISO8601 literal
  (verified live against the project).

Raw rrweb recording preservation (separate from compress, which only
covers scored sessions and caps blobs):
- `SessionMeta.rrwebArchivedAt` / `rrwebArchiveKey`.
- `archive-recordings` job: every recording within 25d (margin before
  30d deletion), full blob range (no 12-cap), gzip → MinIO
  `rrweb-archive/{project}/YYYY/MM/DD/{sessionId}.jsonl.gz`.

- `putBuffer` gzip helper in minio.ts.
- Both jobs wired into pipeline.ts (event-archive@*/15min, recordings@*/6h).
- retention.ts untouched → new table + buckets persist forever (the goal).

Schema applies via the existing `prisma db push` on web start (additive:
new table + nullable columns).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:15:29 +03:00
Semih
fdce3f6bd0 feat(content): Phase 8 content generation (Faz A+B) for Sase.tr
Hybrid content automation pilot: generation + review + drafts live in the
panel (reusing the insight pipeline's DeepSeek client, prompt_templates
versioning, cost_ledger and budget_settings); publishing/distribution will
go through n8n (Faz C, not built). Channels: blog, LinkedIn, X, Instagram.
Topic sourcing is automatic (LLM-generated ideas). Approval model: drafts
sit in the panel for manual review/edit/publish.

Faz A (worker):
- ContentTopic / ContentDraft Prisma models (content_topics, content_drafts)
- content-prompts.ts: 5 seed prompts (topic ideas[pro] + blog[pro] +
  linkedin/x/instagram[flash]), Turkish B2B automotive tone, per-channel
  JSON schemas
- content-budget.ts: separate budget envelope (sums only content_* spend)
- content-topics job (auto idea gen, backlog-capped, title dedupe) +
  content-generate job (queued topic -> one draft per channel)
- content-pipeline scheduler (separate BullMQ queue, topics@*/8h,
  generate@*/10min), wired into index.ts; seeded via seed-runtime
- content budget settings (caps + content_paused kill switch); seed default
  content_paused=true for a safe first deploy

Faz B (web):
- /content (queue + auto/manual triggers + manual topic form),
  /content/t/[id] (per-channel draft cards: preview, JSON edits,
  approve/reject), /content/costs (content-only spend)
- server actions (audit-logged), manual trigger API routes, contentQueue(),
  nav + Cmd+K entries
- content caps surfaced on /insights/settings/budgets + whitelisted

Both packages typecheck. Schema applies on deploy (web start runs
prisma db push).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:11:05 +03:00
Semih
565ad5af59 fix(insights): propagate rrweb counters & resolve click targets
Three coupled fixes that together stop frustrated sessions from being
mislabelled as `power_user_path` and make click timelines actually
diagnosable.

1) Counter propagation (compress.ts, compress-sessions.ts):
   compress now returns rageClickCount / network5xxCount / network4xxCount
   and writes them back to SessionMeta. Previously these stayed at 0
   forever because PostHog recording metadata doesn't expose them and
   nothing updated the row after compress ran.

2) Tag rule hardening (tagger.ts, compress-sessions.ts):
   - power_user_path v1 fallback now requires rageClickCount===0; without
     this guard any auth user with 20+ clicks (rage clusters included)
     was labelled a power user.
   - New frustrated_session tag (rageClickCount>=3, P2) for sustained
     friction beyond a single cluster.
   - compress-sessions re-runs tagSession+scoreSession after writing the
     fresh counters, so the corrected tags land on the row.

3) Click target enrichment (compress.ts):
   describeTarget used to read tagName/attributes off MouseInteraction
   events — fields that don't exist on rrweb type=3 source=2 — so every
   click rendered as `[el]`. Now compress builds a node map from
   FullSnapshot (type=2) and keeps it in sync with mutations
   (type=3 source=0), then resolves clicks via `d.id`. Clicks now show
   real tag + visible text / aria-label / name / data-testid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:49:06 +03:00
Semih
94f279665c feat(insights): mechanical HYPOTHESES from custom events
The HYPOTHESES block was empty on most sessions because the only rules
covered backend failure patterns (5xx + rage). Add 7 behavioral patterns
derived from canonical custom events — these give the LLM analyzer
concrete starting points instead of reasoning from raw timeline alone.

Patterns (all conservative — fire only on clear evidence):
1. Catalog evaluated, no checkout (parts_panel_viewed>=3 + on upgrade
   page + !checkout_started)
2. Fresh trial + successful decode (happy onboarding)
3. VIN decode failure pattern (failures>=2 or fallback>=1)
4. Rage click on subscription UI (upgrade friction)
5. Cancel flow entered without resume (churn risk)
6. Trial urgency banner ignored (banner_viewed without CTA)
7. Search opened but no decode attempted (funnel drop)
2026-05-20 17:09:26 +03:00
Semih
4984190273 fix(insights): sanitizer warning — raise threshold + add real leak signal
The previous 500-token threshold fired on virtually every authenticated
session because rrweb masks inputs at source and stripQuery() removes
URL params before sanitize() runs — so 0 matches is the normal case,
not an anomaly. Confirmed on session 019e455e: 9m32s authed session
with ~1093 tokens, 0 matches, no actual PII leak.

- Raise broad threshold 500→2000 tokens
- Add precise leak detector: any unmasked \`input [tag] "text"\` line
  in the compressed timeline AND authed AND 0 matches → alert. This
  catches the actual failure mode (PostHog source-side masking broken)
  instead of guessing from total token volume.
- Sync UI warning threshold to match (500→2000).
2026-05-20 17:07:57 +03:00
Semih
42096814e3 fix(insights): backfill subscriptionTier from PostHog person props
inferSubscriptionTier only checked subscription_tier|tier|plan_tier|plan,
but Sase.tr's setPeopleProperties writes subscription_plan. Result: every
authed user showed tier=— in /insights/sessions/* and the tagger's
userValue boost (full=1.0, brand=0.7) collapsed to the 0.5 default.

- Add subscription_plan to the key list (priority first since it matches
  Sase.tr's actual property name)
- Extract into shared extractTierFromProps(props) helper
- In tag-sessions, re-evaluate tier from enrichment.userProperties and
  backfill when null. Tag step runs ~2min after ingest, by which point
  PostHog has the user's post-signup $set props that weren't on the
  recording row at ingest time.
2026-05-20 17:06:12 +03:00
Semih
16695de6d2 feat(insights): compress.ts noise filter + per-session reprocess
Drop successful (2xx/3xx) asset and telemetry GETs from the semantic
timeline — these were eating MAX_LINES (80) before any user-behavior
signal could surface. In a 9m32s session we observed only the first
1:18 reached the LLM input because PostHog /collect/* pings filled
the budget. Failures (4xx/5xx) are still emitted as signal.

Filtered prefixes: /collect/, /flags, /array/, /static/, /assets/,
/api/surveys, /css2. Also raise MAX_LINES 80→120.

Add optional { sessionId } to POST /api/insights/reprocess so a single
session can be re-run end-to-end without resetting a whole batch.
2026-05-20 17:00:31 +03:00
Semih
a10996f6c5 feat(sase): deploy regression Telegram alert
Closes the alert side of the deploy-regression view added in Faz 2b.
The dashboard table already flagged regressed deploys; this commit
pushes a Telegram when one happens, so MTTD doesn't depend on the
founder checking the dashboard.

Detection (panel)
- detectVinRegressions() pulls the last 20 Coolify deploys for the
  Sase.tr app, filters to those whose post-window has elapsed (≥30min
  since finishedAt) and isn't too old (≤180min since finishedAt), and
  reuses analyzeDeployRegressions to compute the 30min before/after
  success-rate slices. A row is flagged when:
    - both before and after have ≥5 samples, and
    - success rate dropped ≥10pp (severity 'high'; ≥15pp → 'critical').
- Returns a RegressionHit per flagged deploy with a 24h dedupe TTL
  keyed on deploymentUuid so each deploy alerts exactly once ever
  (regardless of how often the 5-min cron checks).

Endpoint
- GET /api/internal/vin-anomaly-check now returns
  { ok, current, baseline, anomalies, regressions }.

Worker
- sendTelegram() accepts an optional dedupeTtlSeconds override so
  per-call long-TTL dedupes (like deploy alerts) don't have to go
  through the global env default.
- New alertVinRegression() formats severity icon + before/after %
  + deploy commit/timestamp + dashboard link.
- runVinAnomalyDetect now also walks the regressions array and
  fires Telegram for each. Returns { anomalies, regressions,
  alertsFired, alertsDeduped }; pipeline log prints when either
  count is non-zero.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:33:07 +03:00
Semih
4694c1ffdb feat(sase): VIN decode anomaly detection + Telegram alerts
Adds the active half of Phase 8: dashboard was passive, this pushes
when something breaks. Targets MTTD < 10 minutes from the PRD.

Detection (panel-web)
- detectVinAnomalies() compares a 15-minute current window against a
  baseline built from the same 15-minute slot across the previous 7
  days (same-hour-of-day, average across days that had ≥ 1 sample).
- Five hits, two severities:
  * success_rate_drop — high at ≥ 5pp drop, critical at ≥ 15pp
  * p95_latency_spike — high at 2× baseline (and > 1s), critical at 3×
  * volume_drop — high at ≥ 80% below baseline, critical at ≥ 95%
  * volume_spike — high at ≥ 5× baseline
  * timeout_dominance — high when ≥ 50% of failures are timeouts
- Minimum-volume guards on both current and baseline so quiet hours
  don't generate noise (MIN_CURRENT_VOLUME=5, MIN_BASELINE_VOLUME=10).
- Each anomaly carries a 15-min-bucket dedupe key — same anomaly type
  fires at most once per bucket regardless of cron cadence.

Endpoint
- GET /api/internal/vin-anomaly-check, gated by x-internal-worker-token
  header (constant-time compare against INTERNAL_WORKER_TOKEN env).
  Returns { current, baseline, anomalies[] }.

Worker
- New job vin-anomaly-detect, BullMQ scheduler */5 * * * *.
  Fetches the panel endpoint, then for each anomaly calls
  alertVinAnomaly() — sendTelegram with the bucket dedupe key, so
  Redis SETEX NX dedupes across the 1h cooldown window.
- alertVinAnomaly() in worker/lib/telegram.ts formats the message with
  severity icon + baseline/observed/volume context + dashboard link.
- runVinAnomalyDetect returns { ok, checked, anomalies, alertsFired,
  alertsDeduped }; pipeline logs only when something happened or the
  check failed.

Env
- INTERNAL_WORKER_TOKEN set on both panel-web and panel-worker
  (32-byte hex, generated in Coolify).
- PANEL_INTERNAL_URL on panel-worker → coolify-network UUID hostname
  for panel-web, no Cloudflare/Tailscale hop on internal calls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:22:02 +03:00
Semih
bc67c387cd fix(prompts): v3 — wider maxLength budgets for Turkish output
v2 prompts hit schema validation failures on Turkish output (quick_fix,
friction_point, suggested_action commonly exceeded 300 chars). Turkish is
naturally ~15-20% longer than English. Widened limits across all 7 schemas:

- title: 120 → 140
- *hypothesis*: 500 → 700
- friction_point / quick_fix / suggested_action: 300 → 500
- long_term_fix: 500 → 800
- block_point / unclear_concept / documentation_gap / suggested_in_app_help: 200/unbounded → 400
- feature_gap_hypothesis / suggested_intervention: 300 → 500
- pattern_bundle root_cause_hypothesis: 600 → 800
- business_impact_estimate / user_impact_estimate: 300 → 300-500
- affected_component_hypothesis: unbounded → 400
- error_signature: unbounded → 300
- pattern_name: 80 → 100
- priority_recommendation: 200 → 300

SASE_CONTEXT output rules clarified — schema enforces per-field maxLength.

v2 → v3 across all 7 templates. seed-runtime auto-deactivates v2 on next worker boot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 11:40:09 +03:00
Semih
752e69cd01 feat(llm,issues): Türkçe çıktı — prompt v2 + Gitea issue body Türkçe başlıklar
apps/worker/src/lib/prompts.ts:
- SASE_CONTEXT'e Türkçe çıktı kuralı eklendi (tüm doğal dil alanları Türkçe,
  enum/const/teknik tanımlayıcılar İngilizce kalır)
- 7 seed prompt v2'ye bump edildi (bug_triage, ux_friction, payment_issue,
  onboarding_stuck, upgrade_hesitation, pattern_bundle, provider_quality)
- name suffix "v2 (TR)"

apps/worker/src/lib/seed-runtime.ts:
- Yeni version insert ederken, aynı tag'in eski active versiyonlarını otomatik
  deactivate eder (publishPromptVersion ile aynı davranış)

apps/web/src/lib/gitea.ts (buildIssueBody):
- Tüm bölüm başlıkları Türkçe: Bağlam, Hipotez, Kullanıcı niyeti, Friction
  noktası, Tekrarlama adımları, Etkilenen alan, Hata imzası, Şüpheli provider,
  Hata modu, Hızlı çözüm, Uzun vadeli çözüm, Önerilen aksiyon, Araştırma
  önerisi, Tıkanma noktası, Anlaşılmayan kavram, Dokümantasyon eksiği,
  Önerilen in-app yardım, Tahmini efor, Kanıt, Definition of Done
- Önem/Tekrar/İlk/Son görüldü etiketleri

Sonraki analyze döngüsü itibarıyla yeni insight'ların metin alanları Türkçe.
Mevcut insight #cmp4o*, #cmp5e* (v1 ile üretilmiş) değişmez.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 10:45:43 +03:00
Semih
a713505f44 feat(insights): Telegram alerts + daily brief push notifications
apps/worker/src/lib/telegram.ts:
- sendTelegram() with Redis-backed dedupe (NX SETEX, 1h TTL)
- Helpers: alertP0Insight, alertRegression, alertSanitizationAnomaly, alertBudgetCap

Wired into:
- analyze.ts: P0/P1 insight creation → instant alert (dedupe per insight_id);
  budget guard halt → daily cap alert (dedupe per state per day)
- validation.ts: regression detected (≥3 sessions w/ same fingerprint after shippedAt)
  → alert (dedupe per insight_id)
- compress-sessions.ts: sanitization anomaly (>500 tokens, 0 PII matches)
  → alert (dedupe per session_id) — possible PII leak warning

Daily Brief (jobs/daily-brief.ts):
- Cron @05:00 UTC (= 08:00 Europe/Istanbul)
- 24h: sessions/insights/cost/cache-hit + 3 top priorities + 7d shipped/validated/regressed
- POST /api/insights/brief/send for manual trigger / smoke test

Env: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, PANEL_PUBLIC_URL (Coolify both apps).
Bot: @Pl24_mitm_bot (AiFactory), chat 7840804807. Source: airflow3 monitoring DAG.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:36:10 +00:00
Semih
74f0ff4935 fix(ingest): rolling lookback + ongoing-aware watermark to prevent missed sessions
Bug: previous logic advanced watermark to the latest session start_time including
ongoing sessions. PostHog session_recordings filters by start_time, so once a
session was 'seen' as ongoing the watermark moved past its start time and the
session was never re-fetched after it ended. Today 4 auth sessions on
/dashboard/vehicles/* and /dashboard/search (07:17-07:40 UTC) were lost this way.

Fix:
1. ROLLING_LOOKBACK_MINUTES (default 60): every cycle queries date_from =
   min(watermark, now - 60min). Sessions that just finished get re-fetched
   regardless of watermark drift. Upsert dedupes.
2. Track earliestOngoingStart; cap watermark to (earliestOngoingStart - 1s)
   so subsequent cycles re-read that range.

Also added GET/DELETE /api/insights/watermark for manual reset (used to
trigger 24h backfill after this deploy).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:53:38 +00:00
Semih
dfc19c8f13 refactor(integrations): migrate issue tracker GitHub → Gitea
- apps/web/src/lib/gitea.ts: createIssue / getIssue / verifyWebhookSignature / buildIssueBody
  * Endpoint: git.semih.ai/api/v1 (configurable via GITEA_BASE_URL)
  * Auth: 'Authorization: token <PAT>' (Gitea convention)
  * Labels: Gitea expects numeric IDs not strings → ensureLabels() resolves/creates
    with color coding (P0/P1 red, P2 yellow, P3 green, type-* grey, default blue)
  * Webhook signature: X-Gitea-Signature (hex, no sha256= prefix)
- apps/worker/src/lib/gitea.ts: read-only getIssue() for sync polling
- _actions.ts + github-sync.ts now import from /lib/gitea
- Removed old apps/{web,worker}/.../lib/github.ts + /api/webhooks/github route
  (the receiver was already dead — sp.semih.ai is Tailscale-only)
- UI: 'GitHub' label → 'Gitea' on insight detail card
- github-sync job filters by githubIssueUrl.startsWith(GITEA_BASE_URL) so legacy
  GitHub-hosted insights (semihyesilyurt/sase.tr#20) stay frozen rather than
  collide with same-numbered Gitea issues at root/sase.tr.

Env migration (Coolify, panel-web + panel-worker):
- removed: GITHUB_TOKEN, GITHUB_REPO_SASE, GITHUB_WEBHOOK_SECRET
- added:   GITEA_TOKEN, GITEA_REPO_SASE=root/sase.tr, GITEA_BASE_URL=https://git.semih.ai

Provisioned Gitea PAT 'super-panel-insights' (scopes: write:repository + write:issue),
stored in Bitwarden.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:36:57 +00:00
Semih
d0c497d12d feat(panel-taxonomy): track Faz 2 sase.tr events (page_visibility, idle, shortcuts, referrals, settings, bulk)
7 new event names added to TRACKED_EVENTS so enrich.ts fetches them for compression
timeline interleaving + tagger context. No new tag rules yet (Faz 2 is enrichment,
not signal extraction).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 06:28:20 +00:00
Semih
b635b529e2 feat(phase6e): eval framework + prompt editor + daily brief + retention + bundle mode
Schema:
- eval_sets (promptTag, cases JSON: [{id, timeline, expected: {...}}])
- eval_runs (per-execution scoring: passedSchema/Severity/Rubric, cost, tokens, results)

Prompts:
- 2 new seed templates: upgrade_hesitation (flash), pattern_bundle (pro)
- pickPromptTag() routes 'upgrade_hesitation' tag to upgrade_hesitation prompt
- Editor UI at /insights/settings/prompts/[id]: edit system/user/schema/tier/temp,
  publishPromptVersion() creates new version + deactivates old (CRUD with auto bump)
- setPromptActive() to toggle versions

Daily Brief (/insights/brief):
- Last 24h: sessions processed, insights produced (with severity breakdown),
  cost, cache hit rate; today's top 5 priorities; week stats (shipped/validated/regressed)

Retention cron (04:15 UTC daily):
- Delete sessions_meta + session_custom_events older than 90d (unless referenced by
  active insight)
- Delete compressed_sessions rows + MinIO timeline blobs older than 180d
- raw_metadata 30d (currently no-op; we don't persist raw metadata to MinIO)

Eval framework:
- apps/worker/src/jobs/eval-run.ts: runs cases against current/specified prompt version,
  scores schema_pass + severity_match + rubric_substring; stores EvalRun
- apps/worker BullMQ queue handler for 'eval-run' job name
- apps/web installed bullmq; /lib/queue.ts thin Queue accessor
- Web actions: createEvalSet, triggerEvalRun (enqueues job to insight-pipeline queue)
- UI: /insights/settings/eval-sets list, /new create form (paste JSON cases),
  /[id] detail with Run button + recent runs + per-case JSON

Bundle mode (analyze job):
- Pull 3x batch, group by fingerprint
- Groups ≥ INSIGHT_BUNDLE_THRESHOLD (default 3) → use 'pattern_bundle' prompt
- Timeline = primary rep + PATTERN BUNDLE summary block (occurrences, unique users, deltas)
- Insight stores ALL group session IDs as relatedSessionIds; all marked analyzed in one tx
- Cost amortized: 1 LLM call per group

Nav/Cmd+K:
- Inbox header links: Brief, Patterns, Eval sets
- Palette: Daily brief, Eval sets entries

Deferred to backlog: embedding-similarity cross-fingerprint clustering, Telegram brief delivery,
Sase.tr-side data-private audit (separate repo).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 06:11:48 +00:00
Semih
9c7682ae63 feat(phase6d): GitHub polling sync — outbound-only state mirror (Tailscale-only panel)
Webhook can't reach sp.semih.ai from internet (Tailscale-only DNS). Switched to a
worker job that polls GitHub for tracked issue states every 10 minutes.

apps/worker/src/lib/github.ts: read-only getIssue() client
apps/worker/src/jobs/github-sync.ts: scan insights with githubIssueNumber + not validated/dismissed/duplicate,
  fetch remote state, reflect transitions:
    open → closed = shipped + shippedAt + validationStartedAt
    closed → open = in_progress + clear validation state

Scheduler: github-sync@*/10min added.

The /api/webhooks/github route stays (works if reachable in future) but is no longer
the source of truth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 05:54:58 +00:00
Semih
c5acdfc8ec feat(phase6d): GitHub action loop — issue creation, webhook, validation cron, patterns view
Schema:
- Insight.+githubIssueNumber (user-visible #N, separate from id BigInt)

GitHub integration (apps/web/src/lib/github.ts):
- repoFor(projectKey): env-based GITHUB_REPO_<KEY>=owner/repo mapping
- createIssue / getIssue REST wrappers
- verifyWebhookSignature (HMAC-SHA256 timing-safe)
- buildIssueBody: renders structured markdown from insight + LLM body
  (hypothesis, reproduce steps, affected route/provider, quick/long fixes,
  suggested investigation, evidence links to panel, DoD checklist)

Server action createGithubIssueForInsight:
- Auth-gated, audited; idempotent (refuses if issue already exists)
- Labels: insight-driven, severity-<P>, type-<T>, <project>-pilot
- Sets status=in_backlog, stores githubIssueUrl/Id/Number/State

Webhook /api/webhooks/github:
- Signature verify with GITHUB_WEBHOOK_SECRET
- issues.closed → status=shipped + shippedAt + validationStartedAt
- issues.reopened → status=in_progress + clear validation state
- issues.opened → status=in_backlog
- PR linking placeholder (passthrough only for now)

Validation cron (worker, daily 5:00 UTC):
- For each insight in 'shipped' state:
  - Count sessions with same fingerprint after shippedAt
  - >= INSIGHT_REGRESSION_THRESHOLD (default 3) → status=regressed + regressionDetected=true
  - validationPeriodDays elapsed with no regression → status=validated + validatedAt

UI:
- Insight detail: GithubActions card — Create button (when no issue),
  external link + issue # + state (when present)
- New /insights/patterns page: clusters insights by type + affected_route/provider,
  shows ≥2-insight or ≥5-occurrence groups sorted by max severity
- Inbox header link to Patterns
- Cmd+K palette: Patterns entry

Env needed: GITHUB_TOKEN, GITHUB_REPO_SASE, GITHUB_WEBHOOK_SECRET.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 05:45:48 +00:00
Semih
0295b88981 fix(phase6b): DeepSeek V4 disable thinking by default + parse text block (not first content)
- thinking={type:disabled} skips reasoning trace → output_tokens drop 10x
- find first content block of type='text' (V4 may emit thinking before text)
- DEEPSEEK_THINKING=true env to re-enable if needed for hard problems

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:11:09 +00:00
Semih
95c7e8e321 feat(phase6b): LLM analysis layer — DeepSeek + insights + cost ledger + budget guard
Schema:
- insights (fingerprint dedup, occurrence_count, related_session_ids[], priority_score)
- cost_ledger (per-call, cache_hit/miss/output token split + USD)
- prompt_templates (versioned, per-tag, with JSON schema + tier + temperature)
- budget_settings (global key/value: monthly_hard_cap_usd, daily_soft/hard, per_call_max, analysis_paused)

Worker:
- lib/deepseek.ts: Anthropic-compat endpoint client (deepseek-v4-flash | deepseek-v4-pro),
  usage→USD with V4 promo pricing, cache_read_input_tokens awareness, extractJson() helper
- lib/budget.ts: checkBudget() returns active|soft_throttled|hard_paused|monthly_paused,
  forces flash tier on soft cap, halts on hard/monthly cap or analysis_paused
- lib/prompts.ts: 5 seed templates (bug_triage P, ux_friction F, payment_issue P,
  onboarding_stuck F, provider_quality F) with embedded JSON schemas + Sase.tr context;
  pickPromptTag() maps session tags → prompt
- lib/json-validate.ts: lightweight schema validator (no ajv dep)
- lib/seed-runtime.ts: idempotent upsert of prompts + default budget settings on worker boot
- lib/minio.ts: +getText() for compressed timeline fetch
- jobs/analyze.ts: budget guard → 6h fingerprint cache (attach session to existing insight) →
  template lookup → severity-based tier override → DeepSeek call → JSON parse + validate →
  insert insight (or aggregate occurrence) → write cost_ledger
- scheduler: analyze@*/4min on insight-pipeline queue

UI:
- /insights (was pipeline view) → now Insight Inbox: priority-sorted list w/ KPI strip
  (new/in_backlog/shipped/today $/month $), severity badges, link to detail
- /insights/i/[id]: insight detail with structured body render, related sessions,
  per-session cost breakdown, raw JSON collapsible
- /insights/costs: KPI cards (today, month, avg, cache hit), daily 30d bar table,
  by-model + by-prompt breakdowns, top 10 expensive, recent errors
- /insights/pipeline: moved old session-pipeline view here
- /insights/sessions/[id]: unchanged session timeline viewer

Defaults:
- monthly cap $30, daily soft $1.50 / hard $3, per-call $0.20, analysis_paused=false
- Severity→Tier: P0/P1=pro, P2/P3/INFO=flash; budget soft-cap forces flash

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:01:55 +00:00
Semih
978b71d818 fix(phase6a-v1.2): PostHog event filter client-side + backfill distinct_id + power_user_path rrweb fallback
- listSessionEvents: PostHog only honors single event= param; switched to client-side filter
- tag-sessions: backfill posthogDistinctId from getRecording when null (for rows pre-column)
- tagger: power_user_path v1 fallback (auth + clicks>=20 + no errors), vin_decode_no_outcome P3
- ingest upsert: always update posthogDistinctId (idempotent)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:43:44 +00:00
Semih
fc6a3f7a39 feat(phase6a-v1.2): dual-mode custom-event enrichment (v1.0 + v2.0 aliases)
- Prisma: posthog_person_cache (24h TTL), posthog_group_cache, session_custom_events
- SessionMeta: +posthogDistinctId, +groupKey, +customEventCount
- PostHog client: listSessionEvents, getPerson, getGroup
- posthog-cache.ts: cache-with-stale-refresh for person + group properties
- event-taxonomy.ts: dual-mode alias map (vin_decoded↔vin_decode_initiated,
  vin_decode_success↔vin_decode_succeeded, vin_decode_error↔vin_decode_failed,
  payment_success↔payment_succeeded). 56 tracked event names total.
- enrich.ts: per-session custom events fetch, persist to session_custom_events,
  attach user_properties + group_properties via cache.
- tagger.ts: 14 new custom-event tags
  (vin_decode_fail_pattern, provider_reliability_issue, payment_friction,
   payment_ui_silent_failure (P0), payment_failed_session, checkout_abandonment,
   downgrade_pending, compatibility_quality_gap, parts_export_abandoned,
   search_validation_friction, onboarding_stuck, upgrade_hesitation,
   power_user_path, at_risk_active_session, provider_mismatch,
   api_key_friction, webhook_setup_struggle); group-context priority bump.
- heuristic.ts: customEventPromoteReasons() — 10 custom-event promote signals.
- compress.ts: merge_rrweb_and_custom_events interleaving with ⤷ EVENT: prefix,
  ±1.5s grouping, standalone flush for events with no nearby rrweb. Adds
  company_context header line; key_events lists top-5 custom event counts.
- /insights page: +Events column. /insights/sessions/[id]: collapsible custom
  events table + group key display.

Graceful: when Sase.tr emits 0 custom events the pipeline falls back to
rrweb-only tagging from Phase 6a v1.1.

PRD: behavioral-insight-pipeline-prd-1.md (v1.2),
     sase-posthog-events-prd.md (Sase.tr-side, separate codebase).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:17:18 +00:00
Semih
93499faeaa fix(phase6a): PostHog snapshot blob fetch uses start_blob_key/end_blob_key; parse [window_id,event] tuples
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 21:26:36 +00:00
Semih
795528982e fix(phase6a): increase BullMQ lockDuration to 5min (network/db calls exceed 30s)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 21:15:06 +00:00
Semih
1a7a1b5787 feat(phase6a): behavioral insight pipeline ingestion + tagging + compression
- prisma: sessions_meta, compressed_sessions, ingestion_watermarks
- worker: PostHog client (eu.i.posthog.com), heuristic filter
- worker: BullMQ insight-pipeline queue (ingest 5min / tag 2min / compress 3min)
- worker: tagger (bug_suspected, ux_friction, upgrade_hesitation, etc.) + severity scoring
- worker: rrweb -> semantic timeline transform + 8-pattern PII sanitization
- worker: fingerprint hash + MinIO upload (bucket: insight-compressed)
- web: /insights pipeline dashboard + session timeline viewer
- sidebar: Insights nav entry

LLM (Phase 6b), insight inbox, cost dashboard, GitHub loop deferred to later phases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 20:58:36 +00:00
Semih
909cacf7b3 feat(phase4): rate limit + audit archive + panel backup + DR docs
- src/lib/rate-limit.ts (Redis sliding window, fail-open)
- before-hook on /sign-in/email: 5 attempts/min per IP+email
- worker job audit-archive (daily 03:30, JSONL → MinIO, 90d retention)
- worker job panel-backup (daily 04:00, pg_dump -Fc -Z9 → MinIO)
- Dockerfile adds postgresql16-client
- scripts/restore-drill.sh restores latest dump into panel_drill
- docs/disaster-recovery.md + docs/phase4-deferred.md (mTLS + Infisical rationale)
2026-05-13 11:14:36 +00:00
Semih
b9709ed07e feat(phase3): worker container + event bus + scheduled jobs
apps/worker:
- BullMQ nightly scheduler (cron 0 3 * * *)
- Redis Streams consumer-group per wired/active project
- Persists events to Event model

schema:
- Event model (streamId unique, project + type indexed)
2026-05-13 11:03:58 +00:00