diff --git a/.env.example b/.env.example index b41817769..fab8d26ec 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,7 @@ STORAGE_BUCKET=postwoman-api.appspot.com MESSAGING_SENDER_ID=421993993223 APP_ID=1:421993993223:web:ec0baa8ee8c02ffa1fc6a2 MEASUREMENT_ID=G-ERJ6025CEB +FB_MEASUREMENT_ID=G-BBJ3R80PJT # Base URL BASE_URL=https://hoppscotch.io diff --git a/components/realtime/Mqtt.vue b/components/realtime/Mqtt.vue index ecce3de2f..afec01f2b 100644 --- a/components/realtime/Mqtt.vue +++ b/components/realtime/Mqtt.vue @@ -131,6 +131,7 @@ import { Splitpanes, Pane } from "splitpanes" import Paho from "paho-mqtt" import debounce from "~/helpers/utils/debounce" +import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics" export default { components: { Splitpanes, Pane }, @@ -202,6 +203,10 @@ export default { }) this.client.onConnectionLost = this.onConnectionLost this.client.onMessageArrived = this.onMessageArrived + + logHoppRequestRunToAnalytics({ + platform: "mqtt", + }) }, onConnectionFailure() { this.connectionState = false diff --git a/components/realtime/Socketio.vue b/components/realtime/Socketio.vue index d1cfd05ef..ca0d9314d 100644 --- a/components/realtime/Socketio.vue +++ b/components/realtime/Socketio.vue @@ -153,6 +153,7 @@ import { Splitpanes, Pane } from "splitpanes" import { io as Client } from "socket.io-client" import wildcard from "socketio-wildcard" import debounce from "~/helpers/utils/debounce" +import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics" export default { components: { Splitpanes, Pane }, @@ -275,6 +276,10 @@ export default { icon: "error", }) } + + logHoppRequestRunToAnalytics({ + platform: "socketio", + }) }, disconnect() { this.io.close() diff --git a/components/realtime/Sse.vue b/components/realtime/Sse.vue index 941ef7a80..ed6f39a3d 100644 --- a/components/realtime/Sse.vue +++ b/components/realtime/Sse.vue @@ -53,6 +53,7 @@ diff --git a/components/smart/ColorModePicker.vue b/components/smart/ColorModePicker.vue index 9b2fdcacb..3bf62ebb9 100644 --- a/components/smart/ColorModePicker.vue +++ b/components/smart/ColorModePicker.vue @@ -15,15 +15,31 @@ - diff --git a/helpers/fb/analytics.ts b/helpers/fb/analytics.ts new file mode 100644 index 000000000..27d0aa4f4 --- /dev/null +++ b/helpers/fb/analytics.ts @@ -0,0 +1,93 @@ +import firebase from "firebase" +import { authEvents$ } from "./auth" +import { + HoppAccentColor, + HoppBgColor, + settings$, + settingsStore, +} from "~/newstore/settings" + +let analytics: firebase.analytics.Analytics + +type SettingsCustomDimensions = { + usesProxy: boolean + usesExtension: boolean + usesScrollInto: boolean + syncCollections: boolean + syncEnvironments: boolean + syncHistory: boolean + usesBg: HoppBgColor + usesAccent: HoppAccentColor + usesTelemetry: boolean +} + +type HoppRequestEvent = + | { + platform: "rest" | "graphql-query" | "graphql-schema" + strategy: "normal" | "proxy" | "extension" + } + | { platform: "wss" | "sse" | "socketio" | "mqtt" } + +export function initAnalytics() { + analytics = firebase.app().analytics() + + initLoginListeners() + initSettingsListeners() +} + +function initLoginListeners() { + authEvents$.subscribe((ev) => { + if (ev.event === "login") { + if (settingsStore.value.TELEMETRY_ENABLED) { + analytics.setUserId(ev.user.uid) + + analytics.logEvent("login", { + method: ev.user.providerData[0]?.providerId, // Assume the first provider is the login provider + }) + } + } else if (ev.event === "logout") { + if (settingsStore.value.TELEMETRY_ENABLED) { + analytics.logEvent("logout") + } + } + }) +} + +function initSettingsListeners() { + // Keep track of the telemetry status + let telemetryStatus = settingsStore.value.TELEMETRY_ENABLED + + settings$.subscribe((settings) => { + const conf: SettingsCustomDimensions = { + usesProxy: settings.PROXY_ENABLED, + usesExtension: settings.EXTENSIONS_ENABLED, + usesScrollInto: settings.SCROLL_INTO_ENABLED, + syncCollections: settings.syncCollections, + syncEnvironments: settings.syncEnvironments, + syncHistory: settings.syncHistory, + usesAccent: settings.THEME_COLOR, + usesBg: settings.BG_COLOR, + usesTelemetry: settings.TELEMETRY_ENABLED, + } + + // User toggled telemetry mode to off or to on + if ( + (telemetryStatus && !settings.TELEMETRY_ENABLED) || + settings.TELEMETRY_ENABLED + ) { + analytics.setUserProperties(conf) + } + + telemetryStatus = settings.TELEMETRY_ENABLED + + analytics.setAnalyticsCollectionEnabled(telemetryStatus) + }) + + analytics.setAnalyticsCollectionEnabled(telemetryStatus) +} + +export function logHoppRequestRunToAnalytics(ev: HoppRequestEvent) { + if (settingsStore.value.TELEMETRY_ENABLED) { + analytics.logEvent("hopp-request", ev) + } +} diff --git a/helpers/fb/auth.ts b/helpers/fb/auth.ts index 5fa323a45..08a7c0874 100644 --- a/helpers/fb/auth.ts +++ b/helpers/fb/auth.ts @@ -1,11 +1,16 @@ import firebase from "firebase" -import { BehaviorSubject } from "rxjs" +import { BehaviorSubject, Subject } from "rxjs" export type HoppUser = firebase.User & { provider?: string accessToken?: string } +type AuthEvents = + | { event: "login"; user: HoppUser } + | { event: "logout" } + | { event: "authTokenUpdate"; user: HoppUser; newToken: string | null } + /** * A BehaviorSubject emitting the currently logged in user (or null if not logged in) */ @@ -15,6 +20,11 @@ export const currentUser$ = new BehaviorSubject(null) */ export const authIdToken$ = new BehaviorSubject(null) +/** + * A subject that emits events related to authentication flows + */ +export const authEvents$ = new Subject() + /** * Initializes the firebase authentication related subjects */ @@ -22,6 +32,9 @@ export function initAuth() { let extraSnapshotStop: (() => void) | null = null firebase.auth().onAuthStateChanged((user) => { + /** Whether the user was logged in before */ + const wasLoggedIn = currentUser$.value !== null + if (!user && extraSnapshotStop) { extraSnapshotStop() extraSnapshotStop = null @@ -61,14 +74,35 @@ export function initAuth() { userUpdate.provider = data.provider userUpdate.accessToken = data.accessToken } + + currentUser$.next(userUpdate) }) } currentUser$.next(user) + + // User wasn't found before, but now is there (login happened) + if (!wasLoggedIn && user) { + authEvents$.next({ + event: "login", + user: currentUser$.value!!, + }) + } else if (wasLoggedIn && !user) { + // User was found before, but now is not there (logout happened) + authEvents$.next({ + event: "logout", + }) + } }) firebase.auth().onIdTokenChanged(async (user) => { if (user) { authIdToken$.next(await user.getIdToken()) + + authEvents$.next({ + event: "authTokenUpdate", + newToken: authIdToken$.value, + user: currentUser$.value!!, // Force not-null because user is defined + }) } else { authIdToken$.next(null) } diff --git a/helpers/fb/index.ts b/helpers/fb/index.ts index 25566d530..7b90baa17 100644 --- a/helpers/fb/index.ts +++ b/helpers/fb/index.ts @@ -1,4 +1,5 @@ import firebase from "firebase" +import { initAnalytics } from "./analytics" import { initAuth } from "./auth" import { initCollections } from "./collections" import { initEnvironments } from "./environments" @@ -13,21 +14,27 @@ const firebaseConfig = { storageBucket: process.env.STORAGE_BUCKET, messagingSenderId: process.env.MESSAGING_SENDER_ID, appId: process.env.APP_ID, - measurementId: process.env.MEASUREMENT_ID, + measurementId: process.env.FB_MEASUREMENT_ID, } let initialized = false export function initializeFirebase() { if (!initialized) { - firebase.initializeApp(firebaseConfig) + try { + firebase.initializeApp(firebaseConfig) - initAuth() - initSettings() - initCollections() - initHistory() - initEnvironments() + initAuth() + initSettings() + initCollections() + initHistory() + initEnvironments() + initAnalytics() - initialized = true + initialized = true + } catch (e) { + // initializeApp throws exception if we reinitialize + initialized = true + } } } diff --git a/helpers/network.js b/helpers/network.js index 4ba209b03..ca291440e 100644 --- a/helpers/network.js +++ b/helpers/network.js @@ -25,5 +25,21 @@ const runAppropriateStrategy = (req) => { return AxiosStrategy(req) } +/** + * Returns an identifier for how a request will be ran + * if the system is asked to fire a request + * + * @returns {"normal" | "extension" | "proxy"} + */ +export function getCurrentStrategyID() { + if (isExtensionsAllowed() && hasExtensionInstalled()) { + return "extension" + } else if (settingsStore.value.PROXY_ENABLED) { + return "proxy" + } else { + return "normal" + } +} + export const sendNetworkRequest = (req) => runAppropriateStrategy(req).finally(() => window.$nuxt.$loading.finish()) diff --git a/lang/en-US.json b/lang/en-US.json index 925aa778c..5f03d26a9 100644 --- a/lang/en-US.json +++ b/lang/en-US.json @@ -286,6 +286,8 @@ "are_you_sure_remove_folder": "Are you sure you want to remove this folder?", "are_you_sure_remove_request": "Are you sure you want to remove this request?", "are_you_sure_remove_environment": "Are you sure you want to remove this environment?", + "are_you_sure_remove_telemetry": "Are you sure you want to opt-out of Telemetry?", + "telemetry_helps_us": "Telemetry helps us to personalize our operations and deliver the best experience to you.", "select_next_method": "Select Next method", "select_previous_method": "Select Previous method", "select_get_method": "Select GET method", @@ -345,5 +347,6 @@ "share": "Share", "interceptor": "Interceptor", "profile": "Profile", - "are_you_sure_logout": "Are you sure you want to logout?" + "are_you_sure_logout": "Are you sure you want to logout?", + "telemetry": "Telemetry" } diff --git a/layouts/default.vue b/layouts/default.vue index 70f9d3503..0172464c3 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -66,15 +66,12 @@