Sunday 7 April 2024

Einstein Copliot Custom Actions

Image created by DALL-E 3 based on a prompt by Bob Buzzard

Introduction

One of the key features of Einstein Copilot from Salesforce is its extensibility - while there are a bunch of standard actions, and many more coming, there will always be something more you want to give your users. Custom actions allow you to create an AI assistant that targets your specific business challenges, adding capabilities to your copilot that generate real value for your users.

Scenario

In my last post, I introduced the Sales Coach - a custom Lightning Web Component that you add to an Opportunity record page. When the Sales Coach component renders, it executes Apex code to hydrate a prompt template with details of the Opportunity and request some guidance from an LLM. 

While this works well for users that are viewing an Opportunity record, I also want to provide a way tfor users receive guidance on deals while they are in another part of the Salesforce application - their Home Page, for example.

Creating the Custom Copilot Action

Custom Copilot Actions can be created using Apex, Flow or Prompt Templates. I've gone for a Prompt Template, as I already have this in place for the Sales Coach component. Once I choose the Reference Action Type of Prompt Template, I get to select from a list of existing prompts in the Reference Action selector. 


Once I've chosen my action, I provide details of how the Copilot can use it :



The Copilot Action Instructions is pre-populated from my Prompt Template description. As I want to use it in exactly the same way, I can leave that alone. I then provide the instructions around the Candidate Opportunity input, and check the box in the Output section to Show in conversation - without this, the user won't be able to receive the coaching they so richly deserve.

Note that while the Prompt Template pull several fields from the Opportunity record - Name, Amount, Close Date, Stage Name - I don't need to specify any of this information here. I don't even need to specify that it's an Opportunity record - that is picked up automatically from the Prompt Template input. Pretty simple really.

Once my custom action is created, I need to add it to the Copilot. I do this via the Copilot Builder, selecting my new action from the list of available actions and then clicking a button to add it to the Active Copilot :



As an aside, this Copilot isn't Active yet, so the text in the button is slightly misleading, but not a big deal as I can only have one Copilot so I'm hardly likely to get confused.

Putting it Together

Remember earlier when I created the Copilot action I gave it details of how to use it? The Copilot Action Instructions field does what it says on the tin - it instructs the Copilot as to the purpose of the action and when it should be used:


I've defined this as providing advice to progress a specific opportunity, so in order to include the action in the Copilot plan, I need to ask the it to help me do that. As we're in AI world, I don't need to be as obvious as asking for advice to progress an opportunity, instead I used my own words and asked for "help to win a deal".

Interestingly, when I ran this to capture the screenshots, Copilot told me that it was following the instructions that I'd provided for the action, which I hadn't seen before:




Something to remember when using Prompt Template actions is they will lead to a slower response time, as each one involves a round trip to an LLM. There will always be one round trip, to create the plan, but if you manage to generate a request that involves multiple Prompt Template actions, there will be separate requests made for each of those, and you'll be consuming additional tokens. 

Monday 1 April 2024

Deploy Code at the Speed of AI with Ship Happens

Image from DALL-E 3 based on a prompt by Bob Buzzard


Introduction


It's been quite the year for Salesforce Einstein - Copilot, Prompt Builder, lots of new Generative AI features, and it's only the beginning of April!

Einstein for Developers has been out in beta for a while now, built on CodeGen - Salesforce's in-house open-source LLM. To date this as been focused on generating code from natural language prompts, auto-completions. and unit tests, but now it's time for artificial intelligence to dip a toe into the Dev Ops space with Ship Happens.

Ship Happens


In keeping with most Generative AI launches, Einstein Dev Ops goes live with a single feature and the expectation that more will come in a few months. It's quite the feature though - with Ship Happens, your code will barely have a chance to touch down in a scratch org before it's flung to production and into the hands of users.

You've Got to be Shipping Me


Research shows that by far the biggest blocker to getting code into production is developers accepting that they have finished. While this sounds like a simple decision, developers can't help polishing - always looking to squeeze another feature in, or a percentage point of unit test coverage.  Ship Happens unblocks your development team by handing the decision off to Generative AI.

As development progresses, the code is constantly analysed by a Large Language Model trained on years of Salesforce deployments, both successful and unsuccessful, and the quality of code for your specific instance. Once the LLM decides the code is as good as it's ever going to be, it's committed to version control and on it's way to production before the developer knows what has hit them. To ensure full traceability, Ship Happens reuses the same commit message when it takes the decision to ship a feature - "You've Got to be Shipping Me".

Welcome to Ship Creek


Crunching the numbers after the pilot program showed that users are delighted when Ship Happens. An updated experience every time they login, reduced waiting time for new features, and the oh so familiar bugs they have come to expect with every release. Once they accept their role under our new AI overlords, your developers will enjoy the freedom to focus purely on writing code - with Ship Happens in charge, everyone knows that features will be released exactly when they should be, not a moment earlier and not a moment later. Before long, everyone will be happily floating along Ship Creek and forget they were ever in a different place.





