Skip to content

Commit

Permalink
Initial release (#1)
Browse files Browse the repository at this point in the history
* Initial commit of custom objects, layouts, flexipages, console app, permission sets, and Apex schedulable batch job
  • Loading branch information
jongpie authored Feb 7, 2022
1 parent f5e9d0b commit 755d4cd
Show file tree
Hide file tree
Showing 76 changed files with 3,594 additions and 0 deletions.
12 changes: 12 additions & 0 deletions .forceignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status
# More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm
#

package.xml

# LWC configuration files
**/jsconfig.json
**/.eslintrc.json

# LWC Jest
**/__tests__/**
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Folders to exclude
.localdevserver/
.settings/
.sfdx/
.vscode/
wip/

# NPM
node_modules/
yarn.lock

# Files to exclude
*.log
**/lwc/jsconfig.json
6 changes: 6 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# List files or directories below to ignore them when running prettier
# More information: https://prettier.io/docs/en/ignore.html
#

.sfdx
.vscode
16 changes: 16 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"trailingComma": "none",
"tabWidth": 2,
"useTabs": false,
"printWidth": 120,
"overrides": [
{
"files": "**/lwc/**/*.html",
"options": { "parser": "lwc" }
},
{
"files": "*.{cmp,page,component}",
"options": { "parser": "html" }
}
]
}
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Package Analytics

Provides reportable data about your Salesforce packages in your dev hub.

[![Install Unlocked Package in a Dev Hub](./images/btn-install-unlocked-package-production.png)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t4x000000FEjUAAW)

## Why?

When working with packages in Salesforce, the platform automatically tracks & stores data about your packages, package versions, and customer orgs that are using your package(s) (referred to as 'subscriber orgs'). However, the objects that store this data can only be accessed via SOQL or Apex - they are not accessible via the Salesforce UI, which also means that the data is not reportable.

This project aims to help ISVs better monitor their packages by extracting data into custom objects so you can easily view it in your dev hub, using standard Salesforce UI functionality. The data is also reportable, using standard Salesforce report & dashboard functionality.

## Getting Started

1. Install the package into your dev hub
2. Schedule the Apex job using `new PackageDataExtractJob().scheduleHourly();`. This will also immediately run the job.
3. Optional: assign permission sets to any users in your dev hub that should be able to see/report on the data.
- System Admins should already have access to the objects/data, so they should not need to have any permission sets assigned
- 'Package Analytics Admin' permission set - for any users that should be able to modify or delete access to the data in the included custom objects
- 'Package Analytics Viewer' permission set - for any users that should have read-only access to the data in the included custom objects
4. Open the app 'Package Analytics' in App Switcher
5. Enjoy the reportable data about your packages. You can build your own reports & dashboards, using these custom objects
- `Package__c`
- `PackageVersion__c`
- `PackageSubscriberOrg__c`

## Screenshots

View your list of 1GP and 2GP packages

![Packages list view](/images/packages-tab.png)

For each package, you can see the list of package versions & additional details about the package

![Package detail page](/images/package-detail-page.png)

Similarly, for each package version, you can see the list of orgs that have installed the package version, as well as additional details about the package version.

