Commit Graph

106 Commits

Author SHA1 Message Date
Semih
acce1fcc8e fix(analytics): VIN anomaly detector — suppress single-user retry noise
Night of 2026-06-11 one user retrying a catalogless Toyota VIN tripped
volume_spike + unknown_vin_spike, and ~2.5/window night baselines made
quiet windows fire volume_drop at -100%.

- volume_spike: require >=2 distinct users and >=5 distinct (user,vin)
  lookups so same-VIN retries don't count as a spike
- unknown_vin_spike: require unknown failures from >=2 distinct users
- volume_drop: raise expected-per-window floor from 1 to 4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:45:36 +03:00
Semih
79b8b6b356 feat(analytics): recordings intelligence — signal filters + replay links
Recordings tab filters by signal (errors / long >=5m / active >=30 clicks)
with live counts, highlights error sessions, and adds a one-click PostHog
replay player link per recording. Turns the 5k+ archived recordings into a
triage surface for watching problem sessions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:25:45 +03:00
Semih
fffc81f132 fix(sase): catalog-coverage Prisma make_interval(bigint) error
Prisma serialized windowDays as bigint -> make_interval(days => bigint) does
not exist (42883), so detectCatalogCoverageGaps threw on every real call: the
evening brief silently dropped its Sase-DB half and catalog-gap-detect never
created insights. Use a JS Date param (matches the rest of the codebase).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:37:12 +03:00
Semih
ea63f4d167 feat(analytics): experiment significance + experiments in the evening brief
- experiment results compute a two-proportion z-test p-value + 95% CI per
  variant vs control (significant = p<0.05); shown in the archive detail view
  and used to label the leader honestly.
- evening brief: getExperimentBriefSummary feeds active-experiment results
  (leader, lift, p-value, significance) into the brief metrics; prompt tells
  the LLM to surface only statistically significant winners, not noisy lifts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:33:05 +03:00
Semih
99965f4088 feat(analytics): survey + experiment results in the archive detail view
Survey detail now shows the response funnel (shown -> sent -> dismissed) +
per-question distributions (rating/choice bars, open-text list), keyed by
$survey_id -> $survey_response_<questionId> from the archived definition.
Feature-flag/experiment detail shows per-variant exposure -> conversion with
lift-vs-control + a goal-event selector (signup/decode/trial/checkout/payment).
All from posthog_events; no schema change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:59:13 +03:00
Semih
4eec4e7faf fix(archive): recording archive — pace pages + raise cap for full backfill
40-page cap captured only the most recent ~13 days. Add a 150ms inter-page
delay (PostHog list API rate-limits a tight loop) and raise the default cap
to 200 pages / 120d window so a run can sweep the full retention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:27:43 +03:00
Semih
400ed224c5 feat(archive): add groups + recordings to the PostHog archive
- groups: posthog-resource-archive now also snapshots group types + group
  instances (groups_types/ + groups/?group_type_index) into
  posthog_resource_snapshots (change-detected).
- recordings: new posthog-recording-archive job (@*/6h) captures complete
  metadata for EVERY recording into posthog_recordings (skipDuplicates over a
  rolling 90d window) + MinIO dump — a superset of the insight pipeline's
  promoted SessionMeta subset. rrweb blobs still archived by archive-recordings.
- /posthog-archive browser: Groups / Group Types tabs + a Recordings tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:19:54 +03:00
Semih
e953f5fc34 feat(analytics): browsable PostHog Archive (/posthog-archive)
Read view over posthog_resource_snapshots: type tabs (feature flags,
experiments, surveys, insights, dashboards, annotations, actions) with
resource counts, latest-per-resource table, and a detail page showing the
full lossless payload + change history (version timeline). Nav entry added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:05:41 +03:00
Semih
dd54f4983b feat(archive): capture all PostHog resource modules to DB + MinIO
Extends archival beyond events/persons/cohorts. New generic resource
archiver (posthog-resource-archive@*/6h) snapshots surveys, feature flags,
experiments, dashboards, insights, annotations, and actions into
posthog_resource_snapshots — change-detected (hash over non-volatile config),
lossless `data` payload — and dumps a full per-type daily copy to MinIO
(resources/<type>/<date>.json.gz). listResource() pages any PostHog REST
resource. Survey responses + flag calls are already captured as events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:31:19 +03:00
Semih
a9e0d93aa1 feat(analytics): PostHog product-analytics dashboard (/analytics, sase default)
Reusable AnalyticsDashboard over the locally-archived posthog_events stream
(~15min fresh, validated against live PostHog within 1%):
- KPIs with period-over-period deltas: visitors, signups, signup conversion,
  trials, VIN decodes, parts views, OEM copies, WAU
