Alexander Belanger 4 лет назад
Родитель
Сommit
66e0210375
31 измененных файлов с 1177 добавлено и 298 удалено
  1. 1 0
      .gitignore
  2. 1 27
      CONTRIBUTING.md
  3. 11 11
      Makefile
  4. 14 14
      cmd/app/main.go
  5. 100 3
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  6. 1 0
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  7. 71 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  8. 11 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  9. 1 1
      dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx
  10. 10 0
      dashboard/src/shared/api.tsx
  11. 48 61
      docs/developing/setup.md
  12. 4 4
      internal/analytics/identifiers.go
  13. 26 3
      internal/analytics/track_events.go
  14. 460 59
      internal/analytics/tracks.go
  15. 68 0
      internal/kubernetes/provisioner/global_stream.go
  16. 3 0
      internal/models/infra.go
  17. 56 0
      scripts/dev-environment/CreateDefaultEnvFiles.sh
  18. 38 0
      scripts/dev-environment/SetupEnvironment.sh
  19. 33 0
      scripts/dev-environment/StartDevServer.sh
  20. 8 6
      server/api/api.go
  21. 22 34
      server/api/cluster_handler.go
  22. 51 2
      server/api/deploy_handler.go
  23. 9 0
      server/api/integration_handler.go
  24. 2 0
      server/api/notifications_handler.go
  25. 11 2
      server/api/oauth_github_handler.go
  26. 4 2
      server/api/oauth_google_handler.go
  27. 5 0
      server/api/project_handler.go
  28. 62 53
      server/api/provision_handler.go
  29. 17 1
      server/api/registry_handler.go
  30. 24 12
      server/api/release_handler.go
  31. 5 2
      server/api/user_handler.go

+ 1 - 0
.gitignore

@@ -13,6 +13,7 @@ gon*.hcl
 staging.sh
 *.crt
 *.key
+bin
 
 # Local .terraform directories
 **/.terraform/*

+ 1 - 27
CONTRIBUTING.md

@@ -70,33 +70,7 @@ Here's an annotated directory structure to assist you in navigating the codebase
 
 ### Getting started
 
-If you've made it this far, you have all the information required to get your dev environment up and running! After forking and cloning the repo, you should save two `.env` files in the repo. 
-
-First, in `/dashboard/.env`:
-
-```
-NODE_ENV=development
-API_SERVER=localhost:8080
-```
-
-Next, in `/docker/.env`:
-
-```
-SERVER_URL=http://localhost:8080
-SERVER_PORT=8080
-DB_HOST=postgres
-DB_PORT=5432
-DB_USER=porter
-DB_PASS=porter
-DB_NAME=porter
-SQL_LITE=false
-```
-
-Once you've done this, go to the root repository, and run `docker-compose -f docker-compose.dev.yaml up`. You should see postgres, webpack, and porter containers spin up. When the webpack and porter containers have finished compiling and have spun up successfully (this will take 5-10 minutes after the containers start), you can navigate to `localhost:8080` and you should be greeted with the "Log In" screen. 
-
-At this point, you can make a change to any `.go` file to trigger a backend rebuild, and any file in `/dashboard/src` to trigger a hot reload. 
-
-For a more detailed development guide, [go here](/docs/developing/setup.md). 
+If you've made it this far, you have all the information required to get your dev environment up and running! After forking and cloning the repo, you should [follow this guide](/docs/developing/setup.md) for the development setup. 
 
 Happy developing!
 

+ 11 - 11
Makefile

@@ -1,14 +1,14 @@
-#!make
-include ./docker/.env
-export $(shell sed 's/=.*//' ./docker/.env)
+BINDIR      := $(CURDIR)/bin
+VERSION ?= dev
 
-setup:
-	go mod download;
-	cd dashboard && npm install;
-	cd ../;
+start-dev: install setup-env-files
+	bash ./scripts/dev-environment/StartDevServer.sh
 
-run-server: 
-	air -c .air.toml
+install: 
+	bash ./scripts/dev-environment/SetupEnvironment.sh
 
-run-frontend:
-	cd ./dashboard && npm run start
+setup-env-files: 
+	bash ./scripts/dev-environment/CreateDefaultEnvFiles.sh
+
+build-cli: 
+	go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${VERSION}'" -a -tags cli -o $(BINDIR)/porter ./cli

+ 14 - 14
cmd/app/main.go

@@ -58,6 +58,19 @@ func main() {
 
 	repo := gorm.NewRepository(db, &key)
 
+	a, err := api.New(&api.AppConfig{
+		Logger:     logger,
+		Repository: repo,
+		ServerConf: appConf.Server,
+		RedisConf:  &appConf.Redis,
+		CapConf:    appConf.Capabilities,
+		DBConf:     appConf.Db,
+	})
+
+	if err != nil {
+		logger.Fatal().Err(err).Msg("")
+	}
+
 	if appConf.Redis.Enabled {
 		redis, err := adapter.NewRedisClient(&appConf.Redis)
 
@@ -70,20 +83,7 @@ func main() {
 
 		errorChan := make(chan error)
 
-		go prov.GlobalStreamListener(redis, *repo, errorChan)
-	}
-
-	a, err := api.New(&api.AppConfig{
-		Logger:     logger,
-		Repository: repo,
-		ServerConf: appConf.Server,
-		RedisConf:  &appConf.Redis,
-		CapConf:    appConf.Capabilities,
-		DBConf:     appConf.Db,
-	})
-
-	if err != nil {
-		logger.Fatal().Err(err).Msg("")
+		go prov.GlobalStreamListener(redis, *repo, a.AnalyticsClient, errorChan)
 	}
 
 	appRouter := router.New(a)

+ 100 - 3
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -1,31 +1,47 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
+import { useHistory, useLocation, useRouteMatch } from "react-router";
 
 import { ChartType, StorageType } from "shared/types";
 import { Context } from "shared/Context";
 import StatusIndicator from "components/StatusIndicator";
 import { pushFiltered } from "shared/routing";
-import { useHistory, useLocation, useRouteMatch } from "react-router";
+import { useWebsockets } from "shared/hooks/useWebsockets";
 import api from "shared/api";
 
 type Props = {
   chart: ChartType;
   controllers: Record<string, any>;
+  isJob: boolean;
   release: any;
 };
 
+type JobStatusType = {
+  status: "succeeded" | "running" | "failed";
+  start_time: string;
+};
+
 const Chart: React.FunctionComponent<Props> = ({
   chart,
   controllers,
+  isJob,
   release,
 }) => {
   const [expand, setExpand] = useState<boolean>(false);
   const [chartControllers, setChartControllers] = useState<any>([]);
+  const [jobStatus, setJobStatus] = useState<JobStatusType>(null);
   const context = useContext(Context);
   const location = useLocation();
   const history = useHistory();
   const match = useRouteMatch();
 
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    closeWebsocket,
+  } = useWebsockets();
+
   const renderIcon = () => {
     if (chart.chart.metadata.icon && chart.chart.metadata.icon !== "") {
       return <Icon src={chart.chart.metadata.icon} />;
@@ -64,6 +80,59 @@ const Chart: React.FunctionComponent<Props> = ({
     getControllerForChart(chart);
   }, [chart]);
 
+  const setupWebsocket = (kind: string) => {
+    const { currentProject, currentCluster } = context;
+
+    const apiEndpoint = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+
+    const wsConfig = {
+      onmessage(evt: MessageEvent) {
+        const event = JSON.parse(evt.data);
+        let object = event.Object;
+        object.metadata.kind = event.Kind;
+        if (event.event_type != "UPDATE") {
+          return;
+        }
+        getJobStatus();
+      },
+      onerror() {
+        closeWebsocket(kind);
+      },
+    };
+
+    newWebsocket(kind, apiEndpoint, wsConfig);
+    openWebsocket(kind);
+  };
+
+  const getJobStatus = () => {
+    let { currentCluster, currentProject, setCurrentError } = context;
+
+    api
+      .getJobStatus(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+        },
+        {
+          id: currentProject.id,
+          name: chart.name,
+          namespace: chart.namespace,
+        }
+      )
+      .then((res) => {
+        setJobStatus(res.data);
+      })
+      .catch((err) => setCurrentError(err));
+  };
+
+  useEffect(() => {
+    if (isJob) {
+      getJobStatus();
+      setupWebsocket("job");
+    }
+    return () => closeAllWebsockets();
+  }, [isJob]);
+
   const readableDate = (s: string) => {
     const ts = new Date(s);
     const date = ts.toLocaleDateString();
@@ -123,7 +192,18 @@ const Chart: React.FunctionComponent<Props> = ({
         </TagWrapper>
       </BottomWrapper>
 
-      <Version>v{release?.version || chart.version}</Version>
+      <TopRightContainer>
+        {isJob && jobStatus?.status && (
+          <>
+            <JobStatus status={jobStatus.status}>
+              Last run {jobStatus.status.toUpperCase()} at{" "}
+              {readableDate(jobStatus.start_time)}
+            </JobStatus>
+            <StatusDot>•</StatusDot>
+          </>
+        )}
+        <span>v{release?.version || chart.version}</span>
+      </TopRightContainer>
     </StyledChart>
   );
 };
@@ -138,7 +218,7 @@ const BottomWrapper = styled.div`
   margin-top: 12px;
 `;
 
-const Version = styled.div`
+const TopRightContainer = styled.div`
   position: absolute;
   top: 12px;
   right: 12px;
@@ -150,6 +230,10 @@ const Dot = styled.div`
   margin-right: 9px;
 `;
 
+const StatusDot = styled.span`
+  margin: 0 9px;
+`;
+
 const InfoWrapper = styled.div`
   display: flex;
   align-items: center;
@@ -247,6 +331,19 @@ const Title = styled.div`
   }
 `;
 
+const JobStatus = styled.span`
+  font-weight: bold;
+  ${(props: { status: string }) => `
+  color: ${
+    props.status === "succeeded"
+      ? "rgb(56, 168, 138)"
+      : props.status === "failed"
+      ? "rgb(204, 61, 66)"
+      : "#aaaabb"
+  }
+`}
+`;
+
 const StyledChart = styled.div`
   background: #26282f;
   cursor: pointer;

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -241,6 +241,7 @@ const ChartList: React.FunctionComponent<Props> = ({
           key={`${chart.namespace}-${chart.name}`}
           chart={chart}
           controllers={controllers || {}}
+          isJob={currentView === "jobs"}
           release={releases[chart.name] || {}}
         />
       );

+ 71 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -16,6 +16,7 @@ import TempJobList from "./jobs/TempJobList";
 import SettingsSection from "./SettingsSection";
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import ValuesYaml from "./ValuesYaml";
 
 type PropsType = WithAuthProps & {
   namespace: string;
@@ -39,6 +40,7 @@ type StateType = {
   deleting: boolean;
   saveValuesStatus: string | null;
   formData: any;
+  devOpsMode: boolean;
 };
 
 class ExpandedJobChart extends Component<PropsType, StateType> {
@@ -56,6 +58,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     deleting: false,
     saveValuesStatus: null as string | null,
     formData: {} as any,
+    devOpsMode: localStorage.getItem("devOpsMode") === "true",
   };
 
   // Retrieve full chart data (includes form and values)
@@ -372,6 +375,12 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
       });
   };
 
+  toggleDevOpsMode = () => {
+    this.setState((prevState) => ({
+      devOpsMode: !prevState.devOpsMode,
+    }));
+  };
+
   getJobs = async (chart: ChartType) => {
     let { currentCluster, currentProject, setCurrentError } = this.context;
 
@@ -443,6 +452,14 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
             />
           </TabWrapper>
         );
+      case "values":
+        return (
+          <ValuesYaml
+            currentChart={this.state.currentChart}
+            refreshChart={() => this.refreshChart(0)}
+            disabled={!this.props.isAuthorized("job", "", ["get", "update"])}
+          />
+        );
       case "settings":
         return (
           this.props.isAuthorized("job", "", ["get", "delete"]) && (
@@ -478,6 +495,11 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
       });
     }
     let rightTabOptions = [] as any[];
+
+    if (this.state.devOpsMode) {
+      rightTabOptions.push({ label: "Helm Values", value: "values" });
+    }
+
     if (this.props.isAuthorized("job", "", ["get", "delete"])) {
       rightTabOptions.push({ label: "Settings", value: "settings" });
     }
@@ -512,6 +534,18 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     this.setupCronJobWebsocket(currentChart);
   }
 
