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>
- 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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
- 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>
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>
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>
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>
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).
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.
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>
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>
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>
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>
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>
Closes Phase 8: long-term capacity view + per-provider deep dive.
Alert-threshold config UI deferred to teknikborc — the cron defaults
are well-calibrated enough to ship without a config table.
Trends page — /projects/sase/vin-decode/trends
1. Son 90 gün — daily decode volume sparkline, success/total ratio
shading.
2. Haftalık büyüme — WoW % change for last 12 weeks, color-coded.
3. Provider yük dağılımı — stacked area for the last 30 days, one bar
per day decomposed by winning provider; legend below.
4. Düşük destekli markalar — brands with ≥20 30d volume but ≤75%
success. New-provider/decoder investment signal.
5. Peak heatmap — 7×24 grid (day-of-week × hour) for the last 30d,
intensity-shaded; B2B working-hours pattern + infra scaling guide.
6. Cache hit trend — daily cache hit rate sparkline + 30d average.
7. Verimlilik composite — daily score combining success (50%), cache
(20%), latency (20%, 1.0 at <500ms decaying to 0 at >10s), and
1-fallback (10%). 14-day table view.
Provider drill-down — /providers/[name]?range=7d|30d
- Four KPIs: wins total, success rate, avg/P95 latency, chain-attempt
count (separately tracked for chain key vs winning-source).
- Daily wins sparkline scoped to the range.
- Top 10 brands the provider successfully decoded for.
- Fallback chains the provider participated in.
- 20 most recent failures with sanitized VIN + user-link drill.
Dashboard wiring
- Winning-provider and provider-attempts cards on the main VIN
dashboard now link each provider row to /providers/[name]. The
timings-key aliases (pcat → parts-catalogs, vin_api → vin-api) are
remapped so the link reaches the right page.
- New "Trends →" pill in the header next to "Business →".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sase.tr has no monthly VIN quota — access is gated by plan.brandCount
+ user_brands assignments. The PRD's Faz 3 quota-centric metrics don't
apply; this commit ships the 8 business signals that DO apply to that
model. All queries are RO joins of query_logs to subscription/plan/brand.
1. Tier × decode volume (30d) — unique users, decode count, decodes/user,
and success rate per plan. Plan derived from each user's most-recent
active/trial subscription.
2. Brand-tier alignment — for plans with brandCount > 0, the fraction
of decodes that landed on a brand the user actually has access to via
user_brands. Low alignment = wrong tier or upsell signal.
3. Dormant payers — active subscription + no decode in the last 14 days.
Sorted by lastDecodeAt ASC (most dormant first), 30 rows.
4. Empty-handed payers — active subscription + zero lifetime decodes.
Onboarding-broken signal, sorted oldest start_date first.
5. Trial → paid funnel — bucket trial users by decodes-during-trial
(0, 1–2, 3–5, 6–10, 11–25, 26+) and show the conversion rate (any
subsequent active/cancelled subscription) per bucket. Answers
"how many decodes is the aha moment".
6. Retention cohort — weekly signup cohorts (mature ≥30d), split by
"had a successful decode in first 7 days" vs not; columns show 30d
active rate per bucket and the retention lift (pp difference).
7. Power users — top 20 by 30d decode count: plan, decode count,
brand diversity, top brand + occurrences, success rate.
8. Tier mismatch — brand-spesifik plan kullanıcıları whose 30d decodes
include ≥3 hits on brands they don't have access to. Upsell candidates
(or refund/downgrade conversation).
All tables link emails to /projects/sase/users/[id] (existing detail page),
so the dashboard is a "find users to talk to" tool, not just numbers.
VIN dashboard header now has a "Business →" pill next to the time range
picker.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three additions to the VIN Decode Observability dashboard, all read-only.
Faz 2a — Error fingerprint drill-down
- New route /projects/sase/vin-decode/errors/[key] where [key] is one
of the eight bucket keys (BUDGET_EXCEEDED, UNKNOWN_VIN, TIMEOUT, …).
- getErrorBucketDetail(key, range) returns: total + unique-user count,
hourly time series, top 10 affected brands (joined to brands.name/
slug), top providers (winning + chain attempts from timings keys),
top 20 affected users with link to their detail page, six distinct
sample error messages, last 30 raw failures (sanitized VIN, source,
RT, message).
- Error breakdown cards on the main dashboard now link to this page
with the current time range preserved.
- ERROR_BUCKET_KEYS + ErrorBucketKey exported so the [key] route
validates against the same list.
Faz 2b — Deploy regression analysis
- listSaseDeploys(limit) fetches the Sase.tr Coolify app's recent
finished deployments via the Coolify API (COOLIFY_API_TOKEN).
- analyzeDeployRegressions(deploys) slices a 30-minute window
before deploy.startedAt and after deploy.finishedAt, computes the
success-rate delta, and flags rows where the drop ≥ 10pp (and
both windows have ≥ 5 samples).
- Dashboard card lists last ~10 deploys with before/after rates and
Δ pp, color-coded; "regression" badge on flagged rows.
Faz 2c — BIP integration
- getVinRelatedInsights() reads from the panel-pg `insights` table:
type='provider_quality' (always VIN-related by prompt-tag routing)
+ bug_triage insights whose body mentions VIN/decode/provider
keywords. Deduped, severity-then-recency sorted.
- New dashboard card surfaces insight titles with severity + type
badges, occurrence/user counts, confidence, and a link to the
Gitea issue if one was opened.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Süper Panel VIN Decode Observability Module (SP-VIN-001), Faz 1 MVP.
Reads directly from Sase.tr's query_logs via DATABASE_URL_SASE_RO —
no PostHog hop, no ingestion worker, no aggregation tables. Sase.tr's
existing query_logs.timings jsonb already captures the full chain
context (per-provider durations, cache source, circuit-breaker state).
Repo (apps/web/src/lib/sase/vin-decode.ts)
- getOperationalHealth(range) — total/success/failure counts, success
& error rates, P50/P95/P99 + avg response time, cache hit rate,
timeout rate, unknown-VIN rate. Single raw query with FILTER aggs.
- getWinningProviders(range) — group by source (decoded by), share +
avg response time per provider.
- getProviderAttempts(range) — group by timings jsonb keys
(pcat/emex/pl24/vin_api). A row may contribute to multiple providers
if the chain ran through several.
- getFallbackChains(range) — multi-provider rows grouped into chain
strings (e.g. 'pcat→emex→pl24') with frequency.
- getErrorBreakdown(range) — bucket errors via regex
(BUDGET_EXCEEDED, UNKNOWN_VIN, TIMEOUT, …) on errorMessage.
- getDailyTrend(days) — date_trunc('day') volume + success + failure
+ avg latency. Filled with zero-rows so the sparkline is continuous.
- getBrandBreakdown(range, limit) — top brands joined to brands.name/slug
with per-brand success rate.
Dashboard (/projects/sase/vin-decode)
- 4-button time range picker (1h/24h/7d/30d) via URL search-params.
- 6 KPI cards (Success, Error, P95 RT, Cache hit, Timeout, Unknown VIN)
color-coded by threshold.
- Two parallel provider views: "winning provider" (decode resolver)
vs "provider attempt" (timings keys — wider, includes fallthroughs).
- Fallback chain frequency table.
- Error bucket cards with example messages.
- Top brands by query volume with success rate cells (green/red).
- 30-day daily-volume sparkline with success/total ratio shading.
Sase landing gets a "VIN Decode →" link next to the Users link.
No new tables, no migrations, no workers. RO-only by design.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pairs with sase.tr#31 (POST /internal/admin/subscriptions/:id/brands).
Admin SDK
- setSubscriptionBrands({ subscriptionId, brandIds, reason, founderId }).
- BrandSetResult type; SASE_ADMIN_ENDPOINTS list updated.
Repo
- listBrands() helper (RO).
- getUser() now returns currentPlanBrandCount + brandIds for the picker.
Route
- POST /api/sase/subscriptions/[subId]/brands — auth + spoke-wired,
brandIds array required, reason ≥ 5 chars. Audit on both paths.
UI (BrandPicker on the Billing tab, below subscription actions)
- Full plan (brandCount=0): shows "all brands automatic" badge list,
no picker.
- Active/trial with brandCount > 0: checkbox grid of all active brands.
Clicking past the plan limit shows an inline error. Visual deltas:
outline "kaldırılacak" badge on rows being removed, default "yeni"
badge on rows being added.
- Cancelled/expired: read-only message.
- Save button enabled only when selection is exactly plan.brandCount,
there's a dirty diff, and a reason ≥ 5 chars is entered. Saves +
router.refresh().
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pairs with sase.tr#30.
Refund
- admin-sdk: refundPayment({ paymentId, amount?, reason, founderId }) +
RefundResult type; SASE_ADMIN_ENDPOINTS lists the new route.
- POST /api/sase/payments/[paymentId]/refund — auth + spoke-wired,
reason ≥ 5 chars, amount > 0 when provided (full refund if omitted).
Audit on success and failure.
- _refund-button.tsx: per-row [Refund] button in the Billing tab
payments table. Modal has [✓] full vs partial input, reason, and a
destructive submit. Hidden for payments that aren't refundable
(status !== completed/partially_refunded).
- Payment row now exposes an "İşlem" column (only when spoke is wired).
Bonus extension (goodwill)
- BillingActions: new "Bonus +7g/+14g/+30g + özel" buttons surfaced
for active subscriptions. Hits the same /trial/extend wire under the
hood — the spoke generalized that endpoint to active too — but the
panel uses "Bonus süre ekle (goodwill)" copy instead of "Trial uzat".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User decision: emails should display openly on the panel (Tailscale-only,
single founder). The reveal-with-audit pattern was overkill for this
trust model and added a click per inspection.
- List page: u****@x.com → u.email
- Detail header: <EmailReveal/> → plain <span>{user.email}</span>
- Delete _email-reveal.tsx + /api/sase/users/[id]/reveal-email route
- Delete maskEmail() helper (no remaining callers)
Audit log no longer captures per-email reveals; the audit tab now only
shows lifecycle/billing/impersonation actions, which is what we actually
care about.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Completes the billing surface; pairs with sase.tr#29.
Admin SDK
- changePlan(input) + cancelSubscriptionById(input) + resumeSubscription(input).
- PlanChangeResult + SubscriptionStateChange types exported.
- SASE_ADMIN_ENDPOINTS lists the three new spoke routes.
Route
- /api/sase/subscriptions/[subId] now accepts change-plan/cancel/resume
in addition to trial-extend/activate. newPlanId required when
action=change-plan. Reason ≥ 5 chars enforced. Audit captures the
action + days + newPlanId + reasonLen on both paths.
Repo
- getUser() also returns currentPlanId for the plan picker.
UI
- BillingActions extended:
active/trial → [Plan değiştir][İptal et]
cancelled → [Devam ettir]
- Change-plan modal lists active plans (current excluded) with brand
count hint. The spoke flags brand-reassignment via response field;
v1 surfaces only the confirmation.
- Cancel uses destructive variant + clear messaging.
Phase D ships the user-management mutation surface — A/B/C/D all live.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Billing tab on user detail page now has writable actions wired to spoke
endpoints landed in sase.tr#28.
Admin SDK
- extendTrial({ subscriptionId, days, reason, founderId }) + result type
- activateSubscription({ subscriptionId, reason, founderId }) + result type
- SASE_ADMIN_ENDPOINTS list updated
Repo
- getUser() now returns subscriptionId so the billing UI can act on it.
Route
- POST /api/sase/subscriptions/[subId] — single multiplexed endpoint:
body { action: 'trial-extend' | 'activate', reason, days? }.
Auth + spoke-wired + reason ≥ 5 chars + days 1..90 (trial-extend).
Audit on success and failure paths.
UI
- BillingActions client component on the Subscription & Billing tab.
- Trial state: [+7g][+14g][+30g] quick buttons + custom days input +
reason modal.
- Trial or pending state: [Subscription'ı aktive et] button.
- Other states show "no billing action available" hint.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Suspend / Reactivate / Ban buttons on the user detail page; wired to
spoke endpoints (sase.tr#27).
Admin SDK
- suspendUser/reactivateUser/banUser on SaseAdmin + LifecycleResult.
- SASE_ADMIN_ENDPOINTS updated.
Route
- POST /api/sase/users/[id]/lifecycle (action + reason). Auth + spoke
wired + min reason length checks. Audit on both success and failure.
RO model
- Sase Prisma schema adds status/statusReason/statusChangedAt/
statusChangedBy. getUser() returns lifecycleStatus + statusReason.
UI
- LifecycleStatusBadge in header next to email reveal.
- LifecycleButtons renders the right actions for the current state.
- Modals with reason textarea; ban requires a double-confirm checkbox.
- Impersonate hidden when user is suspended/banned (AuthGuard rejects).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Panel-side only — first writable feature on the Sase user detail page.
Notes are stored in the panel database (sase_user_notes); the spoke is
never touched (KVKK minimize).
Model
- SaseUserNote { id, saseUserId, authorUserId, body, pinned, createdAt }
- Index on (saseUserId, pinned, createdAt) for the default render order
API
- GET /api/sase/users/[id]/notes — list (pinned first, then newest)
- POST /api/sase/users/[id]/notes — { body, pinned } create (audit'li)
- PATCH/DELETE /api/sase/notes/[noteId] — toggle pin / hard delete
UI
- New "Notlar" tab on user detail. Textarea + pinned checkbox + submit;
list shows TR-localized timestamps, per-row pin/unpin and delete.
Schema sync
- package.json `start` now runs `prisma db push --skip-generate` before
`next start`. Panel uses db-push style (no migrations dir); this lets
the new table land on next deploy without a separate manual step.
Future destructive changes will require a smarter migration approach.
teknikborc.md updated: super_panel_reader role still needs to be created
on the new sase-postgres (current panel uses sase superuser).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adım 3 — Süper Panel side of the readonly impersonation flow.
- admin-sdk/sase.ts: impersonateReadonly(userId, founderId, ttlMinutes,
reason) — POSTs to /internal/admin/users/:id/impersonate-readonly.
notWiredSdk stub returns same shape so type contract holds when env vars
are absent.
- POST /api/sase/users/[id]/impersonate-readonly — auth-checked panel
endpoint. Validates ttl ∈ {15,30,60} + reason ≥ 5 chars. Calls spoke SDK,
writes audit (double-audit: AdminClient also logs at the wire), returns
{ redirectUrl, expiresAt, sessionIdPrefix }.
- _impersonate-button.tsx (client): shadcn Dialog. TTL pills (15/30/60) +
reason textarea + submit. On success opens spoke redirectUrl in new
window/_blank/noopener.
- Detail header gains [Impersonate (read-only)] button — gated on
saseAdminWired() so it stays hidden until SASE_ADMIN_API_BASE +
INTERNAL_API_TOKEN_SASE land in Coolify env.
Spoke side (sase.tr@79a2616 → … → next release): InternalAdminModule +
ImpersonationReadonlyGuard already merged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously 'all' missed status='analyzed', so re-running v2 prompts on existing
analyzed sessions returned 0 reset.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
apps/web/src/lib/telegram.ts: server-side Telegram client + alertIssueCreated.
createGithubIssueForInsight action fires the alert after issue is created (after
audit log, before revalidate). Severity emoji + insight link + issue link.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>