Sunday 24 March 2024

Einstein Prompt Templates in Apex - the Sales Coach

Image by DALL-E 3 based on a prompt from Bob Buzzard

Introduction

Thus far in my journey with Generative AI in Salesforce I've been focused on using prompt templates out of the box - sales emails, field generation, or in custom copilot actions. Salesforce isn't the boss of me though, so I'm also keen to figure out how to include prompts into my own applications.

The Use Case

I want a way for sales reps to get advice on what they should do with an opportunity - is it worth proceeding with, what does it make sense to do next, that kind of thing. I don't want this happening every time I load the page, as this will consume tokens and cost money, so I'm going to make it available as an LWC inside a tab that isn't the default on the opportunity page:


The Coach tab contains my LWC, but the user has to select the tab to receive the coaching. I could make this a button, but pressing buttons fulfils an innate desire for control and exploration in humans, so it could still end up consuming a lot of tokens. Opening tabs, for some reason, doesn't trigger the same urges.

The Implementation

The first thing to figure out was how to "execute" a prompt from Apex - how to hydrate the prompt with data about an opportunity, send it to a Large Language Model, and receive a response. This is achieved via the Apex ConnectAPI namespace. It's a bit of a multi-step process, so you should be prepared to write a little more code than you might expect.

Prepare the Input

The prompt template for coaching takes a single parameter - the id of the Opportunity the user is accessing. Prompt templates receive their inputs via a ConnectApi.​EinsteinPrompt​Template​GenerationsInput instance, which contains a map of ConnectApi.WrappedValue elements keyed by the API name of the parameter from the prompt template. The ConnectApi.WrappedValue is also a map though, in this case with a single entry of the opportunity id with the key value 'id'. As mentioned, a little more code than you might have expected to get a single id parameter passed to a prompt, but nothing too horrific:

ConnectApi.EinsteinPromptTemplateGenerationsInput promptGenerationsInput = 
                           new ConnectApi.EinsteinPromptTemplateGenerationsInput();

Map<String,ConnectApi.WrappedValue> valueMap = new Map<String,ConnectApi.WrappedValue>();

Map<String, String> opportunityRecordIdMap = new Map<String, String>();
opportunityRecordIdMap.put('id', oppId); 

ConnectApi.WrappedValue opportunityWrappedValue = new ConnectApi.WrappedValue();
opportunityWrappedValue.value = opportunityRecordIdMap;

valueMap.put('Input:Candidate_Opportunity', opportunityWrappedValue);

promptGenerationsInput.inputParams = valueMap;
promptGenerationsInput.isPreview = false;
Note that the ConnectApi.EinsteinPromptTemplateGenerationsInput handles more than just the inputs - for example, I've set the isPreview property to false, indicating that I don't just want to preview the prompt hydrated with data, I want to send it to the LLM and receive a response. There's also a ConnectApi.​EinsteinLlm​Additional​ConfigInput property to send granular configuration information such as temperature to the LLM

Make the Request


Once I have the input ready, I can send the request to the LLM.
ConnectApi.EinsteinPromptTemplateGenerationsRepresentation generationsOutput = 
    ConnectApi.EinsteinLLM.generateMessagesForPromptTemplate('0hfao000000hh9lAAA', 
                                                             promptGenerationsInput); 
Note that I've hardcoded the ID of the prompt template in the request. I really didn't want to do this, but I can't find a way to query the prompt templates in the system. The metadata type is GenAiPromptTemplate, but this doesn't appear to be queryable, not by me at least! This is the name that getSObjectType returns, but it doesn't appear in the Salesforce Object Reference and both Apex and the Tooling API error when I try to use it in a query. I'm sure this is on its way, but if I put this into production I'll probably expose a property on the LWC so that whoever is building the record page can supply the Id there. And if it's my Evil Co-Worker, they can supply a completely different prompt template Id to introduce confusion and chaos, so everybody should be happy.

Extracting the Response


The output from the LLM is in the form of a ConnectAPI.EinsteinLLMGenerationItemOutput. This contains a generations property that is a list of responses from the LLM. Right now I'm just picking the first element in the list and pulling the text property containing the actual information that I want to display to the user.  I can also access the safety score if I want.

Putting it in front of the User


The Lightning Web Component is pretty vanilla - when the user opens the tab the record id is passed via a property setting decorated with @api. This triggers the request to the server to send the prompt to the LLM:
@api get recordId() { 
    return this._recordId; 
}