- Activation funnel (signup cohort -> decode -> parts -> OEM)
- Conversion funnel (trial -> checkout -> payment)
- Acquisition by first-touch source (utm_source -> referrer -> direct)
- Daily trend + product quality (decode success %, empty-parts %, empty catalog)

Parameterized by projectKey (reusable across spokes); sase is the default.
Nav entry added. Reads posthog_events locally — no live PostHog API at render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:21:29 +03:00
Semih
cf7a6b910c Merge: parts/catalog insight detection + Sase 360 evening brief
- tagger parts_render_blocked / catalog_empty_result + catalog-gap-detect job
- Sase 360 evening brief (briefs page + worker job + Telegram@20:00)
2026-06-10 19:36:04 +03:00
Semih
01c93daf6a feat(brief): Sase 360° evening brief — page + worker job + Telegram@20:00
Daily Sase brief aggregating VIN decode health, catalog coverage gaps,
revenue (Stripe/iyzico TRY), users/subscriptions, PostHog behavior + UTM
source, Meta ads (env-gated), and the insight pipeline into one
critical-points-first report.

- Brief model (panel DB): one row/project/day (date, headline, summary,
  health score, structured body, raw 360 metrics).
- Panel: getSaseBriefMetrics() (saseDb half) + /api/internal/sase-brief-data.
- Worker: sase-evening-brief job collects panel metrics + PostHog
  (hogqlQuery) + Meta (lib/meta, env-gated) + pipeline stats -> DeepSeek
  structured brief -> upsert Brief -> Telegram. Scheduled 17:00 UTC
  (20:00 Europe/Istanbul).
- UI: /projects/sase/briefs (table of days) + /briefs/[id] (detail:
  critical-first, sections by status, recommendations, raw metrics).

Meta dormant until META_ACCESS_TOKEN/META_AD_ACCOUNT_ID set on the worker.
Needs a `briefs` table migration on the panel DB before first run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:17:01 +03:00
Semih
1d5daf2e5c feat(insights): detect parts/catalog coverage failures (client + server)
Surfaces "vehicle resolved but no parts/categories" — Sase's #1 churn
signal, previously invisible to insights (a prospect emailed support
instead of any insight firing).

Client (PostHog session tagger):
- fetch the catalog_*/category_view_changed/empty_catalog_cta_clicked
  event family (was never pulled) and tag parts_render_blocked (P1) /
  catalog_empty_result (P2), scoped to the real /dashboard product (demo
  excluded). Harden the search-affordance-misuse guard so a real parts
  failure is never suppressed. Route both tags to bug_triage.

Server (decode-log catalog gaps):
- new catalog-gap-detect worker job (every 6h) -> panel internal API
  /api/internal/catalog-gap-check aggregates "No catalog - identified as
  X" query_logs failures by brand -> upserts catalog_coverage_gap
  insights (Telegram on P1), respecting founder triage. Add NO_CATALOG
  error bucket.

