Read more at jamessimone.net, or return to the homepage





Building An Apex Logging Service

Welcome back to another Joys Of Apex session. We're going to take a quick break from the TDD framework that I have been writing about to cover an interesting topic that came up on r/salesforce:

We are about to launch a high traffic public site and want to enable debug logs continuously for some time(at least a month) but salesforce doesn't allow enabling debug log for more than 24 hours and also there is limit on how many debug logs it can store. Are there any tools or apps so that we can keep getting debug logs without having to worry about extending debug log and moving debug logs?

Having already co-authored a service that got around this issue by polling Salesforce periodically and extracting the debug logs to send on to an ELK instance, I became intrigued — was it possible to accomplish the gathering of log bodies from within Salesforce itself, if the ApexLog object wasn't available to be queried from within SOQL?

linkBring out your logs

The adventure began. You can query the ApexLog SObject from within SOQL, but you can't access the log body there — so I wrote a little Tooling API wrapper to query for the logs. Those familiar with REST services in Apex know that we must needs depart from our TDD mindset in order to do some of these things, since you aren't allowed to make REST requests from within tests. Bummer. Since my usual approach is blocked off, we'll switch to everybody's favorite secondary approach — debugging and praying!

classes/ToolingApi.cls
1linkpublic class ToolingApi {

2link //in a real environment, I would store the API version

3link //in a custom setting or metadata since there's no graceful

4link //way to get it on the fly

5link private static final String TOOLING_API_URI = '/services/data/v48.0/tooling';

6link private final Http http;

7link

8link public ToolingApi() {

9link this.http = new Http();

10link }

11link

12link public Object getLogs() {

13link //before we go in deep, at least we can

14link //get the log Ids through SOQL

15link Set<Id> logIds = this.queryLogIds();

16link return logIds;

17link }

18link

19link private Set<Id> queryLogIds() {

20link //use date literal TODAY for now

21link //we'll make this a trackable value later

22link return new Map<Id, SObject>(

23link [

24link SELECT Id, Status

25link FROM ApexLog

26link WHERE StartTime >= TODAY

27link AND Status != 'Success'

28link AND Operation != 'Async Metadata'

29link ORDER BY StartTime

30link LIMIT 10

31link ]

32link ).keySet();

33link }

34link}

We'll need to make a request to the Tooling API in order to get the log body by using each of the Ids in the response:

classes/ToolingApi.cls
1linkprivate static final String LOG_BODY_QUERY = '/sobjects/ApexLog/{0}/Body/';

2link

3linkpublic Map<String, String> getLogs() {

4link Set<Id> logIds = this.queryLogIds();

5link Map<String, String> logIdToLogBody = new Map<String, String>();

6link for(Id logId : logIds) {

7link HttpRequest logBodyReq = this.createHttpRequest();

8link String logIdPath = String.format(LOG_BODY_QUERY, new List<String> { logId });

9link logBodyReq.setEndpoint(logBodyReq.getEndpoint() + logIdPath);

10link HttpResponse logBodyRes = this.http.Send(logBodyReq);

11link logIdToLogBody.put(logId, logBodyRes.getBody());

12link }

13link

14link return logIdToLogBody;

15link}

16link

17linkprivate HttpRequest createHttpRequest() {

18link HttpRequest request = new HttpRequest();

19link String baseUrl = URL.getSalesforceBaseUrl().toExternalForm();

20link System.debug('Make sure this URL is included in a Remote Site Setting: ' + baseUrl);

21link request.setEndpoint(baseUrl + TOOLING_API_URI);

22link request.setHeader('Authorization', 'OAuth ' + UserInfo.getSessionId());

23link request.setHeader('Content-Type', 'application/json');

24link request.setMethod('GET');

25link return request;

26link}

27link

28linkprivate class ToolingApiResponse {

29link List<LogResponse> records { get; set;}

30link}

31link

32linkprivate class LogResponse {

33link Id Id { get; set; }

34link String Status { get; set; }

35link}

linkApex Exception Logging Roadblocks

I was starting to get pretty excited by this point. Re-executing my Anonymous Apex, I was greeted by a succesful message! Things were going great. I might even take a lunch break before finishing the rest of this off. Writing Apex is fun and easy. Little did I know I was about to hit an Apex exception logging roadblock:

apex log exception