+  componentDidUpdate(
+    prevProps: Readonly<PropsType>,
+    prevState: Readonly<StateType>
+  ) {
+    const { devOpsMode } = this.state;
+
+    if (devOpsMode !== prevState.devOpsMode) {
+      this.updateTabs();
+      localStorage.setItem("devOpsMode", devOpsMode.toString());
+    }
+  }
+
   handleUninstallChart = () => {
     let { currentProject, currentCluster, setCurrentOverlay } = this.context;
     let { currentChart } = this.state;
@@ -603,6 +637,14 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
                   saveValuesStatus={this.state.saveValuesStatus}
                   saveButtonText="Save Config"
                   includeHiddenFields
+                  addendum={
+                    <TabButton
+                      onClick={this.toggleDevOpsMode}
+                      devOpsMode={this.state.devOpsMode}
+                    >
+                      <i className="material-icons">offline_bolt</i> DevOps Mode
+                    </TabButton>
+                  }
                 />
               )}
             </BodyWrapper>
@@ -794,3 +836,32 @@ const StyledExpandedChart = styled.div`
     }
   }
 `;
+
+const TabButton = styled.div`
+  position: absolute;
+  right: 0px;
+  height: 30px;
+  background: linear-gradient(to right, #20222700, #202227 20%);
+  padding-left: 30px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  color: ${(props: { devOpsMode: boolean }) =>
+    props.devOpsMode ? "#aaaabb" : "#aaaabb55"};
+  margin-left: 35px;
+  border-radius: 20px;
+  text-shadow: 0px 0px 8px
+    ${(props: { devOpsMode: boolean }) =>
+      props.devOpsMode ? "#ffffff66" : "none"};
+  cursor: pointer;
+  :hover {
+    color: ${(props: { devOpsMode: boolean }) =>
+      props.devOpsMode ? "" : "#aaaabb99"};
+  }
+
+  > i {
+    font-size: 17px;
+    margin-right: 9px;
+  }
+`;

+ 11 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx

@@ -1,6 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import yaml from "js-yaml";
+import _ from "lodash";
 
 import { ChartType, StorageType } from "shared/types";
 import api from "shared/api";
@@ -49,13 +50,22 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
     let { currentCluster, setCurrentError, currentProject } = this.context;
     this.setState({ saveValuesStatus: "loading" });
 