![Package Version detail page](/images/package-version-detail-page.png)
15 changes: 15 additions & 0 deletions config/scratch-orgs/base-scratch-def.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"orgName": "Package Analytics - Base Scratch Org",
"edition": "Enterprise",
"hasSampleData": true,
"country": "US",
"language": "en_US",
"features": [],
"settings": {
"userManagementSettings": {
"enableEnhancedPermsetMgmt": true,
"enableEnhancedProfileMgmt": true,
"enableNewProfileUI": true
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/package-detail-page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/package-version-detail-page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/packages-tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" ?>
<CustomApplication xmlns="http://soap.sforce.com/2006/04/metadata">
<brand>
<headerColor>#26AE19</headerColor>
<shouldOverrideOrgTheme>false</shouldOverrideOrgTheme>
</brand>
<formFactors>Small</formFactors>
<formFactors>Large</formFactors>
<isNavAutoTempTabsDisabled>false</isNavAutoTempTabsDisabled>
<isNavPersonalizationDisabled>false</isNavPersonalizationDisabled>
<label>Package Analytics</label>
<navType>Console</navType>
<tabs>Package__c</tabs>
<tabs>PackageVersion__c</tabs>
<tabs>PackageSubscriberOrg__c</tabs>
<tabs>standard-report</tabs>
<tabs>standard-Dashboard</tabs>
<uiType>Lightning</uiType>
<utilityBar>PackageAnalyticsConsoleUtilityBar</utilityBar>
<workspaceConfig>
<mappings>
<fieldName>PackageVersion__c</fieldName>
<tab>PackageSubscriberOrg__c</tab>
</mappings>
<mappings>
<tab>PackageVersion__c</tab>
</mappings>
<mappings>
<tab>Package__c</tab>
</mappings>
<mappings>
<tab>standard-Dashboard</tab>
</mappings>
<mappings>
<tab>standard-report</tab>
</mappings>
</workspaceConfig>
</CustomApplication>
249 changes: 249 additions & 0 deletions package-analytics/main/default/classes/PackageDataExtractJob.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
//-------------------------------------------------------------------------------------------------//
// This file is part of the Package Analytics project, released under the MIT License. //
// See LICENSE file or go to https://github.com/jongpie/PackageAnalytics for full license details. //
//-------------------------------------------------------------------------------------------------//

public without sharing class PackageDataExtractJob implements Database.Batchable<SObject>, Database.Stateful, Schedulable {
private static final Integer BATCH_SIZE = 2000;
@TestVisible
private static final String DEFAULT_HOURLY_JOB_NAME = 'Hourly Package Data Extract Job';
private static final Database.DmlOptions DML_OPTIONS {
get {
if (DML_OPTIONS == null) {
DML_OPTIONS = new Database.DmlOptions();
DML_OPTIONS.AllowFieldTruncation = true;
}
return DML_OPTIONS;
}
set;
}
@TestVisible
private static final String HOURLY_CRON_SCHEDULE = '0 0 * * * ?';
@TestVisible
private static final Boolean IS_PACKAGING_ORG = Type.forName('MetadataPackage') != null;
@TestVisible
private static final String METADATA_PACKAGE_NAME = 'MetadataPackage';
@TestVisible
private static final String METADATA_PACKAGE_QUERY = 'SELECT Id, Name, NamespacePrefix, PackageCategory, SystemModStamp FROM MetadataPackage ORDER BY SystemModStamp DESC';
@TestVisible
private static final String METADATA_PACKAGE_VERSION_NAME = 'MetadataPackageVersion';
@TestVisible
private static final String METADATA_PACKAGE_VERSION_QUERY = 'SELECT BuildNumber, Id, IsDeprecated, MajorVersion, MetadataPackageid, MinorVersion, Name, PatchVersion, ReleaseState, SystemModStamp FROM MetadataPackageVersion ORDER BY SystemModStamp DESC';
@TestVisible
private static final String PACKAGE_SUBSCRIBER_NAME = 'PackageSubscriber';
@TestVisible
private static final String PACKAGE_SUBSCRIBER_QUERY = 'SELECT Id, InstanceName, MetadataPackageId, MetadataPackageVersionId, OrgKey, OrgName, OrgStatus, OrgType, ParentOrg, SystemModStamp FROM PackageSubscriber ORDER BY SystemModStamp DESC';

@TestVisible
private String currentQuery;
@TestVisible
private String currentSObjectName;
@TestVisible
private Integer queryLimit;
@TestVisible
private List<String> sobjectNames = new List<String>{
METADATA_PACKAGE_NAME,
METADATA_PACKAGE_VERSION_NAME,
PACKAGE_SUBSCRIBER_NAME
};
@TestVisible
private Boolean willRunAnotherTime = false;

public Id scheduleHourly() {
return this.scheduleHourly(DEFAULT_HOURLY_JOB_NAME, true);
}

public Id scheduleHourly(String jobName, Boolean runImmediately) {
if (runImmediately == true) {
this.executeBatch();
}
return System.schedule(jobName, HOURLY_CRON_SCHEDULE, this);
}

public void execute(System.SchedulableContext context) {
this.executeBatch();
}

public Database.QueryLocator start(Database.BatchableContext context) {
this.currentSObjectName = this.sobjectNames.remove(0);
return getQueryLocator();
}

public void execute(Database.BatchableContext context, List<Object> scope) {
// Switch statements are amazing, but in Apex, you would have to use an inline hardcoded string, which is pretty lame
if (this.currentSObjectName == METADATA_PACKAGE_NAME) {
List<MetadataPackageInfo> packages = (List<MetadataPackageInfo>) this.deserializeTo(
scope,
List<MetadataPackageInfo>.class
);
this.processPackages(packages);
} else if (this.currentSObjectName == METADATA_PACKAGE_VERSION_NAME) {
List<MetadataPackageVersionInfo> packageVersions = (List<MetadataPackageVersionInfo>) this.deserializeTo(
scope,
List<MetadataPackageVersionInfo>.class
);
this.processPackageVersions(packageVersions);
} else if (this.currentSObjectName == PACKAGE_SUBSCRIBER_NAME) {
List<PackageSubscriberInfo> packageSubscribers = (List<PackageSubscriberInfo>) this.deserializeTo(
scope,
List<PackageSubscriberInfo>.class
);
this.processPackageSubscribers(packageSubscribers);
}
}

public void finish(Database.BatchableContext context) {
if (this.sobjectNames.isEmpty() == false) {
this.willRunAnotherTime = true;
if (Test.isRunningTest() == false) {
this.executeBatch();
}
}
}

private void executeBatch() {
Database.executeBatch(this, BATCH_SIZE);
}

private Database.QueryLocator getQueryLocator() {
String query;
// Switch statements are amazing, but in Apex, you would have to use an inline hardcoded string, which is pretty lame
if (this.currentSObjectName == METADATA_PACKAGE_NAME) {
query = METADATA_PACKAGE_QUERY;
} else if (this.currentSObjectName == METADATA_PACKAGE_VERSION_NAME) {
query = METADATA_PACKAGE_VERSION_QUERY;
} else if (this.currentSObjectName == PACKAGE_SUBSCRIBER_NAME) {
query = PACKAGE_SUBSCRIBER_QUERY;
} else {
Exception ex = new IllegalArgumentException();
ex.setMessage('Unsupported SObjectType: ' + this.currentSObjectName);
throw ex;
}

if (this.queryLimit != null) {
query += ' LIMIT ' + this.queryLimit;
}

// A horrible hack - when the 3 SObjects (above) don't exist in the org, the start() method fails because
// the QueryLocator can't find the objects, and scratch orgs (used to create the 2GP) & sandboxes will never have the 3 SObjects.
// Overriding the query string keeps the platform happy, and unit tests check the value of this.query as a workaround
this.currentQuery = query;
if (Test.isRunningTest() == true && IS_PACKAGING_ORG == false) {
query = 'SELECT Id FROM User';
}
return Database.getQueryLocator(query);
}

private List<Object> deserializeTo(List<Object> scope, Type type) {
return (List<Object>) JSON.deserialize(JSON.serialize(scope), type);
}

private void processPackages(List<MetadataPackageInfo> metadataPackages) {
List<Package__c> storedPackages = new List<Package__c>();
for (MetadataPackageInfo metadataPackage : metadataPackages) {
Package__c storedPackage = new Package__c(
LastUpdated__c = metadataPackage.SystemModStamp,
PackageCategory__c = metadataPackage.PackageCategory,
PackageId__c = metadataPackage.Id,
Name = metadataPackage.Name,
NamespacePrefix__c = metadataPackage.NamespacePrefix
);
storedPackage.setOptions(DML_OPTIONS);
storedPackages.add(storedPackage);
}
upsert storedPackages PackageId__c;
}

private void processPackageVersions(List<MetadataPackageVersionInfo> metadataPackageVersions) {
List<PackageVersion__c> storedPackageVersions = new List<PackageVersion__c>();
for (MetadataPackageVersionInfo metadataPackageVersion : metadataPackageVersions) {
PackageVersion__c storedPackageVersion = new PackageVersion__c(
BuildNumber__c = metadataPackageVersion.BuildNumber,
IsDeprecated__c = metadataPackageVersion.IsDeprecated,
LastUpdated__c = metadataPackageVersion.SystemModStamp,
MajorVersion__c = metadataPackageVersion.MajorVersion,
MinorVersion__c = metadataPackageVersion.MinorVersion,
Name = metadataPackageVersion.Name,
Package__r = new Package__c(PackageId__c = metadataPackageVersion.MetadataPackageId),
PatchVersion__c = metadataPackageVersion.PatchVersion,
ReleaseState__c = metadataPackageVersion.ReleaseState,
SubscriberPackageVersionId__c = metadataPackageVersion.Id
);
storedPackageVersion.setOptions(DML_OPTIONS);
storedPackageVersions.add(storedPackageVersion);
}
upsert storedPackageVersions SubscriberPackageVersionId__c;
}

private void processPackageSubscribers(List<PackageSubscriberInfo> packageSubscribers) {
Map<String, PackageSubscriberOrg__c> parentSubscriberOrgsByOrgId = new Map<String, PackageSubscriberOrg__c>();
Map<String, PackageSubscriberOrg__c> childSubscriberOrgsByOrgId = new Map<String, PackageSubscriberOrg__c>();

for (PackageSubscriberInfo packageSubscriber : packageSubscribers) {
PackageSubscriberOrg__c storedOrg = new PackageSubscriberOrg__c(
InstanceName__c = packageSubscriber.InstanceName,
LastUpdated__c = packageSubscriber.SystemModStamp,
Name = packageSubscriber.OrgName,
OrgId__c = packageSubscriber.OrgKey,
OrgStatus__c = packageSubscriber.OrgStatus,
OrgType__c = packageSubscriber.OrgType,
PackageSubscriberId__c = packageSubscriber.Id,
PackageVersion__r = new PackageVersion__c(
SubscriberPackageVersionId__c = packageSubscriber.MetadataPackageVersionId
)
);
if (packageSubscriber.ParentOrg == null) {
parentSubscriberOrgsByOrgId.put(storedOrg.OrgId__c, storedOrg);
} else {
storedOrg.ParentOrgId__c = packageSubscriber.ParentOrg;
childSubscriberOrgsByOrgId.put(storedOrg.OrgId__c, storedOrg);
}
}

// Parent orgs
upsert parentSubscriberOrgsByOrgId.values() OrgId__c;

// Child orgs
for (PackageSubscriberOrg__c childOrg : childSubscriberOrgsByOrgId.values()) {
if (parentSubscriberOrgsByOrgId.containsKey(childOrg.ParentOrgId__c) == true) {
childOrg.ParentOrg__c = parentSubscriberOrgsByOrgId.get(childOrg.ParentOrgId__c).Id;
}
}
upsert childSubscriberOrgsByOrgId.values() OrgId__c;
}

// Inner classes used to substitute the SObjects `MetadataPackage`, `MetadataPackageVersion`, and `PackageSubscriber`
// that don't/won't/can't exist in scratch orgs (used for creating package versions) or sandboxes
public class MetadataPackageInfo {
public Id Id;
public String Name;
public String NamespacePrefix;
public String PackageCategory;
public Datetime SystemModStamp;
}

public class MetadataPackageVersionInfo {
public Decimal BuildNumber;
public Id Id;
public Boolean IsDeprecated;
public Decimal MajorVersion;
public String MetadataPackageId;
public Decimal MinorVersion;
public String Name;
public Decimal PatchVersion;
public String ReleaseState;
public Datetime SystemModStamp;
}

public class PackageSubscriberInfo {
public Id Id;
public String InstanceName;
public Id MetadataPackageVersionId;
public Id OrgKey;
public String OrgName;
public String OrgStatus;
public String OrgType;
public Id ParentOrg;
public Datetime SystemModStamp;
}
}
Loading

0 comments on commit 755d4cd

Please sign in to comment.