That's what happened when I double-clicked to open the log. Expecting to see the contents of my exception logs within the log (inception?), instead I was greeted by a stone wall. What even was happening? Bizarrely, no matter what I did, this message would display any time I tried to view the combined contents of the logs. I went to sleep that night dejected, thinking that perhaps I would write about my experience, tongue-in-cheek, to show that sometimes Apex just isn't a joy. I certainly have some upcoming examples of that. Yet right before I feel asleep, I had this crazy thought ... perhaps it had been premature of me to write this experiment off as a failure after all. The Anonymous Apex had executed successfully ... perhaps the issue was with the Salesforce Developer Console's ability to render the contents of a log body from within a log itself.

Luckily, testing this theory proved easy. The next day, I wrote a little REST wrapper around the ToolingApi object, making use of the aforementioned Factory class as well:

1link//in the Factory

2linkpublic ToolingApi getToolingApi {

3link return new ToolingApi();

4link}

5link

6link//and then a class called LogService:

7link@RestResource(urlMapping='/logs/*')

8linkglobal class LogService {

9link @HttpGet

10link global static Map<String, String> getLogs() {

11link return new ToolingApi().getLogs();

12link }

13link}

And, using Postman to hit my newly created endpoint:

Postman Apex Logging Service

linkApex Exception Logging - Updating Trace Flags

I hope you can see past my terrible editing skills. But this was incredible news! The gathering of the logs was complete. Now all I needed to do was create a little audit object to store the last time the logs had been queried for, and update that object accordingly. We'll call it AuditLog__c and it will have two custom Text fields on it: LastPoll__c and LastTraceFlagUpdate__c. Salesforce only allows trace flags, which lead to the creation of exception logs to begin with, for 24 hour periods. Every 12 or so hours, we'll have to update the traces:

1link//in the Factory

2linkpublic ToolingApi getToolingApi {

3link return new ToolingApi(this);

4link}

5link

6linkpublic class ToolingApi {

7link private static final String TOOLING_API_URI = '/services/data/v47.0/tooling';

8link private static final String LOG_BODY_QUERY = '/sobjects/ApexLog/{0}/Body/';

9link private static final String TRACE_DATE_FORMAT = 'yyyy-MM-dd\'T\'HH:mm:ss.SSSXXX';

10link

11link private final AuditLog__c auditLog

12link private final Http http;

13link private final ICrud crud;

14link

15link public ToolingApi(Factory factory) {

16link this.crud = factory.Crud;

17link this.http = new Http();

18link //for now we'll use raw SOQL

19link //till I cover the repository pattern

20link this.auditLog = [SELECT Id, LastPoll__c, LastTraceFlagUpdate__c FROM AuditLog__c LIMIT 1];

21link }

22link

23link public Map<String, String> getLogs() {

24link Set<Id> logIds = this.queryLogIds();

25link Map<String, String> logIdToLogBody = new Map<String, String>();

26link for(Id logId : logIds) {

27link HttpRequest logBodyReq = this.createHttpRequest();

28link String logIdPath = String.format(LOG_BODY_QUERY, new List<String> { logId });

29link logBodyReq.setEndpoint(logBodyReq.getEndpoint() + logIdPath);

30link HttpResponse logBodyRes = this.http.Send(logBodyReq);

31link logIdToLogBody.put(logId, logBodyRes.getBody());

32link }

33link

34link String twelveHoursFromNow = System.now().addHours(12).format(TRACE_DATE_FORMAT);

35link this.updateTraces(twelveHoursFromNow);

36link this.updateAuditLog(twelveHoursFromNow);

37link

38link return logIdToLogBody;

39link }

40link

41link private void updateTraces(String twelveHoursFromNow) {

42link //we'll get to this in a second

43link //more Tooling API joy

44link }

45link

46link private void updateAuditLog(String twelveHoursFromNow) {

47link this.auditLog.LastPoll__c = System.now();

48link this.auditLog.LastTraceFlagUpdate__c = traceTimestamp;

49link this.crud.doUpdate(this.auditLog);

50link }

51link}

I'll have to go back to the Tooling API docs to re-remember how we get at those TraceFlag values ... OK, it's going to be another query, and then we'll have to do something new, which is a Tooling API update. Since we need the Id of a different kind of SObject being returned from the Tooling API, but are sort-of "object agnostic" with the rest of the potential response, we'll change the name of the previously documented LogResponse class to something more generic, like ... ToolingApiRecord