+    let valuesString = this.state.values;
+
+    // if this is a job, set it to paused
+    if (this.props.currentChart?.chart?.metadata?.name == "job") {
+      const valuesYAML = yaml.load(this.state.values);
+      _.set(valuesYAML, "paused", true);
+      valuesString = yaml.dump(valuesYAML);
+    }
+
     api
       .upgradeChartValues(
         "<token>",
         {
           namespace: this.props.currentChart.namespace,
           storage: StorageType.Secret,
-          values: this.state.values,
+          values: valuesString,
         },
         {
           id: currentProject.id,

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx

@@ -10,7 +10,7 @@ import Helper from "../../../../components/form-components/Helper";
 import CheckboxRow from "../../../../components/form-components/CheckboxRow";
 import SaveButton from "../../../../components/SaveButton";
 
-type PropsType = RouteComponentProps & {
+type PropsType = {
   name: string;
   fullActionConfig: FullActionConfigType;
   shouldCreateWorkflow: boolean;

+ 10 - 0
dashboard/src/shared/api.tsx

@@ -562,6 +562,15 @@ const getJobs = baseApi<
   return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/${pathParams.chart}/${pathParams.release_name}/jobs`;
 });
 
+const getJobStatus = baseApi<
+  {
+    cluster_id: number;
+  },
+  { name: string; namespace: string; id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/${pathParams.name}/jobs/status`;
+});
+
 const getJobPods = baseApi<
   {
     cluster_id: number;
@@ -1132,6 +1141,7 @@ export default {
   getIngress,
   getInvites,
   getJobs,
+  getJobStatus,
   getJobPods,
   getMatchingPods,
   getMetrics,

+ 48 - 61
docs/developing/setup.md

@@ -1,5 +1,27 @@
+> **Note:** if you run into any issues at all, don't hesitate to reach out on the **#contributing** channel in [Discord](https://discord.gg/GJynMR3KXK)!
+
+### Table of Contents
+
+- [Getting Started](#getting-started)
+  - [Makefile Quickstart](#makefile-quickstart)
+  - [Docker Quickstart](#docker-quickstart)
+    - [Getting PostgreSQL Access](#getting-postgresql-access)
+- [Project and Cluster Setup](#project-and-cluster-setup)
+  - [Setting up a Cluster](#setting-up-a-cluster)
+  - [Minikube on MacOS](#minikube-on-macos)
+- [Setup for WSL](#setup-for-wsl)
+- [Secure Localhost Setup](#secure-localhost-setup)
+
 # Getting Started
 
+## Makefile Quickstart
+
+> Prequisites: [Go 1.15+](https://golang.org/doc/install) installed and [Node.js/npm](https://nodejs.org/en/download/) installed.
+
+If working under a bash environment, the easiest way to get started is by running `make start-dev`. This will verify that `go`, `npm` and `node` are found in your path, and will start a development server on `localhost:8081` with live reloading set up for both the backend and frontend. After the services are running successfully, go to [project and cluster setup](#project-and-cluster-setup) to complete the set up. 
+
+## Docker Quickstart
+
 After forking and cloning the repo, you should save two `.env` files in the repo.
 
 First, in `/dashboard/.env`:
@@ -22,79 +44,41 @@ DB_NAME=porter
 SQL_LITE=false
 ```
 
-Once you've done this, go to the root repository, and run `docker-compose -f docker-compose.dev.yaml up`. You should see postgres, webpack, and porter containers spin up. When the webpack and porter containers have finished compiling and have spun up successfully (this will take 5-10 minutes after the containers start), you can navigate to `localhost:8080` and you should be greeted with the "Log In" screen. Create a user by entering an email/password on the "Register" screen.
+Once you've done this, go to the root repository, and run `docker-compose -f docker-compose.dev.yaml up`. You should see postgres, webpack, and porter containers spin up. When the webpack and porter containers have finished compiling and have spun up successfully (this will take 5-10 minutes after the containers start), .
 
 At this point, you can make a change to any `.go` file to trigger a backend rebuild, and any file in `/dashboard/src` to trigger a hot reload.
 
-## Setup without docker
-
-While docker is an awesome way of getting started as it simulates the real environment that we use on our hosted dashboard, for some people this may bee too much.
-
-In order to decrease the complexity of all the environment, you can just run the development environment locally without docker.
-
-To do so you should follow the next steps:
+### Getting PostgreSQL Access
 
-1- First, inside the folder where you cloned the repo (e.g: /home/user/porter) run `make setup`. This should download all the modules for go to work and install all the dependencies that the frontend needs to work!
-
-2- After installing everything you should update the env variables:
-The `/dashboard/.env` should look like this:
-
-```
-NODE_ENV=development
-DEV_SERVER_PORT=8081 # Tell the webpack dev server in wich port we wanna run, it defaults to 8080 but we have to be carefull this is not the same port as the backend
-ENABLE_PROXY=true # Usually we would use nginx, but for this environment we're going to enable webpack-dev-server proxy
-API_SERVER=http://localhost:8080 # API server url, this url will be used for the proxy to redirect all /api calls
-```
-
-And the `/docker/.env` variables should look like this:
-
-```
-SERVER_URL=http://localhost:8080
-SERVER_PORT=8080
-SQL_LITE=true
-SQL_LITE_PATH=./porter.db
-REDIS_ENABLED=false
-```
-
-In this case SQLLite is the simplest solution for getting the environment running, but you can enable postgres or redis if you want!
-
-3- Open two terminals and run `make run-server` and in the second one `make run-frontend`. This should get everything up and running!
-
-### Disclaimer
-
-This environment is experimental, if you run into any issue don't doubt in contact us through our [discord!](https://discord.gg/GJynMR3KXK)
-
-## Getting PostgreSQL Access
-
-You can get `psql` access by running the following:
+The `docker-compose` command automatically starts a PostgreSQL instance on port 5400. You can get `psql` access by running the following:
 
 `psql --host localhost --port 5400 --username porter --dbname porter -W`
 
 This will prompt you for a password. Enter `porter`, and you should see the `psql` shell!
 
-### Setting your email to be verified
-
-If you are getting blocked out of the dashboard because your email is not verified (fixed in `v0.6.2` of Porter, so make sure you've pulled from `master` recently), you can update your email in the database to `verified":
+# Project and Cluster Setup
 
-`UPDATE users SET email_verified='t' WHERE id=1;`
+After the project has spun up, you can navigate to `localhost:8081` (for `make` quickstart) or `localhost:8080` (for `docker-compose` quickstart) and you should be greeted with the "Log In" screen. Create a user by entering an email/password on the "Register" screen. 
 
-## Setting up Minikube
+## Setting up a Cluster 
 
 These steps will help you get set up with a minikube cluster that can be used for development. Prerequisities:
 
 - `kubectl` installed locally
 - Development instance of Porter is running
+- Download the [Porter CLI](https://docs.porter.run/docs/cli-documentation#installation) or build it using `make build-cli`
 
-Following the OS-specific steps to get minikube running:
+At the moment, we only have instructions for setting up [Minikube on MacOS](#minikube-on-macos). However, Porter is compatible with most Kubernetes clusters, as long as the server is reachable from your host network. To connect a cluster that is currently accessible via `kubectl`, you can run the following steps:
 
-- [MacOS](#macos)
-- [Linux](#linux)
+1. `porter config set-host http://localhost:8080` (for `docker-compose` quickstart) or `porter config set-host http://localhost:8081` (for `make` quickstart). 
+2. `porter auth login`
+3. `porter connect kubeconfig` 
 
 If you now navigate to `http://localhost:8080`, you should see the minikube cluster attached! There will be some limitations:
 
-- **It is not possible to expose a service that you create. Whenever you create a web service, de-select the "Expose to external traffic" option.**
+- **When you launch a web application, it is not possible to expose a service that you create. Whenever you create a web service, de-select the "Expose to external traffic" option.**
 
-### MacOS
+### Minikube on MacOS
 
 1. [Install minikube](https://minikube.sigs.k8s.io/docs/start/), and install the `hyperkit` driver. The easiest way to do this is via:
 
@@ -122,23 +106,26 @@ porter auth login
 porter connect kubeconfig
 ```
 
-## Setup for WSL
+## Setting your email to be verified
 
-Follow the steps to install WSL on Windows here https://docs.microsoft.com/en-us/windows/wsl/install-win10
+If you are getting blocked out of the dashboard because your email is not verified (fixed in `v0.6.2` of Porter, so make sure you've pulled from `master` recently), you can update your email in the database to `verified":
 
-### Requirements
+`UPDATE users SET email_verified='t' WHERE id=1;`
 
-`sudo apt install xdg-utils` <br/>
-`sudo apt install postgresql`
+# Setup for WSL
 
-### Setup Process
+Follow the steps to install WSL on Windows here: https://docs.microsoft.com/en-us/windows/wsl/install-win10
 
-Once WSL is installed, head to docker and enable WSL Integration.
-![Docker Enable WSL Integration](https://i.imgur.com/QzMyxQx.png)
+```sh
+sudo apt install xdg-utils
+sudo apt install postgresql
+```
 
-Next, continue with the Getting Started Section
+Once WSL is installed, head to Docker Desktop and enable WSL Integration.
+
+![Docker Enable WSL Integration](https://i.imgur.com/QzMyxQx.png)
 
-## Secure Localhost Setup
+# Secure Localhost Setup
 
 Sometimes, it may be necessary to serve securely over `https://localhost` (for example, required by Slack integrations). Run the following command from the repository root:
 

+ 4 - 4
internal/analytics/identifiers.go

@@ -18,14 +18,14 @@ type segmentIdentifyNewUser struct {
 	isGithub  bool
 }
 
-// Creates a segment Identifier struct for new users. As we handle registration with github, it also accepts a param
-// to check if the new user has registered with github or not.
-func CreateSegmentIdentifyNewUser(user *models.User, registeredViaGithub bool) *segmentIdentifyNewUser {
+// CreateSegmentIdentifyUser creates an identifier for users
+func CreateSegmentIdentifyUser(user *models.User) *segmentIdentifyNewUser {
 	userId := fmt.Sprintf("%v", user.ID)
+
 	return &segmentIdentifyNewUser{
 		userId:    userId,
 		userEmail: user.Email,
-		isGithub:  registeredViaGithub,
+		isGithub:  user.GithubUserID != 0,
 	}
 }
 

+ 26 - 3
internal/analytics/track_events.go

@@ -3,7 +3,30 @@ package analytics
 type SegmentEvent string
 
 const (
-	NewUser            SegmentEvent = "New User"
-	RedeployViaWebhook SegmentEvent = "Triggered Re-deploy via Webhook"
-	NewClusterEvent    SegmentEvent = "New Cluster Event"
+	// onboarding flow
+	UserCreate    SegmentEvent = "New User"
+	ProjectCreate SegmentEvent = "New Project Event"
+
+	ClusterProvisioningStart   SegmentEvent = "Cluster Provisioning Started"
+	ClusterProvisioningError   SegmentEvent = "Cluster Provisioning Error"
+	ClusterProvisioningSuccess SegmentEvent = "Cluster Provisioning Success"
+
+	RegistryProvisioningStart   SegmentEvent = "Registry Provisioning Started"
+	RegistryProvisioningError   SegmentEvent = "Registry Provisioning Error"
+	RegistryProvisioningSuccess SegmentEvent = "Registry Provisioning Success"
+
+	ClusterConnectionStart   SegmentEvent = "Cluster Connection Started"
+	ClusterConnectionSuccess SegmentEvent = "Cluster Connection Success"
+
+	RegistryConnectionStart   SegmentEvent = "Registry Connection Started"
+	RegistryConnectionSuccess SegmentEvent = "Registry Connection Success"
+
+	GithubConnectionStart   SegmentEvent = "Github Connection Started"
+	GithubConnectionSuccess SegmentEvent = "Github Connection Success"
+
+	// launch flow
+	ApplicationLaunchStart   SegmentEvent = "Application Launch Started"
+	ApplicationLaunchSuccess SegmentEvent = "Application Launch Success"
+
+	ApplicationDeploymentWebhook SegmentEvent = "Triggered Re-deploy via Webhook"
 )

+ 460 - 59
internal/analytics/tracks.go

@@ -7,103 +7,504 @@ import (
 	segment "gopkg.in/segmentio/analytics-go.v3"
 )
 
+// TRACK FUNCTIONS
+type UserCreateTrackOpts struct {
+	*UserScopedTrackOpts
+}
+
+func UserCreateTrack(opts *UserCreateTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, UserCreate),
+	)
+}
+
+type ProjectCreateTrackOpts struct {
+	*ProjectScopedTrackOpts
+}
+
+func ProjectCreateTrack(opts *ProjectCreateTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ProjectCreate),
+	)
+}
+
+type ClusterProvisioningStartTrackOpts struct {
+	// note that this is a project-scoped track, since the cluster has not been created yet
+	*ProjectScopedTrackOpts
+
+	ClusterType models.InfraKind
+	InfraID     uint
+}
+
+func ClusterProvisioningStartTrack(opts *ClusterProvisioningStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cluster_type"] = opts.ClusterType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterProvisioningStart),
+	)
+}
+
+type ClusterProvisioningErrorTrackOpts struct {
+	// note that this is a project-scoped track, since the cluster has not been created yet
+	*ProjectScopedTrackOpts
+
+	ClusterType models.InfraKind
+	InfraID     uint
+}
+
+func ClusterProvisioningErrorTrack(opts *ClusterProvisioningErrorTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cluster_type"] = opts.ClusterType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterProvisioningError),
+	)
+}
+
+type ClusterProvisioningSuccessTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	ClusterType models.InfraKind
+	InfraID     uint
+}
+
+func ClusterProvisioningSuccessTrack(opts *ClusterProvisioningSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cluster_type"] = opts.ClusterType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentClusterTrack(
+		opts.ClusterScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterProvisioningSuccess),
+	)
+}
+
+type ClusterConnectionStartTrackOpts struct {
+	// note that this is a project-scoped track, since the cluster has not been created yet
+	*ProjectScopedTrackOpts
+
+	ClusterCandidateID uint
+}
+
+func ClusterConnectionStartTrack(opts *ClusterConnectionStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cc_id"] = opts.ClusterCandidateID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterConnectionStart),
+	)
+}
+
+type ClusterConnectionSuccessTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	ClusterCandidateID uint
+}
+
+func ClusterConnectionSuccessTrack(opts *ClusterConnectionSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cc_id"] = opts.ClusterCandidateID
+
+	return getSegmentClusterTrack(
+		opts.ClusterScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterConnectionSuccess),
+	)
+}
+
+type RegistryConnectionStartTrackOpts struct {
+	// note that this is a project-scoped track, since the cluster has not been created yet
+	*ProjectScopedTrackOpts
+
+	// a random id assigned to this connection request
+	FlowID string
+}
+
+func RegistryConnectionStartTrack(opts *RegistryConnectionStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["flow_id"] = opts.FlowID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryConnectionStart),
+	)
+}
+
+type RegistryConnectionSuccessTrackOpts struct {
+	*RegistryScopedTrackOpts
+
+	// a random id assigned to this connection request
+	FlowID string
+}
+
+func RegistryConnectionSuccessTrack(opts *RegistryConnectionSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["flow_id"] = opts.FlowID
+
+	return getSegmentRegistryTrack(
+		opts.RegistryScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryConnectionSuccess),
+	)
+}
+
+type GithubConnectionStartTrackOpts struct {
+	// note that this is a user-scoped track, since github repos are tied to the user
+	*UserScopedTrackOpts
+}
+
+func GithubConnectionStartTrack(opts *GithubConnectionStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, GithubConnectionStart),
+	)
+}
+
+type GithubConnectionSuccessTrackOpts struct {
+	// note that this is a user-scoped track, since github repos are tied to the user
+	*UserScopedTrackOpts
+}
+
+func GithubConnectionSuccessTrack(opts *GithubConnectionSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, GithubConnectionSuccess),
+	)
+}
+
+type ApplicationLaunchStartTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	FlowID string
+}
+
+func ApplicationLaunchStartTrack(opts *ApplicationLaunchStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["flow_id"] = opts.FlowID
+
+	return getSegmentClusterTrack(
+		opts.ClusterScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ApplicationLaunchStart),
+	)
+}
+
+type ApplicationLaunchSuccessTrackOpts struct {
+	*ApplicationScopedTrackOpts
+
+	FlowID string
+}
+
+func ApplicationLaunchSuccessTrack(opts *ApplicationLaunchSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["flow_id"] = opts.FlowID
+
+	return getSegmentApplicationTrack(
+		opts.ApplicationScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ApplicationLaunchSuccess),
+	)
+}
+
+type ApplicationDeploymentWebhookTrackOpts struct {
+	*ApplicationScopedTrackOpts
+
+	ImageURI string
+}
+
+func ApplicationDeploymentWebhookTrack(opts *ApplicationDeploymentWebhookTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["image_uri"] = opts.ImageURI
+
+	return getSegmentApplicationTrack(
+		opts.ApplicationScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ApplicationDeploymentWebhook),
+	)
+}
+
+type RegistryProvisioningStartTrackOpts struct {
+	// note that this is a project-scoped track, since the registry has not been created yet
+	*ProjectScopedTrackOpts
+
+	RegistryType models.InfraKind
+	InfraID      uint
+}
+
+func RegistryProvisioningStartTrack(opts *RegistryProvisioningStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["registry_type"] = opts.RegistryType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryProvisioningStart),
+	)
+}
+
+type RegistryProvisioningErrorTrackOpts struct {
+	// note that this is a project-scoped track, since the registry has not been created yet
+	*ProjectScopedTrackOpts
+
+	RegistryType models.InfraKind
+	InfraID      uint
+}
+
+func RegistryProvisioningErrorTrack(opts *RegistryProvisioningErrorTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["registry_type"] = opts.RegistryType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryProvisioningError),
+	)
+}
+
+type RegistryProvisioningSuccessTrackOpts struct {
+	*RegistryScopedTrackOpts
+
+	RegistryType models.InfraKind
+	InfraID      uint
+}
+
+func RegistryProvisioningSuccessTrack(opts *RegistryProvisioningSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["registry_type"] = opts.RegistryType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentRegistryTrack(
+		opts.RegistryScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryProvisioningSuccess),
+	)
+}
+
+// HELPERS
+
 type segmentTrack interface {
 	getUserId() string
 	getEvent() SegmentEvent
 	getProperties() segment.Properties
 }
 