Validated: both apps typecheck clean; tagger smoke 21/21; aggregation
over 90d real data yields 13 brand gaps (Renault/Fiat/Honda P1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:22:09 +03:00
f8d38426e6 Merge pull request 'fix(worker): graceful BullMQ worker shutdown (stop Missing-lock on deploy)' (#7) from fix/worker-graceful-shutdown into main 2026-06-03 18:52:48 +00:00
Semih
7a56a72037 fix(worker): graceful BullMQ worker shutdown to stop Missing-lock churn on deploy
On SIGTERM/SIGINT the worker only disconnected prisma/redis and exited — the
BullMQ Workers were never closed, so in-flight job locks were never released. On
every deploy the orchestrator killed the worker mid-flight, the next container
saw the half-finished jobs as "stalled", re-ran them, and the dead worker's
pending moveToFinished surfaced as `Missing lock for job
repeat:nightly-refresh:… moveToFinished`, with duplicate nightly-refresh firings
during the container overlap.

- startScheduledJobs / startInsightPipeline / startContentPipeline now return
  their Worker so index.ts can close them.
- shutdown() closes all workers FIRST (releases locks, drains in-flight),
  bounded by a 15s race so an in-flight job can't block past the orchestrator's
  stop grace period, then disconnects prisma/redis.
- nightly worker now uses lockDuration 5min + stalledInterval 60s (matching the
  pipeline/content workers); the 30s default could expire during panel-backup
  (pg_dump) and trip the same stalled → re-run → Missing-lock cycle.

The scheduler config itself was already correct (nightly@03:00, audit@03:30,
panel-backup@04:00 — verified against the Redis job-scheduler ZSET; next fires
were exactly 03:00/03:30/04:00). tsc --noEmit clean; dedup + tagger smoke 30/30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:51:53 +03:00
6114e27b2d Merge pull request 'fix(insights): suppress no-insight search-affordance misuse in tagger' (#6) from fix/suppress-search-affordance-noise into main 2026-06-03 18:36:23 +00:00
Semih
556cfd6ba0 fix(insights): suppress no-insight search-affordance misuse in tagger
Users routinely use the VIN-only /dashboard/search box to look for a part by
name (e.g. "Cam düğme") or fiddle with search/history and rage-click out of
affordance confusion — while nothing is actually broken. The generic
ux_friction / frustrated_session tags turned these pure-rage sessions into
insights (noise; ~40 of the first 67 open insights were exactly this, all
dismissed by the founder).

apps/worker/src/lib/tagger.ts: when a session used the search box
(search_input_focused) but carries NO concrete failure (errorCount=0,
network5xxCount=0, no vin_decode_failed, search_input_validation_failed<3, no
payment_initiated/failed, no checkout_started), do not emit the generic
ux_friction / frustrated_session tags. With no other actionable tag the session
becomes tag-less and tag-sessions discards it → no compress / analyze / insight.

Scoped narrowly to search-box sessions on purpose, to avoid hiding genuine
parts/category bugs. Every concrete signal stays actionable: JS errors
(bug_suspected), 5xx (server_error_impact), payment friction, VIN upstream
provider failures, and ≥3 client validation failures (search_validation_friction).

Smoke: apps/worker/src/lib/tagger.smoke.ts (12/12) covers the misuse case plus
six must-keep cases. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:35:58 +03:00
1a95a8823f Merge pull request 'fix(insights): tighten session fingerprint so duplicate UX issues dedupe' (#5) from fix/insight-fingerprint-dedupe into main 2026-06-03 17:47:12 +00:00
Semih
b61d955256 feat(insights): semantic dedup gate + dismissed-theme suppression in analyze
The fingerprint hash (even after a7fe80f) still over-splits: the same root
problem produces different tag-sets / first-error / first-failed-endpoint
across sessions, so it hashes to a new fingerprint and creates a duplicate
insight. And generic ux_friction/frustrated_session sessions (category-tree
vs parts-panel vs schema-image) carry no structured signal to separate
sub-problems — only the LLM's reading of the timeline does. Dismissed insights
were only suppressed on an EXACT fingerprint repeat; a near-variant got a fresh
fingerprint and re-opened the theme. Dismissed rows were also excluded from the
fast-path cache, so every recurring session re-ran the LLM for no reason.

Two-layer dedup so (1) duplicates can't be created and (2) nothing similar to a
dismissed/duplicate theme is re-opened:

Layer 1 — fingerprint (or aliased fingerprint) fast-path, no LLM:
  - dismissed/duplicate  → SUPPRESS (bump occ/lastSeen, keep status)
  - validated/shipped    → flag REGRESSION (cheap, no re-analysis)
  - active & fresh        → attach occurrence
  - active & stale        → fall through to refresh re-analysis (unchanged)

Layer 2 — semantic dedup gate before creating a NEW insight (flash, no new infra):
  - build a bounded catalog: all active insights + recently-touched
    dismissed/duplicate/validated/shipped (lookback-windowed, capped)
  - ask the model whether the session is the SAME underlying problem as a
    catalog entry (anti-hallucination: only accept catalog ids; conf >= 0.72)
  - on match: alias this fingerprint onto that insight + attach/suppress, so the
    next identical session fast-paths in Layer 1 (no LLM, no duplicate row)
  - no match: create a new insight as before

Schema: Insight.aliasFingerprints String[] (additive; applied via prisma db push
on deploy). Dedup-gate cost is logged to costLedger as promptTag=dedup_gate.

Tunables: INSIGHT_DEDUP_GATE (default on), INSIGHT_DEDUP_THRESHOLD (0.72),
INSIGHT_DEDUP_SUPPRESS_DAYS (90), INSIGHT_DEDUP_CATALOG_MAX (120).

Pure decision helpers (classifyExistingAction, shouldAcceptMatch) extracted and
covered by a smoke test (apps/worker `pnpm test`, 18/18). tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:41:18 +03:00
Semih
a7fe80f0c5 fix(insights): tighten session fingerprint so duplicate UX issues dedupe
Same root cause was producing one Insight row per user because URL carried
vehicleId/categoryId UUIDs and the raw URL fed the fingerprint hash. Result:
6 active "new" insights describing the same kategori-bouncing problem with
slightly different LLM phrasing, none deduplicated, only one (cmpclg7b9 →
sase.tr#76) had been triaged.

apps/worker/src/lib/compress.ts:
- new normalizePath() — collapses UUID / ULID / CUID2 / numeric path segments
  to ":id", conservative on plain words. Mirrors what the LLM already sees in
  the timeline.
- fingerprint inputs now:
    tags (sorted) | normalizePath(url) | normalizeError(errors[0]) | normalizePath(failedEndpoints[0])
- removed header.severity from the hash — severity is a property of the
  Insight bucket, not its identity; rage-click counts pushing the same root
  cause across P1/P2/P3 was forcing extra rows.

Smoke (12/12 pass via tsx /tmp/check_fingerprint.ts):
- 6 historical kategori-bouncing URLs → 1 fingerprint
- severity changes don't move the hash
- distinct tag sets / distinct errors still split

Historical rows are untouched — only new compressed sessions get the new
hash. Old near-duplicate insights can be merged manually via the panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 19:42:25 +03:00
Semih
9a479f9a5b fix(insights): distinguish client-side VIN validation rejects from provider failures
Reproducing insight cmpvfrjgc000114fzc7cdyh66 (a P1 "PL24 timeout" false
positive): trial user typed VW part numbers ("500 907 521", "5Q0 907 521")
into the VIN field on /; client-side regex rejected them with "Geçersiz şase
numarası. 17 karakter olmalı". No provider was called. The pipeline still
tagged the session as `vin_decode_fail_pattern`, routed to `provider_quality`,
and the LLM dutifully invented a PL24 outage.

Root cause spans three files:

1. tagger.ts grouped vin_decode_failed by `provider_attempted ?? source`. When
   `provider_attempted` is missing, `source: "landing"` (a UI location) was
   treated as a provider name, so a 1-provider set was synthesized and
   `vin_decode_fail_pattern` (P1) was emitted.

2. compress.ts formatCustom whitelist excluded `error`, `source`, `vin`. The
   LLM therefore never saw "Geçersiz şase numarası" or the offending input.
   Pattern 3 mechanical hypothesis told it "check provider health" regardless.

3. prompts.ts pickPromptTag routed any `vin_decode_fail_pattern` straight to
   `provider_quality` with no input-quality check, and the v3 system prompt
   had no guardrail for client-side validation rejects.

Fix:
- tagger: detect client-side rejects by `error` regex (Turkish + English) and
  by VIN shape (length != 17 or contains I/O/Q). When all fails are client
  rejects, emit new tag `vin_decode_client_validation_fail` at P3 instead of
  `vin_decode_fail_pattern` at P1. Real provider failures now require
  `provider_attempted` to be set (no more `source` fallback).
- compress: add `error`, `source`, `vin` to the formatCustom property
  whitelist so the LLM can see the actual failure context. Split Pattern 3
  into client-reject vs. real-provider-failure branches with distinct
  Turkish hypotheses.
- prompts: route `vin_decode_client_validation_fail` to `ux_friction` before
  the provider rule. Ship provider_quality v4 with an explicit guardrail
  instructing the model to return confidence ≤0.15 and reclassify when the
  inlined event properties show client-side rejection.

The seed-runtime upsert path deactivates the active v3 template on next
worker boot and inserts v4 in its place — no manual SQL needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 23:12:39 +03:00
59cb1f63ec Merge pull request 'feat(insights): multi-project Sentry archive' (#3) from feat/sentry-multi-project into main 2026-06-01 15:38:16 +00:00
Semih
8a81cb77bf feat(insights): multi-project Sentry archive
The sentry-archive job was hardcoded to a single SENTRY_PROJECT slug.
Now that sase has a second Sentry project (sase-web, browser SDK), the
worker has to pull both — otherwise frontend events live only in
Sentry's UI and never reach the panel's sentry_events table for the
behavioral-insight pipeline to pick up.

Changes:

- SENTRY_PROJECT becomes a CSV (e.g. "python,sase-web") parsed once at
  module load into PROJECTS[]. Backward-compatible: a single value
  keeps working exactly as before.
- Issues are fetched org-wide in a single loop (unchanged endpoint)
  but each issue's Sentry project slug is now persisted via the new
  `sentrySourceProject` column on sentry_issues.
- Events are project-scoped on Sentry's side, so the job iterates
  projects and calls listEventsPage(slug, cursor) for each. Per-project
  pagination + dedup is preserved. A project-level failure is recorded
  in `error` but doesn't abort the other projects.
- New result shape: `perProject: { [slug]: { fetched, inserted, duplicate } }`
  Pipeline log gains a `[python=N/M sase-web=N/M]` breakdown so it's
  obvious at a glance which project produced what.
- Schema: nullable `sentrySourceProject` on both SentryIssue and
  SentryEvent (+ composite index per projectKey for queries that want
  to filter "panel project = sase AND sentry project = sase-web").
  Migration is a pure nullable add — `prisma db push` on container
  start is safe. Existing rows stay null until next sync touches them.

After merge, set `SENTRY_PROJECT=python,sase-web` on the panel-worker
Coolify app and redeploy.
2026-06-01 18:37:10 +03:00
7eca679ab8 Merge pull request 'feat(insights): permanent archive of PostHog (events+recordings+identity) + Sentry (Phase A+B+C)' (#2) from feat/observability-archive into main 2026-05-27 19:13:31 +00:00
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
fb33692452 feat(vin-decode): VIN row detail modal with timings + behaviour
Click a VIN in the list to open a modal with the full decode internals:
- Proxy times per provider (pcat/emex/pl24/vin-api) from query_logs.timings
- Race/lock status (lock_wait ms + cache_source='lock_wait' → "aynı VIN
  paralelde çözülüyordu"), candidate_pick, pl24 circuit, aborted
- Candidate counts (pcat_car_count, emex_candidate_count), result_kind, wmi
- User behaviour for that vehicle, correlated from PostHog custom events in
  the panel DB by user_id + vehicle_id: categories opened (parts_panel_viewed)
  and OEM copied (oem_code_copied + distinct codes)

New: getVinDecodeDetail/getVinUserBehavior (cross-DB: sase RO + panel),
GET /api/sase/query-log/[id] (auth-guarded), client VinDetailButton modal.
Both queries verified against prod data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:31:28 +03:00
Semih
09438cf513 fix(vin-decode): repair Business page 500 + drop redundant VIN buttons
- Business page returned 500: getTrialFunnel's `ORDER BY CASE bucket ...`
  referenced the SELECT-list alias inside an expression, which Postgres
  can't resolve (error 42703 "column bucket does not exist"). Wrap the
  GROUP BY in a subquery so `bucket` is a real column the ORDER BY can use.
  Verified against prod: original errors, fixed query returns ordered buckets.
- Remove the "Çözülen / Çözülemeyen VIN'ler" dashboard buttons — the VIN
  list already has a success/error status filter, so they were redundant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:22:21 +03:00
Semih
985592aa73 feat(vin-decode): WMI opportunity radar, per-source p50, unknown-VIN spike alarm
Systematize Faz 4 + close three observability gaps:

- WMI opportunity radar (getWmiOpportunities): group query_logs by
  substring(vin,1,3) = WMI, rank low-success WMIs by distinct-user demand.
  brandMatched=0 flags fully-uncovered manufacturer codes. Surfaced on the
  Trends page (#wmi) + a dashboard header shortcut. Replaces the manual Faz 4.
- Per-source p50: add percentile_cont(0.50) to getProviderAttempts and
  getProviderDeepStats; show P50·P95 on the dashboard and provider drill-down.
- unknown_vin_spike anomaly: track "tanınamadı" rate in the 15min/baseline
  windows, fire when it jumps >=2x baseline (or surges from ~0), and attribute
  the dominant failing source (+aborted count) in the Telegram message. Runs
  in the existing 5min anomaly cron; worker/telegram unchanged (generic type).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:58:26 +03:00
Semih
f05e0bc808 feat(vin-decode): add solved/unsolved VIN list shortcut buttons
Add two header buttons on the VIN decode dashboard linking to the
existing /vins list pre-filtered by success=true / success=false,
carrying the current time-range window via the `from` param so the
list matches the dashboard view. Shows actual VIN codes (not counts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:07:55 +03:00
Semih
0419cc271c feat(content): Phase 8c publish layer — panel→n8n webhook (sync) + LinkedIn/blog
Approved drafts publish via a single n8n `publish-content` webhook; the panel
POSTs the effective content (founder edits merged over generated body) and
awaits a synchronous Respond-to-Webhook result (sp.semih.ai is Tailscale-only,
so we avoid an n8n→panel callback). n8n routes by channel.

- lib/n8n.ts: publishToN8n client (X-Content-Secret header, timeout, tolerant
  result parsing: ok|success + publishedUrl|url|postUrl|permalink)
- publishDraft server action: approved|failed → publishing → published(+url) /
  failed(+error), audit-logged; effective content = bodyJson + founderEdits
- DraftCard: "Yayınla" / "Yeniden yayınla" button + publishing state
- docs/n8n: importable publish-content workflow (Webhook → Switch → LinkedIn /
  HTTP-blog → Respond) + runbook (contract, panel envs, LinkedIn OAuth setup,
  blog endpoint = sase.tr POST /blog/posts/internal Bearer)

Needs panel-web envs N8N_PUBLISH_WEBHOOK_URL + N8N_WEBHOOK_SECRET. Publish is a
graceful no-op (clear error) until those are set and the n8n workflow exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:42:11 +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
091e9daa2f fix(sase): VIN filter buildHref — dangling-else bug
For string filter values (q, success, sort, etc.), the URL query string
came out empty — array values (provider, status with multi-select)
worked. Root cause was a brace-less if/for/if chain:

  if (Array.isArray(v)) for (const item of v) if (item) usp.append(k, item);
  else usp.set(k, String(v));

JS associates the `else` with the inner `if (item)`, not the outer
`if (Array.isArray(v))`. So when v is a string, the outer if is false,
nothing runs, usp stays empty, and the URL becomes just "?". The status
pills and the "Ara" search button both looked broken because their
patches were string-typed.

Same buildHref in users/_query.ts already had braces, which is why
the user list search was unaffected.

Add the missing braces. Also drops the temporary console.log
diagnostics from the VIN filter bar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 08:58:54 +03:00
Semih
dbdc0d476b debug: trace inside buildHref 2026-05-19 08:54:36 +03:00
Semih
4defb0bfea debug: expand JSON in nav log 2026-05-19 08:51:26 +03:00
Semih
1b198620ad debug: console.log inside VinsFilterBar.nav + submit (temporary) 2026-05-19 08:48:51 +03:00
Semih
38429a98cd fix(sase): Ara search button — Base UI Button forces type='button'
Root cause: @base-ui/react's Button hard-overrides the type prop to
'button' via getButtonProps in useButton(). Even when we write
<Button type='submit'>, mergeProps replaces it with 'button' before
DOM, so the button does nothing inside a form. Visually it looks
disabled because clicking it doesn't trigger any state change.

Fix: for form submit buttons, use a native <button type='submit'>
styled with buttonVariants() to match. Onsubmit was already in place
from the previous commit — this is the missing half.

Applied to both VIN list and user list filter bars.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 08:25:49 +03:00
Semih
931bcda35d fix(sase): search form not submitting — React form action → onSubmit
Both the VIN list and the user list used the React 19 server-action-
style <form action={fn}> pattern with a client function. In production
that didn't actually navigate — form submitted with no URL change, the
table stayed unfiltered.

Switch to plain onSubmit + controlled input (useState for the query
text). Clears now also resets the input value, not just the URL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 08:14:37 +03:00
Semih
39c26bd4d4 feat(sase): VIN list management view
A user-list-style table at /projects/sase/vin-decode/vins for inspecting
and managing decoded VINs. Pairs with sase.tr#34 (cache-clear + delete
endpoints).

Repo (lib/sase/vin-list.ts)
- listVinDecodes(filter, sort, page) — raw SQL with dynamic WHERE for
  search (VIN/email), provider IN, success boolean, brand IDs, date
  range. Joins users + brands, computes EXISTS(vehicles) per row so the
  action menu knows whether DB delete is meaningful.
- KNOWN_PROVIDERS export.

Admin SDK
- clearVinCache({ vin, reason, founderId }) — POST cache-clear
- deleteVehicleByVin({ vin, reason, founderId }) — DELETE vehicle
- Result types: VinCacheClearResult, VinDeleteResult.
- SASE_ADMIN_ENDPOINTS list updated.

Panel routes
- POST /api/sase/vins/[vin]/cache-clear — auth + spoke-wired + reason
  ≥ 5 chars. Audit on both paths.
- DELETE /api/sase/vins/[vin] — same shape.

UI (/projects/sase/vin-decode/vins)
- URL-driven filters (VIN/email search, provider multi-select pills,
  success/fail toggle) — sharable links.
- Sortable columns: Tarih (createdAt) and RT (responseTimeMs).
- Per-row cells: timestamp · VIN (mono) · brand slug+name · user email
  (link to user detail) · provider badge (link to provider drill-down)
  · ok/fail badge (fail title = errorMessage) · responseTimeMs ·
  cacheSource from timings jsonb · action menu.
- Per-row action menu (gated on saseAdminWired):
    [Cache] — opens reason modal, POSTs cache-clear
    [DB]    — only when vehicles row exists; opens reason modal,
              destructive variant, DELETE
- Pager with prefix/postfix range + ←/→ links preserving filters.

Dashboard header gets a "VIN list →" pill next to Business/Trends.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:59:22 +03:00
Semih
230ef63433 docs: mark super_panel_reader role as done
Role created on the Sase.tr standalone Postgres with SELECT-only
privileges on schema public + ALTER DEFAULT PRIVILEGES so future
tables inherit the grant. Panel-web DATABASE_URL_SASE_RO switched to
this role; panel redeployed; UPDATE/INSERT/DELETE rejected with
'permission denied' as expected; SELECT count(*) FROM users still
works.

Process: temporarily exposed sase-postgres on port 54320, ran the
SQL via psql, then closed the public port. Password generated with
openssl rand -hex 24 (lives only in Coolify env).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 16:46:22 +03:00
Semih
d0f0cadf51 docs: refresh teknikborc + add RUNBOOK.md
Phase 7 (user mgmt, all of A–F) and Phase 8 (VIN observability Faz 1–4
+ regression alert) are shipped. Teknikborc previously listed many of
those as open — clean it up and section the remaining work into:

- Phase 7 still-open items (support actions, KVKK, sessions, bulk ops,
  automation) — explicitly listed at top
- Phase 8 still-open items (alert config UI, vin_anomaly_log table,
  pointing to RUNBOOK.md for documentation)
- PRD ↔ Sase.tr reality gaps (EFT killed, B2B doesn't exist, etc.)
- Defansif teknik borç (super_panel_reader rol, prisma db push
  multi-replica risk, pre-existing Sase.tr typecheck errors)
- Sase.tr-side work (Faz 0+1 PostHog events; notes that the backend
  VIN telemetry was tried and reverted because query_logs already has
  everything we need)
- BIP open work (P1 fix, eval golden set, pattern bundle, embedding
  similarity, multi-project routing)
- Minor / low priority polish items

RUNBOOK.md is new: founder operations guide. Sections:
- Telegram alert → response matrix (success drop, regression, p95
  spike, volume drop/spike, timeout dominance)
- Founder daily review walkthrough (the / "Bugün" page sequence)
- Pipeline/cron health (job cadences + how to check)
- Coolify quick reference (app UUIDs + deploy command)
- User report → fix call chain
- Security model summary
- Local dev + new endpoint scaffolding steps

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:27:18 +03:00
Semih
c7346fd25e feat(home): founder daily overview
Replaces the previous "active spokes / planned / audit24h / audit7d" home
with a synthesizing daily-overview page. Pulls from every module shipped
to date (VIN observability, user management, BIP, audit log, Coolify
deploys) and answers a single question: "what should I look at today?"

Repo (lib/sase/daily-overview.ts)
- getSaseHealthSnapshot — 24h vs prior-24h success rate + volume; single
  raw query with FILTER aggs on the two windows.
- getInsightSummary — open insights grouped by severity (P0..P3), plus
  recent-7d created and 7d-shipped counts (from panel-pg insights).
- getSaseUserCounts — total/new/active-sub/trial/cancelled, suspended,
  banned, dormant payers (14d), empty-handed payers. Two correlated
  subqueries for dormant/empty since they need NOT EXISTS over
  query_logs.
- getRecentAdminActions — last N audit_log mutations (excluding GET and
  /api/internal/*). Maps each endpoint to a human label
  ("Impersonate", "Refund", …) and a deep-link target where possible.
- getRecentDeployStatuses — last N Sase Coolify deploys with
  regression-flag derived from the ±30min slice analyzer.
- buildActionItems — synthesizes "things you should look at" from all
  the above, sorted critical → low. Surfaces deploy regressions, open
  P0/P1 insights, big 24h success drop, dormant/empty payers,
  suspended users.

UI (app/page.tsx)
- Six-column KPI strip across the top: Sase 24h success (color-coded
  by threshold, with pp delta vs prior 24h), volume delta, active sub,
  new users 7d, open insight total, audit count.
- "Bugün bakmam gerekenler" action-item list, color-coded by severity,
  each clickable.
- Two-column split: open-insights-by-priority (links to /insights with
  the severity filter), Sase user health counts (dormant/empty/
  suspended/banned with links).
- Two-column split: last 5 deploys with Δ-success column + regression
  badge, last 10 admin actions with human label + status + deep link.
- Projects grid moved to the bottom as a compact strip (was the
  primary section before).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:04:36 +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