The site runs three tools behind one opt-in consent layer (KVKK K-005, see Consent below): GA4 + PostHog load only after explicit consent; Sentry (errors only) is essential. Final legal texts (aydınlatma / çerez / bülten) are published as DRAFT pending lawyer review — see docs/kvkk/ANALIZ.md / KARARLAR.md.
| Tool | Owns | Delivery |
|---|---|---|
| Google Analytics 4 | Marketing / traffic + conversions | gtag.js (head, lazy) |
| PostHog | Product analytics, autocapture, web vitals, heatmaps (session replay off — K-003) | web snippet (EU cloud) |
| Sentry | Error monitoring — browser and Worker SSR | JS Loader (browser) + dependency-free envelope (server) |
Division of labour: GA4 = marketing, PostHog = product, Sentry = errors. Sentry is not a product-event destination; GA4/PostHog never receive errors.
The entire layer is inert unless the request host is devmultigroup.com
(BRAND.domain). On the workers.dev staging URL, Cloudflare previews, www.*,
and localhost, no third-party script loads and no event (client or server) is sent.
So it only switches on once the apex serves this Worker — and staging traffic never
pollutes production analytics. Three gates, one host:
- Client —
BaseLayout.astrocomputesanalyticsActive = analytics_enabled && Astro.url.host === BRAND.domainand passes it to<Analytics enabled>; the component renders nothing (no scripts, nowindow.track) when false. - Server PostHog —
captureServer()no-ops when the request host ≠ apex. - Server Sentry —
middleware.tsonly captures whenhost === CANONICAL_HOST.
Everything funnels through window.track(name, props) (defined in
src/components/Analytics.astro), which fans
out to both GA4 (gtag) and PostHog (posthog.capture). window.dmgTrack is
a back-compat alias (it used to be GA-only). Two ways to emit:
- Declarative — add
data-track="event_name"(+ optionaldata-track-props={JSON.stringify({…})}) to any element. A single delegated document click listener firestrack(). The legacydata-ga/data-ga-paramsattributes are still honoured by the same listener, sotrack()is a strict superset of the old GA wiring — nothing regressed. - Programmatic — call
window.track('event', {…})from an inline script for JS-driven moments (search submit, modal open, banner dismiss, variant select).
SPA-aware: the site uses <ClientRouter/> view transitions. gtag's and PostHog's
auto-pageviews are disabled; exactly one page_view / $pageview fires per page
on every astro:page-load (initial load and each transition).
EventCard, PostCard, RecordingCard, store/ProductCard, and LinkButton
emit their own click events from their root anchor, and accept an optional page
(LinkButton: group) context prop. Don't add data-track when rendering one —
just pass the context. This is why most pages only wire CTAs / nav / search.
Canonical names live in src/lib/events.ts (EVENTS).
Names are stable snake_case English; UI copy stays Turkish. Reuse a name with a
discriminating prop (page / surface / location) instead of minting a
near-duplicate. ~27 events; highlights:
- Page/nav:
page_view,nav_click,join_click,social_click,cta_click - Search:
search_open,search_submit(surface: palette/blog/communities/store),search_result_click - Events:
event_register_click,event_card_click,event_banner_dismiss - Content:
blog_post_click,blog_filter,recording_play,podcast_card_click - Links:
link_click(client) +link_redirect(server, via /go/[id]) - Lead/news:
contact_modal_open,contact_submit(server),generate_lead(GA4 conversion),newsletter_signup - Store:
store_product_view,store_product_click,store_variant_select,store_reserve_submit,store_reserve_success(server + GA4 mirror),store_reserve_error
Destinations: page-level + conversions → GA4 and PostHog; granular product/funnel events → PostHog-first.
Routes with no client render capture straight from the Worker (fire-and-forget via
ctx.waitUntil), through src/lib/analytics-server.ts
(captureServer → PostHog /capture):
/go/[id]→link_redirect/api/contact→contact_submit/api/subscribe→newsletter_signup(only genuinely new subscribers)/api/store/reserve→store_reserve_success/store_reserve_error
Privacy rules baked in: email is only ever a distinct_id, never a property
value; form name/message are never sent; the visitor's raw IP is not
forwarded ($ip: "0.0.0.0") because the consent decision is client-side only and
can't reach the Worker. distinct_id is read from the ph_<key>_posthog cookie
when present so server events stitch to the same PostHog person.
src/middleware.ts wraps SSR rendering; any thrown
exception is sent to Sentry via src/lib/sentry.ts
(a dependency-free implementation of Sentry's envelope protocol — we deliberately
avoid @sentry/cloudflare/@sentry/astro to not touch the pinned adapter's
generated worker) and re-thrown so Astro still renders its 500. Capture is skipped
in dev. DSN comes from env.SENTRY_DSN (never D1, so it survives a DB outage).
src/components/ConsentBanner.astro is an
opt-in consent banner (KVKK K-005). Model:
- GA4 + PostHog load only after the visitor explicitly grants consent
("Kabul et", or "Tercihleri yönet" → Analytics). With no prior choice — or with
Do-Not-Track — nothing analytics loads (choice stored in
localStorageasdmg_consent; default = deny). - The banner offers three equal-weight actions (Reddet / Tercihleri yönet /
Kabul et — no dark-pattern bias) plus a granular panel (Analytics / Pazarlama),
all unchecked by default. A persistent footer "Çerez Tercihleri" link
(
data-consent-open) re-opens it so consent can be withdrawn as easily as given. - Every decision (
opt-in/opt-out/banner_shown) is recorded server-side viaPOST /api/consent-record→ D1consent_records(no PII: a SHA-256 user-agent hash +0.0.0.0IP only) for the KVKK m.11/m.12 audit trail. - Sentry loads regardless — error monitoring is essential (replay off, IP anonymised — K-003). [AVUKAT: meşru menfaat zemini doğrulanacak.]
- Master switch
settings.analytics_enabled = "0"turns everything off.
Server-side captures (conversions, no PII properties, no raw IP) carry the e-mail only as a SHA-256 hash as
distinct_id(K-011) — plaintext PII never reaches PostHog. They run on legitimate-interest grounds and are not gated by the client consent flag (which never reaches the Worker). [AVUKAT: zemin doğrulanacak.]
Browser-side keys live in D1 settings (admin-editable at /admin/settings,
no redeploy), surfaced via resolveSite():
| Setting | Purpose |
|---|---|
ga_measurement_id |
GA4 measurement id (G-…) |
posthog_key / posthog_host |
PostHog project key + ingest host |
sentry_dsn |
Sentry browser DSN |
analytics_enabled |
master on/off |
Server-side keys live in wrangler.jsonc vars (all
PUBLIC ingest identifiers — they also ship in client HTML, so plain vars, not
secrets): SENTRY_DSN, SENTRY_ENVIRONMENT, POSTHOG_KEY, POSTHOG_HOST.
Seed the browser-side settings with scripts/analytics-settings.sql:
npm run analytics:settings:local # dev
npm run analytics:settings:remote # production (then bump cv:settings/cv:home or wait the TTL)A direct D1 write does not bump the KV cache version — see CLAUDE.md.
- PostHog (project MultiGroup Websites, id 93082, EU cloud): dashboard "MultiGroup Web — Product & Conversions" (pinned) with Pageviews, Conversions, Store funnel, and Content-engagement insights; plus a launch annotation. Replay, web vitals, console logs, heatmaps are enabled project-side.
- Sentry (org
developer-multigroup, projectdevmultigroup-web, EU region): receiving browser + server errors. - GA4: mark the conversion events (
generate_lead,newsletter_signup,event_register_click,store_reserve_success) as conversions in the GA4 UI — this can't be done over MCP (no GA MCP).
The layer is apex-gated (see above), so it self-activates the moment
devmultigroup.com points at this Worker — no staging filter needed. Steps:
npm run analytics:settings:remote(or set the four keys at/admin/settings), then bumpcv:settings/cv:homeor wait the ≤600s TTL.- Deploy the Worker (
npm run deploy). - Point
devmultigroup.comat the Worker (custom domain) — analytics turns on automatically once requests arrive on the apex host. - Mark GA4 conversions in the GA4 UI (no GA MCP).
newsletter_signup diverges by design: the client fires it to GA4 only (every
successful submit), the server fires it to PostHog only (gated on a genuinely
new subscriber). Don't reconcile the two counts 1:1 — GA4 is a superset.