Skip to content

Commit

Permalink
Merge pull request #106 from PierreBeucher/more-analytics
Browse files Browse the repository at this point in the history
More analytics
  • Loading branch information
PierreBeucher authored Jan 7, 2025
2 parents 0fc5e28 + 792be85 commit ed807c6
Show file tree
Hide file tree
Showing 14 changed files with 303 additions and 123 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
[![Discord](https://img.shields.io/discord/856434175455133727?style=for-the-badge&logo=discord&logoColor=ffffff&label=Chat%20with%20us&labelColor=6A7EC2&color=7389D8)](https://discord.gg/dCxDVfVnSD)
[![GitHub License](https://img.shields.io/github/license/PierreBeucher/cloudypad?style=for-the-badge&color=00d4c4)](./LICENSE.txt)

Cloudy Pad lets you deploy a Cloud gaming server anywhere in the world and play your own games - without requiring a powerful gaming machine or a costly subscription:
Cloudy Pad is a Free, Open Source alternative to GeForce Now, Blacknut and similar Cloud Gaming platforms. It lets you deploy a Cloud gaming server anywhere in the world and play your own games - without requiring a powerful gaming machine or a costly subscription:

- Stream with **[Moonlight](https://moonlight-stream.org/)** client
- Run your games through **[Steam](https://store.steampowered.com/)**, **[Pegasus](https://pegasus-frontend.org/)** or **[Lutris](https://lutris.net/)**
Expand Down
18 changes: 17 additions & 1 deletion docs/src/usage/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,23 @@ Cloudy Pad may have a Premium or Pro offer in the future, but for a personal sim

### How are my data collected? How does analytics works?

Cloudy Pad may, upon your initial agreement on install, collect anonymous usage data. This data is only used internally and won't be shared with third party or used for targeted ads. Allowing anonymous data collection helps Cloudy Pad get better !
Cloudy Pad may, with your consent, collect some personal information. Here's the full list of information collected if you consent:
- OS name and details (distribution and version)

This data is only used internally and won't be shared with third party or used for targeted ads. Your data are only used for analytics purpose to understand usage, track feature usage and help resolve issues.

Cloudy Pad will, by default, collect technical data such as when a command is run or certain technical event occurs, _without collecting any personal information._ Collected data:
- Cloudy Pad version
- Techical events (action performed such as instance start/stop without instance details, error without personal info, etc.)

To completely opt out of any data collection (even technical non-personal data) or change data collection method, open `$HOME/.cloudypad/config.yml` and set `analytics.enabled: false`, eg:

```sh
analytics:
posthog:
collectionMethod: none # <<===== EDIT HERE, valid value: "none", "technical", "all"
distinctId: xxx
```

Cloudy Pad uses [Post Hog](https://posthog.com) and will keep data for 1 or 3 months.

Expand Down
22 changes: 17 additions & 5 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#/usr/env/bin bash
#!/usr/bin/env bash

set -e

Expand All @@ -11,23 +11,38 @@ CLOUDYPAD_SCRIPT_REF=${CLOUDYPAD_SCRIPT_REF:-$DEFAULT_CLOUDYPAD_SCRIPT_REF}
INSTALL_POSTHOG_DISTINCT_ID="cli-install-$(date +%Y-%m-%d-%H-%M-%S)-$RANDOM"
INSTALL_POSTHOG_API_KEY="phc_caJIOD8vW5727svQf90FNgdALIyYYouwEDEVh3BI1IH"

# Sends anonymous analytics event during installation
# Sends anonymous technical analytics event during installation
# No personal data is sent
send_analytics_event() {
event=$1
eventDetails=$2

if [ "$CLOUDYPAD_ANALYTICS_DISABLE" != "true" ]; then
curl -s -o /dev/null -L --header "Content-Type: application/json" -d "{
\"api_key\": \"$INSTALL_POSTHOG_API_KEY\",
\"event\": \"$event\",
\"distinct_id\": \"$INSTALL_POSTHOG_DISTINCT_ID\",
\"properties\": {
\"\$process_person_profile\": false,
\"event_details\": $eventDetails,
\"os_name\": \"$(uname -s)\",
\"os_arch\": \"$(uname -m)\"
}
}" https://eu.i.posthog.com/capture/
fi
}

# Identify shell to adapt behavior accordingly
SHELL_NAME=$(basename "${SHELL}")

if [[ $SHELL_NAME == "bash" ]]; then
trap 'send_analytics_event "cli_install_error" "LINENO: $LINENO, FUNCNAME: $FUNCNAME, BASH_SOURCE: $BASH_SOURCE, BASH_VERSION: $BASH_VERSION"' ERR
else
echo "WARNING: install script is not running in a bash shell. This may lead to unexpected behavior."
echo " Maybe bash is not available on your system?"
echo " If you think this is a bug, please create an issue: https://github.com/PierreBeucher/cloudypad/issues"
fi

send_analytics_event "cli_install_start"

echo "Installing Cloudy Pad version $CLOUDYPAD_SCRIPT_REF"
Expand Down Expand Up @@ -92,10 +107,7 @@ echo "Downloading Cloudy Pad container images..."

$SCRIPT_PATH download-container-images

# Identify shell to update *.rc file with PATH update
SHELL_NAME=$(basename "${SHELL}")
STARTUP_FILE=""

case "${SHELL_NAME}" in
"bash")
# Terminal.app on macOS prefers .bash_profile to .bashrc, so we prefer that
Expand Down
49 changes: 27 additions & 22 deletions src/core/config/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,22 @@ import { getLogger } from '../../log/utils'
import { v4 as uuidv4 } from 'uuid'
import * as lodash from 'lodash'

export enum AnalyticsCollectionMethod {
All = "all",
Technical = "technical",
None = "none"
}

const PostHogConfigSchema = z.object({
distinctId: z.string()
distinctId: z.string(),
collectionMethod: z.enum([ AnalyticsCollectionMethod.All, AnalyticsCollectionMethod.Technical, AnalyticsCollectionMethod.None ]).optional()
.describe("The method used to collect analytics data. All: collect everything, including personal data (require user consent). Technical: only collect technical non-personal data. None: do not collect anything."),
})

const AnalyticsConfigSchema = z.object({
enabled: z.boolean().describe("Whether analytics is enabled."),
promptedApproval: z.boolean().describe("Whether user has been prompted for analytics approval yet."),
promptedPersonalDataCollectionApproval: z.boolean().default(false).describe("Whether user has been prompted for personal data collection approval yet."),
posthog: PostHogConfigSchema
}).refine(
(data) => !data.enabled || (data.enabled && data.posthog !== undefined),
{
message: 'PostHog configuration is required when analytics is enabled',
path: ['posthog'] // Optional: Point to the specific field in error
}
)
})

export const GlobalCliConfigSchemaV1 = z.object({
version: z.literal("1"),
Expand All @@ -35,9 +36,11 @@ export type AnalyticsConfig = z.infer<typeof AnalyticsConfigSchema>
export const BASE_DEFAULT_CONFIG: GlobalCliConfigV1 = {
version: "1",
analytics: {
enabled: true,
promptedApproval: false,
posthog: { distinctId: "dummy" }
promptedPersonalDataCollectionApproval: false,
posthog: {
distinctId: "dummy",
collectionMethod: AnalyticsCollectionMethod.Technical
},
}
}

Expand Down Expand Up @@ -82,7 +85,8 @@ export class ConfigManager {
{
analytics: {
posthog: {
distinctId: uuidv4()
distinctId: uuidv4(),
collectionMethod: "technical"
}
}
}
Expand All @@ -99,30 +103,31 @@ export class ConfigManager {
load(): GlobalCliConfigV1 {
const rawConfig = this.readConfigRaw()
const config = this.zodParseSafe(rawConfig, GlobalCliConfigSchemaV1)
this.writeConfigSafe(config) // Rewrite config with correct schema version as current schema may have changed since last load
return config
}

updateAnalyticsPromptedApproval(prompted: boolean): void {
setAnalyticsPromptedPersonalDataCollectionApproval(prompted: boolean): void {
this.logger.debug(`Updating promptedApproval: ${prompted}`)

const updatedConfig = this.load()
updatedConfig.analytics.promptedApproval = prompted
updatedConfig.analytics.promptedPersonalDataCollectionApproval = prompted
this.writeConfigSafe(updatedConfig)
}

setAnalyticsPosthHog(posthog: PostHogConfig): void {
this.logger.debug(`Setting PostHog analytics: ${JSON.stringify(posthog)}`)
setAnalyticsCollectionMethod(collectionMethod: AnalyticsCollectionMethod): void {
this.logger.debug(`Setting analytics collection method: ${collectionMethod}`)

const updatedConfig = this.load()
updatedConfig.analytics.posthog = posthog
updatedConfig.analytics.posthog.collectionMethod = collectionMethod
this.writeConfigSafe(updatedConfig)
}

setAnalyticsEnabled(enable: boolean): void {
this.logger.debug(`Setting analytics enabled: ${enable}`)
setAnalyticsPosthHog(posthog: PostHogConfig): void {
this.logger.debug(`Setting PostHog analytics: ${JSON.stringify(posthog)}`)

const updatedConfig = this.load()
updatedConfig.analytics.enabled = enable
updatedConfig.analytics.posthog = lodash.merge({}, updatedConfig.analytics.posthog, posthog)
this.writeConfigSafe(updatedConfig)
}

Expand Down
80 changes: 65 additions & 15 deletions src/core/initializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { InputPrompter } from "./cli/prompter"
import { InstanceManagerBuilder } from "./manager-builder"
import { StateInitializer } from "./state/initializer"
import { confirm } from '@inquirer/prompts'
import { AnalyticsManager } from "../tools/analytics/manager"
import { CommonInstanceInput } from "./state/state"

export interface InstancerInitializerArgs {
provider: CLOUDYPAD_PROVIDER
Expand All @@ -27,6 +29,7 @@ export class InteractiveInstanceInitializer {
private readonly provider: CLOUDYPAD_PROVIDER
private readonly inputPrompter: InputPrompter
private readonly logger = getLogger(InteractiveInstanceInitializer.name)
protected readonly analytics = AnalyticsManager.get()

constructor(args: InstancerInitializerArgs){
this.provider = args.provider
Expand All @@ -40,47 +43,88 @@ export class InteractiveInstanceInitializer {
* - Run instance initialization
*/
async initializeInstance(cliArgs: CreateCliArgs, options?: InstancerInitializationOptions): Promise<void> {
try {
this.analyticsEvent("create_instance_start")


this.logger.debug(`Initializing instance from CLI args ${JSON.stringify(cliArgs)} and options ${JSON.stringify(options)}`)

const input = await this.cliArgsToInput(cliArgs)
const state = await this.doInitializeState(input)
const manager = await new InstanceManagerBuilder().buildInstanceManager(state.name)

await this.doProvisioning(manager, state.name, cliArgs.yes)
await this.doConfiguration(manager, state.name)
await this.doPairing(manager, state.name, cliArgs.skipPairing ?? false, cliArgs.yes ?? false)

this.showPostInitInfo(options)
this.analyticsEvent("create_instance_finish")
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error)
this.analyticsEvent("create_instance_error", { errorMessage: errMsg })
throw new Error(`Error initializing instance`, { cause: error })
}
}

this.logger.debug(`Initializing instance from CLI args ${JSON.stringify(cliArgs)} and options ${JSON.stringify(options)}`)

private async cliArgsToInput(cliArgs: CreateCliArgs): Promise<CommonInstanceInput> {
this.analyticsEvent("create_instance_start_input_prompt")
const input = await this.inputPrompter.completeCliInput(cliArgs)
this.analyticsEvent("create_instance_finish_input_prompt")
return input
}

private async doInitializeState(input: CommonInstanceInput) {
this.analyticsEvent("create_instance_start_state_init")
const state = await new StateInitializer({
input: input,
provider: this.provider,
}).initializeState()

const manager = await new InstanceManagerBuilder().buildInstanceManager(state.name)
const instanceName = state.name
const autoApprove = cliArgs.yes
this.analyticsEvent("create_instance_finish_state_init")
return state
}

private async doProvisioning(manager: any, instanceName: string, autoApprove?: boolean) {
this.logger.info(`Initializing ${instanceName}: provisioning...`)

this.analyticsEvent("create_instance_start_provision")

await manager.provision({ autoApprove: autoApprove})


this.analyticsEvent("create_instance_finish_provision")
this.logger.info(`Initializing ${instanceName}: provision done.}`)
}

private async doConfiguration(manager: any, instanceName: string) {
this.analyticsEvent("create_instance_start_configure")
this.logger.info(`Initializing ${instanceName}: configuring...}`)

await manager.configure()


this.analyticsEvent("create_instance_finish_configure")
this.logger.info(`Initializing ${instanceName}: configuration done.}`)
}

private async doPairing(manager: any, instanceName: string, skipPairing: boolean, autoApprove: boolean) {

const doPair = cliArgs?.skipPairing ? false : autoApprove ? true : await confirm({
const doPair = skipPairing ? false : autoApprove ? true : await confirm({
message: `Your instance is almost ready ! Do you want to pair Moonlight now?`,
default: true,
})

if (doPair) {
this.analyticsEvent("create_instance_start_pairing")
this.logger.info(`Initializing ${instanceName}: pairing...}`)

await manager.pair()


this.analyticsEvent("create_instance_finish_pairing")
this.logger.info(`Initializing ${instanceName}: pairing done.}`)
} else {
this.analyticsEvent("create_instance_skipped_pairing")
this.logger.info(`Initializing ${instanceName}: pairing skipped.}`)
}

}

private showPostInitInfo(options?: InstancerInitializationOptions) {
if(!options?.skipPostInitInfo){
console.info("")
console.info("Instance has been initialized successfully 🥳")
Expand All @@ -89,6 +133,12 @@ export class InteractiveInstanceInitializer {
console.info("")
console.info("🐛 A bug ? Some feedback ? Do not hesitate to file an issue: https://github.com/PierreBeucher/cloudypad/issues")
}

}
}

private analyticsEvent(event: string, additionalProperties?: Record<string, any>) {
this.analytics.sendEvent(event, {
provider: this.provider,
...additionalProperties
})
}
}
Loading

0 comments on commit ed807c6

Please sign in to comment.