diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 0d2280aaa..3c135f1d4 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -29,6 +29,7 @@ import { ShortenStringPipe } from '@app/shared/pipes/shorten-string-pipe/shorten import { CapAddressPipe } from '@app/shared/pipes/cap-address-pipe/cap-address-pipe'; import { AppPreloadingStrategy } from '@app/app.preloading-strategy'; import { ServicesApiServices } from '@app/services/services-api.service'; +import { AnalyticsService } from '@app/services/analytics.service'; import { DatePipe } from '@angular/common'; const providers = [ @@ -53,6 +54,7 @@ const providers = [ AppPreloadingStrategy, ServicesApiServices, PreloadService, + AnalyticsService, { provide: HTTP_INTERCEPTORS, useClass: HttpCacheInterceptor, multi: true }, { provide: ZONE_SERVICE, useClass: ZoneService }, ]; diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html index 54fe158db..aed5f4aca 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html @@ -632,7 +632,7 @@
- diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts index 6f8636e96..6e0cf50da 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -14,6 +14,9 @@ import { PartnerCodeService } from '@app/services/partner-code.service'; import { ApiService } from '@app/services/api.service'; import { isDevMode } from '@angular/core'; import { ThemeService } from '../../services/theme.service'; +import { StorageService } from '@app/services/storage.service'; +import { AnalyticsService } from '@app/services/analytics.service'; +import { Recommendedfees } from '@interfaces/websocket.interface'; export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp' | 'applePay' | 'googlePay' | 'cardOnFile'; @@ -105,6 +108,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { accelerationSubscription: Subscription; difficultySubscription: Subscription; estimateSubscription: Subscription; + recommendedFeesSubscription: Subscription; estimate: AccelerationEstimate; estimate$: ReplaySubject = new ReplaySubject(1); maxBidBoost: number; // sats @@ -119,6 +123,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { userBid = 0; selectFeeRateIndex = 1; maxRateOptions: RateOption[] = []; + recommendedFees: Recommendedfees | null = null; // square loadingCashapp = false; @@ -150,6 +155,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { private enterpriseService: EnterpriseService, private partnerCodeService: PartnerCodeService, private themeService: ThemeService, + private analyticsService: AnalyticsService, + private storageService: StorageService, ) { this.isProdDomain = this.stateService.isProdDomain; @@ -198,6 +205,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.loadedTheme = state.theme; this.cd.markForCheck(); }); + this.recommendedFeesSubscription = this.stateService.recommendedFees$.subscribe( + (recommendedFees) => { + this.recommendedFees = recommendedFees; + } + ); } ngOnDestroy(): void { @@ -210,6 +222,9 @@ export class AccelerateCheckout implements OnInit, OnDestroy { if (this.themeStateSubscription) { this.themeStateSubscription.unsubscribe(); } + if (this.recommendedFeesSubscription) { + this.recommendedFeesSubscription.unsubscribe(); + } this.destroy$.next(); this.destroy$.complete(); } @@ -220,6 +235,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } } + onAccelerateClicked(): void { + this.analyticsService.action('/tx/:txid/checkout/' + (this.step === 'quote' ? 'quote' : 'summary'), 'accelerate_clicked', this.anonymizedTxInfo()); + this.moveToStep('checkout'); + } + moveToStep(step: CheckoutStep, force: boolean = false): void { if (this.isCheckoutLocked > 0 && !force || this.step === 'success') { return; @@ -249,14 +269,17 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.moveToStep('bitcoin'); } } else if (this._step === 'cashapp') { + this.analyticsService.action('/tx/:txid/checkout/checkout', 'cashapp_clicked', this.anonymizedTxInfo()); this.loadingCashapp = true; this.setupSquare(); this.scrollToElementWithTimeout('confirm-title', 'center', 100); } else if (this._step === 'applepay' && this.applePayEnabled) { + this.analyticsService.action('/tx/:txid/checkout/checkout', 'applypay_clicked', this.anonymizedTxInfo()); this.loadingApplePay = true; this.setupSquare(); this.scrollToElementWithTimeout('confirm-title', 'center', 100); } else if (this._step === 'googlepay' && this.googlePayEnabled) { + this.analyticsService.action('/tx/:txid/checkout/checkout', 'googlepay_clicked', this.anonymizedTxInfo()); this.loadingGooglePay = true; this.setupSquare(); this.scrollToElementWithTimeout('confirm-title', 'center', 100); @@ -267,12 +290,15 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } else if (this._step === 'bitcoin') { this.scrollToElementWithTimeout('confirm-title', 'nearest', 100); } else if (this._step === 'paid') { + this.analyticsService.action('/tx/:txid/checkout', 'acceleration_paid', this.anonymizedTxInfo()); this.timePaid = Date.now(); this.timeoutTimer = setTimeout(() => { if (this.step === 'paid') { this.accelerateError = 'internal_server_error'; } }, 120000); + } else if (this._step === 'quote') { + this.analyticsService.action('/tx/:txid/checkout/summary', 'details_clicked', this.anonymizedTxInfo()); } this.hasDetails.emit(this._step === 'quote'); } @@ -283,6 +309,16 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.accelerateError = ''; } + anonymizedTxInfo(): Record { + return { + vsize: this.estimate.txSummary.effectiveVsize, + fee: this.estimate.txSummary.effectiveFee, + userBid: this.userBid, + recommended: this.recommendedFees?.fastestFee, + cost: this.cost, + }; + } + /** * Scroll to element id with or without setTimeout */ diff --git a/frontend/src/app/components/app/app.component.ts b/frontend/src/app/components/app/app.component.ts index 0143cf9d7..48bd48be4 100644 --- a/frontend/src/app/components/app/app.component.ts +++ b/frontend/src/app/components/app/app.component.ts @@ -6,6 +6,7 @@ import { OpenGraphService } from '@app/services/opengraph.service'; import { NgbTooltipConfig } from '@ng-bootstrap/ng-bootstrap'; import { ThemeService } from '@app/services/theme.service'; import { SeoService } from '@app/services/seo.service'; +import { AnalyticsService } from '@app/services/analytics.service'; @Component({ selector: 'app-root', @@ -21,6 +22,7 @@ export class AppComponent implements OnInit { private openGraphService: OpenGraphService, private seoService: SeoService, private themeService: ThemeService, + private analyticsService: AnalyticsService, private location: Location, private viewportScroller: ViewportScroller, tooltipConfig: NgbTooltipConfig, diff --git a/frontend/src/app/components/tracker/tracker.component.ts b/frontend/src/app/components/tracker/tracker.component.ts index 7ad2817a7..ac0c49983 100644 --- a/frontend/src/app/components/tracker/tracker.component.ts +++ b/frontend/src/app/components/tracker/tracker.component.ts @@ -33,6 +33,8 @@ import { TrackerStage } from '@components/tracker/tracker-bar.component'; import { MiningService, MiningStats } from '@app/services/mining.service'; import { ETA, EtaService } from '@app/services/eta.service'; import { getTransactionFlags, getUnacceleratedFeeRate } from '@app/shared/transaction.utils'; +import { StorageService } from '@app/services/storage.service'; +import { AnalyticsService } from '@app/services/analytics.service'; interface Pool { @@ -147,6 +149,7 @@ export class TrackerComponent implements OnInit, OnDestroy { private priceService: PriceService, private enterpriseService: EnterpriseService, private partnerCodeService: PartnerCodeService, + private analyticsService: AnalyticsService, private miningService: MiningService, private router: Router, private cd: ChangeDetectorRef, @@ -501,6 +504,7 @@ export class TrackerComponent implements OnInit, OnDestroy { this.websocketService.startTrackTransaction(tx.txid); if (!tx.status?.confirmed) { + this.analyticsService.action('/tx/:txid/tracker', 'unconfirmed-tx'); this.trackerStage = 'pending'; if (tx.firstSeen) { this.transactionTime = tx.firstSeen; diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 8c7892c37..f5db3c7b1 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -40,6 +40,7 @@ import { PartnerCodeService } from '@app/services/partner-code.service'; import { ZONE_SERVICE } from '@app/injection-tokens'; import { MiningService, MiningStats } from '@app/services/mining.service'; import { ETA, EtaService } from '@app/services/eta.service'; +import { AnalyticsService } from '@app/services/analytics.service'; export interface Pool { id: number; @@ -219,6 +220,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { private storageService: StorageService, private enterpriseService: EnterpriseService, private partnerCodeService: PartnerCodeService, + private analyticsService: AnalyticsService, private miningService: MiningService, private etaService: EtaService, private cd: ChangeDetectorRef, @@ -704,6 +706,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.setupGraph(); if (!tx.status?.confirmed) { + this.analyticsService.action('/tx/:txid', 'unconfirmed-tx'); if (tx.firstSeen) { this.transactionTime = tx.firstSeen; } else { diff --git a/frontend/src/app/services/analytics.service.ts b/frontend/src/app/services/analytics.service.ts new file mode 100644 index 000000000..b839eadd9 --- /dev/null +++ b/frontend/src/app/services/analytics.service.ts @@ -0,0 +1,283 @@ +import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { Router, NavigationEnd } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; +import { isPlatformBrowser } from '@angular/common'; +import { filter } from 'rxjs/operators'; +import { StateService } from '@app/services/state.service'; +import { LanguageService } from '@app/services/language.service'; + +const SESSION_KEY = 'mlytx'; +const SESSION_TIMEOUT_MS = 6 * 60 * 60 * 1000; +const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const FLUSH_IDLE_MS = 5_000; +const FLUSH_MAX_MS = 30_000; + +interface SessionEntry { + id: string; + ts: number; + st: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class AnalyticsService { + private baseUrl: string; + private sessionId: string; + private startTime: number; + private locale: string | undefined; + private buffer: Record[] = []; + private idleTimer: ReturnType | null = null; + private maxTimer: ReturnType | null = null; + public enabled = false; + + constructor( + @Inject(PLATFORM_ID) platformId: any, + private router: Router, + private http: HttpClient, + private stateService: StateService, + private languageService: LanguageService, + ) { + if (!isPlatformBrowser(platformId) || !this.stateService.env.ANALYTICS_URL || !this.trackingAllowed() || this.doNotTrack()) { + this.enabled = false; + return; + } + this.enabled = true; + this.baseUrl = this.stateService.env.ANALYTICS_URL; + + const lang = this.languageService.getLanguage(); + this.locale = lang && lang !== 'en' ? lang : undefined; + + if (!this.restoreSession()) { + this.startNewSession(); + } + + window.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + this.unload(); + } + }); + window.addEventListener('beforeunload', () => { + this.unload(); + }); + window.addEventListener('pagehide', () => { + this.unload(); + }); + + this.router.events.pipe( + filter(event => event instanceof NavigationEnd), + ).subscribe(() => { + const { path, network } = this.getRouteTemplate(); + this.view(path, network); + }); + } + + view(path: string, network?: string): void { + if (!this.baseUrl || !this.enabled) { + return; + } + const event: Record = { t: 'v', p: path }; + if (network) { + event.n = network; + } + if (this.locale) { + event.l = this.locale; + } + this.pushEvent(event); + } + + action(path: string, id: string, params?: Record, network?: string): void { + if (!this.baseUrl || !this.enabled) { + return; + } + const event: Record = { t: 'a', p: path, id }; + if (network) { + event.n = network; + } + if (this.locale) { + event.l = this.locale; + } + if (params && Object.keys(params).length) { + event.params = params; + } + this.pushEvent(event); + } + + unload(): void { + if (!this.baseUrl || !this.enabled || !this.buffer.length) { + return; + } + this.clearTimers(); + this.flush(); + } + + private restoreSession(): boolean { + try { + const raw = localStorage.getItem(SESSION_KEY); + if (raw) { + const entry: SessionEntry = JSON.parse(raw); + const now = Date.now(); + if (entry.id && (now - entry.ts) < SESSION_TIMEOUT_MS && (now - entry.st) < SESSION_MAX_AGE_MS) { + this.sessionId = entry.id; + this.startTime = entry.st; + return true; + } + } + } catch { } + return false; + } + + private startNewSession(): void { + if (!this.enabled) { + return; + } + this.sessionId = crypto.randomUUID(); + this.startTime = Date.now(); + this.saveSession(this.sessionId); + const sessionEvent = { t: 's', td: 0, ...this.buildSessionPayload() }; + fetch(this.baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ s: this.sessionId, events: [sessionEvent] }), + keepalive: true, + }); + } + + private saveSession(id: string): void { + try { + localStorage.setItem(SESSION_KEY, JSON.stringify({ id, ts: Date.now(), st: this.startTime })); + } catch { + // failed to save session, ignore + } + } + + private pushEvent(event: Record): void { + if (!this.enabled) { + return; + } + if (Date.now() - this.startTime >= SESSION_MAX_AGE_MS) { + this.buffer.push({ t: 'x', td: Date.now() - this.startTime }); + this.flush(); + this.startNewSession(); + } + event.td = Date.now() - this.startTime; + this.buffer.push(event); + this.scheduleFlush(); + } + + private buildSessionPayload(): Record { + const payload: Record = {}; + + payload.d = window.location.hostname; + + const referrer = document.referrer; + if (referrer) { + payload.r = referrer; + } + + const params = new URLSearchParams(window.location.search); + const utmMap: Record = { + utm_source: 'us', + utm_medium: 'um', + utm_campaign: 'uc', + utm_term: 'ut', + utm_content: 'un', + }; + for (const [param, key] of Object.entries(utmMap)) { + const val = params.get(param); + if (val) { + payload[key] = val; + } + } + + return payload; + } + + private scheduleFlush(): void { + if (this.idleTimer !== null) { + clearTimeout(this.idleTimer); + } + this.idleTimer = setTimeout(() => this.flush(), FLUSH_IDLE_MS); + + if (this.maxTimer === null) { + this.maxTimer = setTimeout(() => this.flush(), FLUSH_MAX_MS); + } + } + + private clearTimers(): void { + if (this.idleTimer !== null) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + if (this.maxTimer !== null) { + clearTimeout(this.maxTimer); + this.maxTimer = null; + } + } + + private flush(): void { + if (!this.enabled || !this.buffer.length) { + return; + } + this.clearTimers(); + const payload = this.flushPayload(); + if (payload) { + fetch(this.baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + keepalive: true, + }); + } + } + + private flushPayload(): Record | null { + if (!this.enabled) { + return null; + } + const events = this.buffer; + this.buffer = []; + if (events.length) { + return { s: this.sessionId, events, td: Date.now() - this.startTime }; + } else { + return null; + } + } + + private trackingAllowed(): boolean { + if (this.stateService.env.OFFICIAL_MEMPOOL_SPACE) { + return true; + } + const hostname = window.location.hostname; + if (hostname === 'mempool.space' || hostname.endsWith('.mempool.space')) { + return true; + } + if (this.stateService.env.customize?.enterprise) { + return true; + } + return false; + } + + private doNotTrack(): boolean { + const dnt = (navigator as any).doNotTrack ?? (window as any).doNotTrack ?? (navigator as any).msDoNotTrack; + return dnt != null && (parseInt(dnt, 10) === 1 || dnt === 'yes'); + } + + private getRouteTemplate(): { path: string; network?: string } { + let route = this.router.routerState.snapshot.root; + const segments: string[] = []; + while (route) { + if (route.routeConfig?.path) { + segments.push(route.routeConfig.path); + } + route = route.firstChild; + } + const template = '/' + segments.join('/') || '/'; + const network = this.stateService.network || undefined; + return { path: template, network }; + } +} diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 9529f83c9..ec028b211 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -92,6 +92,7 @@ export interface Env { TWIDGET_API?: string; customize?: Customization; PROD_DOMAINS: string[]; + ANALYTICS_URL?: string; } const defaultEnv: Env = {