diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b44df6..209c432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change Log + +## 28.2.0-rc.7 + +* Added: `Analytics` service and the `Tracking` auto-tracking helper, which were missing from `28.2.0-rc.5` and `rc.6` +* Changed: `Tracking` takes `new Analytics(client)` and a property id instead of an emitter callback; the adapter to `createEvent` now lives inside the SDK +* Changed: `enableAllAutoTracking()` is now `start()` +* Removed: `AnalyticsEventEmitter`; the option types are now `TrackingEventOptions` and `TrackingOptions` + ## 28.2.0-rc.6 * Added: `Push.onNotificationOpened()` fires when a user taps a shown notification @@ -242,3 +250,4 @@ * Fix pong response & chunked upload * Add `ping` support to `Realtime` service + diff --git a/README.md b/README.md index cc5c4d7..02c0483 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ import { Client, Account } from 'appwrite'; To install with a CDN (content delivery network) add the following scripts to the bottom of your tag, but before you use any Appwrite services: ```html - + ``` ## Getting Started diff --git a/docs/examples/account/create-o-auth-2-session.md b/docs/examples/account/create-o-auth-2-session.md index 869e0f3..cfe1580 100644 --- a/docs/examples/account/create-o-auth-2-session.md +++ b/docs/examples/account/create-o-auth-2-session.md @@ -12,5 +12,6 @@ account.createOAuth2Session({ success: 'https://example.com', // optional failure: 'https://example.com', // optional scopes: [], // optional + state: '', // optional }); ``` diff --git a/docs/examples/account/create-o-auth-2-token.md b/docs/examples/account/create-o-auth-2-token.md index 1cb7dd5..754eab2 100644 --- a/docs/examples/account/create-o-auth-2-token.md +++ b/docs/examples/account/create-o-auth-2-token.md @@ -12,5 +12,6 @@ account.createOAuth2Token({ success: 'https://example.com', // optional failure: 'https://example.com', // optional scopes: [], // optional + state: '', // optional }); ``` diff --git a/docs/examples/analytics/create-event.md b/docs/examples/analytics/create-event.md new file mode 100644 index 0000000..fe73aab --- /dev/null +++ b/docs/examples/analytics/create-event.md @@ -0,0 +1,27 @@ +```javascript +import { Client, Analytics } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const analytics = new Analytics(client); + +const result = await analytics.createEvent({ + propertyId: '', + name: '', + url: 'https://example.com', + domain: '', // optional + referrer: '', // optional + screenWidth: null, // optional + sessionHash: '', // optional + scrollDepth: 0, // optional + engagementTime: 0, // optional + props: [], // optional + userId: '', // optional + ip: '', // optional + userAgent: '', // optional +}); + +console.log(result); +``` diff --git a/package-lock.json b/package-lock.json index b5e3fb1..794a463 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "appwrite", - "version": "28.2.0-rc.6", + "version": "28.2.0-rc.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "appwrite", - "version": "28.2.0-rc.6", + "version": "28.2.0-rc.7", "license": "BSD-3-Clause", "dependencies": { "buffer": "6.0.3", diff --git a/package.json b/package.json index 2120e83..b0d5e1d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "appwrite", "homepage": "https://appwrite.io/support", "description": "Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API", - "version": "28.2.0-rc.6", + "version": "28.2.0-rc.7", "license": "BSD-3-Clause", "main": "dist/cjs/sdk.js", "exports": { diff --git a/src/client.ts b/src/client.ts index 4ee6a1e..eb7140a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -401,7 +401,7 @@ class Client { 'x-sdk-name': 'Web', 'x-sdk-platform': 'client', 'x-sdk-language': 'web', - 'x-sdk-version': '28.2.0-rc.6', + 'x-sdk-version': '28.2.0-rc.7', 'X-Appwrite-Response-Format': '2.3.0', }; diff --git a/src/index.ts b/src/index.ts index 915cf34..377b0ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ */ export { Client, Query, AppwriteException } from './client'; export { Account } from './services/account'; +export { Analytics } from './services/analytics'; export { Apps } from './services/apps'; export { Avatars } from './services/avatars'; export { Databases } from './services/databases'; @@ -26,6 +27,7 @@ export { Teams } from './services/teams'; export { VectorsDB } from './services/vectors-db'; export { Realtime } from './services/realtime'; export { Push } from './services/push'; +export { Tracking } from './services/analytics-tracking'; export type { Models, Payload, @@ -40,6 +42,12 @@ export type { SubscribeOptions, MessageCallback, } from './services/push'; +export type { + DownloadTrackingOptions, + OutboundTrackingOptions, + TrackingEventOptions, + TrackingOptions, +} from './services/analytics-tracking'; export type { QueryTypes, QueryTypesList } from './query'; export { Permission } from './permission'; export { Role } from './role'; diff --git a/src/services/account.ts b/src/services/account.ts index b1d3a3c..8d95139 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -2809,6 +2809,7 @@ export class Account { * @param {string} params.success - URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string} params.failure - URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string[]} params.scopes - A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of 100 scopes are allowed, each 4096 characters long. + * @param {string} params.state - An opaque value your app generates and keeps, for example in a cookie. It is returned unchanged as the `state` query parameter on the success and failure URLs, so your app can check that the sign-in it receives is one it started. Printable ASCII only (RFC 6749 Appendix A.5). Max length: 256 chars. * @throws {AppwriteException} * @returns {void | string} */ @@ -2817,6 +2818,7 @@ export class Account { success?: string; failure?: string; scopes?: string[]; + state?: string; }): void | string; /** * Allow the user to login to their account using the OAuth2 provider of their choice. Each OAuth2 provider should be enabled from the Appwrite console first. Use the success and failure arguments to provide a redirect URL's back to your app when login is completed. @@ -2830,6 +2832,7 @@ export class Account { * @param {string} success - URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string} failure - URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string[]} scopes - A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of 100 scopes are allowed, each 4096 characters long. + * @param {string} state - An opaque value your app generates and keeps, for example in a cookie. It is returned unchanged as the `state` query parameter on the success and failure URLs, so your app can check that the sign-in it receives is one it started. Printable ASCII only (RFC 6749 Appendix A.5). Max length: 256 chars. * @throws {AppwriteException} * @returns {void | string} * @deprecated Use the object parameter style method for a better developer experience. @@ -2839,6 +2842,7 @@ export class Account { success?: string, failure?: string, scopes?: string[], + state?: string, ): void | string; createOAuth2Session( paramsOrFirst: @@ -2847,15 +2851,17 @@ export class Account { success?: string; failure?: string; scopes?: string[]; + state?: string; } | OAuthProvider, - ...rest: [string?, string?, string[]?] + ...rest: [string?, string?, string[]?, string?] ): void | string { let params: { provider: OAuthProvider; success?: string; failure?: string; scopes?: string[]; + state?: string; }; if ( @@ -2865,13 +2871,15 @@ export class Account { ('provider' in paramsOrFirst || 'success' in paramsOrFirst || 'failure' in paramsOrFirst || - 'scopes' in paramsOrFirst) + 'scopes' in paramsOrFirst || + 'state' in paramsOrFirst) ) { params = (paramsOrFirst || {}) as { provider: OAuthProvider; success?: string; failure?: string; scopes?: string[]; + state?: string; }; } else { params = { @@ -2879,6 +2887,7 @@ export class Account { success: rest[0] as string, failure: rest[1] as string, scopes: rest[2] as string[], + state: rest[3] as string, }; } @@ -2886,6 +2895,7 @@ export class Account { const success = params.success; const failure = params.failure; const scopes = params.scopes; + const state = params.state; if (typeof provider === 'undefined') { throw new AppwriteException( @@ -2906,6 +2916,9 @@ export class Account { if (typeof scopes !== 'undefined') { apiPayload['scopes'] = scopes; } + if (typeof state !== 'undefined') { + apiPayload['state'] = state; + } const uri = new URL(this.client.config.endpoint + apiPath); apiPayload['project'] = this.client.config.project; @@ -3701,6 +3714,7 @@ export class Account { * @param {string} params.success - URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string} params.failure - URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string[]} params.scopes - A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of 100 scopes are allowed, each 4096 characters long. + * @param {string} params.state - An opaque value your app generates and keeps, for example in a cookie. It is returned unchanged as the `state` query parameter on the success and failure URLs, so your app can check that the sign-in it receives is one it started. Printable ASCII only (RFC 6749 Appendix A.5). Max length: 256 chars. * @throws {AppwriteException} * @returns {void | string} */ @@ -3709,6 +3723,7 @@ export class Account { success?: string; failure?: string; scopes?: string[]; + state?: string; }): void | string; /** * Allow the user to login to their account using the OAuth2 provider of their choice. Each OAuth2 provider should be enabled from the Appwrite console first. Use the success and failure arguments to provide a redirect URL's back to your app when login is completed. @@ -3723,6 +3738,7 @@ export class Account { * @param {string} success - URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string} failure - URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string[]} scopes - A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of 100 scopes are allowed, each 4096 characters long. + * @param {string} state - An opaque value your app generates and keeps, for example in a cookie. It is returned unchanged as the `state` query parameter on the success and failure URLs, so your app can check that the sign-in it receives is one it started. Printable ASCII only (RFC 6749 Appendix A.5). Max length: 256 chars. * @throws {AppwriteException} * @returns {void | string} * @deprecated Use the object parameter style method for a better developer experience. @@ -3732,6 +3748,7 @@ export class Account { success?: string, failure?: string, scopes?: string[], + state?: string, ): void | string; createOAuth2Token( paramsOrFirst: @@ -3740,15 +3757,17 @@ export class Account { success?: string; failure?: string; scopes?: string[]; + state?: string; } | OAuthProvider, - ...rest: [string?, string?, string[]?] + ...rest: [string?, string?, string[]?, string?] ): void | string { let params: { provider: OAuthProvider; success?: string; failure?: string; scopes?: string[]; + state?: string; }; if ( @@ -3758,13 +3777,15 @@ export class Account { ('provider' in paramsOrFirst || 'success' in paramsOrFirst || 'failure' in paramsOrFirst || - 'scopes' in paramsOrFirst) + 'scopes' in paramsOrFirst || + 'state' in paramsOrFirst) ) { params = (paramsOrFirst || {}) as { provider: OAuthProvider; success?: string; failure?: string; scopes?: string[]; + state?: string; }; } else { params = { @@ -3772,6 +3793,7 @@ export class Account { success: rest[0] as string, failure: rest[1] as string, scopes: rest[2] as string[], + state: rest[3] as string, }; } @@ -3779,6 +3801,7 @@ export class Account { const success = params.success; const failure = params.failure; const scopes = params.scopes; + const state = params.state; if (typeof provider === 'undefined') { throw new AppwriteException( @@ -3799,6 +3822,9 @@ export class Account { if (typeof scopes !== 'undefined') { apiPayload['scopes'] = scopes; } + if (typeof state !== 'undefined') { + apiPayload['state'] = state; + } const uri = new URL(this.client.config.endpoint + apiPath); apiPayload['project'] = this.client.config.project; diff --git a/src/services/analytics-tracking.ts b/src/services/analytics-tracking.ts new file mode 100644 index 0000000..04f6b31 --- /dev/null +++ b/src/services/analytics-tracking.ts @@ -0,0 +1,764 @@ +import type { Analytics } from './analytics'; + +/** + * Auto-tracking helpers for the generated `Analytics` service: pageviews, + * outbound links, downloads, scroll depth and active engagement, all sent + * through `Analytics.createEvent`. + * + * ```ts + * const tracking = new Tracking(new Analytics(client), ''); + * tracking.start(); + * ``` + * + * All helpers respect `navigator.doNotTrack === '1'` by default; opt out with + * `respectDoNotTrack: false` in the constructor options. + * + * Engagement time follows Plausible's model: rather than one total at unload, + * a delta is emitted at every natural checkpoint of the visit (tab hidden, SPA + * route change, unload). See `enableAutoEngagementTime` for details. + * + * Auto-emitted event names follow `snake_case` + lowercase (`pageview`, + * `outbound_link`, `file_download`, `screen_view`, etc.). Any prop keys use + * camelCase to match the endpoint's parameter naming. Callers can pass any + * custom event name; the auto-track methods only own the events they emit + * themselves. + */ + +export type TrackingEventOptions = { + props?: Record; + url?: string; // defaults to the current page URL + referrer?: string; + scrollDepth?: number; // 0-100 percentage + engagementTime?: number; // seconds since the previous flush +}; + +export type TrackingOptions = { + /** + * When true (default) the tracker becomes a no-op if `navigator.doNotTrack` + * is set to `'1'`. Set to false to always fire regardless of the DNT signal. + */ + respectDoNotTrack?: boolean; +}; + +export type OutboundTrackingOptions = { + /** + * When true, links pointing at the current host's subdomains are treated + * as outbound. Defaults to false (subdomains are same-origin for tracking). + */ + includeSubdomains?: boolean; +}; + +export type DownloadTrackingOptions = { + /** + * File extensions (without the leading dot) that mark an anchor as a + * download. Defaults cover the common set. + */ + extensions?: string[]; +}; + +const DEFAULT_DOWNLOAD_EXTENSIONS = [ + 'pdf', + 'zip', + 'rar', + '7z', + 'tar', + 'gz', + 'csv', + 'tsv', + 'xls', + 'xlsx', + 'doc', + 'docx', + 'ppt', + 'pptx', + 'dmg', + 'exe', + 'msi', + 'pkg', + 'deb', + 'rpm', + 'apk', + 'mp3', + 'wav', + 'flac', + 'mp4', + 'mov', + 'avi', + 'mkv', + 'webm', +]; + +/** + * Two-part public suffixes common enough to matter for outbound-link detection. + * See `rootDomain()` for why this is a short list rather than the Public Suffix + * List. + */ +const MULTI_PART_SUFFIXES = new Set([ + 'co.uk', + 'org.uk', + 'ac.uk', + 'gov.uk', + 'me.uk', + 'net.uk', + 'sch.uk', + 'co.jp', + 'ne.jp', + 'or.jp', + 'ac.jp', + 'go.jp', + 'com.au', + 'net.au', + 'org.au', + 'edu.au', + 'gov.au', + 'com.br', + 'net.br', + 'org.br', + 'com.cn', + 'net.cn', + 'org.cn', + 'gov.cn', + 'co.in', + 'net.in', + 'org.in', + 'co.nz', + 'net.nz', + 'org.nz', + 'co.za', + 'org.za', + 'com.mx', + 'com.ar', + 'com.co', + 'com.pe', + 'com.tr', + 'com.tw', + 'com.sg', + 'com.hk', + 'com.my', + 'com.ph', + 'com.vn', + 'co.kr', + 'co.il', + 'co.id', + 'co.th', + 'com.ua', + 'com.pl', + 'com.es', +]); + +const SCROLL_DEPTH_THROTTLE_MS = 200; + +type Cleanup = () => void; + +export class Tracking { + private readonly analytics: Analytics; + private readonly propertyId: string; + private readonly respectDoNotTrack: boolean; + + private pageviewCleanups: Cleanup[] = []; + private outboundCleanup?: Cleanup; + private downloadCleanup?: Cleanup; + private scrollCleanups: Cleanup[] = []; + private engagementCleanups: Cleanup[] = []; + + private currentPath?: string; + private maxScrollDepth = 0; + private scrollTicking = false; + private lastScrollReport = 0; + + private engagementStart = 0; + private engagementAccumulatedMs = 0; + private engagementActive = false; + + /** + * @param analytics - The generated `Analytics` service events are sent through. + * @param propertyId - Analytics property every event is recorded against. + */ + constructor( + analytics: Analytics, + propertyId: string, + options: TrackingOptions = {}, + ) { + this.analytics = analytics; + this.propertyId = propertyId; + this.respectDoNotTrack = options.respectDoNotTrack !== false; + } + + /** + * Start the default auto-tracking set: pageviews, outbound links, scroll + * depth and active engagement time. Downloads are opt-in via + * `enableAutoDownloadTracking()` because the extension list is + * application-specific. + */ + public start(): void { + this.enableAutoPageviews(); + this.enableAutoOutboundTracking(); + this.enableAutoScrollDepth(); + this.enableAutoEngagementTime(); + } + + /** + * Track an event manually. Respects the DNT setting and swallows request + * failures so tracking never surfaces errors to the host page. + */ + public track(name: string, options: TrackingEventOptions = {}): void { + if (!this.canTrack()) { + return; + } + try { + // Missing required params throw synchronously rather than rejecting. + this.analytics + .createEvent({ + propertyId: this.propertyId, + name, + url: options.url ?? this.currentPageUrl(), + referrer: options.referrer, + scrollDepth: options.scrollDepth, + engagementTime: options.engagementTime, + // The endpoint takes props as a flat alternating key/value list. + props: options.props + ? Object.entries(options.props).flatMap( + ([key, value]) => [key, String(value)], + ) + : undefined, + }) + .catch((error) => { + console.warn('Tracking: failed to send event', error); + }); + } catch (error) { + console.warn('Tracking: failed to send event', error); + } + } + + /** + * Convenience wrapper for pageview-shaped events. + */ + public pageview(url?: string, referrer?: string): void { + this.track('pageview', { + url: url ?? this.currentPageUrl(), + referrer: + referrer ?? + (typeof document !== 'undefined' + ? document.referrer + : undefined), + }); + } + + /** + * Enable SPA-aware pageview tracking. Fires an initial pageview on + * enable, then again on every `pushState`, `replaceState`, `popstate` + * or hashchange. Idempotent — repeat calls no-op. + */ + public enableAutoPageviews(): void { + if (this.pageviewCleanups.length > 0) { + return; + } + if (typeof window === 'undefined' || typeof history === 'undefined') { + return; + } + + this.currentPath = this.currentPageUrl(); + this.pageview(); + + const handleNavigation = (): void => { + const nextPath = this.currentPageUrl(); + if (nextPath === this.currentPath) { + return; + } + const previous = this.currentPath; + this.currentPath = nextPath; + this.flushScrollDepth(previous); + this.flushEngagement(previous); + this.pageview(nextPath); + // flushEngagement no longer auto-restarts the timer (see its + // implementation note re: bfcache). SPA navigation stays on the + // same document, so restart engagement tracking for the new + // route as long as the tab is visible. + if ( + this.engagementCleanups.length > 0 && + (typeof document === 'undefined' || + document.visibilityState !== 'hidden') + ) { + this.startEngagement(); + } + }; + + const originalPushState = history.pushState.bind(history); + const originalReplaceState = history.replaceState.bind(history); + + // Our wrappers stay reachable after cleanup whenever another library + // patched on top of ours, so they have to be switchable off rather than + // merely unreferenced. + let patchActive = true; + + const patchedPushState = ( + data: unknown, + unused: string, + url?: string | URL | null, + ): void => { + originalPushState(data, unused, url); + if (patchActive) { + handleNavigation(); + } + }; + const patchedReplaceState = ( + data: unknown, + unused: string, + url?: string | URL | null, + ): void => { + originalReplaceState(data, unused, url); + if (patchActive) { + handleNavigation(); + } + }; + + history.pushState = patchedPushState; + history.replaceState = patchedReplaceState; + + const popstateHandler = (): void => handleNavigation(); + const hashchangeHandler = (): void => handleNavigation(); + + window.addEventListener('popstate', popstateHandler); + window.addEventListener('hashchange', hashchangeHandler); + + this.pageviewCleanups.push(() => { + // Only unwind our own patch. A router (Next.js App Router, React + // Router, …) that patched these after us owns the current value, + // and assigning our saved reference back would silently throw its + // wrapper away. In that case ours stays in the chain, which is why + // `patchActive` exists: the wrapper keeps delegating to the original + // but stops reporting. + patchActive = false; + + if (history.pushState === patchedPushState) { + history.pushState = originalPushState; + } + if (history.replaceState === patchedReplaceState) { + history.replaceState = originalReplaceState; + } + window.removeEventListener('popstate', popstateHandler); + window.removeEventListener('hashchange', hashchangeHandler); + }); + } + + /** + * Disable auto-pageview tracking installed by `enableAutoPageviews`. + * Safe to call when auto-pageviews were never enabled. + */ + public disableAutoPageviews(): void { + for (const cleanup of this.pageviewCleanups) { + cleanup(); + } + this.pageviewCleanups = []; + } + + /** + * Enable click-based outbound link tracking. Detects anchor clicks that + * navigate to a different origin and fires an `outbound_link` event + * (with `hostname` and `href` props) before the browser navigates. + */ + public enableAutoOutboundTracking( + options: OutboundTrackingOptions = {}, + ): void { + if (this.outboundCleanup) { + return; + } + if (typeof document === 'undefined') { + return; + } + + const includeSubdomains = options.includeSubdomains === true; + + const clickHandler = (event: MouseEvent): void => { + const anchor = this.findAnchor(event.target); + if (!anchor || !anchor.href) { + return; + } + const target = this.parseUrl(anchor.href); + if (!target || !this.isExternal(target, includeSubdomains)) { + return; + } + this.track('outbound_link', { + url: target.href, + props: { hostname: target.hostname, href: target.href }, + }); + }; + + document.addEventListener('click', clickHandler, { capture: true }); + this.outboundCleanup = () => + document.removeEventListener('click', clickHandler, { + capture: true, + }); + } + + /** + * Disable outbound-link tracking installed by `enableAutoOutboundTracking`. + * Safe to call when auto-outbound was never enabled. + */ + public disableAutoOutboundTracking(): void { + if (this.outboundCleanup) { + this.outboundCleanup(); + this.outboundCleanup = undefined; + } + } + + /** + * Enable click-based download tracking. Detects anchor clicks that point + * at a file whose extension appears in the configured list and fires a + * `file_download` event with `filename`, `extension` and `href` props. + */ + public enableAutoDownloadTracking( + options: DownloadTrackingOptions = {}, + ): void { + if (this.downloadCleanup) { + return; + } + if (typeof document === 'undefined') { + return; + } + + const extensions = ( + options.extensions ?? DEFAULT_DOWNLOAD_EXTENSIONS + ).map((ext) => ext.toLowerCase().replace(/^\./, '')); + const extensionSet = new Set(extensions); + + const clickHandler = (event: MouseEvent): void => { + const anchor = this.findAnchor(event.target); + if (!anchor || !anchor.href) { + return; + } + const url = this.parseUrl(anchor.href); + if (!url) { + return; + } + const filename = url.pathname.split('/').pop() ?? ''; + const extension = filename.includes('.') + ? filename.split('.').pop()?.toLowerCase() + : undefined; + if (!extension || !extensionSet.has(extension)) { + return; + } + this.track('file_download', { + url: url.href, + props: { filename, extension, href: url.href }, + }); + }; + + document.addEventListener('click', clickHandler, { capture: true }); + this.downloadCleanup = () => + document.removeEventListener('click', clickHandler, { + capture: true, + }); + } + + /** + * Disable file-download tracking installed by `enableAutoDownloadTracking`. + * Safe to call when auto-download was never enabled. + */ + public disableAutoDownloadTracking(): void { + if (this.downloadCleanup) { + this.downloadCleanup(); + this.downloadCleanup = undefined; + } + } + + /** + * Enable passive scroll-depth tracking. Records the maximum percent of the + * document scrolled and reports it on `beforeunload` and on SPA route + * changes. Uses a throttled passive listener so it is safe on hot paths. + */ + public enableAutoScrollDepth(): void { + if (this.scrollCleanups.length > 0) { + return; + } + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + this.maxScrollDepth = 0; + this.lastScrollReport = 0; + + const scrollHandler = (): void => { + if (this.scrollTicking) { + return; + } + this.scrollTicking = true; + window.requestAnimationFrame(() => { + const now = Date.now(); + if (now - this.lastScrollReport < SCROLL_DEPTH_THROTTLE_MS) { + this.scrollTicking = false; + return; + } + this.lastScrollReport = now; + const depth = this.computeScrollDepth(); + if (depth > this.maxScrollDepth) { + this.maxScrollDepth = depth; + } + this.scrollTicking = false; + }); + }; + + const unloadHandler = (): void => { + this.flushScrollDepth(this.currentPageUrl()); + }; + + window.addEventListener('scroll', scrollHandler, { passive: true }); + window.addEventListener('beforeunload', unloadHandler); + window.addEventListener('pagehide', unloadHandler); + + this.scrollCleanups.push(() => { + window.removeEventListener('scroll', scrollHandler); + window.removeEventListener('beforeunload', unloadHandler); + window.removeEventListener('pagehide', unloadHandler); + }); + } + + /** + * Disable scroll-depth tracking installed by `enableAutoScrollDepth`. Safe + * to call when auto-scroll-depth was never enabled. Any accumulated depth + * for the current page is dropped. + */ + public disableAutoScrollDepth(): void { + for (const cleanup of this.scrollCleanups) { + cleanup(); + } + this.scrollCleanups = []; + this.maxScrollDepth = 0; + this.scrollTicking = false; + this.lastScrollReport = 0; + } + + /** + * Enable active engagement-time tracking. Counts time the tab is visible + * and uses `visibilitychange` to exclude background time. + * + * Delivery follows Plausible's model rather than a single total at the + * end of the visit: an `engagement_time` event is emitted at every natural + * checkpoint — the tab going hidden, an SPA route change, and finally + * `beforeunload`/`pagehide`. Unload is kept only as a best-effort last + * flush; it is the least reliable moment to send anything (browsers + * routinely cancel in-flight requests there, and `sendBeacon` cannot set + * the `X-Appwrite-Project` header the endpoint requires), so it must never + * be the only delivery. + * + * Each event carries a **delta** — the seconds accrued since the previous + * flush, not a running total — so the values are additive per session and + * a lost event costs only its own slice. + * + * There is deliberately no periodic ping. The cadence is driven by + * navigation and tab switches, so request volume tracks what the visitor + * actually does instead of multiplying with time on page. + */ + public enableAutoEngagementTime(): void { + if (this.engagementCleanups.length > 0) { + return; + } + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + this.engagementAccumulatedMs = 0; + // Do not start the clock for a tab that is already hidden (prerender, + // background tab restore); `visibilitychange` starts it when it is + // actually looked at. + if (document.visibilityState !== 'hidden') { + this.startEngagement(); + } + + const visibilityHandler = (): void => { + if (document.visibilityState === 'hidden') { + // Flush, don't just pause. The page is still fully alive here, + // which makes this the most reliable delivery point in the + // whole visit — and it is the same moment Plausible reports on. + this.flushEngagement(this.currentPageUrl()); + } else if (document.visibilityState === 'visible') { + this.startEngagement(); + } + }; + + const unloadHandler = (): void => { + this.flushEngagement(this.currentPageUrl()); + }; + + const pageshowHandler = (event: PageTransitionEvent): void => { + // When the page is restored from the back-forward cache the + // engagement timer is in a stopped state (flushEngagement paused + // it on pagehide and does not auto-restart). Kick off a fresh + // engagementStart so bfcache dwell time is not billed as + // engagement. + if (event.persisted) { + this.startEngagement(); + } + }; + + document.addEventListener('visibilitychange', visibilityHandler); + window.addEventListener('beforeunload', unloadHandler); + window.addEventListener('pagehide', unloadHandler); + window.addEventListener('pageshow', pageshowHandler); + + this.engagementCleanups.push(() => { + document.removeEventListener('visibilitychange', visibilityHandler); + window.removeEventListener('beforeunload', unloadHandler); + window.removeEventListener('pagehide', unloadHandler); + window.removeEventListener('pageshow', pageshowHandler); + }); + } + + /** + * Disable engagement-time tracking installed by `enableAutoEngagementTime`. + * Safe to call when auto-engagement was never enabled. Any accumulated + * engagement time is dropped without being emitted. + */ + public disableAutoEngagementTime(): void { + for (const cleanup of this.engagementCleanups) { + cleanup(); + } + this.engagementCleanups = []; + this.engagementActive = false; + this.engagementAccumulatedMs = 0; + this.engagementStart = 0; + } + + private canTrack(): boolean { + if (!this.respectDoNotTrack) { + return true; + } + if (typeof navigator === 'undefined') { + return true; + } + return navigator.doNotTrack !== '1'; + } + + private currentPageUrl(): string { + if (typeof window === 'undefined' || !window.location) { + return ''; + } + return window.location.href; + } + + private findAnchor(target: EventTarget | null): HTMLAnchorElement | null { + let node = target as Node | null; + while (node) { + if ((node as HTMLElement).tagName === 'A') { + return node as HTMLAnchorElement; + } + node = (node as Node).parentNode; + } + return null; + } + + private parseUrl(href: string): URL | null { + try { + return new URL(href, window.location.href); + } catch { + return null; + } + } + + private isExternal(target: URL, includeSubdomains: boolean): boolean { + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + return false; + } + if (target.hostname === window.location.hostname) { + return false; + } + if (includeSubdomains) { + return true; + } + const root = this.rootDomain(window.location.hostname); + return this.rootDomain(target.hostname) !== root; + } + + private rootDomain(hostname: string): string { + const parts = hostname.split('.'); + if (parts.length <= 2) { + return hostname; + } + // Taking the last two labels is wrong wherever the registry sells names + // one level down: `shop.example.co.uk` and `google.co.uk` both reduce to + // `co.uk`, so every outbound click between two `.co.uk` sites looks + // internal and is dropped. Take three labels after a known two-part + // suffix instead. + // + // This is a heuristic, not the Public Suffix List — the full list is + // orders of magnitude larger than this whole file, and a tracking + // snippet cannot carry it. Sites on a suffix that is missing here can + // pass `includeSubdomains: true` to treat every other host as outbound. + const suffix = parts.slice(-2).join('.'); + if (parts.length > 2 && MULTI_PART_SUFFIXES.has(suffix)) { + return parts.slice(-3).join('.'); + } + return suffix; + } + + private computeScrollDepth(): number { + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const viewport = + window.innerHeight || document.documentElement.clientHeight; + const total = document.documentElement.scrollHeight; + if (total <= viewport) { + return 100; + } + const depth = Math.round(((scrollTop + viewport) / total) * 100); + return Math.max(0, Math.min(100, depth)); + } + + private flushScrollDepth(url: string | undefined): void { + if (this.scrollCleanups.length === 0) { + return; + } + if (this.maxScrollDepth <= 0) { + return; + } + this.track('scroll_depth', { + url, + scrollDepth: this.maxScrollDepth, + }); + this.maxScrollDepth = 0; + } + + private startEngagement(): void { + if (this.engagementActive) { + return; + } + this.engagementActive = true; + this.engagementStart = Date.now(); + } + + private pauseEngagement(): void { + if (!this.engagementActive) { + return; + } + this.engagementActive = false; + this.engagementAccumulatedMs += Date.now() - this.engagementStart; + } + + private flushEngagement(url: string | undefined): void { + if (this.engagementCleanups.length === 0) { + return; + } + this.pauseEngagement(); + // Deltas, not a running total: emit only what accrued since the last + // flush. `floor` plus keeping the sub-second remainder in the + // accumulator is what makes repeated flushes safe — nothing is emitted + // twice, and a visit chopped into many short segments does not lose a + // fraction of a second to rounding on every one of them. + const seconds = Math.floor(this.engagementAccumulatedMs / 1000); + if (seconds > 0) { + this.engagementAccumulatedMs -= seconds * 1000; + this.track('engagement_time', { + url, + engagementTime: seconds, + }); + } + // NOTE: intentionally do NOT call startEngagement() here. flushEngagement + // runs on `pagehide`, which is also the last hook before the browser + // may freeze the page into the back-forward cache. Auto-restarting + // would snapshot engagementStart just before the freeze; the next + // pauseEngagement (after bfcache restore) would then bill the entire + // bfcache dwell as active engagement. Restart is driven by explicit + // resume signals: `pageshow` (with `persisted === true`), + // `visibilitychange` back to visible, or the SPA navigation flow in + // `enableAutoPageviews`. + } +} diff --git a/src/services/analytics.ts b/src/services/analytics.ts new file mode 100644 index 0000000..176a25f --- /dev/null +++ b/src/services/analytics.ts @@ -0,0 +1,242 @@ +import { AppwriteException, Client, type Payload } from '../client'; + +export class Analytics { + client: Client; + + constructor(client: Client) { + this.client = client; + } + + /** + * Send a tracking event from a browser, native app, or server-side SDK. + * + * @param {string} params.propertyId - Analytics property ID or snippet ID identifying the property. + * @param {string} params.name - Event name. "pageview" is just a conventional event name; events are not modeled specially. + * @param {string} params.url - Full page URL or screen identifier. + * @param {string} params.domain - Hostname (e.g. example.com). + * @param {string} params.referrer - Referrer URL. + * @param {number} params.screenWidth - Viewport width in CSS pixels. + * @param {string} params.sessionHash - Optional session hash (provided by SDK). + * @param {number} params.scrollDepth - Scroll depth percentage 0-100. + * @param {number} params.engagementTime - Engagement time in seconds, 0-4294967295. + * @param {string[]} params.props - Custom string properties as a flat key=value list (max 32 entries, alternating key,value). + * @param {string} params.userId - Override user ID. Requires API-key auth with analytics.write scope. + * @param {string} params.ip - Override IP address. Requires API-key auth with analytics.write scope. + * @param {string} params.userAgent - Override user agent. Requires API-key auth with analytics.write scope. + * @throws {AppwriteException} + * @returns {Promise<{}>} + */ + createEvent(params: { + propertyId: string; + name: string; + url: string; + domain?: string; + referrer?: string; + screenWidth?: number; + sessionHash?: string; + scrollDepth?: number; + engagementTime?: number; + props?: string[]; + userId?: string; + ip?: string; + userAgent?: string; + }): Promise<{}>; + /** + * Send a tracking event from a browser, native app, or server-side SDK. + * + * @param {string} propertyId - Analytics property ID or snippet ID identifying the property. + * @param {string} name - Event name. "pageview" is just a conventional event name; events are not modeled specially. + * @param {string} url - Full page URL or screen identifier. + * @param {string} domain - Hostname (e.g. example.com). + * @param {string} referrer - Referrer URL. + * @param {number} screenWidth - Viewport width in CSS pixels. + * @param {string} sessionHash - Optional session hash (provided by SDK). + * @param {number} scrollDepth - Scroll depth percentage 0-100. + * @param {number} engagementTime - Engagement time in seconds, 0-4294967295. + * @param {string[]} props - Custom string properties as a flat key=value list (max 32 entries, alternating key,value). + * @param {string} userId - Override user ID. Requires API-key auth with analytics.write scope. + * @param {string} ip - Override IP address. Requires API-key auth with analytics.write scope. + * @param {string} userAgent - Override user agent. Requires API-key auth with analytics.write scope. + * @throws {AppwriteException} + * @returns {Promise<{}>} + * @deprecated Use the object parameter style method for a better developer experience. + */ + createEvent( + propertyId: string, + name: string, + url: string, + domain?: string, + referrer?: string, + screenWidth?: number, + sessionHash?: string, + scrollDepth?: number, + engagementTime?: number, + props?: string[], + userId?: string, + ip?: string, + userAgent?: string, + ): Promise<{}>; + createEvent( + paramsOrFirst: + | { + propertyId: string; + name: string; + url: string; + domain?: string; + referrer?: string; + screenWidth?: number; + sessionHash?: string; + scrollDepth?: number; + engagementTime?: number; + props?: string[]; + userId?: string; + ip?: string; + userAgent?: string; + } + | string, + ...rest: [ + string?, + string?, + string?, + string?, + number?, + string?, + number?, + number?, + string[]?, + string?, + string?, + string?, + ] + ): Promise<{}> { + let params: { + propertyId: string; + name: string; + url: string; + domain?: string; + referrer?: string; + screenWidth?: number; + sessionHash?: string; + scrollDepth?: number; + engagementTime?: number; + props?: string[]; + userId?: string; + ip?: string; + userAgent?: string; + }; + + if ( + paramsOrFirst && + typeof paramsOrFirst === 'object' && + !Array.isArray(paramsOrFirst) + ) { + params = (paramsOrFirst || {}) as { + propertyId: string; + name: string; + url: string; + domain?: string; + referrer?: string; + screenWidth?: number; + sessionHash?: string; + scrollDepth?: number; + engagementTime?: number; + props?: string[]; + userId?: string; + ip?: string; + userAgent?: string; + }; + } else { + params = { + propertyId: paramsOrFirst as string, + name: rest[0] as string, + url: rest[1] as string, + domain: rest[2] as string, + referrer: rest[3] as string, + screenWidth: rest[4] as number, + sessionHash: rest[5] as string, + scrollDepth: rest[6] as number, + engagementTime: rest[7] as number, + props: rest[8] as string[], + userId: rest[9] as string, + ip: rest[10] as string, + userAgent: rest[11] as string, + }; + } + + const propertyId = params.propertyId; + const name = params.name; + const url = params.url; + const domain = params.domain; + const referrer = params.referrer; + const screenWidth = params.screenWidth; + const sessionHash = params.sessionHash; + const scrollDepth = params.scrollDepth; + const engagementTime = params.engagementTime; + const props = params.props; + const userId = params.userId; + const ip = params.ip; + const userAgent = params.userAgent; + + if (typeof propertyId === 'undefined' || propertyId === '') { + throw new AppwriteException( + 'Missing required parameter: "propertyId"', + ); + } + if (typeof name === 'undefined') { + throw new AppwriteException('Missing required parameter: "name"'); + } + if (typeof url === 'undefined') { + throw new AppwriteException('Missing required parameter: "url"'); + } + const apiPath = '/analytics/properties/{propertyId}/events'.replace( + '{propertyId}', + encodeURIComponent(String(propertyId)), + ); + const apiPayload: Payload = {}; + if (typeof name !== 'undefined') { + apiPayload['name'] = name; + } + if (typeof url !== 'undefined') { + apiPayload['url'] = url; + } + if (typeof domain !== 'undefined') { + apiPayload['domain'] = domain; + } + if (typeof referrer !== 'undefined') { + apiPayload['referrer'] = referrer; + } + if (typeof screenWidth !== 'undefined') { + apiPayload['screenWidth'] = screenWidth; + } + if (typeof sessionHash !== 'undefined') { + apiPayload['sessionHash'] = sessionHash; + } + if (typeof scrollDepth !== 'undefined') { + apiPayload['scrollDepth'] = scrollDepth; + } + if (typeof engagementTime !== 'undefined') { + apiPayload['engagementTime'] = engagementTime; + } + if (typeof props !== 'undefined') { + apiPayload['props'] = props; + } + if (typeof userId !== 'undefined') { + apiPayload['userId'] = userId; + } + if (typeof ip !== 'undefined') { + apiPayload['ip'] = ip; + } + if (typeof userAgent !== 'undefined') { + apiPayload['userAgent'] = userAgent; + } + const uri = new URL(this.client.config.endpoint + apiPath); + + const apiHeaders: { [header: string]: string } = { + 'X-Appwrite-Project': this.client.config.project, + 'content-type': 'application/json', + accept: 'application/json', + }; + + return this.client.call('post', uri, apiHeaders, apiPayload); + } +}