set recordId(value) {
    this._recordId=value;
    GetAdvice({oppId : this._recordId})
    .then(data => {
        this.thinking=false;
        this.advice=data;
    })

Note that I have to do this via an imperative Apex call rather than using the wire service, as calling an Apex ConnectAPI method consumes a DML call, which means my controller method can't be cacheable, which means it can't be wired.

The user receives a holding message that the coach is thinking:


Then after a few seconds, the Coach is ready to point them in the right direction:


Related Posts





Saturday 16 March 2024

Einstein Prompt Grounding with Apex

Image generated by DALL-E 3 in response to a prompt from Bob Buzzard

Introduction

A couple of weeks ago I published my initial experience and thoughts on Einstein Prompt Builder, and one of my findings was that I couldn't get grounding with Apex to work, even using the example code in the help. Thanks to some help from Claudio Moraes from the Salesforce Partner team, that is no longer the case and I can pull information into my prompt via an Apex class. 

Example Scenario

The prompt I've created is following up on a lead where we have a pretty good idea what product they are interested in. Using standard prompting I can get the user to select the product in questions, but I also want to include the standard price to give the lead an idea of what it might cost them. I can pull the PricebookEntries related list from the product into the prompt, but that is all or nothing, whereas I just want the a single entry from the standard price book.

The Apex Code

In order to be considered for use in a Sales Email Prompt Template, I need a method with the following signature:

@InvocableMethod(label='Price for Lead' 
                 description='Gets the price of a product for a lead' 
                 CapabilityType='PromptTemplateType://einstein_gpt__salesEmail')
public static List<Response> getProductStandardPrices(List<Request> requests) 

The key aspects of this are:

It must be an invocable method

Which simply means it needs the @InvocableMethod annotation. Without this, the Apex class will never be considered by Prompt Builder.

It has to have the CapabilityType attribute on the @InvocableMethod annotation

This identifies the type of Prompt Template that the method can be used with. As this is for a Sales Email, I specify :

     PromptTemplateType://einstein_gpt__salesEmail

It must take a list of Request class instances

Request is an inner class that I have to define, with the format:

 public class Request {
    @InvocableVariable(required=true)
    public User Sender;

    @InvocableVariable(required=true)
    public Lead Recipient;

    @InvocableVariable(required=true)
    public Product2 RelatedObject;
}

The variable names must be exactly as shown here - including the capitalisation (thanks Claudio!). These match the properties of the Prompt Template.

It must return a list of Response class instances

Again, Response is an inner class that I define, with the format:

 public class Response {
    @InvocableVariable(required=true)
    public String prompt;
}


The body of the method itself is pretty straightforward:

@InvocableMethod(label='Price for Lead' 
                 description='Gets the price of a product for a lead' 
                 CapabilityType='PromptTemplateType://einstein_gpt__salesEmail')
public static List<Response> getProductStandardPrices(List<Request> requests) 
{
    Request req=requests[0];
    Id productId=req.RelatedObject.Id;
    Response resp=new Response();
    PricebookEntry entry=[
            select Id, UnitPrice, CurrencyISOCode from PricebookEntry
            where Product2.Id=:productId
            and   Pricebook2.IsStandard = true];

    resp.prompt=entry.CurrencyISOCode + ' ' + entry.UnitPrice;
        
    return new List<Response>{resp};
}

Note that while the method has to take a list of requests and return a list of responses, there's only ever one element present.

Putting it Together

If I deploy my class and it doesn't show up in Prompt Builder because I've got the capitalisation wrong, it doesn't appear to be possible to recover it. Instead, I have to delete the class and try the next attempt with a new one (thanks again, Claudio!).  

Once I have the correct breed of chicken and the right colour chalk for the pentangle, Apex appears in the resource picker :


And clicking into the Apex option shows me the name of my enclosing class and the label attribute from the @InvocableMethod annotation :

And selecting this adds it to my prompt, helpfully highlighted to show me that it is a reference rather than plaintext :

The Prompt and Response

I've added this to a pretty simple prompt - I doubt it would make much difference to a potential customer, but the point of this blog is to show the Apex, not what makes a great prompt! Note that as I didn't ground the prompt with any details of the user, they are expected to add that themselves :


and the response from the LLM:

Subject:

Thank You for Your Interest in Solar Panels

Body:

Dear Lauren Reynolds, 

Thank you for your interest in our solar panels. We are pleased to share the price with you. The cost for a single panel is USD 1725.00. Please note that discounts are available for larger orders, but this price already reflects an excellent value for such a high-quality product. 

We hope this information is helpful to you, and we look forward to the possibility of working with you. 

Should you have any further questions or require additional details, please do not hesitate to reach out. 

Warm regards,
[Your Name]
Credera Sales Team


More Information





Sunday 10 March 2024

Hands on with Salesforce Copilot

Image generated by DALL-E 3 based on a prompt from Bob Buzzard

Introduction

Copilot from Salesforce entered beta at the end of Feb '24, without too much of a build-up I think it's fair to say. Given how quiet the likes of Marc Benioff were on the socials, I was half-expecting it to be delayed until nearer to the Summer release. I'm very pleased that wasn't the case, but not as pleased as I was to get access to it.

Setup

Much like Prompt Builder, enabling copilot is pretty simple :

  • Go to Setup -> Einstein Generative AI -> Einstein Setup and toggle it on
  • The go to Setup -> Einstein Generative AI -> Einstein Copilot Studio (Beta) -> Einstein Copilots and toggle that on.
Copilot is then enabled in your org. It's not active though - you have to head into Builder and click the Activate button to see it in the user interface. You can play around with it pretty well inside the Builder though, so don't activate until you are happy.

Accessing


Once you've activated your copilot, you'll see a new Enstein icon up at the top right of the user interface - looks a bit Heisenberg-esque to me, which I think is awesome:


Clicking this opens copilot in a new sidebar, moving the current screen contents over to the left rather than overlaying them - it's the little things:


One key point to note - copilot doesn't always have the context you are working in - the screengrab above is copilot opening while I'm viewing an opportunity. It's tempting to think that I can just refer to 'this opportunity' and it will pick up the details from my current context/URL, but that doesn't happen - instead it says 'Sure, which opportunity is this?"


As an aside, it also really wants me to use the name of the record - if I try the Id, it tells me that nothing was found in the query. This might seem limited, but I think it'sactually working exactly as intended. There's an action in this copilot configuration that retrieves a record and it requires the record name. If I want to use Ids instead, I need a different action.

Back to context though, if I click the Summarize Opportunity button, it does know about the opportunity and gives me the details : 


Making a Request


As always with generative AI, the quality of the response has a pretty direct correlation with the quality of the prompt. Just vaguely asking for help doesn't go well:


I then tried a request that I thought had enough detail, but in hindsight lacked a little clarity:


Third time's the charm though, and now I have a few accounts to focus on:



Extending


The standard functionality is all well and good, but I'm always interested in how I can extend and customise. There was a bit of trial and error involved, as the docs aren't perfect yet, but remember this is a beta feature.

I was trying to think of something that wasn't in place already, and after a short while it struck me - there's just not enough poetry in enterprise software nowadays, so an action to write a haiku about an account was the obvious, if not the only, choice.

This will be a Prompt Template action, so the first job is to define the prompt:

You are a world renowned haiku writer, who has a side gig as a CRM administrator.
Write a haiku based for the account {!$Input:Account.Name} which operates in the {!$Input:Account.Industry} industry. 
The haiku should not include any PII.

Then the copilot action: 

The important parts of the action are :

  • Copilot Action Instructions - this is used by the AI to determine whether an action is appropriate for a plan. The more detailed the better, although in this case there probably won't be multiple actions competing for the job of writing the haiku.
  • Require user confirmation - tick this if any data is going to be changed. 
  • Account Instructions - these are the instructions for the input named Account that I've specified in the prompt. I've identified which fields I'm interested in, the type, and that it's required. I'm not asking the user to enter any data and there's no PII to mask as I'm just using the Name and Industry fields, which is public information
  • Prompt Response Instructions - here I've defined what type of response, but most importantly ticked Show in conversation. Without this nothing will be returned to the user.
Once this action is added to my copilot, I can add writing a haiku to a request and get the user experience I've been craving:


More Information


Saturday 2 March 2024

Hands on with Prompt Builder

Image generated by DALL-E 3 based on a prompt created by Bob Buzzard

Introduction

Unless you've been living under a rock, you'll have seen some big news from Salesforce in the Generative AI space this week (29th Feb 2024, for future readers) - Prompt Builder is Generally Available and Copilot is in Beta. 

I've been lucky enough to get my hands on both of these new features and have an initial dig around, so continue reading to find out how I got on with Prompt Builder. Like it's General Availability, my hands on experience with Copilot is coming soon!

Setup

Setting up Prompt Builder is very straightforward:

  1. Navigate to Setup -> Einstein Generative AI -> Einstein Setup and toggle it on
  2. Assign the Prompt Builder Manager/User permission sets
  3. Reload Setup, then navigate to Setup -> Einstein Generative AI -> Prompt Builder and start prompting.

Creating Prompts


I've mostly played with the Sales Emails prompts, aside from my Copilot experiments (see below). My initial use case was follow up emails for attendees of one of my World Famous Release Webinars, so I created a new custom object of Webinar and added a few fields that I wanted to pull into my prompt - the usual name and description, but also a couple of fields to capture the URLs of additional resources I wanted to send. I also added a Call to Action - this is a field to capture what I was hoping the attendees would do after attending - try out a couple of the features, for example.

Once I'd added the Webinar custom object, it didn't appear immediately in the prompt builder as a selectable Related Object. After checking the docs to make sure I hadn't missed a checkbox to make it available, I decided it must just be a timing thing - maybe the objects needed reindexing or the like. Then
I found that I couldn't create any prompts for a period of time - not sure how long, but it felt like 30-40 minutes - during that time the Related Object selector wouldn't populate at all. Then clearly whatever had been happening in the background finished, and I could select my new Webinar object.




My prompt is loosely based on the standard follow up email, but includes specific instructions and fields related to my webinar :

You are a Sales Executive and your name is {!$Input:Sender.Name} from an organization called {!$Input:Sender.CompanyName}. Your prospect is {!$Input:Recipient.Name}, who is the {!$Input:Recipient.Title}, from the company called {!$Input:Recipient.Company}.Your prospect {!$Input:Recipient.Name} attended a Salesforce release webinar that your company hosted.
When I ask you to generate a follow-up email to your prospect you must strictly follow my instructions below.
Instructions: """
The salutation must only contain the recipient's first name.
You must strictly not use "I hope this email finds you well" ,"I hope this email finds you doing well", or any other variation that expresses interest in the recipient's well-being to open the email.
Create a follow-up email conveying that you are following up on their attendance of {!$Input:Webinar__c.Name} to see if they need any additional information or support.
Mention the webinar, the number of attendees from {!$Input:Webinar__c.Attendee_Count__c} and include a summary of the {!$Input:Webinar__c.Description__c}. Express the hope that it was useful for them and that they will follow the call to action identified at {!$Input:Webinar__c.Call_to_Action__c}
End the email with details of the two additional resources that are available : {!$Input:Webinar__c.Resource_1__c} , which is {!$Input:Webinar__c.Resource_1_Detail__c} and {!$Input:Webinar__c.Resource_2__c}, which is {!$Input:Webinar__c.Resource_2_Detail__c}
Finish by indirectly encouraging your prospect {!$Input:Recipient.Name} to respond to your email by asking them if they need any further information, have questions or require assistance.
Generate a subject line that can increase open rate using words and content related to the email body.
"""
Now generate the follow-up email to your prospect.

Grounding the Prompt

I then provide a couple of sample records to test the prompt out. The Lead record is one that came with the org and will probably be familiar to anyone that has used a Salesforce Developer Edition before - Lauren Bailey from Omega, Inc. The webinar record is as follows:

The resolution section is useful when you are grounding the prompt with a number of inputs - it shows the expanded merge fields so you can see exactly what you'll be sending the AI model (although sensitive data will be masked by the Einstein Trust Layer, obviously):
You are a Sales Executive and your name is Keir Bowden from an organization called Credera. Your prospect is Lauren Bailey, who is the Director of Services, from the company called Omega,Inc.Your prospect Lauren Bailey attended a Salesforce release webinar that your company hosted.

When I ask you to generate a follow-up email to your prospect you must strictly follow my instructions below.

Instructions: """
The salutation must only contain the recipient's first name.
You must strictly not use "I hope this email finds you well" ,"I hope this email finds you doing well", or any other variation that expresses interest in the recipient's well-being to open the email.

Create a follow-up email conveying that you are following up on their attendance of Spring 24 Release Webinar to see if they need any additional information or support.

Mention the webinar, the number of attendees from 150 and include a summary of the Learn about the key new features and enhancements in the Spring 24 release of Salesforce.. Express the hope that it was useful for them and that they will follow the call to action identified at Enable dynamic forms and add related objects to your records home pages.

End the email with details of the two additional resources that are available : https://help.salesforce.com/s/articleView?id=release-notes.salesforce_release_notes.htm&language=en_US&release=248&type=5 , which is Salesforce Release Notes and https://www.youtube.com/watch?v=c4nKiYlQ6EU, which is Webinar Recording

Finish by indirectly encouraging your prospect Lauren Bailey to respond to your email by asking them if they need any further information, have questions or require assistance.

Generate a subject line that can increase open rate using words and content related to the email body.
"""

Now generate the follow-up email to your prospect.
I found that my original calls to action, even though they looked good in the record, didn't work very well when included in the prompt, so always have a look at the resolution the first couple of times.

The Response


The response from OpenAI GPT 3.5 Turbo was pretty good I thought, although maybe a little brusque - I'll probably rework it to a slightly more chatty version, but it's a great start for a small amount of work:
Subject:
Enhance Your Salesforce Experience with Spring 24 Release Insights

Body:
Hello Lauren,

I trust you found the Spring 24 Release Webinar insightful. With 150 attendees, we hope you gained valuable insights into the key new features and enhancements in the Salesforce Spring 24 release. The webinar highlighted how to enable dynamic forms and add related objects to your records home pages.

For further exploration, two additional resources are available:
- Salesforce Release Notes: https://help.salesforce.com/s/articleView?id=release-notes.salesforce_release_notes.htm&language=en_US&release=248&type=5
- Webinar Recording: https://www.youtube.com/watch?v=c4nKiYlQ6EU

Should you require any additional information, have questions, or need assistance, please feel free to reach out.

Best regards,
Keir Bowden
Credera

Grounding with Apex/Flow

I can see I'll be doing a lot of grounding with pro or low code - if I'm sending an email regarding a product I'll probably want to include a rate card and maybe some contractual information for example.  Grounding with Flow is very straightforward, and once you choose a Template Triggered Prompt Flow and pick the Sales Emails capability, there's a nice screen to define the object types you are using :


Apex is a little different - you define a class with an invocable method that defines the capability, then define Request and Response inner classes to manage the input and output parameters:

@InvocableMethod(label='Price for Lead' description='Gets the price of a product for a lead' 
                 CapabilityType='PromptTemplateType://einstein_gpt__salesEmail')
public static List<Response> getProductStandardPrices(List<Request> requests)  
        --- --- ---
public class Request {
    @InvocableVariable(required=true)
    public User sender;

    @InvocableVariable(required=true)
    public Lead recipient;

    @InvocableVariable(required=true)
    public Webinar__c relatedObject;
}

public class Response {
    @InvocableVariable(required=true)
    public String Prompt;
}

Apex is also a little different in that I couldn't get it to work! Even after copy/pasting the example in the Salesforce help, it wouldn't show up in the Resources selector. I'm sure this will be resolved soon, and as Prompt Builder has been one of Salesforce's more rapid features to go GA, I'm okay with their being a few glitches. As I've written before, I'll take a bit of additional effort tracking down issues if it means I get my hands on things earlier.

More Information





Sunday 25 February 2024

Zip Handling in Apex, Spring '24 Developer Preview

Image generated by DALL-E 3, based on a prompt by Bob Buzzard

Introduction

The Spring '24 release of Salesforce had some great new features and improvements, but for me some of the Beta/Developer Preview features were more interesting - for example the scratch org snapshots beta that I wrote about a few weeks ago.

I've been developing on the Salesforce platform for close to 16 years now, and I've lost count of the number of different approaches that I've taken to handle zip files. Sometimes on the server, sometimes on the client in JavaScript, but always using custom solutions that didn't perform fantastically well. The new functionality that is native to Apex won't solve this problem - governor limits still apply - that fact that it isn't implemented in Apex itself should mean that a little more processing can be wrung out of the transaction.

Trying it Out

This is a departure from previous developer preview functionality that I've tried in the past, as it's available in scratch orgs only. I'm onside with this approach as it feels like it will allow the Apex development team to be more agile and give us access to changes earlier. Anything that does go awry will disappear in a maximum of 30 days, so there won't be loads of developer editions left behind needing to be patched up. I'll take a bit of additional effort tracking down issues if it means I get my hands on things earlier.

You need to enable support for zip file processing via the features property in your scratch org definition, specifying ZipSupportInApex :

  "features": ["EnableSetPasswordInApi", "ZipSupportInApex"],

Then create a new scratch org and you are off to the races.

What Worked

Creating a zip file and then extracting it works great - I did this through some execute anonymous Apex and was pleasantly surprised there were no further hoops to jump through. As this is in developer preview I didn't expend a huge amount of effort, just adding a single entry created from a String via a Blob:

public void SimpleZip()
{
    Compression.ZipWriter writer=new Compression.ZipWriter();
    writer.addEntry('zs.csv', Blob.valueOf('Name, Title\nKeir Bowden, CEO'));
    Blob zipFile=writer.getArchive();

    ContentVersion contentVer = new ContentVersion(
        VersionData =zipFile,
        Title = 'zipsample.zip',
        Description = 'Zip Sample',
        PathOnClient = '/zipsample.zip'
    );

    insert contentVer;
}

Once I'd done this, I could see the zip and download it to extract files:

And to extract the file from the zip and debug the contents in Apex :

public void SimpleExtract()
{
    ContentVersion contentVer=[select Id, VersionData, Title, Description, PathOnClient
                                from ContentVersion
                                where Title='zipsample.zip'];

    Blob zipBody = contentVer.VersionData;
    Compression.ZipReader reader = new Compression.ZipReader(zipBody);
    Compression.ZipEntry csvEntry = reader.getEntry('zs.csv');
    Blob csv = reader.extract(csvEntry);

    System.debug('CSV body = ' + csv.toString());
}

Gave me the contents of the file that I'd created from my String :


What Didn't Work

The demo that I was actually trying to put together was the ability to choose a zip file from the content library and inspect the contents via the UI. Selecting the file was all well and good, but once I tried to extract the details of the files and wrap them in an inner class, I was out of luck.

In case it was something about my processing of the entries, I tried just debugging them :
@AuraEnabled(cacheable=true)
public static List<Entry> GetZipEntries(Id contentVersionId)
{
    List<Entry> entries=new List<Entry>();
    try 
    {
        ContentVersion contentVer=[select Id, VersionData, Title, Description, PathOnClient
                                    from ContentVersion
                                    where Id=:contentVersionId];

        Blob zipBody = contentVer.VersionData;
        Compression.ZipReader reader = new Compression.ZipReader(zipBody);
        for (Compression.ZipEntry entry : reader.getEntries())
        {
            System.debug('Entry = ' + entry.getName());
        }
    }
    catch (Exception e)
    {
        System.debug(e);
    }

    return entries;
}
but this failed in the same, odd way. No exceptions, no errors, just an exit of the server side call when I invoked ZipReader.getZipEntries().



In case it was something Lightning Components related I dusted off my Visualforce skills and tried a custom controller :
public List<Entry> getEntries()
{
    if (null==this.entries)
    {
        entries=new List<Entry>();
        
        String cvId=ApexPages.currentPage().getParameters().get('cvid');
        System.debug('Id = ' + cvId);
        ContentVersion contentVer=[select Id, VersionData, Title, Description, PathOnClient
                                    from ContentVersion
                                    where Id=:cvId];

        Blob zipBody = contentVer.VersionData;
        Compression.ZipReader reader = new Compression.ZipReader(zipBody);
        for (Compression.ZipEntry zipEntry : reader.getEntries()) 
        {
            System.debug('Processing entry');
            Entry entry=new Entry();
            entry.name=zipEntry.getName();
            entry.method=zipEntry.getMethod().name();
            entry.lastModifiedTime=zipEntry.getLastModifiedTime();
            entries.add(entry);
        }
    }

    return entries;
}
This was slightly more successful, in that it very occasionally showed the list of entries after a hard reload of the page. Most of the time though, it behaved the same and just gave up when I executed ZipReader.getEntries().



Just in case it was something about my zip file, which was an old bootstrap library I had lying around, I copied the same code into a new class and invoked that using execute anonymous.

That worked fine:



So it does appear there is an issue accessing the contents of a zip file in a transaction that originated from a custom UI, or something along those lines anyway. I couldn't find anything in the docs to indicate I shouldn't be trying this, but it is in developer preview so I'd expect a few glitches. As a wise man once said, "I'll take a bit of additional effort tracking down issues if it means I get my hands on things earlier.". 

More Information

Sunday 11 February 2024

Apex Null Coalescing Operator

Image generated by DALL-E 3 based on about 10 prompts by Bob Buzzard

Introduction

The Spring 24 release of Salesforce bring a new operator to Apex - the null coalescing operator. It makes me very happy when these kinds of operators make their way to Apex, as it feels like we're a real programming language!

The null coalescing operator consists of an operand, two question marks, and another operand:

  operand1 ?? operand2

When the expressions is evaluated, if operand 1 is not null that will be the result, otherwise operand2 will be the result. Note that this isn't a mechanism for avoiding a null result, as if operand1 is null, operand2 will be the result even if it is null.

Using the Operator

The key use case for the null coalescing operator is removing explicit, and possibly lengthy, null checks. The obvious one is falling back on a default value, so instead of :

public List<Contact> getContactsForAccount(Account account)
{
    List<Contact> contacts=contactsForAccount.get(account.Id);

    if (null==contacts)
    {
        contacts=new List<Contact>();
    }

    return contacts;
}

we can write:

public List<Contact> getContactsForAccountNew(Account account)
{
    List<Contact> contacts=contactsForAccount.get(account.Id) ??
                            new List<Contact>();

    return contacts;
}

Much more compact, but that's not all - there's another aspect to this that is good and bad in equal measure. As there is now a single line evaluating the list of contacts, a single test will give 100% coverage of the line regardless of whether the first or second operand is as the result. For some developers this is good - one less scenario needed for test coverage. For others (including me) this is bad - if both scenarios aren't tested then we don't know how it will behave when the missing scenario happens in production.

Actually there's a third aspect to this too - the right hand operand isn't evaluated if the left hand is non-null, so it's efficient and devoid of unexpected side effects.

You can also chain them. so if you have five potential candidates for an opportunity value to return, you can write:

   Opportunity opp=opp1 ?? opp2 ?? opp3 ?? opp4 ?? opp5;

Safe in the knowledge that the first non-null value will be used. 

You also don't have to use objects or primitives as the operands, but watch out for the loss of clarity if you start to put too much working into a single expression.  For example, I don't think this is the clearest way to extract a title given an Id that might refer to a Contact or a Lead:

return [select Title from Contact where Email=:email][0].Title ??
       [select Title from Lead where Email=:email limit 1].Title ??
       'No contact/lead found matching email';Al

Always think about those that come after you!

Use to pull a collection from a map or add the new collection in if it's not present. Instead of

Apples with Apples

The operands must both be the same type, so you can't have the first operand being a Lead and the second being a Contact unless they can be converted to a common type  - e.g. an sObject. By the time you introduce casting though, I'd say you've lost most of the benefit due to some ugly code:

Contact contactCand=new Contact();
Lead leadCand;

String name = (String) ((sobject) leadCand ?? (sobject) contactCand).get('Name');

You can also compare operands that can be promoted to a common type by the compiler, for example if you have some inheritance at play:

public virtual class First
{
    public String name {get; set;}
}

public class Second extends First
{
}

   ----------
   
First first=new First();
Second second=new Second();
String name=(first ?? second).name;

More Information

Sunday 14 January 2024

Scratch Org Snapshots in Spring '24

Note: This feature is in beta in Spring '24. Like all other betas, this functionality may never go GA and may disappear at any time. Caveat emptor.

Image generated by DALL-E 3 based on a prompt by Bob Buzzard

Introduction

The Spring '24 release of Salesforce moves the scratch org snapshot functionality into beta. I've been waiting to get my hands on this and so far it hasn't disappointed.

Whenever we get new features of this nature, I like to reflect on how far we've come in Salesforce land. In this case I was testing with the codebase of our BrightMEDIA accelerator, and when we started building this in mid-2014 (aka nearly a decade ago), we typically allowed a week to get a new developer set up. We had to spin up a Developer Edition, raise a bunch of tickets to get various features enabled and increase the Apex character limit, install a number of packages, carry out a number of manual setup steps,  deploy the code and assign permission sets. For whatever reason, no two Developer Editions appeared to have the same setup, so typically the deployment was an iterative process where we discovered what was missing or off instead of on by default. Then they'd go through and set up some standing data to be able to work in the org. 

Fast forward to the end of 2023 and I have a node script that creates a scratch org, installs the packages, deploys the code, loads the standing data and produces a ready to go development environment in around 30 minutes. I'm always interested in speeding things up though!

Creating a Snapshot

Thanks to a pre-release environment that I've also had for a decade, I have a pre-release dev hub which meant I could enable the beta before the Spring '24 release goes live. Then I assigned myself the appropriate object permissions for Org Snapshots and I was ready to create. 

I set up my scratch org using my existing script, which creates an org with the following applied:

  • Four managed packages
  • Approximately 9,000 metadata components
  • Approximately 2,000 records
Creating a snapshot of this org took 11 minutes, which I must admit was quite a bit faster than I was expecting.

Using the Snapshot


This started off with a bit of a challenge, in that attempting to use the snapshot kept giving the error that the snapshot wasn't Active, but listing the Dev Hub snapshots showed that it was indeed Active. I spent a while searching through the CLI Github issues list and the snapshot pilot Trailblazer group, but it seemed like I was the lucky one who got to experience this first. This was quite soon after the pre-release had gone live, so I figured it might be a simple bug and played the waiting game.

About 7 hours later my masterful inactivity was rewarded, as my snapshot sprang to life and I was able to run the commend to create an org from it. In fairness, it might have started working 10 minutes later, but it was around 7 hours later I had the time to try it out again.

The even better news was that creating a scratch org from the snapshot took 6 minutes - an 80% saving on the 30 minute creation time for my script. The org was flawless too - all the metadata and data was there.

The End of Sandboxes?


So does this mean that we can all create scratch org snapshots rather than sandboxes going forward? They even contain data, so maybe we can do away with full or partial copy sandboxes too.  I don't think so, for a few reasons.

Lifespan


Scratch orgs and org snapshots, have a 30 day lifespan. From a developer perspective this is fine - we treat these orgs and disposable and typically create a fresh one when we start a new piece of work. That isn't necessarily the case for orgs used for training, QA, integration testing or testing against a new release. It's particularly unsuitable for pre-production environments which mirror production - imagine having to recreate all your test integrations at the start of every month!

Storage


Scratch orgs and org snapshots are limited to 200Mb of data. Again, probably fine for many development tasks, but again likely to be too small for training, pre-production and test environments that are indicative of production. 

Licenses


Sandboxes replicate your production org licenses, so all of your users can have access. Scratch orgs are a much more restrictively licensed, usually somewhere between 1 and 10 seats per feature. When we were adding community (now Experience Cloud) features to BrightMEDIA, we had the princely sum of 1 partner community license available in our scratch orgs - you'd have to be quite brave to promote to production with that kind of limitation on your testing!

Completeness of Version Controlled Metadata


This is where developer/developer pro sandboxes will retain their usefulness once scratch org snapshots are live. Some organisations with large, mature Salesforce orgs won't have all their metadata in version control, because why would they invest the time and money to do that when they don't need to. They'll likely have Apex, flows, lightning components, and maybe some second generation packages in version control, but things like sharing rules, report and dashboard folders, duplicate rules that are managed by administrators probably won't. Yes this is a sweeping generalisation, but you get the general idea. Being able to create a guaranteed replication of production to work in will be an important capability for years to to come in my view.  That said, they'll probably become less used as time goes on and maybe scratch org snapshots get longer lifespans.

So not a sandbox killer, but that was never the intention. For those of us with a very source-centric development approach however, this is another great addition to the developer toolbelt.

More Information