-type segmentNewUserTrack struct {
-	userId    string
-	userEmail string
+type defaultTrackOpts struct {
+	AdditionalProps map[string]interface{}
+}
+
+type defaultSegmentTrack struct {
+	event      SegmentEvent
+	properties segmentProperties
+}
+
+func getDefaultSegmentTrack(additionalProps map[string]interface{}, event SegmentEvent) *defaultSegmentTrack {
+	props := newSegmentProperties()
+	props.addAdditionalProperties(additionalProps)
+
+	return &defaultSegmentTrack{
+		event:      event,
+		properties: props,
+	}
+}
+
+func (t *defaultSegmentTrack) getEvent() SegmentEvent {
+	return t.event
 }
 
-// CreateSegmentNewUserTrack creates a track of type "New User", which
-// tracks when a user has registered
-func CreateSegmentNewUserTrack(user *models.User) *segmentNewUserTrack {
-	userId := fmt.Sprintf("%v", user.ID)
+func (t *defaultSegmentTrack) getProperties() segment.Properties {
+	props := segment.NewProperties()
 
-	return &segmentNewUserTrack{
-		userId:    userId,
-		userEmail: user.Email,
+	for key, val := range t.properties {
+		props = props.Set(key, val)
 	}
+
+	return props
+}
+
+type segmentProperties map[string]interface{}
+
+func newSegmentProperties() segmentProperties {
+	props := make(map[string]interface{})
+
+	return props
 }
 
-func (t *segmentNewUserTrack) getUserId() string {
-	return t.userId
+func (p segmentProperties) addProjectProperties(opts *ProjectScopedTrackOpts) {
+	p["proj_id"] = opts.ProjectID
 }
 
-func (t *segmentNewUserTrack) getEvent() SegmentEvent {
-	return NewUser
+func (p segmentProperties) addClusterProperties(opts *ClusterScopedTrackOpts) {
+	p["cluster_id"] = opts.ClusterID
 }
 
-func (t *segmentNewUserTrack) getProperties() segment.Properties {
-	return segment.NewProperties().Set("email", t.userEmail)
+func (p segmentProperties) addRegistryProperties(opts *RegistryScopedTrackOpts) {
+	p["registry_id"] = opts.RegistryID
 }
 
-type segmentRedeployViaWebhookTrack struct {
-	userId     string
-	repository string
+func (p segmentProperties) addApplicationProperties(opts *ApplicationScopedTrackOpts) {
+	p["app_name"] = opts.Name
+	p["app_namespace"] = opts.Namespace
+	p["chart_name"] = opts.ChartName
 }
 
-// CreateSegmentRedeployViaWebhookTrack creates a track of type "Triggered Re-deploy via Webhook", which
-// tracks whenever a repository is redeployed via webhook call
-func CreateSegmentRedeployViaWebhookTrack(userId string, repository string) *segmentRedeployViaWebhookTrack {
-	return &segmentRedeployViaWebhookTrack{
-		userId:     userId,
-		repository: repository,
+func (p segmentProperties) addAdditionalProperties(props map[string]interface{}) {
+	for key, val := range props {
+		p[key] = val
 	}
 }
 
-func (t *segmentRedeployViaWebhookTrack) getUserId() string {
-	return t.userId
+type UserScopedTrack struct {
+	*defaultSegmentTrack
+
+	userID uint
+}
+
+type UserScopedTrackOpts struct {
+	*defaultTrackOpts
+
+	UserID uint
 }
 
-func (t *segmentRedeployViaWebhookTrack) getEvent() SegmentEvent {
-	return RedeployViaWebhook
+func GetUserScopedTrackOpts(userID uint) *UserScopedTrackOpts {
+	return &UserScopedTrackOpts{
+		UserID: userID,
+	}
 }
 
-func (t *segmentRedeployViaWebhookTrack) getProperties() segment.Properties {
-	return segment.NewProperties().Set("repository", t.repository)
+func (u *UserScopedTrack) getUserId() string {
+	return fmt.Sprintf("%d", u.userID)
 }
 
-type segmentNewClusterEventTrack struct {
-	userId      string
-	projId      string
-	clusterName string
-	clusterType string // EKS, DOKS, or GKE
-	eventType   string // connected, provisioned, or destroyed
+func getSegmentUserTrack(opts *UserScopedTrackOpts, track *defaultSegmentTrack) *UserScopedTrack {
+	return &UserScopedTrack{
+		defaultSegmentTrack: track,
+		userID:              opts.UserID,
+	}
 }
 
-// NewClusterEventOpts are the parameters for creating a "New Cluster Event" track
-type NewClusterEventOpts struct {
-	UserId      string
-	ProjId      string
-	ClusterName string
-	ClusterType string // EKS, DOKS, or GKE
-	EventType   string // connected, provisioned, or destroyed
+type ProjectScopedTrack struct {
+	*UserScopedTrack
+
+	projectID uint
 }
 
-// CreateSegmentNewClusterEvent creates a track of type "New Cluster Event", which
-// tracks whenever a cluster is newly provisioned, connected, or destroyed.
-func CreateSegmentNewClusterEvent(opts *NewClusterEventOpts) *segmentNewClusterEventTrack {
-	return &segmentNewClusterEventTrack{
-		userId:      opts.UserId,
-		projId:      opts.ProjId,
-		clusterName: opts.ClusterName,
-		clusterType: opts.ClusterType,
-		eventType:   opts.EventType,
+type ProjectScopedTrackOpts struct {
+	*UserScopedTrackOpts
+
+	ProjectID uint
+}
+
+func GetProjectScopedTrackOpts(userID, projID uint) *ProjectScopedTrackOpts {
+	return &ProjectScopedTrackOpts{
+		UserScopedTrackOpts: GetUserScopedTrackOpts(userID),
+		ProjectID:           projID,
+	}
+}
+
+func getSegmentProjectTrack(opts *ProjectScopedTrackOpts, track *defaultSegmentTrack) *ProjectScopedTrack {
+	track.properties.addProjectProperties(opts)
+
+	return &ProjectScopedTrack{
+		UserScopedTrack: getSegmentUserTrack(opts.UserScopedTrackOpts, track),
+		projectID:       opts.ProjectID,
+	}
+}
+
+type RegistryScopedTrack struct {
+	*ProjectScopedTrack
+
+	registryID uint
+}
+
+type RegistryScopedTrackOpts struct {
+	*ProjectScopedTrackOpts
+
+	RegistryID uint
+}
+
+func GetRegistryScopedTrackOpts(userID, projID, regID uint) *RegistryScopedTrackOpts {
+	return &RegistryScopedTrackOpts{
+		ProjectScopedTrackOpts: GetProjectScopedTrackOpts(userID, projID),
+		RegistryID:             regID,
 	}
 }
 
-func (t *segmentNewClusterEventTrack) getUserId() string {
-	return t.userId
+func getSegmentRegistryTrack(opts *RegistryScopedTrackOpts, track *defaultSegmentTrack) *RegistryScopedTrack {
+	track.properties.addRegistryProperties(opts)
+
+	return &RegistryScopedTrack{
+		ProjectScopedTrack: getSegmentProjectTrack(opts.ProjectScopedTrackOpts, track),
+		registryID:         opts.RegistryID,
+	}
 }
 
-func (t *segmentNewClusterEventTrack) getEvent() SegmentEvent {
-	return NewClusterEvent
+type ClusterScopedTrack struct {
+	*ProjectScopedTrack
+
+	clusterID uint
 }
 
-func (t *segmentNewClusterEventTrack) getProperties() segment.Properties {
-	return segment.NewProperties().Set("Project ID", t.projId).Set("Cluster Name", t.clusterName).Set("Cluster Type", t.clusterType).Set("Event Type", t.eventType)
+type ClusterScopedTrackOpts struct {
+	*ProjectScopedTrackOpts
+
+	ClusterID uint
+}
+
+func GetClusterScopedTrackOpts(userID, projID, clusterID uint) *ClusterScopedTrackOpts {
+	return &ClusterScopedTrackOpts{
+		ProjectScopedTrackOpts: GetProjectScopedTrackOpts(userID, projID),
+		ClusterID:              clusterID,
+	}
+}
+
+func getSegmentClusterTrack(opts *ClusterScopedTrackOpts, track *defaultSegmentTrack) *ClusterScopedTrack {
+	track.properties.addClusterProperties(opts)
+
+	return &ClusterScopedTrack{
+		ProjectScopedTrack: getSegmentProjectTrack(opts.ProjectScopedTrackOpts, track),
+		clusterID:          opts.ClusterID,
+	}
+}
+
+type ApplicationScopedTrack struct {
+	*ClusterScopedTrack
+
+	name      string
+	namespace string
+}
+
+type ApplicationScopedTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	Name      string
+	Namespace string
+	ChartName string
+}
+
+func GetApplicationScopedTrackOpts(userID, projID, clusterID uint, name, namespace, chartName string) *ApplicationScopedTrackOpts {
+	return &ApplicationScopedTrackOpts{
+		ClusterScopedTrackOpts: GetClusterScopedTrackOpts(userID, projID, clusterID),
+		Name:                   name,
+		Namespace:              namespace,
+	}
+}
+
+func getSegmentApplicationTrack(opts *ApplicationScopedTrackOpts, track *defaultSegmentTrack) *ApplicationScopedTrack {
+	track.properties.addApplicationProperties(opts)
+
+	return &ApplicationScopedTrack{
+		ClusterScopedTrack: getSegmentClusterTrack(opts.ClusterScopedTrackOpts, track),
+		name:               opts.Name,
+		namespace:          opts.Namespace,
+	}
 }

+ 68 - 0
internal/kubernetes/provisioner/global_stream.go

@@ -9,6 +9,7 @@ import (
 	"strings"
 
 	"github.com/aws/aws-sdk-go/service/ecr"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/repository"
 
 	redis "github.com/go-redis/redis/v8"
@@ -83,6 +84,7 @@ type ResourceCRUDHandler interface {
 func GlobalStreamListener(
 	client *redis.Client,
 	repo repository.Repository,
+	analyticsClient analytics.AnalyticsSegmentClient,
 	errorChan chan error,
 ) {
 	for {
@@ -163,6 +165,14 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
+
+					analyticsClient.Track(analytics.RegistryProvisioningSuccessTrack(
+						&analytics.RegistryProvisioningSuccessTrackOpts{
+							RegistryScopedTrackOpts: analytics.GetRegistryScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, reg.ID),
+							RegistryType:            infra.Kind,
+							InfraID:                 infra.ID,
+						},
+					))
 				} else if kind == string(models.InfraEKS) {
 					cluster := &models.Cluster{
 						AuthMechanism:    models.AWS,
@@ -197,6 +207,14 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
+
+					analyticsClient.Track(analytics.ClusterProvisioningSuccessTrack(
+						&analytics.ClusterProvisioningSuccessTrackOpts{
+							ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, cluster.ID),
+							ClusterType:            infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
 				} else if kind == string(models.InfraGCR) {
 					reg := &models.Registry{
 						ProjectID:        projID,
@@ -217,6 +235,14 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
+
+					analyticsClient.Track(analytics.RegistryProvisioningSuccessTrack(
+						&analytics.RegistryProvisioningSuccessTrackOpts{
+							RegistryScopedTrackOpts: analytics.GetRegistryScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, reg.ID),
+							RegistryType:            infra.Kind,
+							InfraID:                 infra.ID,
+						},
+					))
 				} else if kind == string(models.InfraGKE) {
 					cluster := &models.Cluster{
 						AuthMechanism:    models.GCP,
@@ -251,6 +277,14 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
+
+					analyticsClient.Track(analytics.ClusterProvisioningSuccessTrack(
+						&analytics.ClusterProvisioningSuccessTrackOpts{
+							ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, cluster.ID),
+							ClusterType:            infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
 				} else if kind == string(models.InfraDOCR) {
 					reg := &models.Registry{
 						ProjectID:       projID,
@@ -270,6 +304,14 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
+
+					analyticsClient.Track(analytics.RegistryProvisioningSuccessTrack(
+						&analytics.RegistryProvisioningSuccessTrackOpts{
+							RegistryScopedTrackOpts: analytics.GetRegistryScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, reg.ID),
+							RegistryType:            infra.Kind,
+							InfraID:                 infra.ID,
+						},
+					))
 				} else if kind == string(models.InfraDOKS) {
 					cluster := &models.Cluster{
 						AuthMechanism:   models.DO,
@@ -304,6 +346,14 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
+
+					analyticsClient.Track(analytics.ClusterProvisioningSuccessTrack(
+						&analytics.ClusterProvisioningSuccessTrackOpts{
+							ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, cluster.ID),
+							ClusterType:            infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
 				}
 			} else if fmt.Sprintf("%v", msg.Values["status"]) == "error" {
 				infra, err := repo.Infra.ReadInfra(infraID)
@@ -319,6 +369,24 @@ func GlobalStreamListener(
 				if err != nil {
 					continue
 				}
+
+				if infra.Kind == models.InfraDOKS || infra.Kind == models.InfraGKE || infra.Kind == models.InfraEKS {
+					analyticsClient.Track(analytics.ClusterProvisioningErrorTrack(
+						&analytics.ClusterProvisioningErrorTrackOpts{
+							ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID),
+							ClusterType:            infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
+				} else if infra.Kind == models.InfraDOCR || infra.Kind == models.InfraGCR || infra.Kind == models.InfraECR {
+					analyticsClient.Track(analytics.RegistryProvisioningErrorTrack(
+						&analytics.RegistryProvisioningErrorTrackOpts{
+							ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID),
+							RegistryType:           infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
+				}
 			} else if fmt.Sprintf("%v", msg.Values["status"]) == "destroyed" {
 				infra, err := repo.Infra.ReadInfra(infraID)
 

+ 3 - 0
internal/models/infra.go

@@ -48,6 +48,9 @@ type Infra struct {
 	// The project that this infra belongs to
 	ProjectID uint `json:"project_id"`
 
+	// The ID of the user that created this infra
+	CreatedByUserID uint
+
 	// Status is the status of the infra
 	Status InfraStatus `json:"status"`
 

+ 56 - 0
scripts/dev-environment/CreateDefaultEnvFiles.sh

@@ -0,0 +1,56 @@
+#!/bin/bash
+
+create-backend-env-file() {
+  FILE=./docker/.env
+cat > $FILE <<- EOM
+SERVER_URL=http://localhost:8080
+SERVER_PORT=8080 # Be sure that doesn't colision with the frontend port
+SQL_LITE=true
+SQL_LITE_PATH=./porter.db
+
+# Disable redis by default on non docker environment, if you want to setup redis you can complete the variables commented down below
+REDIS_ENABLED=false
+# REDIS_HOST=redis
+# REDIS_PORT=6379
+# REDIS_USER=foo
+# REDIS_PASS=bar
+# REDIS_DB=0
+
+# If you don't wanna use SQL lite you should fill this data with the postgres connection details
+# DB_HOST=localhost 
+# DB_PORT=5400
+# DB_USER=porter
+# DB_PASS=porter
+# DB_NAME=porter
+
+EOM
+}
+
+create-frontend-env-file() {
+  FILE=./dashboard/.env
+cat > $FILE <<- EOM
+NODE_ENV=development
+
+# Tell the webpack dev server in wich port we wanna run, it defaults to 8080 but we have to be carefull this is not the same port as the backend
+DEV_SERVER_PORT=8081
+
+# Usually we would use nginx, but for this environment we're going to enable webpack-dev-server proxy 
+ENABLE_PROXY=true 
+
+# API server url, this url will be used for the proxy to redirect all /api calls
+API_SERVER=http://localhost:8080 
+EOM
+}
+
+if [[ ! -e ./dashboard/.env ]]
+then
+  echo "Dashboard env file (./dashboard/.env) not found, creating one with defaults"
+  create-frontend-env-file
+fi
+
+if [[ ! -e ./docker/.env ]]
+then
+  echo "Server env file (./docker/.env) not found, creating one with defaults"
+  create-backend-env-file
+fi
+

+ 38 - 0
scripts/dev-environment/SetupEnvironment.sh

@@ -0,0 +1,38 @@
+#!/bin/bash
+
+# Setup all the environment requirements.
+
+REQUIRED_APPLICATIONS=('node' 'go' 'npm')
+for i in "${REQUIRED_APPLICATIONS[@]}"; do
+  if ! command -v $i &> /dev/null
+  then
+    echo "${i} could not be found, please install to be able to execute dev environment"
+    exit
+  fi
+done
+
+
+if ! command -v air &> /dev/null
+then 
+  printf "\n"
+  read -p "cosmtrek/air is required to continue, do you want to install it? y/N: " -n 1 -r
+  if [[ $REPLY =~ ^[Yy]$ ]]
+  then
+    echo "Yes"
+    curl -sSfL https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin
+    printf "\nInstalled Air\n"
+    air -v
+  else 
+    printf "\nCanceled script, exiting program\n"
+    exit
+  fi
+fi
+
+
+if [[ ! -d ./dashboard/node_modules ]]; then 
+  echo "Couldn't find node_modules, installing npm packages"
+  cd ./dashboard && npm install;
+  cd ../;  
+else
+  echo "Node modules found! Proceeding to start server"
+fi

+ 33 - 0
scripts/dev-environment/StartDevServer.sh

@@ -0,0 +1,33 @@
+#!/bin/bash
+
+printf "\033c"
+
+startFrontend() {
+  cd ./dashboard && npm start;
+}
+
+startBackend() {
+  # Load env variables for backend
+  if [[ -e ./docker/.env ]]
+  then
+    set -a # automatically export all variables
+    source ./docker/.env
+    set +a
+  else 
+    echo "Couldn't find any backend env variables, exiting process"
+    exit
+  fi
+  air -c .air.toml
+}
+
+startBackend &
+startFrontend &
+wait
+
+cleanup() {
+    rv=$?
+    clear
+    exit $rv
+}
+
+trap "cleanup" EXIT

+ 8 - 6
server/api/api.go

@@ -95,11 +95,13 @@ type App struct {
 	GoogleUserConf    *oauth2.Config
 	SlackConf         *oauth2.Config
 
-	db              *gorm.DB
-	validator       *vr.Validate
-	translator      *ut.Translator
-	tokenConf       *token.TokenGeneratorConf
-	analyticsClient analytics.AnalyticsSegmentClient
+	// analytics client for reporting
+	AnalyticsClient analytics.AnalyticsSegmentClient
+
+	db         *gorm.DB
+	validator  *vr.Validate
+	translator *ut.Translator
+	tokenConf  *token.TokenGeneratorConf
 }
 
 type AppCapabilities struct {
@@ -242,7 +244,7 @@ func New(conf *AppConfig) (*App, error) {
 	}
 
 	newSegmentClient := analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, app.Logger)
-	app.analyticsClient = newSegmentClient
+	app.AnalyticsClient = newSegmentClient
 
 	app.updateChartRepoURLs()
 

+ 22 - 34
server/api/cluster_handler.go

@@ -2,7 +2,6 @@ package api
 
 import (
 	"encoding/json"
-	"fmt"
 	"net/http"
 	"strconv"
 
@@ -17,7 +16,7 @@ import (
 // HandleCreateProjectCluster creates a new cluster
 func (app *App) HandleCreateProjectCluster(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
-	userID, err := app.getUserIDFromRequest(r)
+	// userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -57,15 +56,6 @@ func (app *App) HandleCreateProjectCluster(w http.ResponseWriter, r *http.Reques
 	}
 
 	app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", projID),
-			ClusterName: cluster.Name,
-			ClusterType: "EKS",
-			EventType:   "connected",
-		},
-	))
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -279,14 +269,7 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 
 	extClusters := make([]*models.ClusterCandidateExternal, 0)
 
-	session, err := app.Store.Get(r, app.ServerConf.CookieName)
-
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	userID, _ := session.Values["user_id"].(uint)
+	userID, _ := app.getUserIDFromRequest(r)
 
 	for _, cc := range ccs {
 		// handle write to the database
@@ -297,6 +280,13 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 			return
 		}
 
+		app.AnalyticsClient.Track(analytics.ClusterConnectionStartTrack(
+			&analytics.ClusterConnectionStartTrackOpts{
+				ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+				ClusterCandidateID:     cc.ID,
+			},
+		))
+
 		app.Logger.Info().Msgf("New cluster candidate created: %d", cc.ID)
 
 		// if the ClusterCandidate does not have any actions to perform, create the Cluster
@@ -339,6 +329,13 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 			}
 
 			app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
+
+			app.AnalyticsClient.Track(analytics.ClusterConnectionSuccessTrack(
+				&analytics.ClusterConnectionSuccessTrackOpts{
+					ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(userID, uint(projID), cluster.ID),
+					ClusterCandidateID:     cc.ID,
+				},
+			))
 		}
 
 		extClusters = append(extClusters, cc.Externalize())
@@ -401,14 +398,7 @@ func (app *App) HandleResolveClusterCandidate(w http.ResponseWriter, r *http.Req
 		return
 	}
 
-	session, err := app.Store.Get(r, app.ServerConf.CookieName)
-
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	userID, _ := session.Values["user_id"].(uint)
+	userID, _ := app.getUserIDFromRequest(r)
 
 	// decode actions from request
 	resolver := &models.ClusterResolverAll{}
@@ -447,13 +437,11 @@ func (app *App) HandleResolveClusterCandidate(w http.ResponseWriter, r *http.Req
 	}
 
 	app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", projID),
-			ClusterName: cluster.Name,
-			ClusterType: "",
-			EventType:   "connected",
+
+	app.AnalyticsClient.Track(analytics.ClusterConnectionSuccessTrack(
+		&analytics.ClusterConnectionSuccessTrackOpts{
+			ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(userID, uint(projID), cluster.ID),
+			ClusterCandidateID:     uint(candID),
 		},
 	))
 

+ 51 - 2
server/api/deploy_handler.go

@@ -3,18 +3,21 @@ package api
 import (
 	"encoding/json"
 	"fmt"
-	"gorm.io/gorm"
 	"net/http"
 	"net/url"
 	"strconv"
 	"strings"
 
+	"gorm.io/gorm"
+
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
 	"gopkg.in/yaml.v2"
 )
@@ -22,6 +25,8 @@ import (
 // HandleDeployTemplate triggers a chart deployment from a template
 func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
+	flowID := oauth.CreateRandomState()
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -57,6 +62,13 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.ApplicationLaunchStartTrack(
+		&analytics.ApplicationLaunchStartTrackOpts{
+			ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(userID, uint(projID), uint(clusterID)),
+			FlowID:                 flowID,
+		},
+	))
+
 	getChartForm.PopulateRepoURLFromQueryParams(vals)
 
 	chart, err := loader.LoadChartPublic(getChartForm.RepoURL, getChartForm.Name, getChartForm.Version)
@@ -190,12 +202,28 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		app.createGitActionFromForm(projID, clusterID, form.ChartTemplateForm.Name, gaForm, w, r)
 	}
 
+	app.AnalyticsClient.Track(analytics.ApplicationLaunchSuccessTrack(
+		&analytics.ApplicationLaunchSuccessTrackOpts{
+			ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
+				userID,
+				uint(projID),
+				uint(clusterID),
+				release.Name,
+				release.Namespace,
+				chart.Metadata.Name,
+			),
+			FlowID: flowID,
+		},
+	))
+
 	w.WriteHeader(http.StatusOK)
 }
 
 // HandleDeployAddon triggers a addon deployment from a template
 func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
+	flowID := oauth.CreateRandomState()
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -253,6 +281,13 @@ func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.ApplicationLaunchStartTrack(
+		&analytics.ApplicationLaunchStartTrackOpts{
+			ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(userID, uint(projID), uint(form.ReleaseForm.Cluster.ID)),
+			FlowID:                 flowID,
+		},
+	))
+
 	agent, err := app.getAgentFromReleaseForm(
 		w,
 		r,
@@ -281,7 +316,7 @@ func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
 		Registries: registries,
 	}
 
-	_, err = agent.InstallChart(conf, app.DOConf)
+	rel, err := agent.InstallChart(conf, app.DOConf)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
@@ -292,6 +327,20 @@ func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.ApplicationLaunchSuccessTrack(
+		&analytics.ApplicationLaunchSuccessTrackOpts{
+			ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
+				userID,
+				uint(projID),
+				uint(form.ReleaseForm.Cluster.ID),
+				rel.Name,
+				rel.Namespace,
+				chart.Metadata.Name,
+			),
+			FlowID: flowID,
+		},
+	))
+
 	w.WriteHeader(http.StatusOK)
 }
 

+ 9 - 0
server/api/integration_handler.go

@@ -16,6 +16,7 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/oauth"
 	"golang.org/x/oauth2"
@@ -481,6 +482,14 @@ func (app *App) HandleGithubAppAuthorize(w http.ResponseWriter, r *http.Request)
 
 // HandleGithubAppOauthInit redirects the user to the Porter github app authorization page
 func (app *App) HandleGithubAppOauthInit(w http.ResponseWriter, r *http.Request) {
+	userID, _ := app.getUserIDFromRequest(r)
+
+	app.AnalyticsClient.Track(analytics.GithubConnectionStartTrack(
+		&analytics.GithubConnectionStartTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(userID),
+		},
+	))
+
 	http.Redirect(w, r, app.GithubAppConf.AuthCodeURL("", oauth2.AccessTypeOffline), 302)
 }
 

+ 2 - 0
server/api/notifications_handler.go

@@ -97,6 +97,7 @@ func (app *App) HandleGetNotificationConfig(w http.ResponseWriter, r *http.Reque
 			Code:   ErrReleaseReadData,
 			Errors: []string{"release not found"},
 		}, w)
+		return
 	}
 
 	release, err := app.Repo.Release.ReadRelease(uint(clusterID), name, namespace)
@@ -111,6 +112,7 @@ func (app *App) HandleGetNotificationConfig(w http.ResponseWriter, r *http.Reque
 			Code:   ErrReleaseReadData,
 			Errors: []string{"release not found"},
 		}, w)
+		return
 	}
 
 	config := &models.NotificationConfigExternal{

+ 11 - 2
server/api/oauth_github_handler.go

@@ -131,8 +131,11 @@ func (app *App) HandleGithubOAuthCallback(w http.ResponseWriter, r *http.Request
 		}
 
 		// send to segment
-		app.analyticsClient.Identify(analytics.CreateSegmentIdentifyNewUser(user, true))
-		app.analyticsClient.Track(analytics.CreateSegmentNewUserTrack(user))
+		app.AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
+
+		app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		}))
 
 		// log the user in
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
@@ -352,6 +355,12 @@ func (app *App) HandleGithubAppOAuthCallback(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.GithubConnectionSuccessTrack(
+		&analytics.GithubConnectionSuccessTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		},
+	))
+
 	if session.Values["query_params"] != "" {
 		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
 	} else {

+ 4 - 2
server/api/oauth_google_handler.go

@@ -95,9 +95,11 @@ func (app *App) HandleGoogleOAuthCallback(w http.ResponseWriter, r *http.Request
 	}
 
 	// send to segment
-	app.analyticsClient.Identify(analytics.CreateSegmentIdentifyNewUser(user, true))
+	app.AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
 
-	app.analyticsClient.Track(analytics.CreateSegmentNewUserTrack(user))
+	app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
+		UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+	}))
 
 	// log the user in
 	app.Logger.Info().Msgf("New user created: %d", user.ID)

+ 5 - 0
server/api/project_handler.go

@@ -7,6 +7,7 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
 )
@@ -72,6 +73,10 @@ func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.ProjectCreateTrack(&analytics.ProjectCreateTrackOpts{
+		ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, projModel.ID),
+	}))
+
 	app.Logger.Info().Msgf("New project created: %d", projModel.ID)
 
 	w.WriteHeader(http.StatusCreated)

+ 62 - 53
server/api/provision_handler.go

@@ -7,8 +7,6 @@ import (
 
 	"github.com/go-chi/chi"
 
-	"fmt"
-
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/kubernetes"
@@ -22,6 +20,7 @@ import (
 // container pod
 func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -46,6 +45,8 @@ func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -147,6 +148,7 @@ func (app *App) HandleDestroyTestInfra(w http.ResponseWriter, r *http.Request) {
 // HandleProvisionAWSECRInfra provisions a new aws ECR instance for a project
 func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -177,6 +179,8 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -219,6 +223,14 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 
 	app.Logger.Info().Msgf("New aws ecr infra created: %d", infra.ID)
 
+	app.AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
+		&analytics.RegistryProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			RegistryType:           models.InfraECR,
+			InfraID:                infra.ID,
+		},
+	))
+
 	w.WriteHeader(http.StatusCreated)
 
 	infraExt := infra.Externalize()
@@ -336,6 +348,8 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -378,13 +392,12 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	app.Logger.Info().Msgf("New aws eks infra created: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.EKSName,
-			ClusterType: "EKS",
-			EventType:   "provisioned",
+
+	app.AnalyticsClient.Track(analytics.ClusterProvisioningStartTrack(
+		&analytics.ClusterProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			ClusterType:            models.InfraEKS,
+			InfraID:                infra.ID,
 		},
 	))
 
@@ -402,7 +415,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
-	userID, err := app.getUserIDFromRequest(r)
+	// userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -469,15 +482,6 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	app.Logger.Info().Msgf("AWS EKS infra marked for destruction: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.EKSName,
-			ClusterType: "EKS",
-			EventType:   "destroyed",
-		},
-	))
 
 	w.WriteHeader(http.StatusOK)
 }
@@ -485,6 +489,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 // HandleProvisionGCPGCRInfra enables GCR for a project
 func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -515,6 +520,8 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -556,6 +563,14 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 
 	app.Logger.Info().Msgf("New gcp gcr infra created: %d", infra.ID)
 
+	app.AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
+		&analytics.RegistryProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			RegistryType:           models.InfraGCR,
+			InfraID:                infra.ID,
+		},
+	))
+
 	w.WriteHeader(http.StatusCreated)
 
 	infraExt := infra.Externalize()
@@ -600,6 +615,8 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -641,13 +658,12 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	app.Logger.Info().Msgf("New gcp gke infra created: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.GKEName,
-			ClusterType: "GKE",
-			EventType:   "provisioned",
+
+	app.AnalyticsClient.Track(analytics.ClusterProvisioningStartTrack(
+		&analytics.ClusterProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			ClusterType:            models.InfraGKE,
+			InfraID:                infra.ID,
 		},
 	))
 
@@ -665,7 +681,7 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
-	userID, err := app.getUserIDFromRequest(r)
+	// userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -731,15 +747,6 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	app.Logger.Info().Msgf("GCP GKE infra marked for destruction: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.GKEName,
-			ClusterType: "GKE",
-			EventType:   "destroyed",
-		},
-	))
 
 	w.WriteHeader(http.StatusOK)
 }
@@ -791,6 +798,7 @@ func (app *App) HandleGetProvisioningLogs(w http.ResponseWriter, r *http.Request
 // HandleProvisionDODOCRInfra provisions a new digitalocean DOCR instance for a project
 func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -821,6 +829,8 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -865,6 +875,14 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 
 	app.Logger.Info().Msgf("New do docr infra created: %d", infra.ID)
 
+	app.AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
+		&analytics.RegistryProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			RegistryType:           models.InfraDOCR,
+			InfraID:                infra.ID,
+		},
+	))
+
 	w.WriteHeader(http.StatusCreated)
 
 	infraExt := infra.Externalize()
