@l.x/analytics 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.depcheckrc +9 -0
- package/.eslintrc.js +16 -0
- package/LICENSE +122 -0
- package/README.md +3 -0
- package/package.json +32 -0
- package/project.json +18 -0
- package/src/ai-traffic.ts +137 -0
- package/src/client-identity.ts +71 -0
- package/src/context.ts +33 -0
- package/src/first-visit.ts +144 -0
- package/src/index.ts +21 -0
- package/src/service.ts +108 -0
- package/src/url-utils.ts +22 -0
- package/tsconfig.json +26 -0
- package/tsconfig.lint.json +8 -0
package/.depcheckrc
ADDED
package/.eslintrc.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
extends: ['@luxfi/eslint-config/lib'],
|
|
3
|
+
parserOptions: {
|
|
4
|
+
tsconfigRootDir: __dirname,
|
|
5
|
+
},
|
|
6
|
+
overrides: [
|
|
7
|
+
{
|
|
8
|
+
files: ['*.ts', '*.tsx'],
|
|
9
|
+
rules: {
|
|
10
|
+
'no-relative-import-paths/no-relative-import-paths': 'off',
|
|
11
|
+
// track(event, properties, serverContext) is a natural 3-param signature for analytics.
|
|
12
|
+
'max-params': ['error', { max: 3 }],
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Lux Ecosystem License
|
|
2
|
+
Version 1.2, December 2025
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2020-2025 Lux Industries Inc.
|
|
5
|
+
All rights reserved.
|
|
6
|
+
|
|
7
|
+
TECHNOLOGY PORTFOLIO - PATENT APPLICATIONS PLANNED
|
|
8
|
+
Contact: licensing@lux.network
|
|
9
|
+
|
|
10
|
+
================================================================================
|
|
11
|
+
TERMS AND CONDITIONS
|
|
12
|
+
================================================================================
|
|
13
|
+
|
|
14
|
+
1. DEFINITIONS
|
|
15
|
+
|
|
16
|
+
"Lux Primary Network" means the official Lux blockchain with Network ID=1
|
|
17
|
+
and EVM Chain ID=96369.
|
|
18
|
+
|
|
19
|
+
"Authorized Network" means the Lux Primary Network, official testnets/devnets,
|
|
20
|
+
and any L1/L2/L3 chain descending from the Lux Primary Network.
|
|
21
|
+
|
|
22
|
+
"Descending Chain" means an L1/L2/L3 chain built on, anchored to, or deriving
|
|
23
|
+
security from the Lux Primary Network or its authorized testnets.
|
|
24
|
+
|
|
25
|
+
"Research Use" means non-commercial academic research, education, personal
|
|
26
|
+
study, or evaluation purposes.
|
|
27
|
+
|
|
28
|
+
"Commercial Use" means any use in connection with a product or service
|
|
29
|
+
offered for sale or fee, internal use by a for-profit entity, or any use
|
|
30
|
+
to generate revenue.
|
|
31
|
+
|
|
32
|
+
2. GRANT OF LICENSE
|
|
33
|
+
|
|
34
|
+
Subject to these terms, Lux Industries Inc grants you a non-exclusive,
|
|
35
|
+
royalty-free license to:
|
|
36
|
+
|
|
37
|
+
(a) Use for Research Use without restriction;
|
|
38
|
+
|
|
39
|
+
(b) Operate on the Lux Primary Network (Network ID=1, EVM Chain ID=96369);
|
|
40
|
+
|
|
41
|
+
(c) Operate on official Lux testnets and devnets;
|
|
42
|
+
|
|
43
|
+
(d) Operate L1/L2/L3 chains descending from the Lux Primary Network;
|
|
44
|
+
|
|
45
|
+
(e) Build applications within the Lux ecosystem;
|
|
46
|
+
|
|
47
|
+
(f) Contribute improvements back to the original repositories.
|
|
48
|
+
|
|
49
|
+
3. RESTRICTIONS
|
|
50
|
+
|
|
51
|
+
Without a commercial license from Lux Industries Inc, you may NOT:
|
|
52
|
+
|
|
53
|
+
(a) Fork the Lux Network or any Lux software;
|
|
54
|
+
|
|
55
|
+
(b) Create competing networks not descending from Lux Primary Network;
|
|
56
|
+
|
|
57
|
+
(c) Use for Commercial Use outside the Lux ecosystem;
|
|
58
|
+
|
|
59
|
+
(d) Sublicense or transfer rights outside the Lux ecosystem;
|
|
60
|
+
|
|
61
|
+
(e) Use to create competing blockchain networks, exchanges, custody
|
|
62
|
+
services, or cryptographic systems outside the Lux ecosystem.
|
|
63
|
+
|
|
64
|
+
4. NO FORKS POLICY
|
|
65
|
+
|
|
66
|
+
Lux Industries Inc maintains ZERO TOLERANCE for unauthorized forks.
|
|
67
|
+
Any fork or deployment on an unauthorized network constitutes:
|
|
68
|
+
|
|
69
|
+
(a) Breach of this license;
|
|
70
|
+
(b) Grounds for immediate legal action.
|
|
71
|
+
|
|
72
|
+
5. RIGHTS RESERVATION
|
|
73
|
+
|
|
74
|
+
All rights not explicitly granted are reserved by Lux Industries Inc.
|
|
75
|
+
|
|
76
|
+
We plan to apply for patent protection for the technology in this
|
|
77
|
+
repository. Any implementation outside the Lux ecosystem may require
|
|
78
|
+
a separate commercial license.
|
|
79
|
+
|
|
80
|
+
6. DISCLAIMER OF WARRANTY
|
|
81
|
+
|
|
82
|
+
THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
83
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
84
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
85
|
+
|
|
86
|
+
7. LIMITATION OF LIABILITY
|
|
87
|
+
|
|
88
|
+
IN NO EVENT SHALL LUX INDUSTRIES INC BE LIABLE FOR ANY CLAIM, DAMAGES
|
|
89
|
+
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
90
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE.
|
|
91
|
+
|
|
92
|
+
8. TERMINATION
|
|
93
|
+
|
|
94
|
+
This license terminates immediately upon any breach, including but not
|
|
95
|
+
limited to deployment on unauthorized networks or creation of forks.
|
|
96
|
+
|
|
97
|
+
9. GOVERNING LAW
|
|
98
|
+
|
|
99
|
+
This License shall be governed by the laws of the State of Delaware.
|
|
100
|
+
|
|
101
|
+
10. COMMERCIAL LICENSING
|
|
102
|
+
|
|
103
|
+
For commercial use outside the Lux ecosystem:
|
|
104
|
+
|
|
105
|
+
Lux Industries Inc.
|
|
106
|
+
Email: licensing@lux.network
|
|
107
|
+
Subject: Commercial License Request
|
|
108
|
+
|
|
109
|
+
================================================================================
|
|
110
|
+
TL;DR
|
|
111
|
+
================================================================================
|
|
112
|
+
|
|
113
|
+
- Research/academic use = OK
|
|
114
|
+
- Lux Primary Network (Network ID=1, Chain ID=96369) = OK
|
|
115
|
+
- L1/L2/L3 chains descending from Lux Primary Network = OK
|
|
116
|
+
- Commercial products outside Lux ecosystem = Contact licensing@lux.network
|
|
117
|
+
- Forks = Absolutely not
|
|
118
|
+
|
|
119
|
+
================================================================================
|
|
120
|
+
|
|
121
|
+
See LP-0012 for full licensing documentation:
|
|
122
|
+
https://github.com/luxfi/lps/blob/main/LPs/lp-0012-ecosystem-licensing.md
|
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@l.x/analytics",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"dependencies": {
|
|
5
|
+
"@amplitude/analytics-node": "1.5.36"
|
|
6
|
+
},
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@types/node": "22.13.1",
|
|
9
|
+
"@typescript/native-preview": "7.0.0-dev.20260311.1",
|
|
10
|
+
"depcheck": "1.4.7",
|
|
11
|
+
"eslint": "8.57.1",
|
|
12
|
+
"typescript": "5.8.3",
|
|
13
|
+
"@luxfi/eslint-config": "^1.0.5"
|
|
14
|
+
},
|
|
15
|
+
"nx": {
|
|
16
|
+
"includedScripts": []
|
|
17
|
+
},
|
|
18
|
+
"main": "src/index.ts",
|
|
19
|
+
"private": false,
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"scripts": {
|
|
22
|
+
"typecheck": "nx typecheck analytics",
|
|
23
|
+
"typecheck:tsgo": "nx typecheck:tsgo analytics",
|
|
24
|
+
"lint": "nx lint analytics",
|
|
25
|
+
"lint:fix": "nx lint:fix analytics",
|
|
26
|
+
"lint:biome": "nx lint:biome analytics",
|
|
27
|
+
"lint:biome:fix": "nx lint:biome:fix analytics",
|
|
28
|
+
"lint:eslint": "nx lint:eslint analytics",
|
|
29
|
+
"lint:eslint:fix": "nx lint:eslint:fix analytics",
|
|
30
|
+
"check:deps:usage": "nx check:deps:usage analytics"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@l.x/analytics",
|
|
3
|
+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"sourceRoot": "pkgs/analytics/src",
|
|
5
|
+
"projectType": "library",
|
|
6
|
+
"tags": [],
|
|
7
|
+
"targets": {
|
|
8
|
+
"typecheck": {},
|
|
9
|
+
"typecheck:tsgo": {},
|
|
10
|
+
"lint:biome": {},
|
|
11
|
+
"lint:biome:fix": {},
|
|
12
|
+
"lint:eslint": {},
|
|
13
|
+
"lint:eslint:fix": {},
|
|
14
|
+
"lint": {},
|
|
15
|
+
"lint:fix": {},
|
|
16
|
+
"check:deps:usage": {}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Traffic Classification & Tracking
|
|
3
|
+
*
|
|
4
|
+
* Classifies incoming requests into traffic types (crawler, signed-agent, ai-tool, human)
|
|
5
|
+
* and fires Amplitude events for non-human traffic.
|
|
6
|
+
*
|
|
7
|
+
* Three detection layers:
|
|
8
|
+
* 1. User-Agent matching for known AI crawlers (ClaudeBot, GPTBot, etc.)
|
|
9
|
+
* 2. RFC 9421 HTTP Message Signatures (ChatGPT browser agent)
|
|
10
|
+
* 3. Heuristic signals (Accept: text/markdown, non-browser UAs like axios/curl)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AnalyticsService, ServerEventContext } from './service'
|
|
14
|
+
|
|
15
|
+
export type TrafficType = 'crawler' | 'signed-agent' | 'ai-tool' | 'human'
|
|
16
|
+
|
|
17
|
+
export interface TrafficClassification {
|
|
18
|
+
type: TrafficType
|
|
19
|
+
agent?: string
|
|
20
|
+
signals: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CRAWLER_PATTERNS: Record<string, string> = {
|
|
24
|
+
ClaudeBot: 'anthropic-training',
|
|
25
|
+
'Claude-User': 'anthropic-user-fetch',
|
|
26
|
+
'Claude-SearchBot': 'anthropic-search',
|
|
27
|
+
'Claude-Web': 'anthropic-web',
|
|
28
|
+
'anthropic-ai': 'anthropic',
|
|
29
|
+
GPTBot: 'openai-training',
|
|
30
|
+
'ChatGPT-User': 'openai-user-fetch',
|
|
31
|
+
'OAI-SearchBot': 'openai-search',
|
|
32
|
+
PerplexityBot: 'perplexity',
|
|
33
|
+
'Perplexity-User': 'perplexity-user',
|
|
34
|
+
'Google-Extended': 'google-ai',
|
|
35
|
+
CCBot: 'common-crawl',
|
|
36
|
+
Bytespider: 'bytedance',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function classifyTraffic(request: Request): TrafficClassification {
|
|
40
|
+
const ua = request.headers.get('user-agent') ?? ''
|
|
41
|
+
const accept = request.headers.get('accept') ?? ''
|
|
42
|
+
const signatureAgent = request.headers.get('signature-agent')
|
|
43
|
+
|
|
44
|
+
// Layer 1: Known AI crawlers (self-identifying via User-Agent)
|
|
45
|
+
for (const [pattern, agent] of Object.entries(CRAWLER_PATTERNS)) {
|
|
46
|
+
if (ua.includes(pattern)) {
|
|
47
|
+
return { type: 'crawler', agent, signals: [pattern] }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Layer 2: Signed agents (RFC 9421 — ChatGPT browser agent)
|
|
52
|
+
if (signatureAgent) {
|
|
53
|
+
return {
|
|
54
|
+
type: 'signed-agent',
|
|
55
|
+
agent: signatureAgent.replace(/^"|"$/g, ''),
|
|
56
|
+
signals: ['signature-agent'],
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Layer 3: Heuristic signals for AI tools / non-browser clients
|
|
61
|
+
const signals: string[] = []
|
|
62
|
+
|
|
63
|
+
if (accept.includes('text/markdown') || accept.includes('text/x-markdown')) {
|
|
64
|
+
signals.push('accept-markdown')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (ua.startsWith('curl/')) {
|
|
68
|
+
signals.push('curl')
|
|
69
|
+
}
|
|
70
|
+
if (ua.startsWith('Wget/')) {
|
|
71
|
+
signals.push('wget')
|
|
72
|
+
}
|
|
73
|
+
if (ua.includes('HTTPie/')) {
|
|
74
|
+
signals.push('httpie')
|
|
75
|
+
}
|
|
76
|
+
if (ua.startsWith('node-fetch') || ua.startsWith('undici')) {
|
|
77
|
+
signals.push('node-http')
|
|
78
|
+
}
|
|
79
|
+
if (ua.startsWith('python-requests') || ua.startsWith('python-httpx')) {
|
|
80
|
+
signals.push('python-http')
|
|
81
|
+
}
|
|
82
|
+
if (ua.startsWith('axios/')) {
|
|
83
|
+
signals.push('axios')
|
|
84
|
+
}
|
|
85
|
+
if (!ua) {
|
|
86
|
+
signals.push('no-ua')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (signals.length > 0) {
|
|
90
|
+
return { type: 'ai-tool', signals }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { type: 'human', signals: [] }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface AITrafficTrackerDeps {
|
|
97
|
+
analyticsService: AnalyticsService
|
|
98
|
+
serverContext: ServerEventContext
|
|
99
|
+
eventName: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface AITrafficInput {
|
|
103
|
+
classification: TrafficClassification
|
|
104
|
+
path: string
|
|
105
|
+
request: Request
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create a tracker for non-human traffic events.
|
|
110
|
+
*
|
|
111
|
+
* Dependencies are bound once at the boundary (Hono middleware); the returned
|
|
112
|
+
* function only takes per-request input. Fire-and-forget — never throws.
|
|
113
|
+
*/
|
|
114
|
+
export function createAITrafficTracker({ analyticsService, serverContext, eventName }: AITrafficTrackerDeps) {
|
|
115
|
+
return ({ classification, path, request }: AITrafficInput): void => {
|
|
116
|
+
if (classification.type === 'human') {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
analyticsService.track(
|
|
122
|
+
eventName,
|
|
123
|
+
{
|
|
124
|
+
traffic_type: classification.type,
|
|
125
|
+
agent: classification.agent,
|
|
126
|
+
signals: classification.signals.join(','),
|
|
127
|
+
path,
|
|
128
|
+
user_agent: request.headers.get('user-agent') ?? '',
|
|
129
|
+
accept_header: request.headers.get('accept') ?? '',
|
|
130
|
+
},
|
|
131
|
+
serverContext,
|
|
132
|
+
)
|
|
133
|
+
} catch {
|
|
134
|
+
// Fire and forget — tracking failures must never affect request handling
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client Identity Forwarding
|
|
3
|
+
*
|
|
4
|
+
* During SSR, backend API calls originate from the server, not the browser.
|
|
5
|
+
* Without explicit forwarding, backends see the server's IP and User-Agent,
|
|
6
|
+
* causing issues like OTP emails showing server location instead of the user's.
|
|
7
|
+
*
|
|
8
|
+
* This module extracts client identity from the incoming request once at the
|
|
9
|
+
* boundary and provides it for explicit forwarding to all outbound API calls.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Headers that identify the real client behind SSR requests.
|
|
14
|
+
* Uses cf-connecting-ip for backend compatibility (matches apps/web pattern).
|
|
15
|
+
*/
|
|
16
|
+
export interface ClientIdentityHeaders {
|
|
17
|
+
'cf-connecting-ip'?: string
|
|
18
|
+
'User-Agent'?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract the client's IP address from the request.
|
|
23
|
+
*
|
|
24
|
+
* IP resolution priority (most trusted first):
|
|
25
|
+
* 1. x-real-ip — set by Vercel/infra at the TCP level, cannot be spoofed by clients
|
|
26
|
+
* 2. cf-connecting-ip — set by Cloudflare edge
|
|
27
|
+
* 3. x-forwarded-for — first entry, least trusted (can be spoofed)
|
|
28
|
+
*
|
|
29
|
+
* Use for rate limiting, logging, and any context where you need just the IP string.
|
|
30
|
+
*/
|
|
31
|
+
export function getClientIp(request: Request): string {
|
|
32
|
+
return (
|
|
33
|
+
request.headers.get('x-real-ip') ??
|
|
34
|
+
request.headers.get('cf-connecting-ip') ??
|
|
35
|
+
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
|
36
|
+
'unknown'
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Extract the client's country from edge-injected headers.
|
|
42
|
+
*
|
|
43
|
+
* Resolution priority:
|
|
44
|
+
* 1. x-vercel-ip-country — set by Vercel at the edge
|
|
45
|
+
* 2. cf-ipcountry — set by Cloudflare at the edge
|
|
46
|
+
*/
|
|
47
|
+
export function getClientCountry(request: Request): string | undefined {
|
|
48
|
+
return request.headers.get('x-vercel-ip-country') ?? request.headers.get('cf-ipcountry') ?? undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Extract full client identity headers for forwarding to backend APIs.
|
|
53
|
+
*
|
|
54
|
+
* Forwarded as cf-connecting-ip to match the header the backend already reads.
|
|
55
|
+
* This is the same pattern apps/web uses (see apps/web/functions/app.ts).
|
|
56
|
+
*/
|
|
57
|
+
export function extractClientIdentity(request: Request): ClientIdentityHeaders {
|
|
58
|
+
const headers: ClientIdentityHeaders = {}
|
|
59
|
+
|
|
60
|
+
const ip = getClientIp(request)
|
|
61
|
+
if (ip !== 'unknown') {
|
|
62
|
+
headers['cf-connecting-ip'] = ip
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const userAgent = request.headers.get('User-Agent')
|
|
66
|
+
if (userAgent) {
|
|
67
|
+
headers['User-Agent'] = userAgent
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return headers
|
|
71
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getClientCountry } from './client-identity'
|
|
2
|
+
import type { ServerEventContext } from './service'
|
|
3
|
+
import { extractDomain, stripQueryParams } from './url-utils'
|
|
4
|
+
|
|
5
|
+
interface ServerContextExtractorDeps {
|
|
6
|
+
getAuthSession: (request?: Request) => Promise<{ session?: { userId?: string; provider?: string } | null }>
|
|
7
|
+
getDeviceId: (request: Request) => Promise<string | null>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a server context extractor with auth deps injected.
|
|
12
|
+
*
|
|
13
|
+
* The boundary (middleware, loader, tRPC context) owns the wiring;
|
|
14
|
+
* the returned function only needs the raw request.
|
|
15
|
+
*/
|
|
16
|
+
export function createServerContextExtractor({ getAuthSession, getDeviceId }: ServerContextExtractorDeps) {
|
|
17
|
+
return async (request: Request): Promise<ServerEventContext> => {
|
|
18
|
+
const [authResult, deviceId] = await Promise.all([getAuthSession(request), getDeviceId(request)])
|
|
19
|
+
const session = authResult.session
|
|
20
|
+
const rawReferrer = request.headers.get('Referer') ?? undefined
|
|
21
|
+
const referrer = rawReferrer ? stripQueryParams(rawReferrer) : undefined
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
userId: session?.userId,
|
|
25
|
+
deviceId: deviceId ?? undefined,
|
|
26
|
+
provider: session?.provider,
|
|
27
|
+
language: request.headers.get('Accept-Language')?.split(',')[0]?.trim() ?? undefined,
|
|
28
|
+
country: getClientCountry(request),
|
|
29
|
+
referrer,
|
|
30
|
+
referringDomain: rawReferrer ? extractDomain(rawReferrer) : undefined,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-Visit Attribution Capture
|
|
3
|
+
*
|
|
4
|
+
* Captures UTM params, referrer, browser, and country on the first visit.
|
|
5
|
+
* Persists UTM in a cookie (first-touch attribution) and fires an Amplitude
|
|
6
|
+
* `identify` call to set user properties.
|
|
7
|
+
*
|
|
8
|
+
* Called from the root loader — runs on every SSR page load but only
|
|
9
|
+
* identifies when there's new attribution data to capture.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getClientCountry } from './client-identity'
|
|
13
|
+
import type { AnalyticsService, UserTraits } from './service'
|
|
14
|
+
import { extractDomain, stripQueryParams } from './url-utils'
|
|
15
|
+
|
|
16
|
+
const UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content'] as const
|
|
17
|
+
|
|
18
|
+
export interface AttributionData {
|
|
19
|
+
utmSource?: string
|
|
20
|
+
utmMedium?: string
|
|
21
|
+
utmCampaign?: string
|
|
22
|
+
utmContent?: string
|
|
23
|
+
referrer?: string
|
|
24
|
+
referringDomain?: string
|
|
25
|
+
country?: string
|
|
26
|
+
browser?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Adapter for cookie parse/serialize — lets consumers bring their own
|
|
31
|
+
* cookie implementation (react-router createCookie, hono cookie, etc.).
|
|
32
|
+
*/
|
|
33
|
+
export interface CookieAdapter {
|
|
34
|
+
parse(cookieHeader: string | null): Promise<Record<string, string> | null>
|
|
35
|
+
serialize(value: Record<string, string>): Promise<string>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse browser family from User-Agent string.
|
|
40
|
+
* Intentionally simple — covers the major browsers.
|
|
41
|
+
*/
|
|
42
|
+
function parseBrowser(ua: string): string {
|
|
43
|
+
if (ua.includes('Edg/')) {
|
|
44
|
+
return 'Edge'
|
|
45
|
+
}
|
|
46
|
+
if (ua.includes('OPR/') || ua.includes('Opera')) {
|
|
47
|
+
return 'Opera'
|
|
48
|
+
}
|
|
49
|
+
if (ua.includes('Chrome/') && !ua.includes('Chromium/')) {
|
|
50
|
+
return 'Chrome'
|
|
51
|
+
}
|
|
52
|
+
if (ua.includes('Firefox/')) {
|
|
53
|
+
return 'Firefox'
|
|
54
|
+
}
|
|
55
|
+
if (ua.includes('Safari/') && !ua.includes('Chrome/')) {
|
|
56
|
+
return 'Safari'
|
|
57
|
+
}
|
|
58
|
+
if (ua.includes('MSIE') || ua.includes('Trident/')) {
|
|
59
|
+
return 'IE'
|
|
60
|
+
}
|
|
61
|
+
return 'Other'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface AttributionTrackerDeps {
|
|
65
|
+
analyticsService: AnalyticsService
|
|
66
|
+
cookie: CookieAdapter
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface AttributionInput {
|
|
70
|
+
request: Request
|
|
71
|
+
userId: string | undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create an attribution tracker with the analytics service injected.
|
|
76
|
+
*
|
|
77
|
+
* The boundary (root loader) owns the wiring; the returned function
|
|
78
|
+
* only takes per-request input. Returns a Set-Cookie header for UTM
|
|
79
|
+
* persistence on first-touch, and fires an Amplitude identify call.
|
|
80
|
+
*/
|
|
81
|
+
export function createAttributionTracker({ analyticsService, cookie }: AttributionTrackerDeps) {
|
|
82
|
+
return async ({ request, userId }: AttributionInput): Promise<{ setCookieHeader: string | null }> => {
|
|
83
|
+
if (!userId) {
|
|
84
|
+
return { setCookieHeader: null }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const url = new URL(request.url)
|
|
88
|
+
const ua = request.headers.get('user-agent') ?? ''
|
|
89
|
+
const referrer = request.headers.get('Referer') ?? undefined
|
|
90
|
+
|
|
91
|
+
// Extract UTM from query params
|
|
92
|
+
const utmData: Record<string, string> = {}
|
|
93
|
+
for (const param of UTM_PARAMS) {
|
|
94
|
+
const value = url.searchParams.get(param)
|
|
95
|
+
if (value) {
|
|
96
|
+
utmData[param] = value
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const hasUtm = Object.keys(utmData).length > 0
|
|
101
|
+
const existingUtm = await cookie.parse(request.headers.get('Cookie'))
|
|
102
|
+
const isFirstUtm = hasUtm && !existingUtm
|
|
103
|
+
|
|
104
|
+
// Build traits — setOnce in the service means these won't overwrite
|
|
105
|
+
const traits: UserTraits = {}
|
|
106
|
+
const country = getClientCountry(request)
|
|
107
|
+
const browser = parseBrowser(ua)
|
|
108
|
+
|
|
109
|
+
if (browser !== 'Other') {
|
|
110
|
+
traits.browser = browser
|
|
111
|
+
}
|
|
112
|
+
if (country) {
|
|
113
|
+
traits.country = country
|
|
114
|
+
}
|
|
115
|
+
if (referrer) {
|
|
116
|
+
traits.referrer = stripQueryParams(referrer)
|
|
117
|
+
traits.referringDomain = extractDomain(referrer)
|
|
118
|
+
}
|
|
119
|
+
if (hasUtm) {
|
|
120
|
+
if (utmData['utm_source']) {
|
|
121
|
+
traits.utmSource = utmData['utm_source']
|
|
122
|
+
}
|
|
123
|
+
if (utmData['utm_medium']) {
|
|
124
|
+
traits.utmMedium = utmData['utm_medium']
|
|
125
|
+
}
|
|
126
|
+
if (utmData['utm_campaign']) {
|
|
127
|
+
traits.utmCampaign = utmData['utm_campaign']
|
|
128
|
+
}
|
|
129
|
+
if (utmData['utm_content']) {
|
|
130
|
+
traits.utmContent = utmData['utm_content']
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Only identify if we have something meaningful to set
|
|
135
|
+
const hasTraits = Object.keys(traits).length > 0
|
|
136
|
+
if (hasTraits) {
|
|
137
|
+
analyticsService.identify(userId, traits)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
setCookieHeader: isFirstUtm ? await cookie.serialize(utmData) : null,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Traffic classification
|
|
2
|
+
export type { TrafficClassification, TrafficType } from './ai-traffic'
|
|
3
|
+
export { classifyTraffic, createAITrafficTracker } from './ai-traffic'
|
|
4
|
+
|
|
5
|
+
// Client identity (HTTP header extraction)
|
|
6
|
+
export type { ClientIdentityHeaders } from './client-identity'
|
|
7
|
+
export { extractClientIdentity, getClientCountry, getClientIp } from './client-identity'
|
|
8
|
+
|
|
9
|
+
// Context extraction
|
|
10
|
+
export { createServerContextExtractor } from './context'
|
|
11
|
+
|
|
12
|
+
// Attribution
|
|
13
|
+
export type { AttributionData, CookieAdapter } from './first-visit'
|
|
14
|
+
export { createAttributionTracker } from './first-visit'
|
|
15
|
+
|
|
16
|
+
// Service
|
|
17
|
+
export type { AnalyticsService, ServerEventContext, UserTraits } from './service'
|
|
18
|
+
export { AmplitudeAnalyticsService, NoopAnalyticsService } from './service'
|
|
19
|
+
|
|
20
|
+
// URL utilities
|
|
21
|
+
export { extractDomain, stripQueryParams } from './url-utils'
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as amplitude from '@amplitude/analytics-node'
|
|
2
|
+
|
|
3
|
+
export interface ServerEventContext {
|
|
4
|
+
userId?: string
|
|
5
|
+
deviceId?: string
|
|
6
|
+
provider?: string
|
|
7
|
+
language?: string
|
|
8
|
+
country?: string
|
|
9
|
+
referrer?: string
|
|
10
|
+
referringDomain?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UserTraits {
|
|
14
|
+
loginMethod?: string
|
|
15
|
+
apiKeyCount?: number
|
|
16
|
+
browser?: string
|
|
17
|
+
country?: string
|
|
18
|
+
referrer?: string
|
|
19
|
+
referringDomain?: string
|
|
20
|
+
utmSource?: string
|
|
21
|
+
utmMedium?: string
|
|
22
|
+
utmCampaign?: string
|
|
23
|
+
utmContent?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AnalyticsService<E extends string = string> {
|
|
27
|
+
track(event: E, properties: Record<string, unknown>, serverContext: ServerEventContext): void
|
|
28
|
+
identify(userId: string, traits: UserTraits): void
|
|
29
|
+
flush(): Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class AmplitudeAnalyticsService<E extends string = string> implements AnalyticsService<E> {
|
|
33
|
+
private static initialized = false
|
|
34
|
+
private readonly platform: string
|
|
35
|
+
|
|
36
|
+
constructor(apiKey: string, platform: string) {
|
|
37
|
+
this.platform = platform
|
|
38
|
+
|
|
39
|
+
// Amplitude's Node SDK is a singleton; subsequent instances share this initialization.
|
|
40
|
+
if (!AmplitudeAnalyticsService.initialized) {
|
|
41
|
+
amplitude.init(apiKey, { flushIntervalMillis: 10_000 })
|
|
42
|
+
AmplitudeAnalyticsService.initialized = true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
track(event: E, properties: Record<string, unknown>, serverContext: ServerEventContext): void {
|
|
47
|
+
amplitude.track({
|
|
48
|
+
event_type: event,
|
|
49
|
+
event_properties: { ...properties },
|
|
50
|
+
user_id: serverContext.userId,
|
|
51
|
+
device_id: serverContext.deviceId,
|
|
52
|
+
language: serverContext.language,
|
|
53
|
+
platform: this.platform,
|
|
54
|
+
user_properties: serverContext.provider ? { provider: serverContext.provider } : undefined,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
identify(userId: string, traits: UserTraits): void {
|
|
59
|
+
const identifyEvent = new amplitude.Identify()
|
|
60
|
+
if (traits.loginMethod) {
|
|
61
|
+
identifyEvent.set('loginMethod', traits.loginMethod)
|
|
62
|
+
}
|
|
63
|
+
if (traits.apiKeyCount !== undefined) {
|
|
64
|
+
identifyEvent.set('apiKeyCount', traits.apiKeyCount)
|
|
65
|
+
}
|
|
66
|
+
if (traits.browser) {
|
|
67
|
+
identifyEvent.set('browser', traits.browser)
|
|
68
|
+
}
|
|
69
|
+
if (traits.country) {
|
|
70
|
+
identifyEvent.set('country', traits.country)
|
|
71
|
+
}
|
|
72
|
+
if (traits.referrer) {
|
|
73
|
+
identifyEvent.setOnce('referrer', traits.referrer)
|
|
74
|
+
}
|
|
75
|
+
if (traits.referringDomain) {
|
|
76
|
+
identifyEvent.setOnce('referringDomain', traits.referringDomain)
|
|
77
|
+
}
|
|
78
|
+
if (traits.utmSource) {
|
|
79
|
+
identifyEvent.setOnce('utmSource', traits.utmSource)
|
|
80
|
+
}
|
|
81
|
+
if (traits.utmMedium) {
|
|
82
|
+
identifyEvent.setOnce('utmMedium', traits.utmMedium)
|
|
83
|
+
}
|
|
84
|
+
if (traits.utmCampaign) {
|
|
85
|
+
identifyEvent.setOnce('utmCampaign', traits.utmCampaign)
|
|
86
|
+
}
|
|
87
|
+
if (traits.utmContent) {
|
|
88
|
+
identifyEvent.setOnce('utmContent', traits.utmContent)
|
|
89
|
+
}
|
|
90
|
+
amplitude.identify(identifyEvent, { user_id: userId })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async flush(): Promise<void> {
|
|
94
|
+
await amplitude.flush()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class NoopAnalyticsService implements AnalyticsService {
|
|
99
|
+
track(): void {
|
|
100
|
+
// No-op
|
|
101
|
+
}
|
|
102
|
+
identify(): void {
|
|
103
|
+
// No-op
|
|
104
|
+
}
|
|
105
|
+
async flush(): Promise<void> {
|
|
106
|
+
// No-op
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/url-utils.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared URL helpers for analytics modules.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Extract the hostname from a URL (e.g. "https://google.com/search?q=..." → "google.com"). */
|
|
6
|
+
export function extractDomain(url: string): string | undefined {
|
|
7
|
+
try {
|
|
8
|
+
return new URL(url).hostname
|
|
9
|
+
} catch {
|
|
10
|
+
return undefined
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Strip query params from a URL to avoid leaking PII. */
|
|
15
|
+
export function stripQueryParams(url: string): string | undefined {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = new URL(url)
|
|
18
|
+
return `${parsed.origin}${parsed.pathname}`
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../config/tsconfig/app.json",
|
|
3
|
+
"include": [
|
|
4
|
+
"src/**/*.ts",
|
|
5
|
+
"src/**/*.tsx",
|
|
6
|
+
"src/**/*.json",
|
|
7
|
+
"src/global.d.ts"
|
|
8
|
+
],
|
|
9
|
+
"exclude": [
|
|
10
|
+
"src/**/*.spec.ts",
|
|
11
|
+
"src/**/*.spec.tsx",
|
|
12
|
+
"src/**/*.test.ts",
|
|
13
|
+
"src/**/*.test.tsx"
|
|
14
|
+
],
|
|
15
|
+
"compilerOptions": {
|
|
16
|
+
"noEmit": false,
|
|
17
|
+
"emitDeclarationOnly": true,
|
|
18
|
+
"types": ["node"],
|
|
19
|
+
"paths": {}
|
|
20
|
+
},
|
|
21
|
+
"references": [
|
|
22
|
+
{
|
|
23
|
+
"path": "../eslint-config"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|