@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 ADDED
@@ -0,0 +1,9 @@
1
+ ignores: [
2
+ # Dependencies that depcheck incorrectly marks as unused
3
+ "typescript",
4
+ "@typescript/native-preview",
5
+ "depcheck",
6
+
7
+ # Internal packages / workspaces
8
+ "analytics",
9
+ ]
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
@@ -0,0 +1,3 @@
1
+ # @universe/analytics
2
+
3
+ // TODO
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
+ }
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "preserveSymlinks": true
5
+ },
6
+ "include": ["**/*.ts", "**/*.tsx", "**/*.json"],
7
+ "exclude": ["node_modules"]
8
+ }