@@ -984,6 +1002,8 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -1027,13 +1047,12 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	app.Logger.Info().Msgf("New do doks infra created: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.DOKSName,
-			ClusterType: "DOKS",
-			EventType:   "provisioned",
+
+	app.AnalyticsClient.Track(analytics.ClusterProvisioningStartTrack(
+		&analytics.ClusterProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			ClusterType:            models.InfraDOKS,
+			InfraID:                infra.ID,
 		},
 	))
 
@@ -1051,7 +1070,6 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
-	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -1119,15 +1137,6 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	app.Logger.Info().Msgf("DO DOKS infra marked for destruction: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.DOKSName,
-			ClusterType: "DOKS",
-			EventType:   "destroyed",
-		},
-	))
 
 	w.WriteHeader(http.StatusOK)
 }

+ 17 - 1
server/api/registry_handler.go

@@ -8,8 +8,8 @@ import (
 	"strings"
 	"time"
 
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/oauth"
-
 	"github.com/porter-dev/porter/internal/registry"
 
 	"github.com/go-chi/chi"
@@ -22,12 +22,21 @@ import (
 // HandleCreateRegistry creates a new registry
 func (app *App) HandleCreateRegistry(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
+	flowID := oauth.CreateRandomState()
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.RegistryConnectionStartTrack(
+		&analytics.RegistryConnectionStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			FlowID:                 flowID,
+		},
+	))
+
 	form := &forms.CreateRegistry{
 		ProjectID: uint(projID),
 	}
@@ -62,6 +71,13 @@ func (app *App) HandleCreateRegistry(w http.ResponseWriter, r *http.Request) {
 
 	app.Logger.Info().Msgf("New registry created: %d", registry.ID)
 
+	app.AnalyticsClient.Track(analytics.RegistryConnectionSuccessTrack(
+		&analytics.RegistryConnectionSuccessTrackOpts{
+			RegistryScopedTrackOpts: analytics.GetRegistryScopedTrackOpts(userID, uint(projID), registry.ID),
+			FlowID:                  flowID,
+		},
+	))
+
 	w.WriteHeader(http.StatusCreated)
 
 	regExt := registry.Externalize()

+ 24 - 12
server/api/release_handler.go

@@ -602,7 +602,8 @@ func (app *App) HandleGetReleaseAllPods(w http.ResponseWriter, r *http.Request)
 }
 
 type GetJobStatusResult struct {
-	Status string `json:"status"`
+	Status    string       `json:"status,omitempty"`
+	StartTime *metav1.Time `json:"start_time,omitempty"`
 }
 
 // HandleGetJobStatus gets the status for a specific job
@@ -692,9 +693,7 @@ func (app *App) HandleGetJobStatus(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	res := &GetJobStatusResult{
-		Status: "succeeded",
-	}
+	res := &GetJobStatusResult{}
 
 	// get the most recent job
 	if len(jobs) > 0 {
@@ -708,6 +707,8 @@ func (app *App) HandleGetJobStatus(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 
+		res.StartTime = mostRecentJob.Status.StartTime
+
 		// get the status of the most recent job
 		if mostRecentJob.Status.Succeeded >= 1 {
 			res.Status = "succeeded"
@@ -1017,12 +1018,12 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		conf.Chart = chart
 	}
 
-	rel, err := agent.UpgradeRelease(conf, form.Values, app.DOConf)
+	rel, upgradeErr := agent.UpgradeRelease(conf, form.Values, app.DOConf)
 
 	slackInts, _ := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(projID))
 
 	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
-	release, _ := app.Repo.Release.ReadRelease(uint(clusterID), name, rel.Namespace)
+	release, _ := app.Repo.Release.ReadRelease(uint(clusterID), name, form.Namespace)
 
 	var notifConf *models.NotificationConfigExternal
 	notifConf = nil
@@ -1054,16 +1055,15 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		) + fmt.Sprintf("?project_id=%d", uint(projID)),
 	}
 
-	if err != nil {
+	if upgradeErr != nil {
 		notifyOpts.Status = slack.StatusFailed
-		notifyOpts.Info = err.Error()
+		notifyOpts.Info = upgradeErr.Error()
 
-		slackErr := notifier.Notify(notifyOpts)
-		fmt.Println("SLACK ERROR IS", slackErr)
+		notifier.Notify(notifyOpts)
 
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 			Code:   ErrReleaseDeploy,
-			Errors: []string{err.Error()},
+			Errors: []string{upgradeErr.Error()},
 		}, w)
 
 		return
@@ -1315,7 +1315,19 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 
 	notifier.Notify(notifyOpts)
 
-	app.analyticsClient.Track(analytics.CreateSegmentRedeployViaWebhookTrack("anonymous", repository.(string)))
+	userID, _ := app.getUserIDFromRequest(r)
+
+	app.AnalyticsClient.Track(analytics.ApplicationDeploymentWebhookTrack(&analytics.ApplicationDeploymentWebhookTrackOpts{
+		ImageURI: fmt.Sprintf("%v", repository),
+		ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
+			userID,
+			release.ProjectID,
+			release.ClusterID,
+			release.Name,
+			release.Namespace,
+			rel.Chart.Metadata.Name,
+		),
+	}))
 
 	w.WriteHeader(http.StatusOK)
 }

+ 5 - 2
server/api/user_handler.go

@@ -54,8 +54,11 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 
 	if err == nil {
 		// send to segment
-		app.analyticsClient.Identify(analytics.CreateSegmentIdentifyNewUser(user, false))
-		app.analyticsClient.Track(analytics.CreateSegmentNewUserTrack(user))
+		app.AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
+
+		app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		}))
 
 		app.Logger.Info().Msgf("New user created: %d", user.ID)