classes/ToolingApi.cls
1linkprivate static final String TRACE_UPDATE_QUERY = '/sobjects/TraceFlag/{0}?_HttpMethod=PATCH';

2link

3linkprivate void updateTraces(String twelveHoursFromNow) {

4link String query = 'SELECT Id from TraceFlag where LogType = \'USER_DEBUG\'';

5link HttpRequest request = this.getQueryRequest(query);

6link HttpResponse res = this.http.Send(request);

7link ToolingApiResponse toolingResponse = (ToolingApiResponse)Json.deserialize(res.getBody(), ToolingApiResponse.class);

8link

9link for(ToolingApiRecord traceRecord : toolingResponse.records) {

10link HttpRequest traceRecordReq = this.createHttpRequest();

11link traceRecordReq.setMethod('POST');

12link String traceRecordBody = this.getTraceRecordBody(twelveHoursFromNow);

13link System.debug(traceRecordBody);

14link traceRecordReq.setBody(traceRecordBody);

15link

16link String traceRecordPath = String.format(TRACE_UPDATE_QUERY, new List<String> { traceRecord.Id });

17link traceRecordReq.setEndpoint(traceRecordReq.getEndpoint() + traceRecordPath);

18link this.http.Send(traceRecordReq);

19link }

20link}

21link

22linkprivate String getTraceRecordBody(String twelveHoursFromNow) {

23link JSONGenerator gen = JSON.createGenerator(true);

24link gen.writeStartObject();

25link gen.writeStringField('StartDate', System.now().format(TRACE_DATE_FORMAT));

26link gen.writeStringField('ExpirationDate', twelveHoursFromNow);

27link gen.writeEndObject();

28link return gen.getAsString();

29link}

30link

31link//used to be LogResponse

32linkprivate class ToolingApiRecord {

33link Id Id { get; set; }

34link}

Take special note of that query string parameter, ?_HttpMethod=PATCH that's been added to the TraceFlag update URL. It wouldn't be Salesforce without some wacky hack to support a PATCH operation, since the existing HttpRequest implementation doesn't support PATCH as an HttpMethod. Classic!

Et voila! Our service is now capable of updating our TraceFlags so that we will always have logs at our disposal. That's pretty neat. The finishing touch is updating the queryLogIds method to take in our audit object's field so that the only logs queried are the ones that have occurred after our LastPoll__c value:

classes/ToolingApi.cls
1linkprivate Set<Id> queryLogIds() {

2link return new Map<Id, SObject>(

3link [

4link SELECT Id, Status

5link FROM ApexLog

6link WHERE StartTime >= :this.auditLog.LastPoll__c

7link AND Status != 'Success'

8link AND Operation != 'Async Metadata'

9link ORDER BY StartTime

10link LIMIT 10

11link ]

12link ).keySet();

13link}

The hard part's over. There are still some edge cases to cover; notably, if there are more than 10 exceptions generated in-between calls to get the logs, you'll miss out on some exceptions. That and scheduling Apex to call this service are both trivial to implement, and are exercises left to the reader, as well as what to do with the log bodies once they've been gathered; you could even create a custom object and append the log bodies to a custom field if you wanted to increase the visibility of errors in the system, but I suspect that most people looking to do something like this are more interested in posting the data to another platform, aggregating exception logs for all infrastructure in a shared space.

Hopefully this post helped open your eyes to how to accomplish this within Apex itself - happy logging! The full code for the ToolingApi object can be viewed on my github.

The original version of Apex Logging Service can be read on my blog.

Bring out your logsApex Exception Logging RoadblocksApex Exception Logging - Updating Trace Flags

Home Apex Logging Service Apex Object-Oriented Basics Batchable And Queueable Apex Building A Better Singleton Continuous Integration With SFDX Dependency Injection & Factory Pattern Enum Apex Class Gotchas Extendable Apis Future Methods, Callouts & Callbacks Idiomatic Salesforce Apex Introduction & Testing Philosophy Lazy Iterators Lightweight Trigger Handler LWC Composable Modal LWC Composable Pagination LWC Custom Lead Path Mocking DML React Versus Lightning Web Components Refactoring Tips & Tricks Repository Pattern setTimeout & Implementing Delays Sorting And Performance In Apex Test Driven Development Example Testing Custom Permissions Writing Performant Apex Tests



Read more tech articles