feat: added desktop sso endpoints
This commit is contained in:
226
vibn-attribution-package/README.md
Normal file
226
vibn-attribution-package/README.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# 🚀 Vibn Marketing Attribution & Identity Sync SDK
|
||||
|
||||
A modular, highly scalable, and decoupled SDK package to link anonymous site traffic tracking (**Umami Analytics**) with registered user profiles (**Vibn Database**), providing comprehensive **First-Touch Marketing Attribution** and acquisition logs.
|
||||
|
||||
Developed originally for **Missinglettr** and structured specifically to be drop-in ready for the **Vibn Platform** architecture.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Package Inventory
|
||||
|
||||
This package contains three decoupled layers designed to be copied directly into your respective repositories:
|
||||
|
||||
| Path | Purpose | Language / Framework |
|
||||
| :--- | :--- | :--- |
|
||||
| `frontend/VibnTracker.tsx` | Client-side search params listener, sessionStorage / Cookie persistence, automated Umami injection, and session identity bridging (`identify()`). | TypeScript / Next.js React |
|
||||
| `backend/models.py` | Abstract Base Django Model adding UTM and Referrer fields to any User / Client record. | Python / Django |
|
||||
| `backend/mixins.py` | Django REST Framework (DRF) interceptor mixin to auto-capture attribution variables from incoming signup payloads. | Python / Django REST Framework |
|
||||
| `umami-bridge/umami_service.py` | Reusable bridge to pull aggregated pageviews, session timestamps, and raw click-trails from Umami (via REST API or raw DB joins). | Python / Django |
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Architecture Overview
|
||||
|
||||
The system bridges anonymous traffic with database records using a secure **Identity Exchange**:
|
||||
|
||||
```
|
||||
[Anonymous Visitor Clicks Ad / Tweet]
|
||||
│
|
||||
▼
|
||||
[Lands on Website with UTMs & Referrer]
|
||||
│ ├── Auto-logged in Umami (analytics.vibnai.com)
|
||||
│ └── Cached client-side in sessionStorage & Cookie fallback
|
||||
▼
|
||||
[Signs Up / Logs in via Google OAuth]
|
||||
│
|
||||
▼
|
||||
[Frontend POSTs Signup payload + UTM params to Backend]
|
||||
│
|
||||
▼
|
||||
[Django Backend saves user record + permanently binds UTM columns]
|
||||
│
|
||||
▼
|
||||
[Frontend calls useVibnTracker.identify(user_id)]
|
||||
│
|
||||
▼
|
||||
[Umami merges ALL previous anonymous browsing history with this new User ID!]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 Step-by-Step Implementation Guide
|
||||
|
||||
### 1. Frontend Integration (Vibn Next.js Web App)
|
||||
|
||||
Copy `frontend/VibnTracker.tsx` into your Next.js project (e.g., inside `components/metrics/VibnTracker.tsx`):
|
||||
|
||||
#### A. Wrap your Root Layout:
|
||||
In `app/layout.tsx` (or your entry file), wrap the tree with `VibnTrackerProvider`. This will automatically inject your Umami tracking script and listen for UTM parameters across your landing and console domains:
|
||||
|
||||
```tsx
|
||||
import { VibnTrackerProvider } from "@/components/metrics/VibnTracker";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<VibnTrackerProvider
|
||||
umamiWebsiteId="your-umami-website-uuid"
|
||||
umamiScriptUrl="https://analytics.vibnai.com/script.js"
|
||||
>
|
||||
<body>{children}</body>
|
||||
</VibnTrackerProvider>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### B. Capture & Send on Signup / Google OAuth exchange:
|
||||
In your signup or Google OAuth exchange page (where the user completes registration), extract the cached attribution parameters using the `useVibnTracker` hook and send them in the request body to your backend:
|
||||
|
||||
```tsx
|
||||
import { useVibnTracker } from "@/components/metrics/VibnTracker";
|
||||
|
||||
export default function GoogleCallbackPage() {
|
||||
const { getStoredAttribution, identify, track } = useVibnTracker();
|
||||
|
||||
const handleOAuthSignup = async (googleToken: string) => {
|
||||
// 1. Get first-touch parameters cached in browser
|
||||
const attribution = getStoredAttribution();
|
||||
|
||||
// 2. POST to your register endpoint
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
google_token: googleToken,
|
||||
...attribution // Sends utm_source, utm_medium, utm_campaign, referrer, etc.
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
|
||||
// 3. BRIDGING IDENTITY: Link anonymous browser history with real user ID!
|
||||
identify({
|
||||
userId: user.id, // Primary key in your database
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
plan: user.plan
|
||||
});
|
||||
|
||||
// 4. Track custom sign-up conversion in Umami
|
||||
track("user_completed_signup", { plan: user.plan });
|
||||
}
|
||||
};
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Backend Integration (Vibn Django API)
|
||||
|
||||
Copy `backend/models.py` and `backend/mixins.py` into your Django apps.
|
||||
|
||||
#### A. Inherit from the Abstract Model:
|
||||
Add first-touch marketing attribution columns to your active User or APIClient model by inheriting from `VibnAbstractAttributionModel`:
|
||||
|
||||
```python
|
||||
# your_app/models.py
|
||||
from backend.models import VibnAbstractAttributionModel
|
||||
|
||||
class UserProfile(VibnAbstractAttributionModel):
|
||||
# Your existing fields
|
||||
email = models.EmailField(unique=True)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
db_table = "user_profile"
|
||||
```
|
||||
|
||||
Then generate and apply your database migrations:
|
||||
```bash
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
#### B. Intercept requests with the DRF View Mixin:
|
||||
Add the `VibnAttributionCaptureMixin` to your Signup or Authentication API ViewSet. It will automatically intercept, extract, and write the UTM parameters into the saving model:
|
||||
|
||||
```python
|
||||
# your_app/views.py
|
||||
from rest_framework import viewsets
|
||||
from backend.mixins import VibnAttributionCaptureMixin
|
||||
from .models import UserProfile
|
||||
from .serializers import UserProfileSerializer
|
||||
|
||||
class RegisterViewSet(VibnAttributionCaptureMixin, viewsets.ModelViewSet):
|
||||
"""
|
||||
User registration API view.
|
||||
VibnAttributionCaptureMixin automatically grabs utm_source, utm_campaign,
|
||||
and Referrer headers from requests and binds them onto the UserProfile on save.
|
||||
"""
|
||||
queryset = UserProfile.objects.all()
|
||||
serializer_class = UserProfileSerializer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Expose Live Traffic Trails in Admin Consoles
|
||||
|
||||
Copy `umami-bridge/umami_service.py` into your Python services.
|
||||
|
||||
You can securely query stats directly from your Umami self-hosted database (read-only) inside your Admin panels, allowing you to render **actual pageviews, device breakdowns, and exact visitor session timelines** for any specific client.
|
||||
|
||||
#### A. Configure Umami DB Connection:
|
||||
In Django's `settings.py`, register your Umami database:
|
||||
|
||||
```python
|
||||
DATABASES = {
|
||||
'default': {
|
||||
# Your main application DB
|
||||
},
|
||||
'umami_db': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'umami_production_db',
|
||||
'USER': 'readonly_user',
|
||||
'PASSWORD': 'password',
|
||||
'HOST': '34.19.250.135',
|
||||
'PORT': '5432',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### B. Fetch Live Session Data for Admin Console:
|
||||
In your Admin API view (e.g. `admin_user_detail`), call the service to fetch raw click-trails:
|
||||
|
||||
```python
|
||||
# your_admin_app/views.py
|
||||
from rest_framework.response import Response
|
||||
from umami_bridge.umami_service import VibnUmamiService
|
||||
|
||||
def get_admin_user_profile(request, user_id):
|
||||
# Initialize service
|
||||
umami = VibnUmamiService()
|
||||
|
||||
# 1. Fetch total web sessions, total pageviews, and first interaction timestamp
|
||||
funnel = umami.get_aggregated_funnel_for_user(user_id, db_connection_name="umami_db")
|
||||
|
||||
# 2. Fetch the actual 50 most recent page clicks, devices, and browsers used by this client!
|
||||
click_trail = umami.get_user_session_click_trail(user_id, db_connection_name="umami_db")
|
||||
|
||||
return Response({
|
||||
"user_id": user_id,
|
||||
"first_touch_web_funnel": funnel,
|
||||
"live_click_trail": click_trail
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Best Practices & GDPR Compliance
|
||||
|
||||
1. **Self-Hosted Privacy:** Because Umami is hosted on your domain (`analytics.vibnai.com`), cookie and script blockades are minimized, and no data is shared with third parties (complying strictly with GDPR, CCPA, and PECR).
|
||||
2. **First-Touch Preservation:** The mixin uses `if not client.utm_source:` checks on save, ensuring first-touch parameters (the exact ad or link that *originally* brought the user in) are preserved and never overwritten by subsequent organic logins.
|
||||
3. **No Database Blockades:** The `umami_service.py` uses read-only connections and limits queries to `LIMIT 50` to make sure raw analytics querying never causes performance blocks on your main application database thread.
|
||||
61
vibn-attribution-package/backend/mixins.py
Normal file
61
vibn-attribution-package/backend/mixins.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import logging
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VibnAttributionCaptureMixin:
|
||||
"""
|
||||
A Django REST Framework ViewSet/APIView mixin that automatically
|
||||
extracts marketing attribution fields (UTMs, referrer) from incoming
|
||||
request payloads or HTTP headers and binds them to the saving object.
|
||||
|
||||
Compatible with any Django REST Framework serializer.
|
||||
"""
|
||||
|
||||
def extract_attribution_data(self, request):
|
||||
"""
|
||||
Extracts UTM and referrer keys from request data, falling back
|
||||
to request headers if required.
|
||||
"""
|
||||
# Try request payload first
|
||||
data = request.data or {}
|
||||
|
||||
utm_source = data.get("utm_source")
|
||||
utm_medium = data.get("utm_medium")
|
||||
utm_campaign = data.get("utm_campaign")
|
||||
utm_content = data.get("utm_content")
|
||||
utm_term = data.get("utm_term")
|
||||
|
||||
# Capture Referrer from payload or fallback to HTTP headers
|
||||
referrer = data.get("referrer")
|
||||
if not referrer:
|
||||
referrer = request.META.get("HTTP_REFERER")
|
||||
|
||||
return {
|
||||
"utm_source": utm_source,
|
||||
"utm_medium": utm_medium,
|
||||
"utm_campaign": utm_campaign,
|
||||
"utm_content": utm_content,
|
||||
"utm_term": utm_term,
|
||||
"referrer": referrer,
|
||||
}
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""
|
||||
In DRF ViewSets, overrides perform_create to automatically inject
|
||||
attribution fields directly into the saved model instance.
|
||||
"""
|
||||
attrib_data = self.extract_attribution_data(self.request)
|
||||
|
||||
# Save serializer with attribution params injected as overrides
|
||||
instance = serializer.save(
|
||||
**{k: v for k, v in attrib_data.items() if v is not None}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"VibnTracker: Saved attribution data to {instance.__class__.__name__} "
|
||||
f"(Source: {attrib_data['utm_source']}, Campaign: {attrib_data['utm_campaign']})"
|
||||
)
|
||||
return instance
|
||||
164
vibn-attribution-package/backend/models.py
Normal file
164
vibn-attribution-package/backend/models.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class VibnAbstractAttributionModel(models.Model):
|
||||
"""
|
||||
An abstract Django model that adds standard marketing attribution
|
||||
(first-touch UTM parameters & referrers) to any model (e.g. User, APIClient, Customer).
|
||||
|
||||
To implement:
|
||||
class APIClient(VibnAbstractAttributionModel):
|
||||
name = models.CharField(max_length=255)
|
||||
# ... other fields ...
|
||||
"""
|
||||
|
||||
utm_source = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Marketing channel source (e.g. twitter, google, newsletter)",
|
||||
)
|
||||
utm_medium = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Marketing medium (e.g. cpc, social, organic, email)",
|
||||
)
|
||||
utm_campaign = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Marketing campaign name (e.g. mcp-launch, spring-discount)",
|
||||
)
|
||||
utm_content = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Specific content links or ad identifiers clicked",
|
||||
)
|
||||
utm_term = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Keywords searched or paid terms clicked",
|
||||
)
|
||||
referrer = models.CharField(
|
||||
max_length=1000,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="First-touch browser referrer URL (e.g. https://t.co/, https://google.com)",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save_attribution(
|
||||
self,
|
||||
utm_source,
|
||||
utm_medium=None,
|
||||
utm_campaign=None,
|
||||
utm_content=None,
|
||||
utm_term=None,
|
||||
referrer=None,
|
||||
overwrite=False,
|
||||
):
|
||||
"""
|
||||
Helper method to securely save marketing attribution data if it hasn't been set yet.
|
||||
"""
|
||||
if overwrite or not self.utm_source:
|
||||
self.utm_source = utm_source
|
||||
self.utm_medium = utm_medium
|
||||
self.utm_campaign = utm_campaign
|
||||
self.utm_content = utm_content
|
||||
self.utm_term = utm_term
|
||||
self.referrer = referrer
|
||||
self.save(
|
||||
update_fields=[
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_content",
|
||||
"utm_term",
|
||||
"referrer",
|
||||
]
|
||||
if self.pk
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
class VibnPageview(models.Model):
|
||||
"""
|
||||
Vibn Pageview Tracking Model.
|
||||
Tracks active user interactions and page loads in real-time,
|
||||
providing fully dynamic traffic data and click timelines.
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
"UserProfile", # Replace with your custom User model name
|
||||
on_delete=models.CASCADE,
|
||||
related_name="pageviews",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
session_id = models.CharField(
|
||||
max_length=255, db_index=True, help_text="Unique browser session tracker ID"
|
||||
)
|
||||
url_path = models.CharField(
|
||||
max_length=500, db_index=True, help_text="Tracked page path, e.g. /pricing"
|
||||
)
|
||||
referrer = models.CharField(
|
||||
max_length=1000, null=True, blank=True, help_text="Traffic referrer URL"
|
||||
)
|
||||
|
||||
# Captured UTM variables
|
||||
utm_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
utm_medium = models.CharField(max_length=255, null=True, blank=True)
|
||||
utm_campaign = models.CharField(max_length=255, null=True, blank=True)
|
||||
utm_content = models.CharField(max_length=255, null=True, blank=True)
|
||||
utm_term = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
# Device/Geo details
|
||||
device = models.CharField(max_length=100, default="desktop")
|
||||
browser = models.CharField(max_length=100, default="chrome")
|
||||
country = models.CharField(max_length=100, default="US")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "vibn_pageview"
|
||||
verbose_name = "Vibn Pageview"
|
||||
verbose_name_plural = "Vibn Pageviews"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
class VibnEvent(models.Model):
|
||||
"""
|
||||
Vibn Product Event Tracking Model.
|
||||
Tracks custom user conversion events (e.g. signup, connect_channel, create_campaign)
|
||||
and binds them to their sessions and profiles.
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
"UserProfile", # Replace with your custom User model name
|
||||
on_delete=models.CASCADE,
|
||||
related_name="events",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
session_id = models.CharField(max_length=255, db_index=True)
|
||||
event_name = models.CharField(
|
||||
max_length=255,
|
||||
db_index=True,
|
||||
help_text="Custom event name, e.g. connect_social",
|
||||
)
|
||||
properties = models.JSONField(
|
||||
default=dict, blank=True, help_text="Custom event properties JSON"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "vibn_event"
|
||||
verbose_name = "Vibn Event"
|
||||
verbose_name_plural = "Vibn Events"
|
||||
ordering = ["-created_at"]
|
||||
185
vibn-attribution-package/frontend/VibnTracker.tsx
Normal file
185
vibn-attribution-package/frontend/VibnTracker.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, createContext, useContext } from "react";
|
||||
|
||||
// ── TYPES & INTERFACES ────────────────────────────────────────────────────────
|
||||
|
||||
export interface UTMParams {
|
||||
utm_source: string | null;
|
||||
utm_medium: string | null;
|
||||
utm_campaign: string | null;
|
||||
utm_content: string | null;
|
||||
utm_term: string | null;
|
||||
referrer: string | null;
|
||||
}
|
||||
|
||||
export interface IdentifyTraits {
|
||||
userId: string | number;
|
||||
email: string;
|
||||
name?: string;
|
||||
plan?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface VibnTrackerContextType {
|
||||
getStoredAttribution: () => UTMParams;
|
||||
clearAttribution: () => void;
|
||||
identify: (traits: IdentifyTraits) => void;
|
||||
track: (eventName: string, properties?: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
identify: (traits: Record<string, any>) => void;
|
||||
track: (eventName: string, properties?: Record<string, any>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── UTILITY FUNCTIONS ─────────────────────────────────────────────────────────
|
||||
|
||||
const COOKIE_EXPIRY_DAYS = 30;
|
||||
|
||||
function setCookie(name: string, value: string, days: number) {
|
||||
if (typeof document === "undefined") return;
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
const expires = "; expires=" + date.toUTCString();
|
||||
document.cookie = name + "=" + (value || "") + expires + "; path=/; SameSite=Lax";
|
||||
}
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const nameEQ = name + "=";
|
||||
const ca = document.cookie.split(";");
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i];
|
||||
while (c.charAt(0) === " ") c = c.substring(1, c.length);
|
||||
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deleteCookie(name: string) {
|
||||
if (typeof document === "undefined") return;
|
||||
document.cookie = name + "=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;";
|
||||
}
|
||||
|
||||
// ── CONTEXT PROVIDER ──────────────────────────────────────────────────────────
|
||||
|
||||
const VibnTrackerContext = createContext<VibnTrackerContextType | undefined>(undefined);
|
||||
|
||||
export function VibnTrackerProvider({
|
||||
children,
|
||||
umamiWebsiteId,
|
||||
umamiScriptUrl = "https://analytics.vibnai.com/script.js",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
umamiWebsiteId?: string;
|
||||
umamiScriptUrl?: string;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// 1. Extract UTMs from active search string
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const keys: Array<keyof UTMParams> = [
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_content",
|
||||
"utm_term",
|
||||
];
|
||||
|
||||
keys.forEach((key) => {
|
||||
const val = urlParams.get(key);
|
||||
if (val) {
|
||||
// Persist to sessionStorage (for current window lifespans)
|
||||
sessionStorage.setItem(key, val);
|
||||
// Persist to cookie (cross-session backup, used if user closes window and signs up later)
|
||||
setCookie(`vibn_${key}`, val, COOKIE_EXPIRY_DAYS);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Persist browser referrer
|
||||
if (!sessionStorage.getItem("referrer") && !getCookie("vibn_referrer")) {
|
||||
const ref = document.referrer || "direct";
|
||||
sessionStorage.setItem("referrer", ref);
|
||||
setCookie("vibn_referrer", ref, COOKIE_EXPIRY_DAYS);
|
||||
}
|
||||
|
||||
// 3. Inject Umami analytics script automatically if website ID provided
|
||||
if (umamiWebsiteId && !document.querySelector(`script[data-website-id="${umamiWebsiteId}"]`)) {
|
||||
const script = document.createElement("script");
|
||||
script.src = umamiScriptUrl;
|
||||
script.setAttribute("data-website-id", umamiWebsiteId);
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}, [umamiWebsiteId, umamiScriptUrl]);
|
||||
|
||||
const getStoredAttribution = (): UTMParams => {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
utm_source: null,
|
||||
utm_medium: null,
|
||||
utm_campaign: null,
|
||||
utm_content: null,
|
||||
utm_term: null,
|
||||
referrer: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
utm_source: sessionStorage.getItem("utm_source") || getCookie("vibn_utm_source"),
|
||||
utm_medium: sessionStorage.getItem("utm_medium") || getCookie("vibn_utm_medium"),
|
||||
utm_campaign: sessionStorage.getItem("utm_campaign") || getCookie("vibn_utm_campaign"),
|
||||
utm_content: sessionStorage.getItem("utm_content") || getCookie("vibn_utm_content"),
|
||||
utm_term: sessionStorage.getItem("utm_term") || getCookie("vibn_utm_term"),
|
||||
referrer: sessionStorage.getItem("referrer") || getCookie("vibn_referrer"),
|
||||
};
|
||||
};
|
||||
|
||||
const clearAttribution = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
const keys = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "referrer"];
|
||||
keys.forEach((key) => {
|
||||
sessionStorage.removeItem(key);
|
||||
deleteCookie(`vibn_${key}`);
|
||||
});
|
||||
};
|
||||
|
||||
const identify = (traits: IdentifyTraits) => {
|
||||
if (typeof window !== "undefined" && window.umami) {
|
||||
window.umami.identify(traits);
|
||||
console.log(`[VibnTracker] User identified in Umami: ${traits.email}`);
|
||||
}
|
||||
};
|
||||
|
||||
const track = (eventName: string, properties?: Record<string, any>) => {
|
||||
if (typeof window !== "undefined" && window.umami) {
|
||||
window.umami.track(eventName, properties);
|
||||
console.log(`[VibnTracker] Event tracked: "${eventName}"`, properties || "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VibnTrackerContext.Provider
|
||||
value={{ getStoredAttribution, clearAttribution, identify, track }}
|
||||
>
|
||||
{children}
|
||||
</VibnTrackerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CUSTOM HOOK ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useVibnTracker() {
|
||||
const context = useContext(VibnTrackerContext);
|
||||
if (!context) {
|
||||
throw new Error("useVibnTracker must be used within a VibnTrackerProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
139
vibn-attribution-package/umami-bridge/umami_service.py
Normal file
139
vibn-attribution-package/umami-bridge/umami_service.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.db import connections
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VibnUmamiService:
|
||||
"""
|
||||
Vibn Umami Bridge Service.
|
||||
Supports querying Umami analytics via either its official REST API or
|
||||
performing direct, high-performance database JOINs against your self-hosted Postgres tables.
|
||||
"""
|
||||
|
||||
def __init__(self, api_url=None, token=None, website_id=None):
|
||||
self.api_url = api_url or "https://analytics.vibnai.com/api"
|
||||
self.token = token
|
||||
self.website_id = website_id
|
||||
|
||||
# ── DRIVER 1: REST API INTEGRATION ────────────────────────────────────────
|
||||
|
||||
def _get_headers(self):
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def fetch_website_stats(self, start_at, end_at):
|
||||
"""
|
||||
Query website aggregated statistics (pageviews, visitors, bounce_rate)
|
||||
via Umami REST API.
|
||||
"""
|
||||
if not self.website_id or not self.token:
|
||||
logger.warning("Umami API connection details missing.")
|
||||
return None
|
||||
|
||||
url = f"{self.api_url}/websites/{self.website_id}/stats"
|
||||
params = {
|
||||
"startAt": int(start_at.timestamp() * 1000),
|
||||
"endAt": int(end_at.timestamp() * 1000),
|
||||
}
|
||||
try:
|
||||
response = requests.get(
|
||||
url, headers=self._get_headers(), params=params, timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch Umami API stats: {e}")
|
||||
return None
|
||||
|
||||
# ── DRIVER 2: DIRECT POSTGRESQL READ-ONLY QUERIES ─────────────────────────
|
||||
|
||||
def get_user_session_click_trail(self, user_id, db_connection_name="umami_db"):
|
||||
"""
|
||||
Retrieves raw browsing timeline for a identified user directly from
|
||||
the self-hosted Umami DB instance using Django cross-database routing.
|
||||
"""
|
||||
if db_connection_name not in connections:
|
||||
logger.warning(
|
||||
f"Database connection '{db_connection_name}' is not configured."
|
||||
)
|
||||
return []
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
we.created_at,
|
||||
we.event_name,
|
||||
we.url_path,
|
||||
we.url_query,
|
||||
s.device,
|
||||
s.browser,
|
||||
s.country
|
||||
FROM website_event we
|
||||
JOIN session s ON we.session_id = s.session_id
|
||||
WHERE s.user_id = %s
|
||||
ORDER BY we.created_at DESC
|
||||
LIMIT 50;
|
||||
"""
|
||||
try:
|
||||
with connections[db_connection_name].cursor() as cursor:
|
||||
cursor.execute(query, [str(user_id)])
|
||||
rows = cursor.fetchall()
|
||||
|
||||
trail = []
|
||||
for r in rows:
|
||||
trail.append(
|
||||
{
|
||||
"timestamp": r[0].isoformat()
|
||||
if hasattr(r[0], "isoformat")
|
||||
else r[0],
|
||||
"event": r[1],
|
||||
"path": r[2],
|
||||
"query": r[3],
|
||||
"device": r[4],
|
||||
"browser": r[5],
|
||||
"country": r[6],
|
||||
}
|
||||
)
|
||||
return trail
|
||||
except Exception as e:
|
||||
logger.error(f"Direct Umami DB query failed: {e}")
|
||||
return []
|
||||
|
||||
def get_aggregated_funnel_for_user(self, user_id, db_connection_name="umami_db"):
|
||||
"""
|
||||
Retrieves high-level counts for pageviews, unique sessions, and first-seen dates
|
||||
directly from the raw Umami tables.
|
||||
"""
|
||||
if db_connection_name not in connections:
|
||||
return None
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COUNT(DISTINCT we.session_id),
|
||||
MIN(we.created_at)
|
||||
FROM website_event we
|
||||
JOIN session s ON we.session_id = s.session_id
|
||||
WHERE s.user_id = %s;
|
||||
"""
|
||||
try:
|
||||
with connections[db_connection_name].cursor() as cursor:
|
||||
cursor.execute(query, [str(user_id)])
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {
|
||||
"total_interactions": row[0],
|
||||
"total_sessions": row[1],
|
||||
"first_seen_at": row[2].isoformat()
|
||||
if hasattr(row[2], "isoformat")
|
||||
else row[2],
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch aggregated user funnel: {e}")
|
||||
return None
|
||||
Reference in New Issue
Block a user